Files
Aislo/resources/tester/test_b08_formula.py
T
eomsangdonandClaude Opus 5 31c2602f4b feat(b08): 찰쌓기 양식 한 벌 + 식 칸 셋(when·destination·spec) — 지금 전개와 값 대조
- 풀이기에 명세 13장 보강분: when(안 선 줄, 가리키면 오류) · destination 필수(기본값 없음) ·
  spec 의 {제원} 채움 · 반올림 trunc(ROUNDDOWN)·ceil_away(ROUNDUP)
- resources/library_structure/masonry_wet.json — 줄 15 · 뒷길이×돌종류 표 · 제원 vars ·
  물구멍 2.5㎡ 실무 관측은 대안 후보로
- 양식 제원 채우기 B08_Quantity_Engine_StructureTemplate.py(계산 없음)
- 대조 시험 121건: 높이·뒷길이·돌종류·기초·사용자 칸·when 갈래에서 전개와 한 줄도 안 갈림.
  알려진 차이 하나(실무 관행 계수가 돌종류를 지우는 전개 결함)는 시험에 드러냄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 17:02:26 +09:00

323 lines
14 KiB
Python

"""구조물도 식 풀이기 — 명세 13장 언어·반올림·참조를 **서버 Node 경로 그대로** 시험 (2026-09-13).
풀이는 `B08_Quantity_Formula.ts` 한 벌이고(판정 Ⓐ), 서버는 그것을 Node 로 돌림.
그래서 이 시험도 파이썬 껍데기(`evaluate_sheets`) → Node 번들 길로 부름 — 화면과 서버가
같은 코드를 쓰는지까지 한 번에 잼.
⭐ 기준값 하나는 **실무 엑셀 캐시값** — 소광리 `07-구조도-소광리.xlsx` 「돌기슭막이(45)찰-고1.0」
U20~U31 11줄(2026-09-13 openpyxl 로 읽음). 식을 셀 그대로 옮겨 같은 값이 나오는지 봄.
⚠ 부동소수로 풀면 틀리는 자리를 일부러 넣음 — `ROUND(0.95*0.3,2)`(부동소수 0.28499… → 0.28)·
`0.29*100` 버림(28.999… → 28). 분수로 풀어야 엑셀·실무 값과 맞음.
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets # noqa: E402
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _solve(rows: list[dict], vars: dict | None = None, tables: dict | None = None) -> list[dict]:
# 갈 곳은 계약상 필수(기본값 없음) — 값 시험에서는 「보여주기만」으로 채워 넣음.
rows = [{"destination": "reference", **row} for row in rows]
result = evaluate_sheets([{"rows": rows, "vars": vars or {}, "tables": tables or {}}])
assert result is not None, "Node 풀이가 안 돌았다"
return result[0]
def _one(formula: str, rounding: dict | None = None, **vars) -> dict:
return _solve([{"seq": 1, "name": "x", "formula": formula, "rounding": rounding}], vars)[0]
def _amount(formula: str, rounding: dict | None = None, **vars) -> str:
row = _one(formula, rounding, **vars)
assert row["error"] is None, row
return row["amount"]
def test_적힌_차례대로_풂() -> None:
"""명세 13장 지킬 것 ① — 접지 않음. 적힌 상수는 적힌 대로."""
assert _amount("267360*5/24") == "55700"
assert _amount("267360*0.20833") == "55699.1088"
def test_우선순위는_엑셀과_같다() -> None:
assert _amount("2+3*4") == "14"
assert _amount("10-2-3") == "5"
assert _amount("8/2/2") == "2"
assert _amount("2^3^2") == "64" # 엑셀은 왼쪽부터
assert _amount("-2^2") == "4" # 엑셀은 부호가 ^ 보다 먼저
assert _amount("(1+2)*3") == "9"
def test_부동소수_함정을_안_밟는다() -> None:
assert _amount("0.29*100", {"mode": "floor", "digits": 0}) == "29"
assert _amount("1.15*100", {"mode": "floor", "digits": 0}) == "115"
assert _amount("0.95*0.3", {"mode": "round", "digits": 2}) == "0.29"
def test_반올림_갈래() -> None:
assert _amount("2.675", {"mode": "round", "digits": 2}) == "2.68" # 사사오입
assert _amount("-2.5", {"mode": "round", "digits": 0}) == "-3" # 0 에서 먼 쪽(엑셀 ROUND)
assert _amount("2.665", {"mode": "round_half_even", "digits": 2}) == "2.66"
assert _amount("2.675", {"mode": "round_half_even", "digits": 2}) == "2.68"
assert _amount("0.121", {"mode": "ceil", "digits": 2}) == "0.13"
assert _amount("2.61*0.15", {"mode": "floor", "digits": 2}) == "0.39"
assert _amount("1234", {"mode": "round", "digits": -2}) == "1200"
row = _one("1/3", {"mode": "none", "digits": 0})
assert row["amount"] == "0." + "3" * 30
def test_엑셀_대응표_음수에서_갈리는_넷() -> None:
"""명세 13장 대응표 — 양수에서는 같아 시험을 빠져나가던 자리(브레인 검산)."""
갈래 = {"floor": "-3", "trunc": "-2", "ceil_away": "-3", "ceil": "-2", "round": "-3"}
for mode, expected in 갈래.items():
assert _amount("-2.5", {"mode": mode, "digits": 0}) == expected, mode
assert _amount("-2.5", {"mode": "round_half_even", "digits": 0}) == "-2"
# 양수에서는 둘씩 같음 — 그래서 음수를 따로 봄.
assert _amount("2.5", {"mode": "trunc", "digits": 0}) == "2"
assert _amount("2.5", {"mode": "ceil_away", "digits": 0}) == "3"
assert "모르는 반올림" in _one("1", {"mode": "banker", "digits": 0})["error"]
def test_함수() -> None:
assert _amount("2*1*SQRT(1+0.3^2)", {"mode": "round", "digits": 3}) == "2.088"
assert _amount("SQRT(2.25)") == "1.5"
assert _amount("SUM(1,2,3.5)") == "6.5"
assert _amount("MIN(3,1,2)") == "1"
assert _amount("MAX(3,1,2)") == "3"
def test_IF_와_비교() -> None:
= "IF(L3=35,0.12,IF(L3=45,0.15,0.18))"
assert _amount(, L3=45) == "0.15"
assert _amount(, L3=55) == "0.18"
assert _amount("IF(돌종류='야면석',0.15,0.2)", 돌종류="야면석") == "0.15"
assert _amount("IF(L3<>45,1,2)", L3=45) == "2"
assert _amount("IF(L3>=45,1,2)", L3=45) == "1"
# 안 고른 갈래는 풀지 않음 — 0 으로 나누는 갈래가 줄을 막지 않게.
assert _amount("IF(1=1,5,1/0)") == "5"
뒷길이표 = {
"뒷길이표": {
"keys": [35, 45, 55, 60],
"columns": {"고임돌": [0.12, 0.15, 0.18, 0.2], "빈칸": [0.1, None, 0.2, 0.3]},
}
}
def _lookup(formula: str, **vars) -> dict:
return _solve([{"seq": 1, "name": "x", "formula": formula}], vars, 뒷길이표)[0]
def test_LOOKUP_은_근사_일치가_기본() -> None:
"""엑셀 `VLOOKUP(…,1)` — 키 이하 중 가장 큰 줄(실무 13건 전부 근사)."""
assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=45)["amount"] == "0.15"
assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=50)["amount"] == "0.15"
assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=100)["amount"] == "0.2"
assert "못 찾음" in _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=30)["error"]
def test_LOOKUP_EXACT_와_빈칸() -> None:
assert _lookup("LOOKUP_EXACT(뒷길이표, L3, '고임돌')", L3=55)["amount"] == "0.18"
assert "못 찾음" in _lookup("LOOKUP_EXACT(뒷길이표, L3, '고임돌')", L3=50)["error"]
# 원문 「-」 칸은 값이 아니라 오류 — 0 으로 때우지 않음.
assert "비어 있음" in _lookup("LOOKUP(뒷길이표, L3, '빈칸')", L3=45)["error"]
assert "없는 열" in _lookup("LOOKUP(뒷길이표, L3, '없는열')", L3=45)["error"]
def test_표_키가_오름차순이_아니면_오류() -> None:
tables = {"표": {"keys": [45, 35], "columns": {"a": [1, 2]}}}
row = _solve([{"seq": 1, "name": "x", "formula": "LOOKUP(표, 40, 'a')"}], {}, tables)[0]
assert "오름차순" in row["error"]
def test_앞_줄만_참조한다() -> None:
rows = [
{"seq": 1, "name": "면적", "formula": "H*L"},
{"seq": 2, "name": "고임돌", "formula": "AREA*0.15", "refs": {"AREA": 1}},
{"seq": 3, "name": "뒷줄", "formula": "X*2", "refs": {"X": 4}},
{"seq": 4, "name": "자기", "formula": "S*2", "refs": {"S": 4}},
]
result = _solve(rows, {"H": 2.5, "L": 10})
assert [row["amount"] for row in result[:2]] == ["25", "3.75"]
assert "앞 줄만" in result[2]["error"]
assert "앞 줄만" in result[3]["error"]
def test_오류_난_줄을_가리키면_막힌다() -> None:
rows = [
{"seq": 1, "name": "나눔", "formula": "1/0"},
{"seq": 2, "name": "뒤", "formula": "A+1", "refs": {"A": 1}},
]
first, second = _solve(rows)
assert "0 으로 나눔" in first["error"] and first["amount"] is None
assert "앞 줄 1" in second["error"] and second["amount"] is None
def test_참조는_반올림_뒤_값을_받는다() -> None:
"""엑셀 셀 참조와 같음 — `INT(…)` 가 든 셀을 가리키면 버린 값이 옴."""
rows = [
{"seq": 1, "name": "a", "formula": "0.456", "rounding": {"mode": "floor", "digits": 2}},
{"seq": 2, "name": "b", "formula": "A*100", "refs": {"A": 1}},
]
result = _solve(rows)
assert result[0]["raw"] == "0.456" and result[0]["amount"] == "0.45"
assert result[1]["amount"] == "45"
def test_when_거짓이면_줄이_안_서고_가리키면_막힌다() -> None:
"""명세 13장 Ⓐ — 「안 섬」은 0 이 아님. 가리키는 뒷줄은 오류, 안 쓰면 영향 없음."""
rows = [
{"seq": 1, "name": "버림", "formula": "2", "when": "BLIND<>'안 넣음'"},
{"seq": 2, "name": "잡석", "formula": "B*2", "refs": {"B": 1}},
{
"seq": 3,
"name": "잡석_조건",
"formula": "B*2",
"refs": {"B": 1},
"when": "BLIND<>'안 넣음'",
},
{"seq": 4, "name": "무관", "formula": "7", "refs": {"B": 1}},
]
넣음 = _solve(rows, {"BLIND": "넣음"})
assert [row["amount"] for row in 넣음] == ["2", "4", "4", "7"]
안넣음 = _solve(rows, {"BLIND": "안 넣음"})
assert 안넣음[0]["skipped"] and 안넣음[0]["amount"] is None and 안넣음[0]["error"] is None
assert "조건이 거짓" in 안넣음[0]["reason"]
assert "앞 줄 1(버림) 안 섬" in 안넣음[1]["error"]
assert 안넣음[2]["skipped"] and 안넣음[2]["error"] is None # 조건이 먼저라 막히지 않음
assert 안넣음[3]["amount"] == "7" # 이름을 안 쓰면 영향 없음
def test_갈_곳이_빠지면_오류_기본값_없음() -> None:
"""명세 13장 Ⓑ ⛔⛔ — 기본값이 빠진 줄을 조용히 사라지게 한 결함이 실재함."""
rows = [
{"seq": 1, "name": "빠짐", "formula": "1"},
{"seq": 2, "name": "모름", "formula": "1", "destination": "somewhere"},
{"seq": 3, "name": "맞음", "formula": "1", "destination": "material"},
]
result = evaluate_sheets([{"rows": rows}])[0]
assert "갈 곳(destination)이 없음" in result[0]["error"]
assert "모르는 갈 곳" in result[1]["error"]
assert result[2]["amount"] == "1"
def test_이름은_고정_규격이_제원을_따른다() -> None:
"""명세 13장 Ⓒ — 조인 키(이름+규격)가 흔들리지 않게 이름은 고정."""
rows = [
{"seq": 1, "name": "돌", "spec": "{돌종류}", "formula": "1"},
{"seq": 2, "name": "물구멍관", "spec": {DIA}", "formula": "1"},
{"seq": 3, "name": "모름", "spec": "{없는칸}", "formula": "1"},
]
result = _solve(rows, {"돌종류": "깬돌", "DIA": 50})
assert (result[0]["name"], result[0]["spec"]) == ("돌", "깬돌")
assert result[1]["spec"] == "Ø50"
assert "규격의 모르는 제원" in result[2]["error"]
assert _solve(rows[:1], {"돌종류": ""})[0]["spec"] == "" # 안 고르면 규격 빔 — 매칭 성공 아님
def test_고정형과_줄_제원() -> None:
rows = [
{"seq": 1, "name": "박힌값", "amount": "2.088"},
{"seq": 2, "name": "빈", "formula": ""},
{"seq": 3, "name": "줄이이김", "formula": "K", "vars": {"K": 7}},
]
result = _solve(rows, {"K": 3})
assert result[0]["amount"] == "2.088"
assert "식도 값도 없음" in result[1]["error"]
assert result[2]["amount"] == "7"
def test_모르는_이름_함수_글자는_오류() -> None:
assert "모르는 이름" in _one("constructor")["error"]
assert "모르는 함수" in _one("EVAL(1)")["error"]
assert "읽을 수 없는 글자" in _one("1;2")["error"]
assert "따옴표" in _one("'abc")["error"]
assert (
"같은 차례"
in _solve(
[{"seq": 1, "name": "a", "formula": "1"}, {"seq": 1, "name": "b", "formula": "2"}]
)[1]["error"]
)
def test_실무_찰쌓기_탭을_셀_그대로_재현() -> None:
"""소광리 「돌기슭막이(45)찰-고1.0」 — H=1 · 뒷길이 45 · 1:0.3 · 윗폭 300㎜.
셀 식을 이름만 바꿔 옮김(C5→H, D3→L3, J11→N, L4→TOP_MM). 엑셀 캐시값과 같아야 함.
"""
vars = {"H": 1, "L3": 45, "N": 0.3, "TOP_MM": 300}
rows = [
{"seq": 1, "name": "면적", "formula": "H*1*1.044"},
{"seq": 2, "name": "중량", "formula": "A*0.91", "refs": {"A": 1}},
{
"seq": 3,
"name": "체적",
"formula": "((L3*10+TOP_MM)/1000+(L3*10+H*1000*N)/1000)/2*A",
"refs": {"A": 1},
},
{
"seq": 4,
"name": "고임돌",
"formula": "A*IF(L3=35,0.12,IF(L3=45,0.15,IF(L3=55,0.18,0)))",
"refs": {"A": 1},
"rounding": {"mode": "floor", "digits": 2},
},
{"seq": 5, "name": "석적", "formula": "H*L3/100*0.77"},
{"seq": 6, "name": "막자갈", "formula": "(TOP_MM/1000+H*1000*N/1000)/2*H"},
{
"seq": 7,
"name": "채움콘크리트",
"formula": "A*IF(L3=35,0.16,IF(L3=45,0.2,IF(L3=55,0.25,0)))",
"refs": {"A": 1},
"rounding": {"mode": "floor", "digits": 2},
},
{"seq": 8, "name": "모르터", "formula": "A*0.009", "refs": {"A": 1}},
{"seq": 9, "name": "물구멍", "formula": "A*1/2.5*0.5", "refs": {"A": 1}},
{
"seq": 10,
"name": "터파기",
"formula": "((H*1000*N+L3*10)/1000+0.2)*0.3*1",
"rounding": {"mode": "round", "digits": 2},
},
{
"seq": 11,
"name": "되메우기",
"formula": "(0.2*0.3+(TOP_MM/1000+0.2)*(TOP_MM/1000+0.2)/2)*1",
},
{"seq": 12, "name": "잔토정리", "formula": "T-B", "refs": {"T": 10, "B": 11}},
]
엑셀 = {
"면적": 1.044,
"중량": 0.9500400000000001,
"체적": 0.783,
"고임돌": 0.15,
"석적": 0.34650000000000003,
"막자갈": 0.3,
"채움콘크리트": 0.2,
"모르터": 0.009396,
"물구멍": 0.2088,
"터파기": 0.29,
"되메우기": 0.185,
"잔토정리": 0.10499999999999998,
}
result = {row["name"]: row for row in _solve(rows, vars)}
for name, cached in 엑셀.items():
assert result[name]["error"] is None, result[name]
# 엑셀 캐시는 부동소수 끝자리가 흔들림 — 유효 12자리까지 같으면 같은 값.
assert float(result[name]["amount"]) == pytest.approx(cached, rel=1e-12), name
# 그러나 우리 값은 **흔들림 없는 십진수**다.
assert result["잔토정리"]["amount"] == "0.105"
assert result["석적"]["amount"] == "0.3465"