tests/test_xlsx_skill.py
tests/test_xlsx_skill.pyBrowse 11 files
6,128 tokens
21,956 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""End-to-end tests for the xlsx skill helper scripts.2 3Runs each script as a subprocess under LC_ALL=C to prove all text I/O4uses explicit UTF-8 rather than locale defaults. No network access.5"""6from __future__ import annotations7 8import csv9import json10import os11import shutil12import subprocess13import sys14from datetime import date15from pathlib import Path16 17import pytest18from openpyxl import load_workbook19 20SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"21 22 23def run(script, *args, expect_ok=True):24 env = dict(os.environ, LC_ALL="C", LANG="C")25 env.pop("PYTHONIOENCODING", None)26 proc = subprocess.run(27 [sys.executable, str(SCRIPTS / script), *map(str, args)],28 capture_output=True, text=True, env=env, encoding="utf-8")29 if expect_ok:30 assert proc.returncode == 0, f"{script} failed: {proc.stderr}"31 return proc32 33 34SPEC = {35 "full_calc_on_load": True,36 "sheets": [37 {38 "name": "Data",39 "rows": [40 [41 {"value": "Region", "bold": True, "fill": "DDEBF7",42 "border": "thin", "align": "center", "valign": "center"},43 {"value": "Sales", "bold": True, "fill": "DDEBF7"},44 {"value": "Growth", "bold": True},45 {"value": "Audited", "bold": True},46 {"value": "Closed", "bold": True},47 {"value": "Status", "bold": True},48 ],49 ["North", 1500.5, {"value": 0.125, "format": "0.0%"}, True,50 {"value": "2026-01-31", "type": "date",51 "format": "yyyy-mm-dd"}, "Yes"],52 ["South", 900, {"value": -0.03, "format": "0.0%"}, False,53 {"value": "2026-02-28", "type": "date",54 "format": "yyyy-mm-dd"}, "No"],55 ["East", 2100, {"value": 0.4, "format": "0.0%"}, True,56 {"value": "2026-03-31", "type": "date",57 "format": "yyyy-mm-dd"}, "Yes"],58 ],59 "cells": {60 "A6": {"value": "Total", "bold": True, "italic": True,61 "font_size": 12, "font_color": "1F4E78"},62 "B6": {"formula": "SUM(B2:B4)", "format": "$#,##0.00"},63 },64 "column_widths": {"A": 18, "B": 14},65 "row_heights": {"1": 24},66 "merges": ["A8:C8"],67 "freeze_panes": "A2",68 "autofilter": "A1:F4",69 "conditional_formats": [70 {"range": "B2:B4", "type": "cell_is",71 "operator": "greaterThan", "formula": ["1000"],72 "fill": "C6EFCE"},73 {"range": "C2:C4", "type": "color_scale"},74 ],75 "charts": [76 {"type": "bar", "title": "Sales by region", "anchor": "H2",77 "data": "B1:B4", "categories": "A2:A4"},78 {"type": "line", "title": "Growth", "anchor": "H18",79 "data": "C1:C4", "categories": "A2:A4"},80 {"type": "pie", "title": "Share", "anchor": "P2",81 "data": "B2:B4", "categories": "A2:A4",82 "titles_from_data": False},83 ],84 "validations": [85 {"range": "F2:F10", "type": "list",86 "formula1": '"Yes,No,Maybe"'},87 ],88 },89 {"name": "Notes", "rows": [["Zürich", "Фамилия", "12,5%"]]},90 ],91}92 93 94@pytest.fixture95def workbook(tmp_path):96 spec_path = tmp_path / "spec.json"97 spec_path.write_text(json.dumps(SPEC), encoding="utf-8")98 out = tmp_path / "report.xlsx"99 proc = run("xlsx_create.py", spec_path, out)100 summary = json.loads(proc.stdout)101 assert summary["ok"] and summary["sheets"] == ["Data", "Notes"]102 return out103 104 105def test_create_features_roundtrip(workbook):106 wb = load_workbook(workbook)107 ws = wb["Data"]108 # typed values109 assert ws["B2"].value == 1500.5110 assert ws["D2"].value is True111 e2 = ws["E2"].value112 assert (e2.date() if hasattr(e2, "date") else e2) == date(2026, 1, 31)113 # formula + number formats114 assert ws["B6"].value == "=SUM(B2:B4)"115 assert ws["B6"].number_format == "$#,##0.00"116 assert ws["C2"].number_format == "0.0%"117 assert ws["E2"].number_format == "yyyy-mm-dd"118 # styling119 assert ws["A1"].font.bold is True120 assert ws["A1"].fill.fgColor.rgb.endswith("DDEBF7")121 assert ws["A1"].border.left.style == "thin"122 assert ws["A1"].alignment.horizontal == "center"123 assert ws["A6"].font.italic is True and ws["A6"].font.size == 12124 # dimensions125 assert ws.column_dimensions["A"].width == 18126 assert ws.row_dimensions[1].height == 24127 # merges / freeze / autofilter128 assert "A8:C8" in [str(r) for r in ws.merged_cells.ranges]129 assert ws.freeze_panes == "A2"130 assert ws.auto_filter.ref == "A1:F4"131 # conditional formatting, charts, validation132 assert len(list(ws.conditional_formatting)) == 2133 assert len(ws._charts) == 3134 types = {type(c).__name__ for c in ws._charts}135 assert types == {"BarChart", "LineChart", "PieChart"}136 assert len(ws.data_validations.dataValidation) == 1137 # recalc flag138 assert wb.calculation.fullCalcOnLoad is True139 140 141def test_read_sheets_json_formulas(workbook, tmp_path):142 inv = json.loads(run("xlsx_read.py", workbook, "--sheets").stdout)143 names = [s["name"] for s in inv["sheets"]]144 assert names == ["Data", "Notes"]145 data_info = inv["sheets"][0]146 assert data_info["charts"] == 3147 assert "A8:C8" in data_info["merged"]148 assert data_info["freeze_panes"] == "A2"149 150 dump = json.loads(151 run("xlsx_read.py", workbook, "--json", "--sheet", "Data").stdout)152 assert dump["rows"][1][0] == "North"153 assert dump["rows"][1][4] == "2026-01-31T00:00:00"154 155 notes = json.loads(156 run("xlsx_read.py", workbook, "--json", "--sheet", "Notes").stdout)157 assert notes["rows"][0] == ["Zürich", "Фамилия", "12,5%"]158 159 formulas = json.loads(run("xlsx_read.py", workbook, "--formulas").stdout)160 entry = [f for f in formulas["formulas"] if f["cell"] == "B6"][0]161 assert entry["formula"] == "=SUM(B2:B4)"162 # openpyxl never computes: cached value absent on a fresh file163 assert entry["cached"] is None164 165 csv_out = tmp_path / "data.csv"166 run("xlsx_read.py", workbook, "--csv", "--sheet", "Notes",167 "--out", csv_out)168 text = csv_out.read_text(encoding="utf-8")169 assert "Zürich" in text and "Фамилия" in text170 171 172def test_csv_roundtrip_nonascii(tmp_path):173 src = tmp_path / "src.csv"174 with open(src, "w", newline="", encoding="utf-8") as fh:175 w = csv.writer(fh)176 w.writerow(["City", "Share", "Surname", "Active", "When"])177 w.writerow(["Zürich", "12,5%", "Фамилия", "true", "2026-05-01"])178 w.writerow(["Oslo", "7", "Ås", "false", "2026-06-01"])179 xlsx = tmp_path / "conv.xlsx"180 run("csv_to_xlsx.py", src, xlsx, "--sheet-name", "Import")181 182 wb = load_workbook(xlsx)183 ws = wb["Import"]184 assert ws["A2"].value == "Zürich"185 assert ws["B2"].value == "12,5%" # decimal comma stays a string186 assert ws["C2"].value == "Фамилия"187 assert ws["D2"].value is True # bool inferred188 assert ws["E2"].value.date() == date(2026, 5, 1) # date inferred189 assert ws["B3"].value == 7 # int inferred190 assert ws["A1"].font.bold is True # styled header191 assert ws.freeze_panes == "A2"192 193 back = tmp_path / "back.csv"194 run("xlsx_to_csv.py", xlsx, back, "--sheet", "Import")195 with open(back, newline="", encoding="utf-8") as fh:196 rows = list(csv.reader(fh))197 assert rows[1][0] == "Zürich"198 assert rows[1][2] == "Фамилия"199 assert rows[1][3] == "True"200 assert rows[1][4] == "2026-05-01"201 202 # encoding override203 latin = tmp_path / "latin.csv"204 run("xlsx_to_csv.py", xlsx, latin, "--sheet", "Import",205 "--encoding", "utf-8-sig")206 assert latin.read_bytes().startswith(b"\xef\xbb\xbf")207 208 209def test_edit_existing(workbook, tmp_path):210 edited = tmp_path / "edited.xlsx"211 proc = run("xlsx_edit.py", workbook, "--sheet", "Notes",212 "--out", edited,213 "--copy-sheet", "Notes:Backup",214 "--rename-sheet", "Data:Main",215 "--set", "B1=Änderung",216 "--set", "C1=99.5",217 "--set", "D1=2026-12-24",218 "--set", "E1==SUM(C1:C1)",219 "--append", '["appended", 1, false]',220 "--insert-rows", "1:1",221 "--recalc")222 result = json.loads(proc.stdout)223 assert result["ok"]224 225 wb = load_workbook(edited)226 assert set(wb.sheetnames) == {"Main", "Notes", "Backup"}227 ws = wb["Notes"]228 # insert-rows ran before --set per documented order, so row 1 is blank229 # and original data moved to row 2... check documented ordering:230 # structural ops run before --set, so B1 etc. were written after insert.231 assert ws["B1"].value == "Änderung"232 assert ws["C1"].value == 99.5233 assert ws["D1"].value.date() == date(2026, 12, 24)234 assert ws["E1"].value == "=SUM(C1:C1)"235 assert wb.calculation.fullCalcOnLoad is True236 # appended row present237 found = [r for r in ws.iter_rows(values_only=True)238 if r and r[0] == "appended"]239 assert found and found[0][1] == 1 and found[0][2] is False240 # copy preserved data241 assert wb["Backup"]["A1"].value == "Zürich"242 243 244def test_help_and_errors():245 for script in ["xlsx_create.py", "xlsx_read.py", "xlsx_edit.py",246 "csv_to_xlsx.py", "xlsx_to_csv.py",247 "xlsx_restructure.py", "xlsx_recalc.py"]:248 proc = run(script, "--help")249 assert "usage" in proc.stdout.lower()250 bad = run("xlsx_read.py", "/nonexistent.xlsx", "--sheets",251 expect_ok=False)252 assert bad.returncode != 0253 assert json.loads(bad.stderr)["ok"] is False254 255 256# ---------------------------------------------------------------------------257# Reference-aware restructuring (xlsx_restructure.py)258# ---------------------------------------------------------------------------259 260RESTRUCTURE_SPEC = {261 "defined_names": {"SalesRange": "'Data'!$B$2:$B$4"},262 "sheets": [263 {264 "name": "Data",265 "rows": [266 ["Region", "Sales", "Weight"],267 ["North", 100, 0.5],268 ["South", 200, 0.3],269 ["East", 300, 0.2],270 [None, None, None],271 ["Total", None, None],272 ],273 "cells": {274 "B6": {"formula": "SUM(B2:B4)"},275 "C6": {"formula": "$B$2*C2"},276 "D6": {"formula": "LOG10(B4)"},277 "E6": {"formula": "SUM(B:B)"},278 "F6": {"formula": '"row B2: "&B2'},279 },280 "merges": ["E2:E4", "A7:B7"],281 "freeze_panes": "A2",282 "autofilter": "A1:C4",283 "conditional_formats": [284 {"range": "B2:B4", "type": "cell_is",285 "operator": "greaterThan", "formula": ["150"],286 "fill": "C6EFCE"},287 ],288 "validations": [289 {"range": "C2:C4", "type": "list",290 "formula1": '"0.2,0.3,0.5"'},291 ],292 "tables": [293 {"name": "SalesTbl", "range": "A1:C4"},294 ],295 },296 {297 "name": "Summary",298 "rows": [["Grand total"]],299 "cells": {300 "B1": {"formula": "SUM(Data!B2:B4)"},301 "B2": {"formula": "'Data'!$B$3"},302 "B3": {"formula": "SUM(A1:A1)"},303 },304 },305 ],306}307 308 309@pytest.fixture310def restructure_book(tmp_path):311 spec_path = tmp_path / "rspec.json"312 spec_path.write_text(json.dumps(RESTRUCTURE_SPEC), encoding="utf-8")313 out = tmp_path / "restructure.xlsx"314 run("xlsx_create.py", spec_path, out)315 return out316 317 318def test_restructure_insert_rows_shifts_everything(restructure_book):319 # merge A6:C6 gets pushed down; A1:A1 merge is before the insert point320 proc = run("xlsx_restructure.py", restructure_book,321 "--sheet", "Data", "--insert-rows", "3:2")322 report = json.loads(proc.stdout)323 assert report["ok"] and report["op"] == "insert"324 325 wb = load_workbook(restructure_book)326 data, summary = wb["Data"], wb["Summary"]327 # values physically moved328 assert data["A2"].value == "North"329 assert data["A5"].value == "South" # was row 3330 assert data["A8"].value == "Total" # was row 6331 # same-sheet formulas rewritten (range expanded across insert point)332 assert data["B8"].value == "=SUM(B2:B6)"333 # absolute ref before insert point unchanged; relative arm shifted334 assert data["C8"].value == "=$B$2*C2"335 # function names, whole-column refs, string literals untouched336 assert data["D8"].value == "=LOG10(B6)"337 assert data["E8"].value == "=SUM(B:B)"338 assert data["F8"].value == '="row B2: "&B2'339 # cross-sheet formulas on the OTHER sheet rewritten340 assert summary["B1"].value == "=SUM(Data!B2:B6)"341 assert summary["B2"].value == "='Data'!$B$5"342 # Summary-local refs not confused with Data refs343 assert summary["B3"].value == "=SUM(A1:A1)"344 # merges: E2:E4 spans the insert point -> expanded; A7:B7 -> shifted345 merged = [str(r) for r in data.merged_cells.ranges]346 assert "E2:E6" in merged and "A9:B9" in merged347 # autofilter expanded, freeze panes intact348 assert data.auto_filter.ref == "A1:C6"349 assert data.freeze_panes == "A2"350 # validation + conditional format ranges shifted351 dv = data.data_validations.dataValidation[0]352 assert str(dv.sqref) == "C2:C6"353 cf = list(data.conditional_formatting)[0]354 assert str(cf.sqref) == "B2:B6"355 # native table expanded356 assert data.tables["SalesTbl"].ref == "A1:C6"357 # defined name rewritten358 assert wb.defined_names["SalesRange"].attr_text == "'Data'!$B$2:$B$6"359 # report is honest about limits360 assert "chart anchors" in report["not_shifted"]361 assert any(f["cell"] == "B1" and f["sheet"] == "Summary"362 for f in report["formulas"])363 364 365def test_restructure_delete_rows_and_ref_errors(restructure_book):366 run("xlsx_restructure.py", restructure_book,367 "--sheet", "Data", "--delete-rows", "3")368 wb = load_workbook(restructure_book)369 data, summary = wb["Data"], wb["Summary"]370 assert data["A3"].value == "East" # South deleted371 assert data["B5"].value == "=SUM(B2:B3)" # range clamped372 # single-cell ref into the deleted row becomes #REF!373 assert summary["B2"].value == "='Data'!#REF!"374 assert summary["B1"].value == "=SUM(Data!B2:B3)"375 assert data.tables["SalesTbl"].ref == "A1:C3"376 377 378def test_restructure_insert_cols(restructure_book):379 proc = run("xlsx_restructure.py", restructure_book,380 "--sheet", "Data", "--insert-cols", "B:1")381 report = json.loads(proc.stdout)382 assert report["axis"] == "cols" and report["index"] == 2383 wb = load_workbook(restructure_book)384 data, summary = wb["Data"], wb["Summary"]385 assert data["C2"].value == 100 # Sales moved B->C386 assert data["C6"].value == "=SUM(C2:C4)"387 assert data["D6"].value == "=$C$2*D2"388 assert summary["B1"].value == "=SUM(Data!C2:C4)"389 assert wb.defined_names["SalesRange"].attr_text == "'Data'!$C$2:$C$4"390 merged = [str(r) for r in data.merged_cells.ranges]391 assert "F2:F4" in merged # merge shifted right392 assert "A7:C7" in merged # merge expanded across col B393 394 395# ---------------------------------------------------------------------------396# Tables, defined names, hyperlinks, notes, protection (edit + read paths)397# ---------------------------------------------------------------------------398 399def test_tables_create_append_list(tmp_path):400 spec = {"sheets": [{"name": "T",401 "rows": [["Item", "Qty"], ["a", 1], ["b", 2]],402 "tables": [{"name": "Stock", "range": "A1:B3",403 "style": "TableStyleLight1"}]}]}404 spec_path = tmp_path / "tspec.json"405 spec_path.write_text(json.dumps(spec), encoding="utf-8")406 book = tmp_path / "tables.xlsx"407 run("xlsx_create.py", spec_path, book)408 409 wb = load_workbook(book)410 tbl = wb["T"].tables["Stock"]411 assert tbl.ref == "A1:B3"412 assert tbl.tableStyleInfo.name == "TableStyleLight1"413 414 # --add-table + --table-append auto-extends the range415 run("xlsx_edit.py", book, "--sheet", "T",416 "--add-table", "Extra:D1:E2",417 "--table-append", 'Stock=["c", 3]')418 wb = load_workbook(book)419 ws = wb["T"]420 assert ws.tables["Stock"].ref == "A1:B4"421 assert ws["A4"].value == "c" and ws["B4"].value == 3422 assert ws.tables["Extra"].ref == "D1:E2"423 424 listing = json.loads(425 run("xlsx_edit.py", book, "--sheet", "T", "--list-tables").stdout)426 assert listing["tables"]["Stock"]["ref"] == "A1:B4"427 assert set(listing["tables"]) == {"Stock", "Extra"}428 # tables also appear in the read inventory429 inv = json.loads(run("xlsx_read.py", book, "--sheets").stdout)430 assert inv["sheets"][0]["tables"]["Stock"] == "A1:B4"431 432 433def test_names_hyperlinks_notes(tmp_path):434 spec = {435 "defined_names": {"Rate": "'D'!$B$1"},436 "sheets": [{"name": "D", "cells": {437 "A1": {"value": "docs",438 "hyperlink": "https://example.com/docs"},439 "B1": {"value": 0.07, "note": "quarterly rate"},440 "C1": {"value": 1, "note": {"text": "check", "author": "QA"}},441 }}],442 }443 spec_path = tmp_path / "nspec.json"444 spec_path.write_text(json.dumps(spec), encoding="utf-8")445 book = tmp_path / "names.xlsx"446 run("xlsx_create.py", spec_path, book)447 448 wb = load_workbook(book)449 ws = wb["D"]450 assert ws["A1"].hyperlink.target == "https://example.com/docs"451 assert ws["B1"].comment.text == "quarterly rate"452 assert ws["C1"].comment.author == "QA"453 assert wb.defined_names["Rate"].attr_text == "'D'!$B$1"454 455 # edit path: add/delete names, hyperlink, note, clear note456 run("xlsx_edit.py", book, "--sheet", "D",457 "--define-name", "Extra='D'!$C$1",458 "--delete-name", "Rate",459 "--hyperlink", "D1=https://example.com/more|More",460 "--note", "D1=see more|Reviewer",461 "--clear-note", "B1")462 wb = load_workbook(book)463 ws = wb["D"]464 assert "Rate" not in wb.defined_names465 assert wb.defined_names["Extra"].attr_text == "'D'!$C$1"466 assert ws["D1"].hyperlink.target == "https://example.com/more"467 assert ws["D1"].value == "More"468 assert ws["D1"].comment.author == "Reviewer"469 assert ws["B1"].comment is None470 471 # read path: --notes and --names JSON output472 notes = json.loads(run("xlsx_read.py", book, "--notes").stdout)["notes"]473 coords = {(n["cell"], n["author"]) for n in notes}474 assert ("D1", "Reviewer") in coords and ("C1", "QA") in coords475 names = json.loads(run("xlsx_read.py", book, "--names").stdout)476 assert names["defined_names"] == {"Extra": "'D'!$C$1"}477 478 479def test_sheet_protection(tmp_path):480 spec = {"sheets": [{"name": "P", "rows": [["locked", "open"]],481 "protection": {"password": "your-password",482 "unlock": ["B1:B1"]}}]}483 spec_path = tmp_path / "pspec.json"484 spec_path.write_text(json.dumps(spec), encoding="utf-8")485 book = tmp_path / "prot.xlsx"486 run("xlsx_create.py", spec_path, book)487 488 wb = load_workbook(book)489 ws = wb["P"]490 assert ws.protection.sheet is True491 assert ws.protection.password # hash stored492 assert ws["B1"].protection.locked is False493 assert ws["A1"].protection.locked is not False494 inv = json.loads(run("xlsx_read.py", book, "--sheets").stdout)495 assert inv["sheets"][0]["protected"] is True496 497 # edit path on a fresh unprotected sheet498 plain = tmp_path / "plain.xlsx"499 spec_path.write_text(json.dumps(500 {"sheets": [{"name": "P", "rows": [["a", "b"]]}]}), encoding="utf-8")501 run("xlsx_create.py", spec_path, plain)502 run("xlsx_edit.py", plain, "--sheet", "P",503 "--protect", "your-password", "--unlock", "B1:B1")504 ws = load_workbook(plain)["P"]505 assert ws.protection.sheet is True and ws["B1"].protection.locked is False506 507 508# ---------------------------------------------------------------------------509# Headless recalculation (xlsx_recalc.py) — branches on soffice presence510# ---------------------------------------------------------------------------511 512def test_recalc_reports_json_both_ways(tmp_path):513 spec = {"sheets": [{"name": "R", "rows": [[2], [3]],514 "cells": {"A3": {"formula": "SUM(A1:A2)"}}}]}515 spec_path = tmp_path / "cspec.json"516 spec_path.write_text(json.dumps(spec), encoding="utf-8")517 book = tmp_path / "calc.xlsx"518 run("xlsx_create.py", spec_path, book)519 520 # absent-soffice branch is always testable by hiding PATH521 env = dict(os.environ, LC_ALL="C", LANG="C", PATH=str(tmp_path))522 proc = subprocess.run(523 [sys.executable, str(SCRIPTS / "xlsx_recalc.py"), str(book)],524 capture_output=True, text=True, env=env, encoding="utf-8")525 assert proc.returncode == 0526 absent = json.loads(proc.stdout)527 assert absent["recalculated"] is False and "soffice" in absent["reason"]528 assert "guidance" in absent529 530 if not shutil.which("soffice"):531 pytest.skip("LibreOffice not installed; absent branch covered above")532 533 out = tmp_path / "calced.xlsx"534 proc = run("xlsx_recalc.py", book, "--out", out, "--timeout", "300")535 result = json.loads(proc.stdout)536 assert result["recalculated"] is True537 assert result["formula_cells"] == 1538 assert result["with_cached_values"] == 1539 # cached value now visible to --formulas540 formulas = json.loads(run("xlsx_read.py", out, "--formulas").stdout)541 entry = formulas["formulas"][0]542 assert entry["formula"] == "=SUM(A1:A2)" and entry["cached"] == 5543