feat(M02): 흐름도 다이어그램 — 기댐 선(규칙의 조건 알약 → 규칙 자리) · 빈 판 안내 세 줄 · 쉼 갈래(회색 점선 · 받는 상자 흐리게 · 「!」 는 문제에만)(PLAN 35-9 브레인 검증)
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>(
|
||||
`.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 = <K extends keyof SVGElementTagNameMap>(
|
||||
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<string, { x: number; y: number }>();
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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줄"]
|
||||
Reference in New Issue
Block a user