"""시트 로직 표 상자 — 찾기 수식 짜기(`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.blockInfo || 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 === "rename") { const sl = { key: null, name: "t", resultUnit: "", edges: [], workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [ { id: "s1", 이름: "표", 칸: {} }, { id: "s2", 이름: "계산", 칸: { B2: { 식: "'표'!G7*2" }, B3: { 식: "표!G7+1" } } } ] }, nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] }; const done = m.applyTable(sl, "n1", job.info, job.roles, { ref: job.info.key, fillBlock: false, outside: true }).sl; const texts = Object.values(done.workbook.시트[0].칸).map((c) => String(c.값 ?? "")); return { sheets: done.workbook.시트.map((x) => x.이름), refs: done.workbook.시트[1].칸, ref: done.nodes[0].ref, node: done.nodes[0].name, texts }; } 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" def test_값_칸_범위도_아래_위_두_칸_숫자_하나는_같은_수(): """서버 표 블록 차례 — 범위 값은 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸이 같은 수.""" table = { **R, "conds": {"높이": "범위"}, "vals": {"증가율": "%"}, "rule": "초과·이하", "rows": [{"높이": [3, 4], "증가율": [80, 100]}, {"높이": [4, None], "증가율": 30}], } low = solve(table, {"높이": 3.5}, ["증가율"]) assert float(low["증가율·아래"]) == 80 and float(low["증가율·위"]) == 100 one = solve(table, {"높이": 5}, ["증가율"]) assert float(one["증가율·아래"]) == 30 and float(one["증가율·위"]) == 30 def test_블록_머리_차례(): table = { **R, "conds": {"가": "고르기", "높이": "범위"}, "vals": {"율": "%", "폭": "m"}, "rows": [{"가": "a", "높이": [1, 2], "율": 1, "폭": [20, 45]}], } plan = run([{"op": "plan", "info": table, "roles": {"cond": ["가"], "out": ["율"]}}])[0]["plan"] assert [c["header"] for c in plan["cols"]] == [ "가", "높이·아래", "높이·위", "율", "폭·아래", "폭·위", ] def test_표가_늘어도_찾기_범위가_따라감(): """마스터 표 줄이 시험 때보다 늘었을 때 — 찾기 판이 만든 줄 수 밖 새 줄도 찾음(범위 표 · 같음 표 둘 다).""" small = {**RATE, "rows": RATE["rows"][:2]} extra = [{"높이": [5.5 + i, 6.5 + i], "증가율": 70 + i} for i in range(5)] grown = {**small, "rows": [*small["rows"], *extra]} roles = {"cond": ["높이"], "out": ["증가율"]} plan = run( [{"op": "plan", "info": small, "roles": roles, "inputs": {"높이": 8.5}, "blockInfo": grown}] )[0] book = { "종류": "통합문서", "판": 1, "열": "t", "서식": [{}], "시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}], "활성": "s1", } values = recalc_workbooks([book])[0]["s1"] assert values[plan["plan"]["statusCell"]]["글"] == "찾음" assert float(values[plan["plan"]["outCells"]["증가율"]]["수"]) == 72 def test_표_상자에_표_번호가_안_보임_이름으로_보임(): info = {**EXACT, "key": "QF000421", "name": "메쌓기(인력)", "no": "13-4-1", "base": "1㎡"} roles = {"cond": list(info["conds"]), "out": ["석공"]} out = run([{"op": "rename", "info": info, "roles": roles}])[0] assert out["sheets"] == ["표 · 메쌓기(인력)", "계산"] assert out["ref"] == "QF000421" # 번호는 상자 ref 에만 assert "QF000421" not in " ".join(out["texts"]) assert any("13-4-1" in t and "1㎡" in t for t in out["texts"]) assert out["refs"]["B2"]["식"] == "'표 · 메쌓기(인력)'!G7*2" assert out["refs"]["B3"]["식"] == "'표 · 메쌓기(인력)'!G7+1"