"""표 양식 식 풀이 — 서버 Node 길(`common_util_sheet_recalc`) 그대로 시험. 풀이는 `ui_template/sheet/ui_template_sheet_recalc.ts` 한 벌 · 화면과 서버가 같은 TS 를 씀. 거울 — 파이썬 Decimal 로 따로 푼 답과 Node 답을 무작위 문서 여럿에서 견줌. ⭐ 기준값 하나는 실무 엑셀 캐시값 — 울진 2공구 `4.2 수량산출(2공구).xlsx` 「구조물집계표」 5~14줄 · 16줄 `=SUM(…)` 합계(openpyxl data_only 로 읽음). """ from __future__ import annotations import random import shutil import sys from decimal import ROUND_DOWN, ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP, Decimal, getcontext from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from common_util.common_util_sheet_recalc import recalc_sheet, recalc_sheets # noqa: E402 pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def _doc(cols: list[dict], rows: list[dict], **head) -> dict: for col in cols: col.setdefault("머리", [col["id"]]) return {"양식": "시험", "종류": "표", "판": 1, "열": cols, "줄": rows, **head} def _run(doc: dict) -> dict: out = recalc_sheet(doc) assert out is not None, "Node 번들 실행 실패" return out # ── 실무 캐시값 ────────────────────────────────────────────────────────── PRACTICE = { # 측점: {엑셀 열: 값} — 5~14줄 "전구간": {"AC": 10}, "71+13": {"D": 10, "F": 20, "G": 10, "H": 10, "AQ": 22, "AR": 3}, "75+0": {"AF": 1}, "78+7": {"F": 10, "H": 20, "AK": 18, "AL": 2, "AM": 1}, "84+0": {"F": 10, "H": 20, "AK": 16, "AL": 2, "AN": 1}, "89+10": {"E": 10, "H": 10, "AK": 18, "AL": 2, "AN": 1}, "100+0": {"AF": 1}, "102+0": {"F": 10, "H": 10, "AK": 16, "AL": 1, "AM": 1}, "105+12": {"D": 10, "F": 10, "G": 10, "H": 10, "AO": 16, "AP": 2}, "112+0": {"E": 10, "H": 10, "AK": 16, "AL": 2, "AN": 1}, } PRACTICE_SUM = {"C": "0", "D": "20", "E": "20", "F": "60", "G": "20", "H": "90", "AC": "10"} PRACTICE_SUM |= {"AF": "2", "AK": "84", "AL": "9", "AM": "2", "AN": "3", "AO": "16"} PRACTICE_SUM |= {"AP": "2", "AQ": "22", "AR": "3", "S": "0", "U": "0"} def test_practice_totals_match_excel(): names = sorted(PRACTICE_SUM) cols = [{"id": "B", "꼴": "글"}] + [{"id": n} for n in names if n not in ("S", "U")] cols += [{"id": "Q"}, {"id": "R"}] cols += [{"id": "S", "식": "[Q]*[R]"}, {"id": "U", "식": "INT([R]/6.00000001)*[Q]"}] rows = [ {"id": f"r{i}", "값": {"B": sta, **vals}} for i, (sta, vals) in enumerate(PRACTICE.items()) ] doc = _doc(cols, rows, 합계줄=[{"id": "sum", "이름": "계", "식": "SUM"}]) out = _run(doc) assert out["오류"] == [] assert {k: out["합계"]["sum"][k] for k in PRACTICE_SUM} == PRACTICE_SUM # 엑셀에 있는 합 칸만 assert out["계산"]["r0"] == {"S": "0", "U": "0"} # 엑셀 S5 · U5 = 0 def test_pavement_joint_coupling_and_rounding(): cols = [ {"id": "b"}, {"id": "l"}, {"id": "a", "식": "[b]*[l]"}, {"id": "jt", "식": "INT([l]/6.00000001)*[b]"}, {"id": "cp", "식": "ROUNDUP([l]/[$본],0)-1"}, {"id": "r", "식": "[l]/3", "끝수": {"자리": 2, "방법": "반올림"}}, {"id": "up", "식": "[l]/3", "끝수": {"자리": 1, "방법": "올림"}}, {"id": "dn", "식": "[b]*1.15*100", "끝수": {"자리": 0, "방법": "버림"}}, ] rows = [{"id": "r1", "값": {"b": 3, "l": 120}}, {"id": "r2", "값": {"b": "0.29", "l": 16}}] out = _run(_doc(cols, rows, 변수={"본": 8})) assert out["오류"] == [] assert out["계산"]["r1"] == { "a": "360", "jt": "57", # 120/6.00000001 = 19.99… → 19 × 3 "cp": "14", # 올림(120/8)−1 "r": "40", "up": "40", "dn": "345", } # 부동소수면 틀어지는 자리 — 0.29×1.15×100 = 33.35 → 버림 33 · 16/3 = 5.333… → 올림 5.4 assert out["계산"]["r2"] == { "a": "4.64", "jt": "0.58", "cp": "1", "r": "5.33", "up": "5.4", "dn": "33", } def test_row_override_total_map_and_cell_ref(): cols = [{"id": "sta", "꼴": "글"}, {"id": "x"}, {"id": "y", "식": "[x]*2"}] rows = [ {"id": "r1", "값": {"sta": "전구간", "x": 5}, "고정": "전구간"}, {"id": "r2", "값": {"x": 7}, "식": {"y": "[x]+[x@r1]"}}, ] totals = [ {"id": "sum", "이름": "계", "식": {"x": "SUM", "*": "MAX([y@r1],[y@r2])"}}, {"id": "avg", "이름": "평균", "식": {"x": "ROUND([x@sum]/2,1)"}}, ] out = _run(_doc(cols, rows, 합계줄=totals)) assert out["오류"] == [] assert out["계산"] == {"r1": {"y": "10"}, "r2": {"y": "12"}} assert out["합계"] == {"sum": {"x": "12", "y": "12"}, "avg": {"x": "6"}} def test_errors_stay_in_their_cells(): cols = [ {"id": "x"}, {"id": "p", "식": "[q]+1"}, {"id": "q", "식": "[p]+1"}, {"id": "z", "식": "[x]/0"}, {"id": "n", "식": "[없음]+1"}, {"id": "t", "식": "[x]*2"}, {"id": "bad", "식": "[x]*("}, ] rows = [{"id": "r1", "값": {"x": "글자"}}, {"id": "r2", "값": {"x": 4}}] out = _run(_doc(cols, rows)) why = {(e["줄"], e["열"]): e["까닭"] for e in out["오류"]} assert "돌고 도는 참조" in why.values() assert {("r2", "p"), ("r2", "q")} <= set(why) assert "0 으로 나눔" in why[("r2", "z")] assert "없는 열" in why[("r2", "n")] assert "글" in why[("r1", "t")] assert ("r2", "bad") in why assert out["계산"]["r2"] == {"t": "8"} # 나머지 칸은 계속 풂 # ── 거울 — 파이썬 Decimal 로 따로 푼 답 ───────────────────────────────── getcontext().prec = 60 TEMPLATES = { "mul": ("[a]*[b]", lambda a, b, k: a * b), "joint": ( "INT([b]/6.00000001)*[a]", lambda a, b, k: (b / Decimal("6.00000001")).to_integral_value(ROUND_FLOOR) * a, ), "ratio": ( "ROUND([a]/[b]*100,2)", lambda a, b, k: (a / b * 100).quantize(Decimal("0.01"), ROUND_HALF_UP), ), "band": ( "ROUNDUP([a]/8,0)-1", lambda a, b, k: (a / 8).to_integral_value(ROUND_UP) - 1, ), "down": ( "ROUNDDOWN([a]*1.15,1)", lambda a, b, k: (a * Decimal("1.15")).quantize(Decimal("0.1"), ROUND_DOWN), ), "mix": ("[a]-[b]*2+[$k]", lambda a, b, k: a - b * 2 + k), } def _plain(x: Decimal) -> str: text = format(x.normalize(), "f") return "0" if text in ("-0", "") else text def test_mirror_random_docs_match_decimal(): rnd = random.Random(20260925) docs, expected = [], [] for _ in range(40): k = Decimal(rnd.randint(-500, 500)) / 10 rows, want = [], {} for i in range(rnd.randint(1, 12)): a = Decimal(rnd.randint(0, 99999)) / 100 b = Decimal(rnd.randint(1, 99999)) / 100 rows.append({"id": f"r{i}", "값": {"a": str(a), "b": str(b)}}) want[f"r{i}"] = {name: fn(a, b, k) for name, (_, fn) in TEMPLATES.items()} cols = [{"id": "a"}, {"id": "b"}] + [{"id": n, "식": f} for n, (f, _) in TEMPLATES.items()] docs.append( _doc(cols, rows, 변수={"k": str(k)}, 합계줄=[{"id": "s", "이름": "계", "식": "SUM"}]) ) sums = {n: sum((w[n] for w in want.values()), Decimal(0)) for n in TEMPLATES} expected.append((want, sums)) results = recalc_sheets(docs) assert results is not None for out, (want, sums) in zip(results, expected, strict=True): assert out["오류"] == [] assert out["계산"] == {r: {n: _plain(v) for n, v in w.items()} for r, w in want.items()} for name, total in sums.items(): assert out["합계"]["s"][name] == _plain(total)