Files
Aislo/resources/tester/test_sheet_recalc.py
T
eomsangdonandClaude Opus 5.5 02ba8cbf02 feat(sheet): 표 식 풀이 한 벌 — 화면 · 서버 Node 가 같이 씀 (PLAN 10-2)
- 옛 B08 식 풀이기(git 8472fc9f)의 분수 · 파서를 ui_template/sheet/ 로 되살림 — 필요한 함수만
- 열 참조 [열id] · 셀 참조 [열id@줄id] · 변수 [$이름] · SUM · INT · ROUND · ROUNDUP · ROUNDDOWN · MIN · MAX · IF
- 한 칸만 다른 식(줄.식) · 합계 줄 열마다 다른 식 · 끝수(반올림 · 올림 · 버림) · 순환은 그 칸만 오류
- 서버 껍데기 common_util_sheet_recalc.py · build:formula 가 새 진입점을 가리킴(깨진 B08 경로 고침)
- 시험 — 실무 울진 2공구 구조물집계표 합계 캐시값 · 파이썬 Decimal 거울 40문서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
2026-09-25 09:36:54 +09:00

196 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""표 양식 식 풀이 — 서버 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)