Files
Aislo/resources/tester/test_m01_easycalc_model.py
T
eomsangdonandClaude Opus 5.5 63235dc203 feat(m01-easycalc): 쉬운 단가산출 Model 속 채움 · 시험
- toDoc · toRow: 호표 인력 · 재료 · 기계경비 로직(GC000267~276) · 그 밖 로직 카드 · 숫자 아닌 수량은 잠금 + 쉬운 말 까닭
- 덧줄 「바탕 * 숫자 / 100」(노무비 · 재료비 · 경비 · 사람 줄 노임 합) = 덧붙임 카드 · 그 밖 = 잠금
- 안 바뀐 줄은 원래 글자 그대로 — 견본 셋 · 마스터 로직 1,469개 왕복 같음
- addCard · addPercent · withInputs · updateCard · removeCard · moveCard(같은 묶음 안) · checkCard · checkName · EasyHistory
- 시험 resources/tester/test_m01_easycalc_model.py 14개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 09:37:05 +09:00

403 lines
15 KiB
Python

"""M01 쉬운 단가산출 Model 시험(34-3b) — Node 로 `M01_EasyCalc_Model.ts` 를 바로 돌림 · 화면 조작은 ORCA.
· 견본 로직 줄 셋(인력+재료 · 기계+% 덧줄 · 표 찾기 잠금)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
· 왕복(toDoc → toRow 가 원래 줄과 같음) · 카드 종류 · 잠금 까닭 · 더하기 · 지우기 · 차례 · 숫자로 바꾸기 · 되돌리기 · 검사
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MODEL = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Model.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
# ── 견본 줄 ───────────────────────────────────────────────────────────────
HEAD = {"원문번호": "", "구분": "자체", "상세구분": "시험", "출처": "자체", "소유": "현장"}
PERSON_MATERIAL = {
**HEAD,
"키": "GX900001",
"이름": "시험 찰쌓기",
"결과단위": "원/㎡",
"입력": [],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": "LB000005",
"이름": "석공",
"단위": "인",
"수량": "0.072",
"비목": "노무비",
},
{
"종류": "재료",
"요소": {"구분": "시멘트", "검색어": "시멘트 40kg"},
"이름": "시멘트",
"규격": "40kg",
"단위": "포",
"수량": "0.50",
"비목": "재료비",
"출처": "산림품셈 12-1-1",
},
],
"덧줄": [],
"끝수": None,
"비고": "견본",
}
MACHINE_PERCENT = {
**HEAD,
"키": "GX900002",
"이름": "시험 깬돌 쌓기",
"결과단위": "원/㎡",
"입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": "LB000002",
"이름": "보통인부",
"단위": "인",
"수량": "0.036",
"비목": "노무비",
},
{
"종류": "로직",
"요소": "로직(GC000270, 기계='0201-0060', 집게='7206-0070', 지역=지역)",
"이름": "굴삭기+집게",
"규격": "0.6㎥",
"단위": "시간",
"수량": "0.27",
},
{
"종류": "로직",
"요소": "로직(GX000777)",
"이름": "모르터바르기",
"단위": "㎥",
"수량": "0.009",
},
],
"덧줄": [
{"이름": "공구손료", "식": "(0.036 * LB000002) * 2 / 100", "비목": "재료비"},
{"이름": "잡재료비", "식": "노무비 * 3 / 100", "비목": "재료비", "출처": "산림품셈 1-2-6"},
],
"끝수": {"대상": "계", "자리": 0, "방법": "버림"},
}
TABLE_LOCKED = {
**HEAD,
"키": "GX900003",
"이름": "시험 메쌓기",
"결과단위": "원/㎡",
"입력": [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "보정", "단위": "배"}],
"중간": [{"이름": "석공품", "식": "찾기(QF000041, 뒷길이=뒷길이).석공"}],
"호표": [
{
"종류": "인력",
"요소": "LB000005",
"이름": "석공",
"단위": "인",
"수량": "석공품",
"비목": "노무비",
},
{
"종류": "인력",
"요소": "LB000002",
"이름": "보통인부",
"단위": "인",
"수량": "만약(뒷길이 > 40, 0.4, 0.3)",
"비목": "노무비",
},
{
"종류": "인력",
"요소": "LB000003",
"이름": "특별인부",
"단위": "인",
"수량": "보정 * 0.1",
"비목": "노무비",
},
{
"종류": "인력",
"요소": "LB000001",
"이름": "작업반장",
"단위": "인",
"수량": "0.1 * 2",
"비목": "노무비",
},
],
"덧줄": [
{
"이름": "공구손료",
"식": "(석공품 * LB000005 + (만약(뒷길이 > 40, 0.4, 0.3)) * LB000002"
" + (보정 * 0.1) * LB000003 + (0.1 * 2) * LB000001) * 3 / 100",
"비목": "경비",
},
{
"이름": "잡재료",
"식": "주재료비 * 찾기(QC000589, 구분='잡재료비').값 / 100",
"비목": "재료비",
},
],
"끝수": None,
}
SAMPLES = [PERSON_MATERIAL, MACHINE_PERCENT, TABLE_LOCKED]
META = {"key": "GX900001", "version": "v1", "isBase": False}
def _run(body: str, *args: object) -> object:
"""견본을 받아 Model 을 돌리는 Node 글 — `M` = Model 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답"""
script = (
f"const M = await import({json.dumps(MODEL.as_uri())});\n"
"const A = JSON.parse(process.argv[1]);\n"
"const out = (x: unknown) => console.log(JSON.stringify(x));\n" + body
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script, json.dumps(list(args))],
capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _doc(row: dict) -> dict:
return _run("out(M.toDoc(A[0], A[1]));", row, META)
# ── 왕복 ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("row", SAMPLES, ids=["인력재료", "기계덧붙임", "표찾기잠금"])
def test_왕복_같음(row: dict) -> None:
back = _run("out(M.toRow(M.toDoc(A[0], A[1])));", row, META)
assert back == row
def test_새_로직_최소칸() -> None:
row = _run("out(M.toRow(M.newDoc('새 일', 'm')));")
assert row == {
"키": "",
"원문번호": "",
"이름": "새 일",
"결과단위": "원/m",
"출처": "자체",
"입력": [],
"중간": [],
"호표": [],
"덧줄": [],
"끝수": None,
}
# ── 카드 바꾸기 ─────────────────────────────────────────────────────────────
def test_카드_종류와_숫자() -> None:
d = _doc(MACHINE_PERCENT)
assert [c["kind"] for c in d["cards"]] == ["person", "machine", "logic", "percent", "percent"]
person, machine, logic, tool, misc = d["cards"]
assert person["qty"] == 0.036 and person["cost"] == "노무비" and person["qtyFormula"] is None
assert machine["cost"] is None and machine["spec"] == "0.6㎥"
assert logic["cost"] is None
assert (tool["percentBase"], tool["qty"], tool["unit"], tool["cost"]) == (
"사람줄합",
2,
"%",
"재료비",
)
assert misc["percentBase"] == "노무비" and misc["qty"] == 3
assert d["inputs"] == MACHINE_PERCENT["입력"] and d["resultUnit"] == "원/㎡"
def test_잠금_까닭_쉬운말() -> None:
d = _doc(TABLE_LOCKED)
kinds = [c["kind"] for c in d["cards"]]
assert kinds == ["person"] * 4 + ["percent", "locked"]
why = [c["lockedWhy"] for c in d["cards"]]
assert why[:4] == [
"품셈 표에서 찾는 값",
"조건에 따라 달라지는 값",
"설계 때 정하는 값으로 정해짐",
"계산으로 정해지는 값",
]
assert d["cards"][0]["qtyFormula"] == "석공품" and d["cards"][0]["qty"] is None
assert d["cards"][4]["percentBase"] == "사람줄합"
assert why[5] == "품셈 표에서 찾는 값"
for w in filter(None, why):
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
assert bad not in w
# ── 카드 조작 ───────────────────────────────────────────────────────────────
def test_더하기_묶음_뒤_기계는_설계값_더함() -> None:
pick_person = {
"kind": "person",
"name": "석공",
"spec": "",
"unit": "인",
"element": "LB000005",
"qty": 0.5,
}
pick_machine = {
"kind": "machine",
"name": "굴삭기",
"spec": "0.7㎥",
"unit": "시간",
"element": "로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업='해당 없음')",
"qty": 0.3,
"needInputs": [{"이름": "지역", "고르기": ["전국평균"]}],
}
got = _run(
"let d = M.toDoc(A[0], A[1]);\n"
"d = M.addCard(d, A[2]); d = M.addCard(d, A[3]); d = M.addCard(d, A[3]);\n"
"d = M.addPercent(d, '공구손료', '사람줄합', 2, '경비');\n"
"out(M.toRow(d));",
PERSON_MATERIAL,
META,
pick_person,
pick_machine,
)
ho = got["호표"]
assert [h["이름"] for h in ho] == ["석공", "석공", "시멘트", "굴삭기", "굴삭기"]
assert ho[1] == {
"종류": "인력",
"요소": "LB000005",
"이름": "석공",
"단위": "인",
"수량": "0.5",
"비목": "노무비",
}
assert ho[3]["종류"] == "로직" and "비목" not in ho[3] and ho[3]["규격"] == "0.7㎥"
assert got["입력"] == [{"이름": "지역", "고르기": ["전국평균"]}]
assert got["덧줄"] == [
{"이름": "공구손료", "식": "(0.072 * LB000005 + 0.5 * LB000005) * 2 / 100", "비목": "경비"}
]
def test_지우기와_사람줄합_다시짬() -> None:
got = _run(
"let d = M.toDoc(A[0], A[1]);\n"
"const extra = M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005', qty:0.1});\n"
"const removed = M.removeCard(extra, d.cards[0].id);\n"
"out([M.toRow(extra), M.toRow(removed), M.toRow(M.removeCard(d, d.cards[0].id))]);",
MACHINE_PERCENT,
META,
)
two, one, none = got
assert two["덧줄"][0]["식"] == "(0.036 * LB000002 + 0.1 * LB000005) * 2 / 100"
assert two["덧줄"][1] == MACHINE_PERCENT["덧줄"][1]
assert one["덧줄"][0]["식"] == "(0.1 * LB000005) * 2 / 100"
assert [h["이름"] for h in none["호표"]] == ["굴삭기+집게", "모르터바르기"]
assert none["덧줄"][0]["식"] == "(0) * 2 / 100"
def test_차례_같은_묶음_안에서만() -> None:
got = _run(
"const d = M.toDoc(A[0], A[1]);\n"
"const ids = d.cards.map((c: any) => c.id);\n"
"const names = (x: any) => x.cards.map((c: any) => c.name);\n"
"out([names(M.moveCard(d, ids[1], 1)), names(M.moveCard(d, ids[0], -1)),"
" names(M.moveCard(d, ids[3], 1)), names(M.moveCard(d, ids[0], 1)) ]);",
TABLE_LOCKED,
META,
)
down, top, add_down, end = got
assert down == ["석공", "특별인부", "보통인부", "작업반장", "공구손료", "잡재료"]
assert top == ["석공", "보통인부", "특별인부", "작업반장", "공구손료", "잡재료"]
assert add_down == top # 사람 묶음 끝 → 덧붙임으로 안 넘어감
assert end == ["보통인부", "석공", "특별인부", "작업반장", "공구손료", "잡재료"]
def test_잠금을_숫자로_바꾸기() -> None:
got = _run(
"const d = M.toDoc(A[0], A[1]);\n"
"const n = M.updateCard(d, d.cards[0].id, { qty: 0.5, qtyFormula: null });\n"
"out([n.cards[0], M.toRow(n), d.cards[0].qtyFormula]);",
TABLE_LOCKED,
META,
)
card, row, before = got
assert card["lockedWhy"] is None and card["qty"] == 0.5
assert row["호표"][0]["수량"] == "0.5"
assert before == "석공품" # 받은 문서는 안 바뀜
# 석공 수량이 바뀌어 사람 줄 합이 다시 짜임
assert row["덧줄"][0]["식"].startswith("(0.5 * LB000005 + ")
def test_되돌리기와_저장안함() -> None:
got = _run(
"const h = new M.EasyHistory();\n"
"const d0 = M.toDoc(A[0], A[1]);\n"
"const log: unknown[] = [];\n"
"h.push(d0); log.push([h.canUndo, h.dirty]);\n"
"const d1 = M.updateCard(d0, d0.cards[0].id, { qty: 0.08 });\n"
"h.push(d1); h.push(d1); log.push([h.canUndo, h.dirty]);\n"
"h.markSaved(); log.push([h.dirty]);\n"
"const back = h.undo(); log.push([back.cards[0].qty, h.canUndo, h.dirty, h.undo()]);\n"
"out(log);",
PERSON_MATERIAL,
META,
)
assert got == [[False, False], [True, True], [False], [0.072, False, True, None]]
# ── 검사 ───────────────────────────────────────────────────────────────────
def test_검사_쉬운말_한줄() -> None:
got = _run(
"const d = M.toDoc(A[0], A[1]);\n"
"const c = d.cards[0];\n"
"const t = (p: any) => M.checkCard({ ...c, ...p }, d);\n"
"out([t({}), t({ qty: null }), t({ qty: Number.NaN }), t({ qty: 0 }), t({ qty: -1 }),"
" t({ name: ' ' }), t({ qty: 12 }), t({ qty: null, qtyFormula: '석공품' }),"
" M.checkCard(M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005',"
" qty:0.1}).cards[1], M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인',"
" element:'LB000005', qty:0.1})),"
" M.checkName('시험 찰쌓기', ['시험 찰쌓기']), M.checkName('', []), M.checkName('새것', ['헌것'])]);",
PERSON_MATERIAL,
META,
)
assert got == [
None,
"얼마인지 숫자로 넣어야 함",
"얼마인지 숫자로 넣어야 함",
"0 은 안 됨 — 빼려면 지움",
"0 보다 큰 수만 됨",
"이름이 비어 있음",
"1㎡ 에 12인 — 너무 큰 수 아닌지?",
None,
"같은 줄이 이미 있음 — 한 줄에 합쳐 넣음",
"내가 만든 것에 같은 이름이 있음",
"이름이 비어 있음",
None,
]
def test_영어_글() -> None:
got = _run(
"M.setEasyModelLanguage(1);\n"
"const d = M.toDoc(A[0], A[1]);\n"
"out([d.cards[0].lockedWhy, M.checkCard({ ...d.cards[1], qty: 0, qtyFormula: null }, d)]);",
TABLE_LOCKED,
META,
)
assert got == [
"Looked up from the standard table",
"0 is not allowed — remove the line instead",
]
def test_화면_글에_금지_낱말_없음() -> None:
src = MODEL.read_text(encoding="utf-8")
block = src[src.index("const TEXT = {") : src.index("} as const;")]
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
assert len(korean) == 12
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"):
assert bad not in text, (bad, text)