From 078e168f7d8d3fe68eeaed342a650468713b6c3f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 20:35:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=ED=9D=90=EB=A6=84=EB=8F=84=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=E2=80=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8C=90=20=EC=84=A0(=EA=B7=9C=EC=B9=99=EC=9D=98=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=95=8C=EC=95=BD=20=E2=86=92=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=9E=90=EB=A6=AC)=20=C2=B7=20=EB=B9=88=20?= =?UTF-8?q?=ED=8C=90=20=EC=95=88=EB=82=B4=20=EC=84=B8=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=EC=89=BC=20=EA=B0=88=EB=9E=98(=ED=9A=8C=EC=83=89=20?= =?UTF-8?q?=EC=A0=90=EC=84=A0=20=C2=B7=20=EB=B0=9B=EB=8A=94=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=ED=9D=90=EB=A6=AC=EA=B2=8C=20=C2=B7=20=E3=80=8C!?= =?UTF-8?q?=E3=80=8D=20=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EC=97=90=EB=A7=8C)(P?= =?UTF-8?q?LAN=2035-9=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model ruleSources · elementPort 로 기댐 선(표 조건 · 조건 · 재료 「규격에 포함」) · 흐름 길에도 칠함 · 누르면 그 규칙 자리(선 · 재료 상자) 고름 - breaks level(쉼 · 문제) · 없으면 「통과 안 됨」 = 쉼 - 끌기 손잡이를 Parts 로(Diagram 681줄 · Parts 320줄) - 시험 test_m02_df_diagram_parts.py 4 통과 · DataFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- .../DataFlow/M02_DF_Diagram.css | 88 +++++++ M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 157 +++++++----- .../DataFlow/M02_DF_Diagram_Parts.ts | 89 ++++++- resources/tester/test_m02_df_diagram_parts.py | 238 ++++++++++++++++++ 4 files changed, 513 insertions(+), 59 deletions(-) create mode 100644 resources/tester/test_m02_df_diagram_parts.py diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css index 9381a6c08..74e5bed78 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css @@ -410,3 +410,91 @@ font-size: 11px; font-weight: 700; } + +/* 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 + 화살 */ +.m02-dfdg__dep { + pointer-events: auto; + cursor: pointer; +} + +.m02-dfdg__depwire { + fill: none; + stroke: color-mix(in srgb, var(--color-text-muted) 70%, transparent); + stroke-width: 1.5; + stroke-dasharray: 3 4; +} + +.m02-dfdg__arrow { + fill: var(--color-text-muted); +} + +.m02-dfdg__deptext rect { + fill: var(--color-canvas); + stroke: none; + opacity: 0.85; +} + +.m02-dfdg__deptext text { + fill: var(--color-text-muted); + font-size: 10px; +} + +.m02-dfdg__dep.is-strong .m02-dfdg__depwire { + stroke: var(--color-accent); + stroke-width: 2.5; +} + +.m02-dfdg__dep.is-back .m02-dfdg__depwire { + stroke: var(--dfdg-back); + stroke-width: 2.5; +} + +.m02-dfdg__dep.is-fwd .m02-dfdg__depwire { + stroke: var(--dfdg-fwd); + stroke-width: 2.5; +} + +.m02-dfdg__dep.is-dim { + opacity: 0.25; +} + +/* 쉼 — 조건이 안 맞아 이번엔 안 지나감(문제 아님) */ +.m02-dfdg__link.is-rest .m02-dfdg__wire { + stroke: var(--color-text-muted); + stroke-width: 1.5; + stroke-dasharray: 2 5; +} + +.m02-dfdg__restmark rect { + fill: var(--color-surface); + stroke: var(--color-text-muted); + stroke-dasharray: 2 2; +} + +.m02-dfdg__restmark text { + fill: var(--color-text-muted); + font-size: 10px; +} + +.m02-dfdg__box.is-rest { + opacity: 0.5; +} + +/* 빈 판 안내 — 가운데 세 줄 */ +.m02-dfdg__empty { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + padding: var(--spacing-16) var(--spacing-24); + list-style: none; + border: 1px dashed var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + color: var(--color-text-secondary); + display: flex; + flex-direction: column; + gap: var(--spacing-8); + pointer-events: none; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index a6ccd0742..f67ab35cd 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -14,6 +14,10 @@ import { clampZoom, fitView, KIND_COLOR, + depInTrace, + follow, + depLines, + isRest, linkLabel, midOf, pillsOf, @@ -76,7 +80,15 @@ export function renderDiagram( states.set(host, st); const values = opts.values ?? {}; const labels = opts.labels ?? {}; - const breaks = opts.breaks ?? []; + const allBreaks = opts.breaks ?? []; + /** 「!」 는 문제에만 · 쉼(조건이 안 맞아 이번엔 안 지나감)은 회색 점선 선 + 받는 상자 흐리게 */ + 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 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 }); @@ -210,7 +222,8 @@ export function renderDiagram( const node = el("div", { className: `m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}` + - (st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))), + (st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))) + + (restBoxes.has(b.id) ? " is-rest" : ""), attrs: { "data-box": b.id, tabindex: "0", @@ -400,7 +413,7 @@ export function renderDiagram( }), ], }), - lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }), + view, ], }); root.addEventListener("keydown", (ev) => { @@ -438,19 +451,27 @@ export function renderDiagram( ); view.addEventListener("auxclick", (ev) => ev.button === 1 && ev.preventDefault()); view.addEventListener("mousedown", (ev) => ev.button === 1 && ev.preventDefault()); + if (!lg.boxes.length) + view.append( + el("ol", { + className: "m02-dfdg__empty", + children: (["Empty1", "Empty2", "Empty3"] as const).map((k) => el("li", { text: tt(k) })), + }), + ); host.replaceChildren(root); applyView(); - /* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 */ + /* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 · 쉼 · 기댐 선 */ function drawLines(): void { svg.replaceChildren(); - const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => { + const at = (end: CfPort, dir: "in" | "out", side: "near" | "far" = "near") => { const pill = world.querySelector( `.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`, ); if (!pill) return null; const r = pill.getBoundingClientRect(); - return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2); + const right = (dir === "out") === (side === "near"); + return toWorld(right ? r.right : r.left, r.top + r.height / 2); }; const add = ( parent: Element, @@ -462,6 +483,33 @@ export function renderDiagram( parent.append(n); return n; }; + const label = (parent: Element, x: number, y: number, text: string, cls: string): void => { + const tag = add(parent, "g", { class: cls }); + const box = add(tag, "rect", { rx: "4", ry: "4" }); + const t = add(tag, "text", { + x: x.toFixed(1), + y: y.toFixed(1), + "dominant-baseline": "central", + "text-anchor": "middle", + }); + t.textContent = text; + const w = (t.getComputedTextLength?.() || text.length * 7) + 10; + Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) => + box.setAttribute(k, v.toFixed(1)), + ); + }; + const defs = add(svg, "defs", {}); + const arrow = add(defs, "marker", { + id: "m02-dfdg-arrow", + viewBox: "0 0 8 8", + refX: "7", + refY: "4", + markerWidth: "7", + markerHeight: "7", + orient: "auto", + }); + add(arrow, "path", { d: "M0,0 L8,4 L0,8 z", 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"); @@ -474,42 +522,30 @@ export function renderDiagram( const traced = markOf((s) => s.some((x) => x.link === l.id)); const mark = strong && traced === " is-dim" ? "" : traced; // 고른 선은 흐리게 하지 않음 const dim = !mark && st.selected !== null && !strong ? " is-dim" : ""; + const rest = restLinks.has(l.id) ? " is-rest" : ""; const g = add(svg, "g", { - class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}`, + class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}${rest}`, "data-link": l.id, "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__wire" }); const mid = midOf(a, b); + mids.set(l.id, mid); const rule = labels[l.id] ?? linkLabel(l); - if (rule) { - const tag = add(g, "g", { class: "m02-dfdg__rule" }); - const box = add(tag, "rect", { rx: "4", ry: "4" }); - const text = add(tag, "text", { - x: mid.x.toFixed(1), - y: mid.y.toFixed(1), - "dominant-baseline": "central", - "text-anchor": "middle", - }); - text.textContent = rule; - const w = (text.getComputedTextLength?.() || rule.length * 7) + 10; - Object.entries({ x: mid.x - w / 2, y: mid.y - 9, width: w, height: 18 }).forEach(([k, v]) => - box.setAttribute(k, v.toFixed(1)), - ); - } + if (rule) label(g, mid.x, mid.y, rule, "m02-dfdg__rule"); + 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)] + .map((k) => k.text) + .join(" · "); if (broken.length) { - const why = broken.map((k) => k.text).join(" · "); + const cy = (mid.y - (rule ? 18 : 0)).toFixed(1); const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why }); - add(badge, "circle", { - cx: mid.x.toFixed(1), - cy: (mid.y - (rule ? 18 : 0)).toFixed(1), - r: "8", - }); + add(badge, "circle", { cx: mid.x.toFixed(1), cy, r: "8" }); const t = add(badge, "text", { x: mid.x.toFixed(1), - y: (mid.y - (rule ? 18 : 0)).toFixed(1), + y: cy, "dominant-baseline": "central", "text-anchor": "middle", }); @@ -518,10 +554,43 @@ export function renderDiagram( } g.addEventListener("click", (ev) => { ev.stopPropagation(); - const why = broken.map((k) => k.text).join(" · "); select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why); }); } + // 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 · 화살 + 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 where = "link" in dep.to ? dep.to.link : dep.to.box; + const steps = opts.trace; + const traced = + steps?.back && depInTrace(dep, steps.back) + ? " is-back" + : steps?.forward && depInTrace(dep, steps.forward) + ? " is-fwd" + : steps && (steps.back?.length || steps.forward?.length) + ? " is-dim" + : ""; + const g = add(svg, "g", { + class: `m02-dfdg__dep${traced}${st.selected === where ? " is-strong" : ""}`, + "data-dep": dep.id, + "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)" }); + const m = midOf(a, b); + label(g, m.x, m.y, dep.text, "m02-dfdg__deptext"); + g.addEventListener("click", (ev) => { + ev.stopPropagation(); + select(where); + }); + } } const firstFit = (): void => { if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines(); @@ -534,34 +603,6 @@ export function renderDiagram( if (view.isConnected) firstFit(); /* 끌기 셋 — 상자 · 판 · 선 */ - function follow( - ev: PointerEvent, - move: (m: PointerEvent) => void, - end: (u: PointerEvent, moved: boolean) => void, - ): void { - const x0 = ev.clientX; - const y0 = ev.clientY; - let moved = false; - const mv = (m: PointerEvent): void => { - if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return; - moved = true; - move(m); - }; - const up = (u: PointerEvent): void => { - window.removeEventListener("pointermove", mv); - window.removeEventListener("pointerup", up); - if (moved) { - // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 - const swallow = (c: MouseEvent): void => c.stopPropagation(); - window.addEventListener("click", swallow, { capture: true, once: true }); - setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); - } - end(u, moved); - }; - window.addEventListener("pointermove", mv); - window.addEventListener("pointerup", up); - } - function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void { ev.preventDefault(); // 끌면서 글자가 잡히지 않게 const start = toWorld(ev.clientX, ev.clientY); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index 2b9ac51e0..6ee7fdf61 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_DF_Diagram_Parts.ts * 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 · - * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기. 그리기는 `M02_DF_Diagram.ts`. + * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이. 그리기는 `M02_DF_Diagram.ts`. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -15,6 +15,8 @@ import { type CfPort, type CfLink, type CfStep, + elementPort, + ruleSources, } from "./M02_DF_Model"; import type { CfBreak } from "./M02_DF_Eval"; import { ruleLabel } from "./M02_DF_Rule"; @@ -63,6 +65,13 @@ export const TEXT = { ], Break: ["끊긴 곳", "Break"], Back: ["온 길", "Came from"], + DepLookup: ["표 조건", "table key"], + DepGate: ["조건", "condition"], + DepMaterial: ["{f}에 {o}", "{f} {o}"], + Rest: ["쉼", "Resting"], + Empty1: ["① 「+ 입력」 으로 상자를 놓음", "① Place a box with “+ Input”"], + Empty2: ["② 알약을 끌어 다른 상자에 놓으면 선", "② Drag a pill onto another box to draw a line"], + Empty3: ["③ 선을 누르면 규칙", "③ Tap a line to set its rule"], Forward: ["가는 길", "Goes to"], } as const; @@ -231,3 +240,81 @@ export const midOf = ( x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, }); + +/* ── 기댐 선 · 쉼 ──────────────────────────────────────────────────── */ + +/** 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳(선이면 그 선 이름표 · 재료 조건 찾기면 그 재료 알약) */ +export interface DepLine { + id: string; + from: CfPort; + to: { link: string } | { box: string; port: string }; + text: string; +} + +export function depLines(lg: CfLogic): DepLine[] { + const out: DepLine[] = []; + for (const l of lg.links) + ruleSources(l.rule).forEach((from, i) => + out.push({ + id: `dep:${l.id}:${i}`, + from, + to: { link: l.id }, + text: tt(l.rule?.kind === "lookup" ? "DepLookup" : "DepGate"), + }), + ); + for (const b of lg.boxes) + for (const e of b.input?.elements ?? []) + if (e.mode === "rule" && e.rule) + out.push({ + id: `dep:${b.id}:${elementPort(e)}`, + from: e.rule.from, + to: { box: b.id, port: elementPort(e) }, + text: tt("DepMaterial", { f: e.rule.field, o: e.rule.op }), + }); + return out; +} + +/** 기댐 선이 흐름 길에 드나 — 기대는 알약과 닿는 곳(선 · 상자)이 같은 길에 있을 때 */ +export function depInTrace(dep: DepLine, steps: CfStep[]): boolean { + const src = steps.some((s) => s.box === dep.from.box && s.port === dep.from.name); + const dst = + "link" in dep.to + ? steps.some((s) => s.link === (dep.to as { link: string }).link) + : steps.some((s) => s.box === (dep.to as { box: string }).box); + return src && dst; +} + +/** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — level 이 없으면 「통과 안 됨」 을 쉼으로 */ +export const isRest = (k: CfBreak): boolean => + ((k as CfBreak & { level?: string }).level ?? (k.kind === "통과 안 됨" ? "쉼" : "문제")) === "쉼"; + +/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */ + +/** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */ +export function follow( + ev: PointerEvent, + move: (m: PointerEvent) => void, + end: (u: PointerEvent, moved: boolean) => void, +): void { + const x0 = ev.clientX; + const y0 = ev.clientY; + let moved = false; + const mv = (m: PointerEvent): void => { + if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return; + moved = true; + move(m); + }; + const up = (u: PointerEvent): void => { + window.removeEventListener("pointermove", mv); + window.removeEventListener("pointerup", up); + if (moved) { + // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 + const swallow = (c: MouseEvent): void => c.stopPropagation(); + window.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); + } + end(u, moved); + }; + window.addEventListener("pointermove", mv); + window.addEventListener("pointerup", up); +} diff --git a/resources/tester/test_m02_df_diagram_parts.py b/resources/tester/test_m02_df_diagram_parts.py new file mode 100644 index 000000000..372581c53 --- /dev/null +++ b/resources/tester/test_m02_df_diagram_parts.py @@ -0,0 +1,238 @@ +"""단가산출 흐름도 다이어그램 순수 부분(`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); + if (job.op === "value") return job.values.map((v) => m.showValue(v)); + 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": "통과 안 됨", "box": "f", "link": "l2", "text": "지반이 암이 아님"}, + {"kind": "빈 받는 자리", "box": "f", "port": "품", "text": "비어 있음"}, + {"kind": "통과 안 됨", "box": "f", "level": "문제", "text": "level 이 먼저"}, + {"kind": "조건 겹침", "box": "f", "level": "쉼", "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줄"]