- 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
403 lines
15 KiB
Python
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)
|