- 파일 M02_CF_* → M02_DF_* · css m02-cf/m01-cf → m02-df · 서버 길 /api/m02/cf → /api/m02/df · 키 CF- → DF- · 사용자 요소 UE- 그대로 - _sample · .gitignore(_data git 밖) 옮김 · DataFlow 는 CostFlow 를 import 안 함 - 시험 test_m02_df_model · router · sample · inputpanel_build(옛 cf 시험은 그대로) - 서버 등록은 sub1 몫 — M02_MasterTemplete.DataFlow.M02_DF_Router 의 router Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`.
|
||
|
||
· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
|
||
· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진
|
||
(Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만
|
||
· ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100
|
||
· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow"
|
||
SAMPLE_TS = FOLDER / "M02_DF_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]
|
||
|
||
|
||
# Model 기본 색(`M02_DF_Model.ts` BOX_COLOR) — 견본 색은 이것과 같아야 함
|
||
BOX_COLOR = dict(
|
||
re.findall(
|
||
r'(\w+): "(#[0-9a-fA-F]{6})"',
|
||
re.search(
|
||
r"BOX_COLOR[^{]*\{([^}]*)\}", (FOLDER / "M02_DF_Model.ts").read_text(encoding="utf-8")
|
||
).group(1),
|
||
)
|
||
)
|
||
|
||
|
||
@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"] == BOX_COLOR[b["kind"]]
|
||
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 ("노무비", "재료비", "경비", "값")
|
||
units = {e["unit"] for e in (b.get("input") or {}).get("elements") or []}
|
||
if len(units) == 1: # 요소 단위가 모두 같으면 상자 단위도 그것(공통 단위 규칙)
|
||
assert b["unit"] == next(iter(units)), b["id"]
|
||
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_견본_표_그룹이_마스터와_같음(master) -> None:
|
||
"""표 입력 상자의 group = 그 표가 든 마스터 파일 그룹(소요량 · 계수)"""
|
||
cm, files = master
|
||
group = {str(t["키"]): d["그룹"] for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip
|
||
for lg in SAMPLES.values():
|
||
for b in lg["boxes"]:
|
||
table = b.get("input", {}).get("table")
|
||
if table:
|
||
assert b["input"]["group"] == group[table["key"]], (b["id"], table["key"])
|
||
|
||
|
||
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
|