"""F — 금빛 시험: 실무 구조도 xlsx 세 벌의 수식을 새 엔진으로 풀어 엑셀 캐시값과 대조. xlsx 는 **시험에서만** openpyxl 로 읽음(앱에는 가져오기 없음). 식 = 엑셀 글 그대로 · 값 = 칸 값 · 바깥 통합문서 참조(`[172]관집계!…`)는 식을 못 푸니 캐시값을 값으로 굳힘(대조에서 뺌). 갈린 칸은 조용히 맞추지 않음 — 목록을 `tmp/spreadsheet_golden_report.json` 에 적고 시험을 떨굼. 엔진(A · B)이 빈 몸이면 건너뜀 — 엔진이 차는 대로 다시 돌림. """ import datetime as dt import json import re import shutil from decimal import Decimal, InvalidOperation from pathlib import Path import pytest openpyxl = pytest.importorskip("openpyxl") from openpyxl.utils.datetime import to_excel # noqa: E402 from common_util import common_util_spreadsheet as spreadsheet # noqa: E402 ROOT = Path(__file__).resolve().parents[3] PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" NAMES = ("07-구조도-소광리.xlsx", "5. 구조도(기번3).xlsx", "구조도(변경).xlsx") FILES = [next(PRACTICE.rglob(name)) for name in NAMES] REPORT = ROOT / "tmp" / "spreadsheet_golden_report.json" EXPECTED_FORMULAS = 2699 _EXTERNAL = re.compile(r"\[\d+\]") _ERRORS = {"#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A"} pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def _plain(value): """칸 값 → JSON(날짜 · 시각 형식 칸은 엑셀 일련수).""" if isinstance(value, (dt.datetime, dt.date, dt.time, dt.timedelta)): return to_excel(value) return value def _book(path: Path): """xlsx → 통합문서 + 대조할 칸 `[(시트 id, A1, 캐시값)]`.""" formulas = openpyxl.load_workbook(path, data_only=False) cached = openpyxl.load_workbook(path, data_only=True) sheets, checks = [], [] for i, ws in enumerate(formulas.worksheets, start=1): sid, cells = f"s{i}", {} values = cached[ws.title] for row in ws.iter_rows(): for c in row: if c.value is None: continue keep = _plain(values[c.coordinate].value) if c.data_type == "f" and isinstance(c.value, str): if _EXTERNAL.search(c.value): if keep is not None: cells[c.coordinate] = {"값": keep} continue cells[c.coordinate] = {"식": c.value[1:]} checks.append((sid, c.coordinate, keep)) elif c.data_type != "f": cells[c.coordinate] = {"값": _plain(c.value)} sheets.append({"id": sid, "이름": ws.title, "칸": cells}) book = {"종류": "통합문서", "판": 1, "열": path.stem, "서식": [{}], "시트": sheets} return book, checks def _same(want, got) -> bool: if got is None: return False if isinstance(want, bool): return got.get("참") is want if isinstance(want, (int, float)): try: a, b = Decimal(got["수"]), Decimal(repr(float(want))) except (KeyError, InvalidOperation): return False return abs(a - b) <= Decimal("1e-9") * max(Decimal(1), abs(b)) if want is None or want == "": return got.get("글") == "" or got.get("수") == "0" if isinstance(want, str) and want in _ERRORS: return got.get("오류") == want return got.get("글") == want def test_실무_구조도_수식_엑셀과_같음(): loaded = [(path, *_book(path)) for path in FILES] total = sum(len(checks) for _p, _b, checks in loaded) external = 6 # 소광리 `[172]관집계` — 값으로 굳혀 대조에서 뺌 assert total + external == EXPECTED_FORMULAS try: results = spreadsheet.recalc_workbooks([book for _p, book, _c in loaded]) except spreadsheet.RecalcError as error: if "아직 없음" in str(error): pytest.skip(f"엔진 아직 — {error}") raise mismatches = [] for (path, book, checks), values in zip(loaded, results, strict=True): names = {s["id"]: s["이름"] for s in book["시트"]} for sid, a1, want in checks: got = values.get(sid, {}).get(a1) if not _same(want, got): formula = next(s for s in book["시트"] if s["id"] == sid)["칸"][a1]["식"] mismatches.append( { "파일": path.name, "시트": names[sid], "칸": a1, "식": formula, "엑셀": want, "엔진": got, } ) REPORT.parent.mkdir(exist_ok=True) REPORT.write_text( json.dumps( {"대조": total, "갈림": len(mismatches), "목록": mismatches}, ensure_ascii=False, indent=1, default=str, ), encoding="utf-8", ) head = "\n".join( f"{m['파일']} {m['시트']}!{m['칸']} ={m['식']} 엑셀 {m['엑셀']!r} · 엔진 {m['엔진']}" for m in mismatches[:30] ) assert not mismatches, f"{len(mismatches)}/{total} 칸 갈림 — 전체 {REPORT}\n{head}"