"""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)