"""쉬운 단가산출 2단계 — 표 보고 고르기(`M01_EasyCalc_Table.ts`)의 식 읽기 · 새 식 짜기. 화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 화면 부품 · 서버는 빈 흉내(순수 함수만 부름). 견본 식은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음). """ import json import shutil import subprocess from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] SOURCE = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Table.ts" RUNNER = r""" const fs = require("fs"); const path = require("path"); const [root, file] = process.argv.slice(1); const ts = require(path.join(root, "config", "node_modules", "typescript")); const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const stub = new Proxy({}, { get: () => () => undefined }); const box = { exports: {} }; new Function("exports", "module", "require", js)(box.exports, box, () => stub); const m = box.exports; const jobs = JSON.parse(fs.readFileSync(0, "utf8")); const out = jobs.map((job) => { if (job.op === "read") return m.readFinds(job.formula); if (job.op === "swap") { const calls = m.readFinds(job.formula); const swaps = calls.map((c, i) => { const s = job.swaps[i]; return s ? m.writeFind(c.table, s.args ?? c.args, s.col ?? c.col) : null; }); return m.replaceFinds(job.formula, calls, swaps); } if (job.op === "literal") return m.literal(job.value, job.rule ?? ""); return null; }); process.stdout.write(JSON.stringify(out)); """ def run(jobs: list[dict]) -> list: if shutil.which("node") is None: pytest.skip("node 가 없음") done = subprocess.run( ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], input=json.dumps(jobs, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", check=True, ) return json.loads(done.stdout) MESSANGI = "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)" def test_식_읽기_흔한_꼴(): [calls] = run([{"op": "read", "formula": MESSANGI}]) assert len(calls) == 1 c = calls[0] assert c["table"] == "QF000421" and c["col"] == "석공" assert [(a["key"], a["rhs"]) for a in c["args"]] == [ ("뒷길이", "뒷길이"), ("돌", "돌"), ("쌓기", "쌓기"), ] assert MESSANGI[c["start"] : c["end"]] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공" def test_식_읽기_따옴표_쉼표_괄호_겹침(): f = ( "(찾기(QC000524, 형식=형식).특별인부 - 만약(정밀절단 == '아님', " "찾기(QC000583, 구분='가, 나 (다)').값, 0)) / 시공량" ) [calls] = run([{"op": "read", "formula": f}]) assert [c["table"] for c in calls] == ["QC000524", "QC000583"] assert calls[1]["args"] == [{"key": "구분", "rhs": "'가, 나 (다)'"}] assert calls[1]["col"] == "값" def test_식_읽기_못_읽는_꼴과_없는_꼴(): out = run( [ {"op": "read", "formula": "찾기(QF:{지역}, 구분='a').값"}, # {자리} 낀 키 {"op": "read", "formula": "찾기(QF000001, 구분='a'"}, # 안 닫힘 {"op": "read", "formula": "품 * 1.2"}, {"op": "read", "formula": "'찾기(QF000001, 구분=1).값'"}, # 따옴표 글 안 ] ) assert out[0] is None and out[1] is None assert out[2] == [] and out[3] == [] def test_새_식_짜기_줄_고정과_값칸_바꾸기(): [new] = run( [ { "op": "swap", "formula": MESSANGI, "swaps": [ { "args": [ {"key": "뒷길이", "rhs": "35"}, {"key": "돌", "rhs": "'깬잡석'"}, {"key": "쌓기", "rhs": "쌓기"}, ], "col": "보통인부", } ], } ] ) assert new == ( "찾기(QF000421, 뒷길이=35, 돌='깬잡석', 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)" ) def test_새_식_짜기_둘째_찾기만_바꿈_나머지_글_그대로(): f = "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기=공기).손율 / 100" [new] = run( [ { "op": "swap", "formula": f, "swaps": [None, {"args": [{"key": "공기", "rhs": "'1개월'"}]}], } ] ) assert new == "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기='1개월').손율 / 100" # 다시 읽어도 같은 모양(왕복) [calls] = run([{"op": "read", "formula": new}]) assert calls[1]["args"] == [{"key": "공기", "rhs": "'1개월'"}] def test_칸_값을_식_글로(): out = run( [ {"op": "literal", "value": 25}, {"op": "literal", "value": "깬잡석"}, {"op": "literal", "value": "it's"}, # 따옴표 든 글은 못 씀 {"op": "literal", "value": [20, 40], "rule": "초과·이하"}, {"op": "literal", "value": [None, 60], "rule": "이하"}, {"op": "literal", "value": [None, 60], "rule": "이상·미만"}, {"op": "literal", "value": [80, None], "rule": "초과·이하"}, {"op": "literal", "value": [80, None], "rule": "이상·미만"}, ] ) assert out == ["25", "'깬잡석'", None, "30", "60", "59", "81", "80"]