Files
Aislo/resources/tester/spreadsheet/test_spreadsheet_golden.py
T
eomsangdonandClaude Opus 5.5 cd824aca25 feat(spreadsheet): F 서버 재계산 · 산출근거 따로 저장 · M02 끼우기 · 금빛 시험
- common_util_spreadsheet_node.ts · common_util_spreadsheet.py · build:spreadsheet — [저장] 때 화면과 같은 TS 엔진을 Node 로 돌려 계산값 정본(브라우저 값 버림 · 못 풀면 503)
- M02 새 종류 basis — resources/master_template/basis/<열 id>.json(구조물집계표 열마다) · 층 복사 · 뼈대 검사 · 옛 구조물 문서 산출근거 49 벌 옮김(migrate_basis) · 새 열도 같이 만듦
- 상세 산출근거 카드 = 새 스프레드시트(import() 로 늦게 받음) · basis 읽기 · 저장
- ⚠ 임시 [스프레드시트 시험] 단추 — 빈 통합문서(시트 셋) · 저장 자리 tmp/spreadsheet_trial.json · 1단계 검증 뒤 지움
- 시험 resources/tester/spreadsheet/ — 저장 · 서버 다리 · 크기 예산 · M02 만 늦게 불러옴 · 실무 구조도 xlsx 세 벌 수식 2,699 칸 엑셀 캐시값 대조(엔진 빈 몸이면 건너뜀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 20:12:39 +09:00

130 lines
5.2 KiB
Python

"""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}"