543 lines
21 KiB
Python
543 lines
21 KiB
Python
"""End-to-end tests for the xlsx skill helper scripts.
|
|
|
|
Runs each script as a subprocess under LC_ALL=C to prove all text I/O
|
|
uses explicit UTF-8 rather than locale defaults. No network access.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from openpyxl import load_workbook
|
|
|
|
SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
|
|
|
|
|
|
def run(script, *args, expect_ok=True):
|
|
env = dict(os.environ, LC_ALL="C", LANG="C")
|
|
env.pop("PYTHONIOENCODING", None)
|
|
proc = subprocess.run(
|
|
[sys.executable, str(SCRIPTS / script), *map(str, args)],
|
|
capture_output=True, text=True, env=env, encoding="utf-8")
|
|
if expect_ok:
|
|
assert proc.returncode == 0, f"{script} failed: {proc.stderr}"
|
|
return proc
|
|
|
|
|
|
SPEC = {
|
|
"full_calc_on_load": True,
|
|
"sheets": [
|
|
{
|
|
"name": "Data",
|
|
"rows": [
|
|
[
|
|
{"value": "Region", "bold": True, "fill": "DDEBF7",
|
|
"border": "thin", "align": "center", "valign": "center"},
|
|
{"value": "Sales", "bold": True, "fill": "DDEBF7"},
|
|
{"value": "Growth", "bold": True},
|
|
{"value": "Audited", "bold": True},
|
|
{"value": "Closed", "bold": True},
|
|
{"value": "Status", "bold": True},
|
|
],
|
|
["North", 1500.5, {"value": 0.125, "format": "0.0%"}, True,
|
|
{"value": "2026-01-31", "type": "date",
|
|
"format": "yyyy-mm-dd"}, "Yes"],
|
|
["South", 900, {"value": -0.03, "format": "0.0%"}, False,
|
|
{"value": "2026-02-28", "type": "date",
|
|
"format": "yyyy-mm-dd"}, "No"],
|
|
["East", 2100, {"value": 0.4, "format": "0.0%"}, True,
|
|
{"value": "2026-03-31", "type": "date",
|
|
"format": "yyyy-mm-dd"}, "Yes"],
|
|
],
|
|
"cells": {
|
|
"A6": {"value": "Total", "bold": True, "italic": True,
|
|
"font_size": 12, "font_color": "1F4E78"},
|
|
"B6": {"formula": "SUM(B2:B4)", "format": "$#,##0.00"},
|
|
},
|
|
"column_widths": {"A": 18, "B": 14},
|
|
"row_heights": {"1": 24},
|
|
"merges": ["A8:C8"],
|
|
"freeze_panes": "A2",
|
|
"autofilter": "A1:F4",
|
|
"conditional_formats": [
|
|
{"range": "B2:B4", "type": "cell_is",
|
|
"operator": "greaterThan", "formula": ["1000"],
|
|
"fill": "C6EFCE"},
|
|
{"range": "C2:C4", "type": "color_scale"},
|
|
],
|
|
"charts": [
|
|
{"type": "bar", "title": "Sales by region", "anchor": "H2",
|
|
"data": "B1:B4", "categories": "A2:A4"},
|
|
{"type": "line", "title": "Growth", "anchor": "H18",
|
|
"data": "C1:C4", "categories": "A2:A4"},
|
|
{"type": "pie", "title": "Share", "anchor": "P2",
|
|
"data": "B2:B4", "categories": "A2:A4",
|
|
"titles_from_data": False},
|
|
],
|
|
"validations": [
|
|
{"range": "F2:F10", "type": "list",
|
|
"formula1": '"Yes,No,Maybe"'},
|
|
],
|
|
},
|
|
{"name": "Notes", "rows": [["Zürich", "Фамилия", "12,5%"]]},
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def workbook(tmp_path):
|
|
spec_path = tmp_path / "spec.json"
|
|
spec_path.write_text(json.dumps(SPEC), encoding="utf-8")
|
|
out = tmp_path / "report.xlsx"
|
|
proc = run("xlsx_create.py", spec_path, out)
|
|
summary = json.loads(proc.stdout)
|
|
assert summary["ok"] and summary["sheets"] == ["Data", "Notes"]
|
|
return out
|
|
|
|
|
|
def test_create_features_roundtrip(workbook):
|
|
wb = load_workbook(workbook)
|
|
ws = wb["Data"]
|
|
# typed values
|
|
assert ws["B2"].value == 1500.5
|
|
assert ws["D2"].value is True
|
|
e2 = ws["E2"].value
|
|
assert (e2.date() if hasattr(e2, "date") else e2) == date(2026, 1, 31)
|
|
# formula + number formats
|
|
assert ws["B6"].value == "=SUM(B2:B4)"
|
|
assert ws["B6"].number_format == "$#,##0.00"
|
|
assert ws["C2"].number_format == "0.0%"
|
|
assert ws["E2"].number_format == "yyyy-mm-dd"
|
|
# styling
|
|
assert ws["A1"].font.bold is True
|
|
assert ws["A1"].fill.fgColor.rgb.endswith("DDEBF7")
|
|
assert ws["A1"].border.left.style == "thin"
|
|
assert ws["A1"].alignment.horizontal == "center"
|
|
assert ws["A6"].font.italic is True and ws["A6"].font.size == 12
|
|
# dimensions
|
|
assert ws.column_dimensions["A"].width == 18
|
|
assert ws.row_dimensions[1].height == 24
|
|
# merges / freeze / autofilter
|
|
assert "A8:C8" in [str(r) for r in ws.merged_cells.ranges]
|
|
assert ws.freeze_panes == "A2"
|
|
assert ws.auto_filter.ref == "A1:F4"
|
|
# conditional formatting, charts, validation
|
|
assert len(list(ws.conditional_formatting)) == 2
|
|
assert len(ws._charts) == 3
|
|
types = {type(c).__name__ for c in ws._charts}
|
|
assert types == {"BarChart", "LineChart", "PieChart"}
|
|
assert len(ws.data_validations.dataValidation) == 1
|
|
# recalc flag
|
|
assert wb.calculation.fullCalcOnLoad is True
|
|
|
|
|
|
def test_read_sheets_json_formulas(workbook, tmp_path):
|
|
inv = json.loads(run("xlsx_read.py", workbook, "--sheets").stdout)
|
|
names = [s["name"] for s in inv["sheets"]]
|
|
assert names == ["Data", "Notes"]
|
|
data_info = inv["sheets"][0]
|
|
assert data_info["charts"] == 3
|
|
assert "A8:C8" in data_info["merged"]
|
|
assert data_info["freeze_panes"] == "A2"
|
|
|
|
dump = json.loads(
|
|
run("xlsx_read.py", workbook, "--json", "--sheet", "Data").stdout)
|
|
assert dump["rows"][1][0] == "North"
|
|
assert dump["rows"][1][4] == "2026-01-31T00:00:00"
|
|
|
|
notes = json.loads(
|
|
run("xlsx_read.py", workbook, "--json", "--sheet", "Notes").stdout)
|
|
assert notes["rows"][0] == ["Zürich", "Фамилия", "12,5%"]
|
|
|
|
formulas = json.loads(run("xlsx_read.py", workbook, "--formulas").stdout)
|
|
entry = [f for f in formulas["formulas"] if f["cell"] == "B6"][0]
|
|
assert entry["formula"] == "=SUM(B2:B4)"
|
|
# openpyxl never computes: cached value absent on a fresh file
|
|
assert entry["cached"] is None
|
|
|
|
csv_out = tmp_path / "data.csv"
|
|
run("xlsx_read.py", workbook, "--csv", "--sheet", "Notes",
|
|
"--out", csv_out)
|
|
text = csv_out.read_text(encoding="utf-8")
|
|
assert "Zürich" in text and "Фамилия" in text
|
|
|
|
|
|
def test_csv_roundtrip_nonascii(tmp_path):
|
|
src = tmp_path / "src.csv"
|
|
with open(src, "w", newline="", encoding="utf-8") as fh:
|
|
w = csv.writer(fh)
|
|
w.writerow(["City", "Share", "Surname", "Active", "When"])
|
|
w.writerow(["Zürich", "12,5%", "Фамилия", "true", "2026-05-01"])
|
|
w.writerow(["Oslo", "7", "Ås", "false", "2026-06-01"])
|
|
xlsx = tmp_path / "conv.xlsx"
|
|
run("csv_to_xlsx.py", src, xlsx, "--sheet-name", "Import")
|
|
|
|
wb = load_workbook(xlsx)
|
|
ws = wb["Import"]
|
|
assert ws["A2"].value == "Zürich"
|
|
assert ws["B2"].value == "12,5%" # decimal comma stays a string
|
|
assert ws["C2"].value == "Фамилия"
|
|
assert ws["D2"].value is True # bool inferred
|
|
assert ws["E2"].value.date() == date(2026, 5, 1) # date inferred
|
|
assert ws["B3"].value == 7 # int inferred
|
|
assert ws["A1"].font.bold is True # styled header
|
|
assert ws.freeze_panes == "A2"
|
|
|
|
back = tmp_path / "back.csv"
|
|
run("xlsx_to_csv.py", xlsx, back, "--sheet", "Import")
|
|
with open(back, newline="", encoding="utf-8") as fh:
|
|
rows = list(csv.reader(fh))
|
|
assert rows[1][0] == "Zürich"
|
|
assert rows[1][2] == "Фамилия"
|
|
assert rows[1][3] == "True"
|
|
assert rows[1][4] == "2026-05-01"
|
|
|
|
# encoding override
|
|
latin = tmp_path / "latin.csv"
|
|
run("xlsx_to_csv.py", xlsx, latin, "--sheet", "Import",
|
|
"--encoding", "utf-8-sig")
|
|
assert latin.read_bytes().startswith(b"\xef\xbb\xbf")
|
|
|
|
|
|
def test_edit_existing(workbook, tmp_path):
|
|
edited = tmp_path / "edited.xlsx"
|
|
proc = run("xlsx_edit.py", workbook, "--sheet", "Notes",
|
|
"--out", edited,
|
|
"--copy-sheet", "Notes:Backup",
|
|
"--rename-sheet", "Data:Main",
|
|
"--set", "B1=Änderung",
|
|
"--set", "C1=99.5",
|
|
"--set", "D1=2026-12-24",
|
|
"--set", "E1==SUM(C1:C1)",
|
|
"--append", '["appended", 1, false]',
|
|
"--insert-rows", "1:1",
|
|
"--recalc")
|
|
result = json.loads(proc.stdout)
|
|
assert result["ok"]
|
|
|
|
wb = load_workbook(edited)
|
|
assert set(wb.sheetnames) == {"Main", "Notes", "Backup"}
|
|
ws = wb["Notes"]
|
|
# insert-rows ran before --set per documented order, so row 1 is blank
|
|
# and original data moved to row 2... check documented ordering:
|
|
# structural ops run before --set, so B1 etc. were written after insert.
|
|
assert ws["B1"].value == "Änderung"
|
|
assert ws["C1"].value == 99.5
|
|
assert ws["D1"].value.date() == date(2026, 12, 24)
|
|
assert ws["E1"].value == "=SUM(C1:C1)"
|
|
assert wb.calculation.fullCalcOnLoad is True
|
|
# appended row present
|
|
found = [r for r in ws.iter_rows(values_only=True)
|
|
if r and r[0] == "appended"]
|
|
assert found and found[0][1] == 1 and found[0][2] is False
|
|
# copy preserved data
|
|
assert wb["Backup"]["A1"].value == "Zürich"
|
|
|
|
|
|
def test_help_and_errors():
|
|
for script in ["xlsx_create.py", "xlsx_read.py", "xlsx_edit.py",
|
|
"csv_to_xlsx.py", "xlsx_to_csv.py",
|
|
"xlsx_restructure.py", "xlsx_recalc.py"]:
|
|
proc = run(script, "--help")
|
|
assert "usage" in proc.stdout.lower()
|
|
bad = run("xlsx_read.py", "/nonexistent.xlsx", "--sheets",
|
|
expect_ok=False)
|
|
assert bad.returncode != 0
|
|
assert json.loads(bad.stderr)["ok"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reference-aware restructuring (xlsx_restructure.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
RESTRUCTURE_SPEC = {
|
|
"defined_names": {"SalesRange": "'Data'!$B$2:$B$4"},
|
|
"sheets": [
|
|
{
|
|
"name": "Data",
|
|
"rows": [
|
|
["Region", "Sales", "Weight"],
|
|
["North", 100, 0.5],
|
|
["South", 200, 0.3],
|
|
["East", 300, 0.2],
|
|
[None, None, None],
|
|
["Total", None, None],
|
|
],
|
|
"cells": {
|
|
"B6": {"formula": "SUM(B2:B4)"},
|
|
"C6": {"formula": "$B$2*C2"},
|
|
"D6": {"formula": "LOG10(B4)"},
|
|
"E6": {"formula": "SUM(B:B)"},
|
|
"F6": {"formula": '"row B2: "&B2'},
|
|
},
|
|
"merges": ["E2:E4", "A7:B7"],
|
|
"freeze_panes": "A2",
|
|
"autofilter": "A1:C4",
|
|
"conditional_formats": [
|
|
{"range": "B2:B4", "type": "cell_is",
|
|
"operator": "greaterThan", "formula": ["150"],
|
|
"fill": "C6EFCE"},
|
|
],
|
|
"validations": [
|
|
{"range": "C2:C4", "type": "list",
|
|
"formula1": '"0.2,0.3,0.5"'},
|
|
],
|
|
"tables": [
|
|
{"name": "SalesTbl", "range": "A1:C4"},
|
|
],
|
|
},
|
|
{
|
|
"name": "Summary",
|
|
"rows": [["Grand total"]],
|
|
"cells": {
|
|
"B1": {"formula": "SUM(Data!B2:B4)"},
|
|
"B2": {"formula": "'Data'!$B$3"},
|
|
"B3": {"formula": "SUM(A1:A1)"},
|
|
},
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def restructure_book(tmp_path):
|
|
spec_path = tmp_path / "rspec.json"
|
|
spec_path.write_text(json.dumps(RESTRUCTURE_SPEC), encoding="utf-8")
|
|
out = tmp_path / "restructure.xlsx"
|
|
run("xlsx_create.py", spec_path, out)
|
|
return out
|
|
|
|
|
|
def test_restructure_insert_rows_shifts_everything(restructure_book):
|
|
# merge A6:C6 gets pushed down; A1:A1 merge is before the insert point
|
|
proc = run("xlsx_restructure.py", restructure_book,
|
|
"--sheet", "Data", "--insert-rows", "3:2")
|
|
report = json.loads(proc.stdout)
|
|
assert report["ok"] and report["op"] == "insert"
|
|
|
|
wb = load_workbook(restructure_book)
|
|
data, summary = wb["Data"], wb["Summary"]
|
|
# values physically moved
|
|
assert data["A2"].value == "North"
|
|
assert data["A5"].value == "South" # was row 3
|
|
assert data["A8"].value == "Total" # was row 6
|
|
# same-sheet formulas rewritten (range expanded across insert point)
|
|
assert data["B8"].value == "=SUM(B2:B6)"
|
|
# absolute ref before insert point unchanged; relative arm shifted
|
|
assert data["C8"].value == "=$B$2*C2"
|
|
# function names, whole-column refs, string literals untouched
|
|
assert data["D8"].value == "=LOG10(B6)"
|
|
assert data["E8"].value == "=SUM(B:B)"
|
|
assert data["F8"].value == '="row B2: "&B2'
|
|
# cross-sheet formulas on the OTHER sheet rewritten
|
|
assert summary["B1"].value == "=SUM(Data!B2:B6)"
|
|
assert summary["B2"].value == "='Data'!$B$5"
|
|
# Summary-local refs not confused with Data refs
|
|
assert summary["B3"].value == "=SUM(A1:A1)"
|
|
# merges: E2:E4 spans the insert point -> expanded; A7:B7 -> shifted
|
|
merged = [str(r) for r in data.merged_cells.ranges]
|
|
assert "E2:E6" in merged and "A9:B9" in merged
|
|
# autofilter expanded, freeze panes intact
|
|
assert data.auto_filter.ref == "A1:C6"
|
|
assert data.freeze_panes == "A2"
|
|
# validation + conditional format ranges shifted
|
|
dv = data.data_validations.dataValidation[0]
|
|
assert str(dv.sqref) == "C2:C6"
|
|
cf = list(data.conditional_formatting)[0]
|
|
assert str(cf.sqref) == "B2:B6"
|
|
# native table expanded
|
|
assert data.tables["SalesTbl"].ref == "A1:C6"
|
|
# defined name rewritten
|
|
assert wb.defined_names["SalesRange"].attr_text == "'Data'!$B$2:$B$6"
|
|
# report is honest about limits
|
|
assert "chart anchors" in report["not_shifted"]
|
|
assert any(f["cell"] == "B1" and f["sheet"] == "Summary"
|
|
for f in report["formulas"])
|
|
|
|
|
|
def test_restructure_delete_rows_and_ref_errors(restructure_book):
|
|
run("xlsx_restructure.py", restructure_book,
|
|
"--sheet", "Data", "--delete-rows", "3")
|
|
wb = load_workbook(restructure_book)
|
|
data, summary = wb["Data"], wb["Summary"]
|
|
assert data["A3"].value == "East" # South deleted
|
|
assert data["B5"].value == "=SUM(B2:B3)" # range clamped
|
|
# single-cell ref into the deleted row becomes #REF!
|
|
assert summary["B2"].value == "='Data'!#REF!"
|
|
assert summary["B1"].value == "=SUM(Data!B2:B3)"
|
|
assert data.tables["SalesTbl"].ref == "A1:C3"
|
|
|
|
|
|
def test_restructure_insert_cols(restructure_book):
|
|
proc = run("xlsx_restructure.py", restructure_book,
|
|
"--sheet", "Data", "--insert-cols", "B:1")
|
|
report = json.loads(proc.stdout)
|
|
assert report["axis"] == "cols" and report["index"] == 2
|
|
wb = load_workbook(restructure_book)
|
|
data, summary = wb["Data"], wb["Summary"]
|
|
assert data["C2"].value == 100 # Sales moved B->C
|
|
assert data["C6"].value == "=SUM(C2:C4)"
|
|
assert data["D6"].value == "=$C$2*D2"
|
|
assert summary["B1"].value == "=SUM(Data!C2:C4)"
|
|
assert wb.defined_names["SalesRange"].attr_text == "'Data'!$C$2:$C$4"
|
|
merged = [str(r) for r in data.merged_cells.ranges]
|
|
assert "F2:F4" in merged # merge shifted right
|
|
assert "A7:C7" in merged # merge expanded across col B
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tables, defined names, hyperlinks, notes, protection (edit + read paths)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_tables_create_append_list(tmp_path):
|
|
spec = {"sheets": [{"name": "T",
|
|
"rows": [["Item", "Qty"], ["a", 1], ["b", 2]],
|
|
"tables": [{"name": "Stock", "range": "A1:B3",
|
|
"style": "TableStyleLight1"}]}]}
|
|
spec_path = tmp_path / "tspec.json"
|
|
spec_path.write_text(json.dumps(spec), encoding="utf-8")
|
|
book = tmp_path / "tables.xlsx"
|
|
run("xlsx_create.py", spec_path, book)
|
|
|
|
wb = load_workbook(book)
|
|
tbl = wb["T"].tables["Stock"]
|
|
assert tbl.ref == "A1:B3"
|
|
assert tbl.tableStyleInfo.name == "TableStyleLight1"
|
|
|
|
# --add-table + --table-append auto-extends the range
|
|
run("xlsx_edit.py", book, "--sheet", "T",
|
|
"--add-table", "Extra:D1:E2",
|
|
"--table-append", 'Stock=["c", 3]')
|
|
wb = load_workbook(book)
|
|
ws = wb["T"]
|
|
assert ws.tables["Stock"].ref == "A1:B4"
|
|
assert ws["A4"].value == "c" and ws["B4"].value == 3
|
|
assert ws.tables["Extra"].ref == "D1:E2"
|
|
|
|
listing = json.loads(
|
|
run("xlsx_edit.py", book, "--sheet", "T", "--list-tables").stdout)
|
|
assert listing["tables"]["Stock"]["ref"] == "A1:B4"
|
|
assert set(listing["tables"]) == {"Stock", "Extra"}
|
|
# tables also appear in the read inventory
|
|
inv = json.loads(run("xlsx_read.py", book, "--sheets").stdout)
|
|
assert inv["sheets"][0]["tables"]["Stock"] == "A1:B4"
|
|
|
|
|
|
def test_names_hyperlinks_notes(tmp_path):
|
|
spec = {
|
|
"defined_names": {"Rate": "'D'!$B$1"},
|
|
"sheets": [{"name": "D", "cells": {
|
|
"A1": {"value": "docs",
|
|
"hyperlink": "https://example.com/docs"},
|
|
"B1": {"value": 0.07, "note": "quarterly rate"},
|
|
"C1": {"value": 1, "note": {"text": "check", "author": "QA"}},
|
|
}}],
|
|
}
|
|
spec_path = tmp_path / "nspec.json"
|
|
spec_path.write_text(json.dumps(spec), encoding="utf-8")
|
|
book = tmp_path / "names.xlsx"
|
|
run("xlsx_create.py", spec_path, book)
|
|
|
|
wb = load_workbook(book)
|
|
ws = wb["D"]
|
|
assert ws["A1"].hyperlink.target == "https://example.com/docs"
|
|
assert ws["B1"].comment.text == "quarterly rate"
|
|
assert ws["C1"].comment.author == "QA"
|
|
assert wb.defined_names["Rate"].attr_text == "'D'!$B$1"
|
|
|
|
# edit path: add/delete names, hyperlink, note, clear note
|
|
run("xlsx_edit.py", book, "--sheet", "D",
|
|
"--define-name", "Extra='D'!$C$1",
|
|
"--delete-name", "Rate",
|
|
"--hyperlink", "D1=https://example.com/more|More",
|
|
"--note", "D1=see more|Reviewer",
|
|
"--clear-note", "B1")
|
|
wb = load_workbook(book)
|
|
ws = wb["D"]
|
|
assert "Rate" not in wb.defined_names
|
|
assert wb.defined_names["Extra"].attr_text == "'D'!$C$1"
|
|
assert ws["D1"].hyperlink.target == "https://example.com/more"
|
|
assert ws["D1"].value == "More"
|
|
assert ws["D1"].comment.author == "Reviewer"
|
|
assert ws["B1"].comment is None
|
|
|
|
# read path: --notes and --names JSON output
|
|
notes = json.loads(run("xlsx_read.py", book, "--notes").stdout)["notes"]
|
|
coords = {(n["cell"], n["author"]) for n in notes}
|
|
assert ("D1", "Reviewer") in coords and ("C1", "QA") in coords
|
|
names = json.loads(run("xlsx_read.py", book, "--names").stdout)
|
|
assert names["defined_names"] == {"Extra": "'D'!$C$1"}
|
|
|
|
|
|
def test_sheet_protection(tmp_path):
|
|
spec = {"sheets": [{"name": "P", "rows": [["locked", "open"]],
|
|
"protection": {"password": "your-password",
|
|
"unlock": ["B1:B1"]}}]}
|
|
spec_path = tmp_path / "pspec.json"
|
|
spec_path.write_text(json.dumps(spec), encoding="utf-8")
|
|
book = tmp_path / "prot.xlsx"
|
|
run("xlsx_create.py", spec_path, book)
|
|
|
|
wb = load_workbook(book)
|
|
ws = wb["P"]
|
|
assert ws.protection.sheet is True
|
|
assert ws.protection.password # hash stored
|
|
assert ws["B1"].protection.locked is False
|
|
assert ws["A1"].protection.locked is not False
|
|
inv = json.loads(run("xlsx_read.py", book, "--sheets").stdout)
|
|
assert inv["sheets"][0]["protected"] is True
|
|
|
|
# edit path on a fresh unprotected sheet
|
|
plain = tmp_path / "plain.xlsx"
|
|
spec_path.write_text(json.dumps(
|
|
{"sheets": [{"name": "P", "rows": [["a", "b"]]}]}), encoding="utf-8")
|
|
run("xlsx_create.py", spec_path, plain)
|
|
run("xlsx_edit.py", plain, "--sheet", "P",
|
|
"--protect", "your-password", "--unlock", "B1:B1")
|
|
ws = load_workbook(plain)["P"]
|
|
assert ws.protection.sheet is True and ws["B1"].protection.locked is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Headless recalculation (xlsx_recalc.py) — branches on soffice presence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_recalc_reports_json_both_ways(tmp_path):
|
|
spec = {"sheets": [{"name": "R", "rows": [[2], [3]],
|
|
"cells": {"A3": {"formula": "SUM(A1:A2)"}}}]}
|
|
spec_path = tmp_path / "cspec.json"
|
|
spec_path.write_text(json.dumps(spec), encoding="utf-8")
|
|
book = tmp_path / "calc.xlsx"
|
|
run("xlsx_create.py", spec_path, book)
|
|
|
|
# absent-soffice branch is always testable by hiding PATH
|
|
env = dict(os.environ, LC_ALL="C", LANG="C", PATH=str(tmp_path))
|
|
proc = subprocess.run(
|
|
[sys.executable, str(SCRIPTS / "xlsx_recalc.py"), str(book)],
|
|
capture_output=True, text=True, env=env, encoding="utf-8")
|
|
assert proc.returncode == 0
|
|
absent = json.loads(proc.stdout)
|
|
assert absent["recalculated"] is False and "soffice" in absent["reason"]
|
|
assert "guidance" in absent
|
|
|
|
if not shutil.which("soffice"):
|
|
pytest.skip("LibreOffice not installed; absent branch covered above")
|
|
|
|
out = tmp_path / "calced.xlsx"
|
|
proc = run("xlsx_recalc.py", book, "--out", out, "--timeout", "300")
|
|
result = json.loads(proc.stdout)
|
|
assert result["recalculated"] is True
|
|
assert result["formula_cells"] == 1
|
|
assert result["with_cached_values"] == 1
|
|
# cached value now visible to --formulas
|
|
formulas = json.loads(run("xlsx_read.py", out, "--formulas").stdout)
|
|
entry = formulas["formulas"][0]
|
|
assert entry["formula"] == "=SUM(A1:A2)" and entry["cached"] == 5
|