- 옛 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
196 lines
7.9 KiB
Python
196 lines
7.9 KiB
Python
"""표 양식 식 풀이 — 서버 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)
|