"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`. · 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) · 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진 (Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만 · ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100 · `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀 """ from __future__ import annotations import json import shutil import subprocess from decimal import Decimal from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow" SAMPLE_TS = FOLDER / "M02_CF_Sample.ts" SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip HOOKS = r""" import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { stripTypeScriptTypes } from "node:module"; export async function load(url, ctx, next) { if (!url.endsWith(".ts")) return next(url, ctx); let src = readFileSync(fileURLToPath(url), "utf8"); src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples"); src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; }); return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; } """ def _node(body: str) -> object: files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()} script = ( "import { register } from 'node:module';\n" f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" "globalThis.__stub = { currentLanguageIndex: 0 };\n" f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n" f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n" "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body ) result = subprocess.run( ["node", "--no-warnings", "--input-type=module", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, ) # fmt: skip assert result.returncode == 0, result.stderr[-2000:] return json.loads(result.stdout) # ── 모양 ───────────────────────────────────────────────────────────────── def _ports_out(box: dict) -> list[str]: if box["kind"] == "function": return [box["func"]["output"]["name"]] if box["kind"] == "output": return [] inp = box["input"] return ( [e["name"] for e in inp.get("elements", [])] + (["표"] if inp.get("table") else []) + [v["name"] for v in inp.get("variables", [])] ) def _ports_in(box: dict) -> list[str]: if box["kind"] == "function": return [i["port"] for i in box["func"]["inputs"]] return ["값"] if box["kind"] == "output" else [] def _order(lg: dict) -> list[dict]: """함수 상자 차례(앞 값을 받는 것이 뒤) — 순환이면 실패""" boxes = {b["id"]: b for b in lg["boxes"]} need = {b["id"]: {link["from"]["box"] for link in lg["links"] if link["to"]["box"] == b["id"]} for b in lg["boxes"]} # fmt: skip done: list[str] = [] while len(done) < len(boxes): ready = [i for i in boxes if i not in done and need[i] <= set(done)] assert ready, "선이 돌고 돎" done += ready return [boxes[i] for i in done] @pytest.mark.parametrize("name", ["masonry", "rate", "value"]) def test_견본_모양(name: str) -> None: lg = SAMPLES[name] assert lg["key"] and lg["name"] and lg["resultUnit"] and lg["userElements"] == [] boxes = {b["id"]: b for b in lg["boxes"]} assert len(boxes) == len(lg["boxes"]) sheets = [b["func"]["sheet"]["id"] for b in lg["boxes"] if b["kind"] == "function"] assert len(set(sheets)) == len(sheets) for b in lg["boxes"]: assert b["kind"] in ("input", "function", "output") assert ( b["color"].startswith("#") and b["unit"] and isinstance(b["x"], int) and isinstance(b["y"], int) ) if b["kind"] == "function": fn = b["func"] cells = fn["sheet"]["칸"] assert "식" in cells[fn["output"]["cell"]] # 출력 하나 = 수식 칸 assert len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"]) if b["kind"] == "output": assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값") for link in lg["links"]: a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]] assert link["from"]["name"] in _ports_out(a), link assert link["to"]["name"] in _ports_in(z), link # 받는 자리마다 선 하나 · 출력에서 나가는 선 없음 · 왼 → 오 for b in lg["boxes"]: for port in _ports_in(b): got = [x for x in lg["links"] if x["to"]["box"] == b["id"] and x["to"]["name"] == port] assert len(got) == 1, (b["id"], port) for link in lg["links"]: assert boxes[link["from"]["box"]]["kind"] != "output" assert boxes[link["from"]["box"]]["x"] < boxes[link["to"]["box"]]["x"] _order(lg) def test_견본_목록_열기() -> None: got = _node( "out({ list: S.sampleList(), one: S.sampleLoad('masonry'), none: S.sampleLoad('없음') });" ) assert [x["id"] for x in got["list"]] == ["masonry", "rate", "value"] assert all(x["note"] for x in got["list"]) assert got["one"]["key"] is None and got["one"]["boxes"] == SAMPLES["masonry"]["boxes"] assert got["none"] is None # ── 값 ─────────────────────────────────────────────────────────────────── @pytest.fixture(scope="module") def master(): from M01_MasterData import M01_MasterData_Store as store cm = store.cm files = cm.load(folder=store.FOLDER) return cm, files def _table_rows(table: dict) -> list[list]: """품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙(범위 = 「이름·아래」 「이름·위」 · 숫자 하나면 둘 다)""" conds, lines = table.get("조건") or {}, table.get("줄") or [] names = [*conds, *(table.get("값칸") or {})] split = { n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) } head = [h for n in names for h in ((f"{n}·아래", f"{n}·위") if n in split else (n,))] rows = [] for r in lines: line: list = [] for n in names: v = r.get(n) if n in split: line += list(v) if isinstance(v, list) else [v, v] else: line.append(v) rows.append(line) return [head, *rows] def _plain(v): return float(v) if isinstance(v, Decimal) else v def _run(lg: dict, master) -> dict: """상자 차례대로 — 입력 값(예시값 · 노임 · 표) → 함수 상자 시트에 넣고 엔진으로 셈 → 출력 상자 값""" from common_util.common_util_spreadsheet import recalc_workbooks cm, files = master m = cm.mf.Master(files) tables = {str(t["키"]): t for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip ports: dict[str, dict] = {} for b in _order(lg): into = {x["to"]["name"]: ports[x["from"]["box"]][x["from"]["name"]] for x in lg["links"] if x["to"]["box"] == b["id"]} # fmt: skip if b["kind"] == "input": inp = b["input"] got = {v["name"]: v["example"] for v in inp.get("variables", [])} got |= { e["name"]: _plain(cm.mf.element(m, e["ref"], {})[0]) for e in inp.get("elements", []) } if inp.get("table"): got["표"] = _table_rows(tables[inp["table"]["key"]]) ports[b["id"]] = got elif b["kind"] == "function": fn = b["func"] sheet = json.loads(json.dumps(fn["sheet"])) for i in fn["inputs"]: value = into[i["port"]] col, row = ord(i["cell"][0]) - 65, int(i["cell"][1:]) block = value if isinstance(value, list) else [[value]] for r, line in enumerate(block): for c, x in enumerate(line): if x is not None: sheet["칸"][f"{chr(65 + col + c)}{row + r}"] = {"값": _plain(x)} book = {"종류": "통합문서", "판": 1, "열": "cf", "서식": [{}], "시트": [sheet]} out = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]] assert "수" in out, out ports[b["id"]] = {fn["output"]["name"]: float(out["수"])} else: ports[b["id"]] = {"값": into["값"]} return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"} def test_견본1_원가_calc_와_같음(master) -> None: from M01_MasterData.LogicBuilder import M01_LB_Calc as lb cm, files = master row = next( r for d in files.values() if d.get("그룹") == "로직" for r in d["줄"] if r["키"] == "GF000219" ) given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]} want = lb.calc(json.loads(json.dumps(row, default=_plain)), {**given, "초과증가율": 80}, {}) assert want["ok"], want got = _run(SAMPLES["masonry"], master)["out-pay"] print(f"\n견본 ① 노무비 {got} · /calc {want['sums']['계']}") assert abs(got - float(want["sums"]["계"])) < 1 def test_견본3_값_calc_와_같음_견본2_비율(master) -> None: from M01_MasterData.LogicBuilder import M01_LB_Calc as lb cm, files = master row = next( r for d in files.values() if d.get("그룹") == "로직" for r in d["줄"] if r["키"] == "GF000133" ) given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]} given["qt"] = given.pop("q") # 견본은 정함대로 「q」 · 기존 로직 입력 이름은 「qt」 want = lb.calc(json.loads(json.dumps(row, default=_plain)), given, {}) assert _run(SAMPLES["value"], master)["out-q"] == float(want["result"]) assert _run(SAMPLES["rate"], master)["out-mat"] == 100000 * 3 / 100