diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py index d447e8eda..3abcdc73b 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -440,6 +440,9 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict: return sheet +REST = "앞 조건이 안 맞아 쉬는 갈래" + + def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | None = None) -> dict: """셈 — {ports, outputs, cost, errors, breaks, filled}(filled = 받는 칸에 값만 되돌려 넣음).""" ctx = {"load": load, "local": local, "stack": stack or [lg.get("key") or ""]} @@ -448,6 +451,7 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N name = lambda i: boxes.get(i, {}).get("name") or i # noqa: E731 errors, breaks, ports, arrived, outputs = [], [], {}, {}, {} blocked: set[str] = set() + resting: set[str] = set() # 조건이 안 맞아 쉬는 상자 — Eval 과 같은 뜻 cost: dict[str, Any] = {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}} def through(ln: dict) -> Any: @@ -464,13 +468,15 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N got, why = apply_rule(rule, v, conds) if why: blocked.add(ln["to"].get("box")) + if why == "통과 안 됨": + resting.add(ln["to"].get("box")) text = f"「{name(src.get('box'))}」 → 「{name(ln['to'].get('box'))}」 {why}" kind = "오류" if why in ("표에 여럿 맞음", "오류") else why to = ln["to"] breaks.append( { "kind": kind, - # 무게 — 통과 안 됨 = 쉼(조건이 안 맞아 쉬는 갈래) · 나머지 = 문제(Eval CfBreak.level 과 같음) + # 무게 — 통과 안 됨 = 쉼 · 나머지 = 문제(Eval CfBreak.level 과 같음) "level": "쉼" if kind == "통과 안 됨" else "문제", "box": to.get("box"), "port": to.get("name"), @@ -483,9 +489,32 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N return None if why else got def arrive(box: dict) -> dict: - got = { - ln["to"].get("name"): through(ln) for ln in links if ln["to"].get("box") == box["id"] - } + """받는 값 — 쉬는 갈래 빈 값은 「쉼」 줄만 · 선이 모두 쉬면 상자도 쉼(Eval 과 같음).""" + ins = [ln for ln in links if ln["to"].get("box") == box["id"]] + got, rest = {}, 0 + for ln in ins: + src, to = ln.get("from") or {}, ln["to"] + if ( + src.get("box") in resting + and ports.get(src.get("box"), {}).get(src.get("name")) is None + ): + rest += 1 + got[to.get("name")] = None + breaks.append( + { + "kind": "통과 안 됨", + "level": "쉼", + "box": to.get("box"), + "port": to.get("name"), + "link": ln.get("id"), + "text": f"「{name(src.get('box'))}」 → 「{name(to.get('box'))}」 — {REST}", + } + ) + continue + got[to.get("name")] = through(ln) + if ins and rest == len(ins): + resting.add(box["id"]) + blocked.add(box["id"]) arrived[box["id"]] = got return got diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css index 74e5bed78..2687a588f 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css @@ -498,3 +498,11 @@ gap: var(--spacing-8); pointer-events: none; } + +/* 쉼 — 받는 알약만(상자 흐림은 들어오는 선이 모두 쉼일 때) */ +.m02-dfdg__pill.is-in.is-rest { + border-style: dashed; + border-color: var(--color-text-muted); + color: var(--color-text-muted); + opacity: 0.7; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index f67ab35cd..9dd41038a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -14,10 +14,13 @@ import { clampZoom, fitView, KIND_COLOR, + depEnd, depInTrace, + depPath, follow, depLines, isRest, + restParts, linkLabel, midOf, pillsOf, @@ -84,11 +87,11 @@ export function renderDiagram( /** 「!」 는 문제에만 · 쉼(조건이 안 맞아 이번엔 안 지나감)은 회색 점선 선 + 받는 상자 흐리게 */ const breaks = allBreaks.filter((k) => !isRest(k)); const rests = allBreaks.filter(isRest); - const restLinks = new Set(rests.map((k) => k.link).filter(Boolean)); - const restBoxes = new Set([ - ...rests.filter((k) => !k.link).map((k) => k.box), - ...lg.links.filter((l) => restLinks.has(l.id)).map((l) => l.to.box), - ]); + const { + links: restLinks, + pills: restPills, + boxes: restBoxes, + } = restParts(lg, rests, opts.values ?? {}); const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? ""; const redraw = (): void => renderDiagram(host, lg, on, { ...opts, selected: st.selected, selectedPill: st.pill }); @@ -158,6 +161,7 @@ export function renderDiagram( wired ? "" : "is-empty", chosen ? "is-chosen" : "", broken ? "is-broken" : "", + dir === "in" && restPills.has(`${b.id}|${p.name}`) ? "is-rest" : "", st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "", ].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)), attrs: { @@ -483,7 +487,7 @@ export function renderDiagram( parent.append(n); return n; }; - const label = (parent: Element, x: number, y: number, text: string, cls: string): void => { + const label = (parent: Element, x: number, y: number, text: string, cls: string): number => { const tag = add(parent, "g", { class: cls }); const box = add(tag, "rect", { rx: "4", ry: "4" }); const t = add(tag, "text", { @@ -497,6 +501,7 @@ export function renderDiagram( Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) => box.setAttribute(k, v.toFixed(1)), ); + return w; }; const defs = add(svg, "defs", {}); const arrow = add(defs, "marker", { @@ -509,7 +514,16 @@ export function renderDiagram( orient: "auto", }); add(arrow, "path", { d: "M0,0 L8,4 L0,8 z", class: "m02-dfdg__arrow" }); - const mids = new Map(); + const dot = add(defs, "marker", { + id: "m02-dfdg-dot", + viewBox: "0 0 8 8", + refX: "4", + refY: "4", + markerWidth: "6", + markerHeight: "6", + }); + add(dot, "circle", { cx: "4", cy: "4", r: "3", class: "m02-dfdg__arrow" }); + const mids = new Map(); for (const l of lg.links) { const a = at(l.from, "out"); const b = at(l.to, "in"); @@ -531,9 +545,8 @@ export function renderDiagram( add(g, "path", { d, class: "m02-dfdg__hit" }); add(g, "path", { d, class: "m02-dfdg__wire" }); const mid = midOf(a, b); - mids.set(l.id, mid); const rule = labels[l.id] ?? linkLabel(l); - if (rule) label(g, mid.x, mid.y, rule, "m02-dfdg__rule"); + mids.set(l.id, { ...mid, w: rule ? label(g, mid.x, mid.y, rule, "m02-dfdg__rule") : 0 }); if (rest) label(g, mid.x, mid.y + (rule ? 18 : 0), tt("Rest"), "m02-dfdg__restmark"); const broken = breaks.filter((k) => k.link === l.id); const why = [...broken, ...rests.filter((k) => k.link === l.id)] @@ -559,14 +572,14 @@ export function renderDiagram( } // 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 · 화살 for (const dep of depLines(lg)) { + // 시작 = 조건 알약 오른쪽 · 끝 = 그 규칙 선의 이름표 가장자리 또는 받는 재료 알약 오른쪽 const a = at(dep.from, "out"); - const b = - "link" in dep.to - ? mids.get(dep.to.link) - : at({ box: dep.to.box, name: dep.to.port }, "out", "far"); - if (!a || !b) continue; - const dx = Math.max(40, Math.abs(b.x - a.x) / 2); - const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`; + const tag = "link" in dep.to ? mids.get(dep.to.link) : null; + const pill = "box" in dep.to ? at({ box: dep.to.box, name: dep.to.port }, "out") : null; + const end = tag ? depEnd(a, tag) : pill && { ...pill, side: "right" as const }; + if (!a || !end) continue; + const b = { x: end.x, y: end.y }; + const d = depPath(a, b, end.side); const where = "link" in dep.to ? dep.to.link : dep.to.box; const steps = opts.trace; const traced = @@ -583,7 +596,12 @@ export function renderDiagram( "data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","), }); add(g, "path", { d, class: "m02-dfdg__hit" }); - add(g, "path", { d, class: "m02-dfdg__depwire", "marker-end": "url(#m02-dfdg-arrow)" }); + add(g, "path", { + d, + class: "m02-dfdg__depwire", + "marker-start": "url(#m02-dfdg-dot)", + "marker-end": "url(#m02-dfdg-arrow)", + }); const m = midOf(a, b); label(g, m.x, m.y, dep.text, "m02-dfdg__deptext"); g.addEventListener("click", (ev) => { diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index 6c03d9b21..cf0616712 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -284,9 +284,52 @@ export function depInTrace(dep: DepLine, steps: CfStep[]): boolean { return src && dst; } +type Pt = { x: number; y: number }; + +/** 이름표에 닿는 끝 — 시작이 왼쪽이면 이름표 왼쪽 가장자리 · 오른쪽이면 오른쪽 가장자리(이름표가 없으면 선 가운데) */ +export function depEnd(a: Pt | null, tag: Pt & { w: number }): Pt & { side: "left" | "right" } { + const fromLeft = !a || a.x <= tag.x; + return { + x: tag.x + (fromLeft ? -1 : 1) * (tag.w / 2 + 2), + y: tag.y, + side: fromLeft ? "left" : "right", + }; +} + +/** 기댐 선 길 — 알약 오른쪽에서 나가 끝에 닿음 · side = 끝으로 들어가는 쪽(재료 알약은 오른쪽에서) */ +export function depPath(a: Pt, b: Pt, side: "left" | "right"): string { + const dx = Math.max(40, Math.abs(b.x - a.x) / 2); + const bx = side === "left" ? b.x - dx : b.x + dx; + return `M${a.x},${a.y} C${a.x + dx},${a.y} ${bx},${b.y} ${b.x},${b.y}`; +} + /** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — Eval 의 level 그대로 */ export const isRest = (k: CfBreak): boolean => k.level === "쉼"; +/** 쉼 모양 자리 — 선 · 받는 알약(「상자|알약」) · 흐릴 상자 + * 상자 흐림은 들어오는 선이 모두 쉼일 때 · 또는 상자에 걸린 쉼이 있고 그 상자 내보내는 값이 모두 빈 값일 때만 */ +export function restParts( + lg: CfLogic, + rests: CfBreak[], + values: Record>, +): { links: Set; pills: Set; boxes: Set } { + const links = new Set(rests.map((k) => k.link).filter((x): x is string => !!x)); + const pills = new Set( + lg.links.filter((l) => links.has(l.id)).map((l) => `${l.to.box}|${l.to.name}`), + ); + const boxes = new Set(); + const empty = (v: unknown): boolean => v === null || v === undefined || v === ""; + for (const b of lg.boxes) { + const incoming = lg.links.filter((l) => l.to.box === b.id); + const allRest = incoming.length > 0 && incoming.every((l) => links.has(l.id)); + // 내보내는 알약 값만(들어온 값은 빼고) · 쉼이 그 상자에 걸렸나(Eval 쉼 줄은 link 가 늘 있음) + const outs = portsOut(b).map((p) => values[b.id]?.[p.name]); + const hit = rests.some((k) => k.box === b.id); + if (allRest || (hit && outs.every(empty))) boxes.add(b.id); + } + return { links, pills, boxes }; +} + /* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */ /** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css index 0047e4863..05228f379 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css @@ -254,6 +254,10 @@ border-bottom: 0; } +.m02-df__detail-row[hidden] { + display: none; +} + .m02-df__detail-row h4 { margin: 0; color: var(--color-text-secondary); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts index 00cf48c4c..e6a60b0e8 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts @@ -474,6 +474,8 @@ function openDataFlow(): void { if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null; const box = lg.boxes.find((b) => b.id === pickedBox()); drawHead(detail, lg, pick); + // 설정 줄(상자 이름 · 색)은 상자를 고른 때만 — 선 · 알약이면 줄째 숨김 + (detail.settings.parentElement as HTMLElement).hidden = !box; colorRow.hidden = !box; boxName.root.hidden = !box || box.kind === "output"; if (box && document.activeElement !== boxName.input) boxName.input.value = box.name; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts index aba6e5f26..a35d22cf9 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -263,21 +263,6 @@ export function evalLogic( /** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */ const through = (l: CfLink): CfValue => { const v = valueOf(l.from.box, l.from.name); - // 쉬는 갈래에서 온 빈 값 — 받는 상자도 쉼(셈하지 않음 · 문제로 안 셈) - if (v === null && resting.has(l.from.box)) { - resting.add(l.to.box); - blocked.add(l.to.box); - const text = tl("Rest", { from: name(l.from.box), box: name(l.to.box) }); - found.push({ - kind: "통과 안 됨", - level: "쉼", - box: l.to.box, - port: l.to.name, - link: l.id, - text, - }); - return null; - } if (!l.rule) return v; if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null; const conds = Object.fromEntries( @@ -311,10 +296,33 @@ export function evalLogic( if (res.why) blocked.add(l.to.box); return res.why ? null : res.value; }; + /** 받는 값 — 쉬는 갈래에서 온 빈 값은 「쉼」 줄만 남김 · 들어오는 선이 모두 쉬는 갈래면 받는 상자도 쉼 + * (하나라도 살아 있으면 셈 · 쉬는 갈래 값은 빈 칸) · 조건 선 바로 뒤 상자는 through 가 막음 */ const arrive = (box: CfBox): Record => { - const got = Object.fromEntries( - lg.links.filter((l) => l.to.box === box.id).map((l) => [l.to.name, through(l)]), - ); + const ins = lg.links.filter((l) => l.to.box === box.id); + const got: Record = {}; + let rest = 0; + for (const l of ins) { + if (resting.has(l.from.box) && valueOf(l.from.box, l.from.name) === null) { + rest++; + got[l.to.name] = null; + const text = tl("Rest", { from: name(l.from.box), box: name(l.to.box) }); + found.push({ + kind: "통과 안 됨", + level: "쉼", + box: l.to.box, + port: l.to.name, + link: l.id, + text, + }); + continue; + } + got[l.to.name] = through(l); + } + if (ins.length && rest === ins.length) { + resting.add(box.id); + blocked.add(box.id); + } arrived[box.id] = got; return got; }; diff --git a/resources/master_template/area/item3.json b/resources/master_template/area/item3.json new file mode 100644 index 000000000..a57a2c5e6 --- /dev/null +++ b/resources/master_template/area/item3.json @@ -0,0 +1,17 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "item3", + "서식": [ + {} + ], + "시트": [ + { + "id": "s1", + "이름": "면적산출", + "칸": {} + } + ], + "활성": "s1", + "면적번": "SA-000003" +} diff --git a/resources/master_template/inputs/item1.json b/resources/master_template/inputs/item1.json new file mode 100644 index 000000000..6d62c3446 --- /dev/null +++ b/resources/master_template/inputs/item1.json @@ -0,0 +1,96 @@ +{ + "판": 1, + "줄": [ + { + "로직": "GC000297", + "입력": "형식", + "자리": "선택" + }, + { + "로직": "GC000297", + "입력": "두께", + "자리": "면적", + "알약": "입력|GC000297|두께" + }, + { + "로직": "GF000156", + "입력": "구분", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "믹서", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "지역", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "재료배합", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "골재최대치수", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "수중", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "모래상태", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "자갈상태", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "모래단위중량", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "자갈단위중량", + "자리": "선택" + }, + { + "로직": "GF000156", + "입력": "자재지역", + "자리": "선택" + }, + { + "로직": "GF000162", + "입력": "포장두께", + "자리": "선택" + }, + { + "로직": "GF000162", + "입력": "운반", + "자리": "선택" + }, + { + "로직": "GF000162", + "입력": "운반감률", + "자리": "선택" + }, + { + "로직": "GF000162", + "입력": "공급감률", + "자리": "선택" + }, + { + "로직": "GF000162", + "입력": "종단경사", + "자리": "선택" + } + ] +} diff --git a/resources/master_template/material/item3.json b/resources/master_template/material/item3.json new file mode 100644 index 000000000..a75069ccd --- /dev/null +++ b/resources/master_template/material/item3.json @@ -0,0 +1,17 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "item3", + "서식": [ + {} + ], + "시트": [ + { + "id": "s1", + "이름": "재료산출", + "칸": {} + } + ], + "활성": "s1", + "재료번": "SM-000003" +} diff --git a/resources/master_template/structure/item3.json b/resources/master_template/structure/item3.json new file mode 100644 index 000000000..c344045ef --- /dev/null +++ b/resources/master_template/structure/item3.json @@ -0,0 +1,19 @@ +{ + "양식": "구조물도면", + "종류": "structure", + "판": 1, + "열": "item3", + "도면": { + "format": 6, + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] + }, + "도번": "SD-000003" +} diff --git a/resources/master_template/table/구조물집계표.json b/resources/master_template/table/구조물집계표.json index 3ba7f70fc..33c51c0f4 100644 --- a/resources/master_template/table/구조물집계표.json +++ b/resources/master_template/table/구조물집계표.json @@ -53,7 +53,7 @@ "id": "item1", "단위": "m", "꼴": "수", - "일위대가": null, + "일위대가": "UA000006", "자재조합": null, "경로": [ "기슭막이", @@ -95,6 +95,25 @@ "ss" ] }, + { + "id": "item3", + "단위": null, + "꼴": "수", + "일위대가": null, + "자재조합": null, + "경로": [ + "기슭막이", + "ㄴㄴ" + ], + "머리": [ + "기슭막이", + "ㄴㄴ", + null, + null, + null, + null + ] + }, { "id": "memo", "머리": [ diff --git a/resources/master_template/구조물집계표조합.json b/resources/master_template/구조물집계표조합.json index 3cf8e81dd..9c261bbf4 100644 --- a/resources/master_template/구조물집계표조합.json +++ b/resources/master_template/구조물집계표조합.json @@ -2,7 +2,7 @@ "그룹": "구조물집계표조합", "판": 1, "비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키(마스터 SS · 회사 SC{회사}- · 개인 SP{사용자}- + 6자리)는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 옛키 = 옮기기 전 키 · 경로 = 폴더 이름 배열", - "다음": 54, + "다음": 55, "지운번호": [ "01010100", "01010101", @@ -74,7 +74,7 @@ "분류2": "ss", "분류3": "aaa", "단위": "m", - "일위대가": null, + "일위대가": "UA000006", "자재조합": null, "도번": "SD-000001", "면적번": "SA-000001", @@ -106,6 +106,27 @@ "재료번": "SM-000002", "바인딩": null, "펼침": null + }, + { + "키": "SS000054", + "열": "item3", + "경로": [ + "기슭막이", + "ㄴㄴ" + ], + "이름": "기슭막이 ㄴㄴ", + "종류": "기슭막이", + "분류1": "ㄴㄴ", + "분류2": null, + "분류3": null, + "단위": null, + "일위대가": null, + "자재조합": null, + "도번": "SD-000003", + "면적번": "SA-000003", + "재료번": "SM-000003", + "바인딩": null, + "펼침": null } ] } diff --git a/resources/tester/test_m02_df_diagram_parts.py b/resources/tester/test_m02_df_diagram_parts.py index ef01ab2f1..ae8248233 100644 --- a/resources/tester/test_m02_df_diagram_parts.py +++ b/resources/tester/test_m02_df_diagram_parts.py @@ -45,6 +45,12 @@ const out = jobs.map((job) => { if (job.op === "rest") return job.breaks.map((b) => m.isRest(b)); if (job.op === "fit") return m.fitView(job.rects, job.view); 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)); @@ -248,3 +254,153 @@ def test_한눈에_90_아래로_안_내림과_알약_값(): ) 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|브레이커 경비"] diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index fe429974c..4cfbee4d8 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -643,3 +643,40 @@ def test_쉬는_갈래와_문제_무게() -> None: # 하나도 안 맞음 · 표에 없는 조건만 문제 — 쉬는 갈래 넷은 문제로 안 셈 assert b_problems == ["표에 없는 조건", "조건 안 맞음"] assert outs["b7"] is None # 쉬는 갈래 끝 출력은 빈 값(0 아님) + + +def _ground() -> dict: + return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8")) + + +def _server(lg: dict, values: dict) -> dict: + from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp + + return cp.run(lg, values, lambda key: {}, []) + + +def test_만나는_상자는_살아_있는_선이_있으면_셈_서버와_같음() -> None: + lg = _ground() + soil = {"in-ground": {"지반": "토사"}} + rock = {"in-ground": {"지반": "암"}} + got = _run( + f"const lg = {json.dumps(lg, ensure_ascii=False)};\n" + f"const s = E.evalLogic(lg, {json.dumps(soil, ensure_ascii=False)});\n" + f"const r = E.evalLogic(lg, {json.dumps(rock, ensure_ascii=False)});\n" + "out([s.outputs, s.breaks.map(b => [b.kind, b.level, b.link]), r.outputs, r.breaks.map(b => [b.kind, b.level, b.link])," + " s.ports['fn-sum'], r.ports['fn-sum']]);" + ) + s_out, s_breaks, r_out, r_breaks, s_sum, r_sum = got + assert s_out == {"out-exp": 2000} # 굴삭기 갈래만 · 브레이커 갈래는 쉼(빈 칸) + assert s_breaks == [["통과 안 됨", "쉼", "l2"], ["통과 안 됨", "쉼", "l6"]] + assert [b for b in s_breaks if b[1] == "문제"] == [] + assert r_breaks == [["통과 안 됨", "쉼", "l1"], ["통과 안 됨", "쉼", "l5"]] + assert r_out["out-exp"] == r_sum["1㎥ 경비"] and r_out["out-exp"] != 2000 # 브레이커 갈래 값 + assert s_sum == {"1㎥ 경비": 2000} + # 서버(Compute) = 화면(Eval) + for values, screen_out, screen_breaks in ((soil, s_out, s_breaks), (rock, r_out, r_breaks)): + srv = _server(lg, values) + assert {k: float(v) for k, v in srv["outputs"].items()} == { + k: float(v) for k, v in screen_out.items() + } + assert [[b["kind"], b["level"], b["link"]] for b in srv["breaks"]] == screen_breaks