- 다이어그램 opts.fitAllMin(단추) · fitMin(저절로) 나눔 · Paper · Panel 이 넘김 - 시험 diagram_parts 8 + panel 2 통과 · DataFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
422 lines
15 KiB
Python
422 lines
15 KiB
Python
"""단가산출 흐름도 다이어그램 순수 부분(`M02_DF_Diagram_Parts.ts`) — 기댐 선 · 흐름 길 포함 · 쉼 가르기 · 맞춤.
|
|
|
|
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(Model · Rule …)는 이어 트랜스파일 ·
|
|
화면 부품(@ui)은 빈 흉내. 견본 로직은 이 시험 안에 둠.
|
|
"""
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_Diagram_Parts.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 stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) });
|
|
const cache = {};
|
|
function load(f) {
|
|
if (cache[f]) return cache[f].exports;
|
|
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
|
}).outputText;
|
|
const box = (cache[f] = { exports: {} });
|
|
const req = (id) => {
|
|
if (id.startsWith(".")) {
|
|
const p = path.resolve(path.dirname(f), id);
|
|
return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub;
|
|
}
|
|
return stub;
|
|
};
|
|
new Function("exports", "module", "require", js)(box.exports, box, req);
|
|
return box.exports;
|
|
}
|
|
const m = load(file);
|
|
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
const out = jobs.map((job) => {
|
|
if (job.op === "deps") return m.depLines(job.lg).map((d) => ({ id: d.id, from: d.from, to: d.to }));
|
|
if (job.op === "inTrace") return m.depLines(job.lg).map((d) => m.depInTrace(d, job.steps));
|
|
if (job.op === "rest") return job.breaks.map((b) => m.isRest(b));
|
|
if (job.op === "fit") return m.fitView(job.rects, job.view, 40, job.min);
|
|
if (job.op === "value") return job.values.map((v) => m.showValue(v));
|
|
if (job.op === "end") return m.depEnd(job.a, job.tag);
|
|
if (job.op === "restParts") {
|
|
const r = m.restParts(job.lg, job.rests, job.values ?? {});
|
|
return { links: [...r.links].sort(), pills: [...r.pills].sort(), boxes: [...r.boxes].sort() };
|
|
}
|
|
if (job.op === "path") return m.depPath(job.a, job.b, job.side);
|
|
return null;
|
|
});
|
|
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)
|
|
|
|
|
|
def port(box, name):
|
|
return {"box": box, "name": name}
|
|
|
|
|
|
# 견본 — 관경 → 흄관 재료(조건 찾기) · 지반 → 조건 선 · 뒷길이 → 표 조건
|
|
LOGIC = {
|
|
"key": None,
|
|
"name": "견본",
|
|
"resultUnit": "원/m",
|
|
"userElements": [],
|
|
"boxes": [
|
|
{
|
|
"id": "v",
|
|
"kind": "input",
|
|
"name": "설계 값",
|
|
"color": "",
|
|
"unit": "-",
|
|
"x": 0,
|
|
"y": 0,
|
|
"input": {
|
|
"mode": "variable",
|
|
"elements": [],
|
|
"variables": [
|
|
{"name": "관경", "type": "choice", "unit": "㎜", "example": 600},
|
|
{"name": "지반", "type": "choice", "unit": "-", "example": "암"},
|
|
{"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
"id": "m",
|
|
"kind": "input",
|
|
"name": "재료",
|
|
"color": "",
|
|
"unit": "-",
|
|
"x": 0,
|
|
"y": 200,
|
|
"input": {
|
|
"mode": "group",
|
|
"group": "재료",
|
|
"variables": [],
|
|
"elements": [
|
|
{
|
|
"ref": "",
|
|
"name": "흄관",
|
|
"spec": "",
|
|
"unit": "본",
|
|
"mode": "rule",
|
|
"slot": "흄관",
|
|
"rule": {"field": "규격", "op": "포함", "from": port("v", "관경")},
|
|
},
|
|
{"ref": "MT1", "name": "모래", "spec": "", "unit": "㎥", "mode": "fixed"},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
"id": "t",
|
|
"kind": "input",
|
|
"name": "품 표",
|
|
"color": "",
|
|
"unit": "-",
|
|
"x": 0,
|
|
"y": 400,
|
|
"input": {
|
|
"mode": "group",
|
|
"group": "소요량",
|
|
"elements": [],
|
|
"variables": [],
|
|
"table": {"key": "QF1"},
|
|
},
|
|
},
|
|
{
|
|
"id": "f",
|
|
"kind": "function",
|
|
"name": "셈",
|
|
"color": "",
|
|
"unit": "-",
|
|
"x": 300,
|
|
"y": 100,
|
|
"func": {
|
|
"sheet": {},
|
|
"inputs": [{"port": "품", "cell": "B1"}, {"port": "흄관", "cell": "B2"}],
|
|
"variables": [],
|
|
"output": {"cell": "B9", "name": "금액", "unit": "원"},
|
|
},
|
|
},
|
|
],
|
|
"links": [
|
|
{
|
|
"id": "l1",
|
|
"from": port("t", "표"),
|
|
"to": port("f", "품"),
|
|
"rule": {
|
|
"kind": "lookup",
|
|
"conds": [{"col": "뒷길이", "from": port("v", "뒷길이")}],
|
|
"col": "석공",
|
|
},
|
|
},
|
|
{
|
|
"id": "l2",
|
|
"from": port("m", "흄관"),
|
|
"to": port("f", "흄관"),
|
|
"rule": {"kind": "gate", "from": port("v", "지반"), "op": "=", "value": "암"},
|
|
},
|
|
{"id": "l3", "from": port("v", "관경"), "to": port("f", "흄관")},
|
|
],
|
|
}
|
|
|
|
|
|
def test_기댐_선_셋():
|
|
[deps] = run([{"op": "deps", "lg": LOGIC}])
|
|
got = sorted((d["from"]["name"], json.dumps(d["to"], ensure_ascii=False)) for d in deps)
|
|
assert got == sorted(
|
|
[
|
|
("뒷길이", json.dumps({"link": "l1"})),
|
|
("지반", json.dumps({"link": "l2"})),
|
|
("관경", json.dumps({"box": "m", "port": "흄관"}, ensure_ascii=False)),
|
|
]
|
|
)
|
|
|
|
|
|
def test_기댐_선이_흐름_길에_듦():
|
|
steps = [
|
|
{"box": "v", "port": "지반", "dir": "out"},
|
|
{"box": "f", "port": "흄관", "dir": "in", "link": "l2"},
|
|
{"box": "m", "port": "흄관", "dir": "out"},
|
|
]
|
|
[marks] = run([{"op": "inTrace", "lg": LOGIC, "steps": steps}])
|
|
# 차례: l1 표 조건(뒷길이) · l2 조건(지반) · 재료 조건 찾기(관경)
|
|
assert marks == [False, True, False]
|
|
|
|
|
|
def test_쉼_가르기():
|
|
[rest] = run(
|
|
[
|
|
{
|
|
"op": "rest",
|
|
"breaks": [
|
|
{
|
|
"kind": "통과 안 됨",
|
|
"level": "쉼",
|
|
"box": "f",
|
|
"link": "l2",
|
|
"text": "지반이 암이 아님",
|
|
},
|
|
{
|
|
"kind": "빈 받는 자리",
|
|
"level": "문제",
|
|
"box": "f",
|
|
"port": "품",
|
|
"text": "비어 있음",
|
|
},
|
|
{"kind": "통과 안 됨", "level": "문제", "box": "f", "text": "level 이 정함"},
|
|
{"kind": "조건 겹침", "level": "쉼", "box": "f", "text": "level 이 정함"},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
assert rest == [True, False, False, True]
|
|
|
|
|
|
def test_한눈에_90_아래로_안_내림과_알약_값():
|
|
fit, values = run(
|
|
[
|
|
{
|
|
"op": "fit",
|
|
"rects": [{"x": 0, "y": 0, "w": 3000, "h": 200}],
|
|
"view": {"w": 1000, "h": 600},
|
|
},
|
|
{
|
|
"op": "value",
|
|
"values": [
|
|
97882.512,
|
|
268908,
|
|
0.3640,
|
|
"깬돌",
|
|
[["뒷길이", "석공"], [25, 0.15], [30, 0.22]],
|
|
],
|
|
},
|
|
]
|
|
)
|
|
assert fit["zoom"] == 0.9
|
|
assert values == ["97,882.512", "268,908", "0.364", "깬돌", "· 2줄"]
|
|
|
|
|
|
def test_기댐_선_끝이_알약과_이름표에_붙음():
|
|
left, right, path_left, path_right = run(
|
|
[
|
|
{"op": "end", "a": {"x": 100, "y": 50}, "tag": {"x": 400, "y": 120, "w": 80}},
|
|
{"op": "end", "a": {"x": 600, "y": 50}, "tag": {"x": 400, "y": 120, "w": 80}},
|
|
{"op": "path", "a": {"x": 100, "y": 50}, "b": {"x": 358, "y": 120}, "side": "left"},
|
|
{"op": "path", "a": {"x": 100, "y": 50}, "b": {"x": 130, "y": 300}, "side": "right"},
|
|
]
|
|
)
|
|
# 시작이 왼쪽이면 이름표 왼쪽 가장자리(400 − 40 − 2) · 오른쪽이면 오른쪽 가장자리
|
|
assert left == {"x": 358, "y": 120, "side": "left"}
|
|
assert right == {"x": 442, "y": 120, "side": "right"}
|
|
# 길은 시작 알약에서 나가 끝에서 끝남 · 재료 알약은 오른쪽에서 들어감(끝 손잡이가 끝보다 오른쪽)
|
|
assert path_left.startswith("M100,50 ") and path_left.endswith(" 358,120")
|
|
assert path_right.startswith("M100,50 ") and path_right.endswith(" 130,300")
|
|
handle = float(path_right.split(" ")[2].split(",")[0])
|
|
assert handle > 130
|
|
|
|
|
|
def test_쉼은_일부면_선과_받는_알약만_모두면_상자도():
|
|
two_in = {
|
|
**LOGIC,
|
|
"links": [
|
|
{"id": "a", "from": port("v", "지반"), "to": port("f", "품")},
|
|
{"id": "b", "from": port("m", "흄관"), "to": port("f", "흄관")},
|
|
],
|
|
}
|
|
one_rest = [
|
|
{"kind": "통과 안 됨", "level": "쉼", "box": "f", "port": "흄관", "link": "b", "text": "쉼"}
|
|
]
|
|
all_rest = one_rest + [
|
|
{"kind": "통과 안 됨", "level": "쉼", "box": "f", "port": "품", "link": "a", "text": "쉼"}
|
|
]
|
|
box_rest = [{"kind": "통과 안 됨", "level": "쉼", "box": "m", "text": "쉼"}]
|
|
part, every, filled, empty = run(
|
|
[
|
|
{"op": "restParts", "lg": two_in, "rests": one_rest, "values": {"f": {"금액": 2000}}},
|
|
{"op": "restParts", "lg": two_in, "rests": all_rest, "values": {"f": {"금액": None}}},
|
|
{"op": "restParts", "lg": two_in, "rests": box_rest, "values": {"m": {"흄관": 52000}}},
|
|
{"op": "restParts", "lg": two_in, "rests": box_rest, "values": {"m": {"흄관": None}}},
|
|
]
|
|
)
|
|
# 하나만 쉼 — 셈하는 상자는 안 흐림 · 그 선과 받는 알약만
|
|
assert part == {"links": ["b"], "pills": ["f|흄관"], "boxes": []}
|
|
# 들어오는 선이 모두 쉼 — 상자도 흐림
|
|
assert every["boxes"] == ["f"]
|
|
# 상자에 걸린 쉼 — 내보내는 값이 있으면 안 흐림 · 모두 빈 값이면 흐림
|
|
assert filled["boxes"] == [] and empty["boxes"] == ["m"]
|
|
|
|
|
|
def test_지반_토사_쉼_흐림은_브레이커로_깨기_하나():
|
|
def fn(id_, name, ins, out):
|
|
return {
|
|
"id": id_,
|
|
"kind": "function",
|
|
"name": name,
|
|
"color": "",
|
|
"unit": "-",
|
|
"x": 0,
|
|
"y": 0,
|
|
"func": {
|
|
"sheet": {},
|
|
"inputs": [{"port": p, "cell": f"B{i + 1}"} for i, p in enumerate(ins)],
|
|
"variables": [],
|
|
"output": {"cell": "B9", "name": out, "unit": "원"},
|
|
},
|
|
}
|
|
|
|
gate = {"kind": "gate", "from": port("ig", "지반"), "op": "=", "value": "암"}
|
|
ground = {
|
|
"key": None,
|
|
"name": "지반",
|
|
"resultUnit": "원/㎥",
|
|
"userElements": [],
|
|
"boxes": [
|
|
{
|
|
"id": "ig",
|
|
"kind": "input",
|
|
"name": "지반 · 굴착량",
|
|
"color": "",
|
|
"unit": "-",
|
|
"x": 0,
|
|
"y": 0,
|
|
"input": {
|
|
"mode": "variable",
|
|
"elements": [],
|
|
"variables": [
|
|
{"name": "지반", "type": "choice", "unit": "-", "example": "토사"},
|
|
{"name": "굴착량", "type": "number", "unit": "㎥", "example": 1},
|
|
],
|
|
},
|
|
},
|
|
fn("exc", "굴삭기로 파기", ["굴착량", "시간당 경비"], "굴삭기 경비"),
|
|
fn("brk", "브레이커로 깨기", ["굴착량", "시간당 경비"], "브레이커 경비"),
|
|
fn("sum", "1㎥ 경비", ["굴삭기 경비", "브레이커 경비", "굴착량"], "1㎥ 경비"),
|
|
{
|
|
"id": "out",
|
|
"kind": "output",
|
|
"name": "경비",
|
|
"color": "",
|
|
"unit": "원",
|
|
"x": 0,
|
|
"y": 0,
|
|
"output": {"cost": "경비", "unit": "원"},
|
|
},
|
|
],
|
|
"links": [
|
|
{
|
|
"id": "l1",
|
|
"from": port("ig", "굴착량"),
|
|
"to": port("exc", "굴착량"),
|
|
"rule": {**gate, "op": "≠"},
|
|
},
|
|
{"id": "l2", "from": port("ig", "굴착량"), "to": port("brk", "굴착량"), "rule": gate},
|
|
{"id": "l5", "from": port("exc", "굴삭기 경비"), "to": port("sum", "굴삭기 경비")},
|
|
{"id": "l6", "from": port("brk", "브레이커 경비"), "to": port("sum", "브레이커 경비")},
|
|
{"id": "l7", "from": port("ig", "굴착량"), "to": port("sum", "굴착량")},
|
|
{"id": "l8", "from": port("sum", "1㎥ 경비"), "to": port("out", "값")},
|
|
],
|
|
}
|
|
# Eval 쉼 줄은 link 가 늘 있음 — 조건 선 l2(받는 상자 브레이커) · 빈 값이 흘러간 l6(받는 상자 1㎥ 경비)
|
|
rests = [
|
|
{
|
|
"kind": "통과 안 됨",
|
|
"level": "쉼",
|
|
"box": "brk",
|
|
"port": "굴착량",
|
|
"link": "l2",
|
|
"text": "암이 아님",
|
|
},
|
|
{
|
|
"kind": "통과 안 됨",
|
|
"level": "쉼",
|
|
"box": "sum",
|
|
"port": "브레이커 경비",
|
|
"link": "l6",
|
|
"text": "쉼",
|
|
},
|
|
]
|
|
values = {
|
|
"brk": {"시간당 경비": 80000, "굴착량": None, "브레이커 경비": None},
|
|
"sum": {"굴삭기 경비": 2000, "브레이커 경비": None, "굴착량": 1, "1㎥ 경비": 2000},
|
|
"out": {"값": 2000},
|
|
}
|
|
[got] = run([{"op": "restParts", "lg": ground, "rests": rests, "values": values}])
|
|
assert got["boxes"] == ["brk"]
|
|
assert got["links"] == ["l2", "l6"]
|
|
assert got["pills"] == ["brk|굴착량", "sum|브레이커 경비"]
|
|
|
|
|
|
def test_패널_처음_배율은_읽히는_최소_넘치면_왼쪽_위부터():
|
|
rects = [{"x": 40, "y": 40, "w": 200, "h": 120}, {"x": 1500, "y": 600, "w": 200, "h": 120}]
|
|
auto, whole = run(
|
|
[
|
|
{"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.6},
|
|
{"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.25},
|
|
]
|
|
)
|
|
# 처음 — 0.6 아래로 안 내림 · 넘치면 왼쪽 위(첫 상자 - 여백)가 판 왼쪽 위
|
|
assert auto["zoom"] == 0.6
|
|
assert auto["panX"] == 0 and auto["panY"] == 0
|
|
# 한눈에 — 전체가 들어가게 더 작게
|
|
assert whole["zoom"] < 0.6
|