"""시트 로직 표 상자 — 찾기 수식 짜기(`M01_SL_Table_Build.ts`) · 붙여넣기 읽기. 화면 TS 를 그 자리에서 트랜스파일해 node 로 짜고, 스프레드시트 엔진(서버 Node 번들)이 그 수식을 풀어 값을 대조. 견본 표는 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음). """ import json import shutil import subprocess from pathlib import Path import pytest from common_util.common_util_spreadsheet import recalc_workbooks ROOT = Path(__file__).resolve().parents[2] SOURCE = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Table_Build.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 box = { exports: {} }; new Function("exports", "module", "require", js)(box.exports, box, () => ({})); const m = box.exports; const jobs = JSON.parse(fs.readFileSync(0, "utf8")); const out = jobs.map((job) => { if (job.op === "plan") { const plan = m.planLookup(job.info, job.roles); const cells = { ...m.blockCells(job.info), ...plan.cells }; for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v }; return { cells, plan }; } if (job.op === "paste") return m.parsePaste(job.text); if (job.op === "reapply") { const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [], workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] }, nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] }; const opts = { ref: "T1", fillBlock: true, outside: false }; const first = m.applyTable(base, "n1", job.info, job.roles, opts).sl; const cell = first.nodes[0].pillsIn[0].cell; first.workbook.시트[0].칸[cell] = { 식: "'다른 시트'!B2" }; const second = m.applyTable(first, "n1", job.info, job.roles, { ...opts, fillBlock: false }).sl; return { cell, kept: second.workbook.시트[0].칸[cell], pills: second.nodes[0].pillsIn.map((p) => p.name), name: second.nodes[0].name }; } return null; }); process.stdout.write(JSON.stringify(out)); """ R = {"key": "T1", "name": "견본", "base": "1㎡", "source": "", "no": "", "rule": None, "notes": []} EXACT = { **R, "conds": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"}, "vals": {"석공": "인", "보통": "인"}, "rows": [ {"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15, "보통": 0.12}, {"뒷길이": 25, "돌": "호박돌", "쌓기": None, "석공": 0.1, "보통": 0.09}, {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "석공": 0.26, "보통": 0.21}, {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.23, "보통": 0.18}, {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.5, "보통": 0.5}, ], } RATE = { **R, "conds": {"높이": "범위"}, "vals": {"증가율": "%"}, "rule": "초과·이하", "rows": [ {"높이": [3, 4], "증가율": 30}, {"높이": [4, 5.5], "증가율": 40}, {"높이": [5.5, None], "증가율": 60}, ], } MIXED = { **R, "conds": {"구분": "고르기", "높이": "범위"}, "vals": {"율": "%"}, "rule": "초과·이하", "rows": [ {"구분": "가", "높이": [0, 2], "율": 1}, {"구분": "가", "높이": [2, 4], "율": 2}, {"구분": "나", "높이": [0, 3], "율": 5}, ], } MINE = { **R, "conds": {"경사": "고르기"}, "vals": {"할증": "%"}, "rows": [{"경사": "완", "할증": 0}, {"경사": "급", "할증": 10}], } 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) def solve(info: dict, inputs: dict, out: list[str], cond: list[str] | None = None) -> dict: """견본 표를 찾기 판 수식으로 풀어 → {상태, 걸린 줄, 결과 칸들}.""" roles = {"cond": cond or list(info["conds"]), "out": out} plan = run([{"op": "plan", "info": info, "roles": roles, "inputs": inputs}])[0] book = { "종류": "통합문서", "판": 1, "열": "t", "서식": [{}], "시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}], "활성": "s1", } values = recalc_workbooks([book])[0]["s1"] p = plan["plan"] def got(a1: str): v = values.get(a1) or {} return v.get("수", v.get("글", v.get("오류"))) res = {c: got(a) for c, a in p["outCells"].items()} return {"상태": got(p["statusCell"]), "줄": got(p["idxCell"]), **res} def test_같음_조건_셋은_열쇠로_찾음(): r = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공", "보통"]) assert r["상태"] == "찾음" and r["줄"] == "3" assert float(r["석공"]) == 0.26 and float(r["보통"]) == 0.21 def test_빈칸은_무관_줄로_찾음(): r = solve(EXACT, {"뒷길이": 25, "돌": "호박돌", "쌓기": "켜쌓기"}, ["석공"]) assert r["상태"] == "찾음" and float(r["석공"]) == 0.1 def test_맞는_줄_없음과_둘_이상(): none = solve(EXACT, {"뒷길이": 99, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공"]) assert none["상태"] == "맞는 줄 없음" and none["석공"] == "#N/A" dup = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기"}, ["석공"]) assert dup["상태"] == "맞는 줄 둘 이상" and dup["석공"] == "#N/A" def test_범위_조건은_아래_위_두_칸으로_초과_이하(): assert float(solve(RATE, {"높이": 4}, ["증가율"])["증가율"]) == 30 # 4 는 3~4 줄(이하) assert float(solve(RATE, {"높이": 4.5}, ["증가율"])["증가율"]) == 40 assert float(solve(RATE, {"높이": 9}, ["증가율"])["증가율"]) == 60 # 위 칸이 빈 줄 = 끝 없음 assert solve(RATE, {"높이": 3}, ["증가율"])["상태"] == "맞는 줄 없음" # 3 은 초과가 아님 assert solve(RATE, {"높이": 1}, ["증가율"])["상태"] == "맞는 줄 없음" def test_같음_조건과_범위_조건이_섞임(): assert float(solve(MIXED, {"구분": "가", "높이": 3}, ["율"])["율"]) == 2 assert float(solve(MIXED, {"구분": "나", "높이": 3}, ["율"])["율"]) == 5 assert solve(MIXED, {"구분": "다", "높이": 3}, ["율"])["상태"] == "맞는 줄 없음" assert solve(MIXED, {"구분": "나", "높이": 9}, ["율"])["상태"] == "맞는 줄 없음" def test_내_표_경사에서_할증_두_줄(): assert float(solve(MINE, {"경사": "급"}, ["할증"])["할증"]) == 10 assert float(solve(MINE, {"경사": "완"}, ["할증"])["할증"]) == 0 def test_조건_칸_역할을_바꾸면_찾기_수식도_바뀜(): r = solve(EXACT, {"뒷길이": 30}, ["석공"], cond=["뒷길이"]) assert r["상태"] == "맞는 줄 둘 이상" # 뒷길이 30 줄이 셋 def test_붙여넣기_읽기(): text = "경사\t범위\t할증\n완\t0~10\t0\n급\t10~\t10" out = run([{"op": "paste", "text": text}])[0] info = out["info"] assert out["error"] is None assert info["conds"] == {"경사": "고르기", "범위": "범위"} assert info["vals"] == {"할증": ""} assert info["rows"][1] == {"경사": "급", "범위": [10, None], "할증": 10} assert run([{"op": "paste", "text": "한 줄뿐"}])[0]["error"] def test_다시_짜도_선_수식은_그대로(): out = run([{"op": "reapply", "info": MINE, "roles": {"cond": ["경사"], "out": ["할증"]}}])[0] assert out["kept"] == {"식": "'다른 시트'!B2"} assert out["pills"] == ["경사"] and out["name"] == "표 · 견본" def test_표가_비었어도_수식이_깨지지_않음(): """내 표를 막 만든 때(줄이 다 빈칸) — 빈칸 변형 열쇠가 빈 수식이 되면 안 됨.""" empty = {**MINE, "rows": [{}, {}]} r = solve(empty, {"경사": "급"}, ["할증"]) assert r["상태"] == "맞는 줄 없음" and r["할증"] == "#N/A"