"""로직 만들기 판 — 검사(`M01_LB_Check.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" / "LogicBuilder" / "M01_LB_Check.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")); Promise.all( jobs.map(async (job) => { if (job.op === "gaps") return m.gaps(job.lb, job.tables); if (job.op === "skipped") return m.gapSkipped(job.lb, job.tables); if (job.op === "hits") return m.lookupHits(job.lb, job.tables, job.values); if (job.op === "find") return m.findRows(job.table, job.conds, job.want ?? ""); if (job.op === "same") return m.sameAsExpect(job.got, job.expect); if (job.op === "expect") { const answers = job.answers; let i = 0; return m.runExpect({}, {}, job.rows, async () => answers[i++]); } return null; }), ).then((out) => 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) # 견본 표 ① — 돌쌓기 꼴: 뒷길이 × 돌 × 쌓기 · 「호박돌」 줄은 쌓기 칸이 비어 무관 STONE = { "키": "T1", "조건": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"}, "값칸": {"석공": "인"}, "줄": [ {"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15}, {"뒷길이": 25, "돌": "깬잡석", "쌓기": "켜쌓기", "석공": 0.13}, {"뒷길이": 25, "돌": "호박돌", "석공": 0.10}, {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.22}, {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.23}, # 같은 조합 두 줄 = 중복 ], } # 견본 표 ② — 범위 줄(이상·미만 · 마지막 칸 이하 포함) HEIGHT = { "키": "T2", "범위규칙": "이상·미만 · 마지막 칸(4~6)은 이하 포함", "조건": {"높이": "범위"}, "값칸": {"증가율": "%"}, "줄": [ {"높이": [None, 2], "증가율": 0}, {"높이": [2, 4], "증가율": 30}, {"높이": [4, 6], "증가율": 40}, ], } # 견본 표 ③ — 범위 줄(이하 · 위 끝만 · 여러 줄이면 위 끝 가장 작은 줄) UPTO = { "키": "T3", "범위규칙": "이하", "조건": {"거리": "범위"}, "값칸": {"값": ""}, "줄": [{"거리": [None, 10], "값": 1}, {"거리": [None, 20], "값": 2}], } TABLES = {"T1": STONE, "T2": HEIGHT, "T3": UPTO} def lk(id_, table, conds, col): return {"id": id_, "table": table, "conds": conds, "col": col} LOGIC = { "inputs": [ {"이름": "뒷길이", "고르기": [25, 30]}, {"이름": "돌", "고르기": ["깬잡석", "호박돌"]}, {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, {"이름": "높이", "고르기": [1, 3, 6, 7]}, {"이름": "길이"}, # 숫자 입력 — 선택 목록 없음 ], "lookups": [ lk( "a", "T1", [ {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, {"col": "돌", "from": {"k": "input", "name": "돌"}}, {"col": "쌓기", "from": {"k": "input", "name": "쌓기"}}, ], "석공", ), lk("b", "T2", [{"col": "높이", "from": {"k": "input", "name": "높이"}}], "증가율"), lk("c", "T3", [{"col": "거리", "from": {"k": "input", "name": "길이"}}], "값"), ], } def by(gaps, lookup): return sorted( ( (g["kind"], json.dumps(g["conds"], ensure_ascii=False)) for g in gaps if g["lookup"] == lookup ) ) def test_빈틈_중복_무관_빈칸(): [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) got = by(gaps, "a") # 뒷길이 2 × 돌 2 × 쌓기 2 = 8 조합 # 무관 빈칸 — 25 · 호박돌 은 쌓기 둘 다 한 줄로 걸림(빈틈 아님) # 빈틈 — 30 · 깬잡석 · 켜쌓기 · 30 · 호박돌 · 골/켜 # 중복 — 30 · 깬잡석 · 골쌓기(두 줄) assert got == sorted( [ ( "없음", json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "켜쌓기"}, ensure_ascii=False), ), ( "없음", json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, ensure_ascii=False), ), ( "없음", json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "켜쌓기"}, ensure_ascii=False), ), ( "중복", json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기"}, ensure_ascii=False), ), ] ) dup = next(g for g in gaps if g["kind"] == "중복") assert dup["rows"] == [3, 4] def test_범위_줄_이상미만_마지막칸_이하포함(): [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) # 높이 1 → [null,2) · 3 → [2,4) · 6 → 마지막 칸 이하 포함 · 7 → 없음 assert by(gaps, "b") == [("없음", json.dumps({"높이": 7}, ensure_ascii=False))] out = run( [ {"op": "find", "table": HEIGHT, "conds": {"높이": 2}}, {"op": "find", "table": HEIGHT, "conds": {"높이": 4}}, {"op": "find", "table": HEIGHT, "conds": {"높이": 6}}, ] ) assert out == [[1], [2], [2]] def test_범위_이하_위끝_가장_작은_줄(): out = run( [ {"op": "find", "table": UPTO, "conds": {"거리": 5}}, {"op": "find", "table": UPTO, "conds": {"거리": 15}}, {"op": "find", "table": UPTO, "conds": {"거리": 25}}, ] ) assert out == [[0], [1], []] def test_셀_수_없는_조회는_건너뜀(): gaps, skipped = run( [ {"op": "gaps", "lb": LOGIC, "tables": TABLES}, {"op": "skipped", "lb": LOGIC, "tables": TABLES}, ] ) assert by(gaps, "c") == [] assert skipped == ["c"] def test_예시값으로_걸린_줄(): [hits] = run( [ { "op": "hits", "lb": LOGIC, "tables": TABLES, "values": {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "높이": 3, "길이": 15}, } ] ) assert hits == {"a": "중복", "b": 1, "c": 1} [hits] = run( [ { "op": "hits", "lb": LOGIC, "tables": TABLES, "values": {"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, } ] ) assert hits == {"a": "없음", "b": None, "c": None} # 높이 · 길이 예시값 없음 = 모름 def test_고정값_조건(): lb = { "inputs": [], "lookups": [ lk( "f", "T1", [ {"col": "돌", "from": {"k": "fixed", "value": "호박돌"}}, {"col": "뒷길이", "from": {"k": "fixed", "value": 25}}, ], "석공", ), lk("g", "T1", [{"col": "돌", "from": {"k": "calc", "name": "x"}}], "석공"), ], } gaps, hits = run( [ {"op": "gaps", "lb": lb, "tables": TABLES}, {"op": "hits", "lb": lb, "tables": TABLES, "values": {}}, ] ) assert gaps == [] assert hits == {"f": 2, "g": None} def test_기대값_시험표(): answers = [ { "ok": True, "lines": [{"이름": "석공", "수량": 0.26, "금액": 52437.06}], "sums": {"계": 52437.06}, "middle": {}, }, { "ok": True, "lines": [{"이름": "석공", "수량": 0.25, "금액": 50000}], "sums": {"계": 50000}, "middle": {}, }, {"ok": False, "reason": "표에 줄이 없음"}, ] rows = [ {"inputs": {"뒷길이": 30}, "expect": {"석공": 0.26, "계": 52437}}, {"inputs": {"뒷길이": 35}, "expect": {"석공": 0.26}}, {"inputs": {"뒷길이": 99}, "expect": {"계": 1}}, ] [res] = run([{"op": "expect", "rows": rows, "answers": answers}]) assert [r["ok"] for r in res] == [True, False, False] assert res[0]["got"]["석공 금액"] == 52437.06 assert res[2]["why"] == "표에 줄이 없음" same = run( [ {"op": "same", "got": 52437.4, "expect": 52437}, {"op": "same", "got": 52437.6, "expect": 52437}, {"op": "same", "got": 0.264, "expect": 0.26}, ] ) assert same == [True, False, True] SOURCE_UI = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Source.ts" RUNNER_UI = 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 === "parse") return m.parseCell(job.text, job.before); if (job.op === "notes") return [...m.usedNotes(job.row)].map(([k, v]) => [k, [...v]]); if (job.op === "role") return m.roleOf(job.lookups, job.col); return null; }); process.stdout.write(JSON.stringify(out)); """ def run_ui(jobs: list[dict]) -> list: if shutil.which("node") is None: pytest.skip("node 가 없음") done = subprocess.run( ["node", "-e", RUNNER_UI, str(ROOT), str(SOURCE_UI)], input=json.dumps(jobs, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", check=True, ) return json.loads(done.stdout) def test_원문_칸_고친_글_읽기(): out = run_ui( [ {"op": "parse", "text": "0.3", "before": 0.26}, {"op": "parse", "text": "1,200", "before": 1000}, {"op": "parse", "text": "abc", "before": 0.26}, # 수 칸에 글 — 못 읽음 {"op": "parse", "text": "3~4", "before": [2, 3]}, {"op": "parse", "text": "~5", "before": [None, 4]}, {"op": "parse", "text": "7~", "before": [6, None]}, {"op": "parse", "text": "5~3", "before": [3, 5]}, # 거꾸로 — 못 읽음 {"op": "parse", "text": "깬돌", "before": "깬잡석"}, {"op": "parse", "text": "25", "before": "25"}, # 글 칸은 글 그대로 {"op": "parse", "text": "", "before": 1}, ] ) assert out == [0.3, 1200, None, [3, 4], [None, 5], [7, None], None, "깬돌", "25", None] def test_계산에_쓰인_주와_칸_역할(): [notes] = run_ui( [ { "op": "notes", "row": { "중간": [{"이름": "증가율", "출처": "산림품셈 13-4-1 주③"}], "비고": "주① 참고", }, } ] ) assert sorted(notes) == [["*", ["①"]], ["13-4-1", ["③"]]] lookups = [ { "id": "a", "table": "T1", "col": "석공", "conds": [ {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, {"col": "돌", "from": {"k": "fixed", "value": "깬잡석"}}, ], } ] out = run_ui( [ {"op": "role", "lookups": lookups, "col": "석공"}, {"op": "role", "lookups": lookups, "col": "뒷길이"}, {"op": "role", "lookups": lookups, "col": "돌"}, {"op": "role", "lookups": lookups, "col": "쌓기"}, ] ) assert out == ["결과", "조건", "고정", None]