From 20720ea6f852406af96a74f262aba17cc9618289 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:26:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(M02):=20=ED=9D=90=EB=A6=84=EB=8F=84=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=98=EB=8A=94=20=ED=95=B5=EC=8B=AC=20M02=5FDF=5FP?= =?UTF-8?q?aper.ts(=ED=8C=A8=EB=84=90=20=C2=B7=20=ED=81=AC=EA=B2=8C=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EA=B3=B5=EC=9A=A9)=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EB=A7=9E=EC=B6=94?= =?UTF-8?q?=EA=B8=B0(PLAN=2036-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entry 시험판 고치는 화면을 옮겨 적음(Entry 는 안 고침) — 다이어그램 · 상세 내용 · 원가 · 검증 · 실행 취소 · onChange 객체 그대로 - 다이어그램 opts.readOnly(끌기 · 잇기 · 더하기 · 지우기 막음 · 고르기 · 흐름 따라가기는 됨) · opts.fitKey(바뀌면 다시 맞춤) - 알약 정보 글 · SVG 도우미를 Parts 로(Diagram 681줄) - DataFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 78 ++-- .../DataFlow/M02_DF_Diagram_Parts.ts | 59 ++- M02_MasterTemplete/DataFlow/M02_DF_Paper.ts | 341 ++++++++++++++++++ 3 files changed, 429 insertions(+), 49 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Paper.ts diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index 9dd41038a..e74e30fa7 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -23,6 +23,9 @@ import { restParts, linkLabel, midOf, + pillInfo, + svgAdd as add, + svgLabel as label, pillsOf, resolveDrop, showValue, @@ -46,6 +49,7 @@ interface HostState { panX: number; panY: number; fitted: boolean; + fitKey?: number; watch?: ResizeObserver; } @@ -79,6 +83,9 @@ export function renderDiagram( fitted: false, }; if (opts.selected !== undefined) st.selected = opts.selected; + if (opts.fitKey !== undefined && opts.fitKey !== st.fitKey) + [st.fitKey, st.fitted] = [opts.fitKey, false]; + const ro = !!opts.readOnly; // 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음(고르기 · 흐름 따라가기는 됨) if (opts.selectedPill !== undefined) st.pill = opts.selectedPill; states.set(host, st); const values = opts.values ?? {}; @@ -243,7 +250,7 @@ export function renderDiagram( className: "m02-dfdg__ins", children: [ ...ins.map((p) => pillEl(b, p, "in")), - ...(b.kind === "function" ? [plusSlot(b)] : []), + ...(b.kind === "function" && !ro ? [plusSlot(b)] : []), ], }), el("div", { @@ -282,13 +289,13 @@ export function renderDiagram( ArrowUp: [0, -step], ArrowDown: [0, step], }[ev.key]; - if (d && st.selected === b.id) { + if (d && st.selected === b.id && !ro) { ev.preventDefault(); on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1])); } }); head.addEventListener("pointerdown", (ev) => { - if (ev.button === 0 && !spaceDown) dragBox(ev, b, node); + if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node); }); return node; }; @@ -350,15 +357,8 @@ export function renderDiagram( ); } else if (st.pill) { const p = st.pill; - const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port; - const where = lg.links - .filter((l) => mine(p.dir === "in" ? l.to : l.from)) - .map((l) => endName(p.dir === "in" ? l.from : l.to)) - .join(", "); - const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`; - const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""; - tools.push(el("span", { text: [p.port, now, dir].filter(Boolean).join(" · ") })); - if (p.dir === "out") + tools.push(el("span", { text: pillInfo(lg, p, values) })); + if (p.dir === "out" && !ro) tools.push( sm( "Connect", @@ -371,9 +371,9 @@ export function renderDiagram( const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) }); tools.push( el("span", { text: rule ? `${line} · ${rule}` : line }), - sm("Disconnect", () => on.unlink(selLink.id), "danger"), + ...(ro ? [] : [sm("Disconnect", () => on.unlink(selLink.id), "danger")]), ); - } else if (selBox) { + } else if (selBox && !ro) { tools.push(sm("Remove", () => void askRemove(selBox), "danger")); } const legend = @@ -388,18 +388,26 @@ export function renderDiagram( className: "m02-dfdg__bar", children: [ el("strong", { className: "m02-dfdg__title", text: tt("Title") }), - addBtn("input", "AddInput"), - addBtn("function", "AddFunc"), - addBtn("output", "AddOutput"), + ...(ro + ? [] + : [ + addBtn("input", "AddInput"), + addBtn("function", "AddFunc"), + addBtn("output", "AddOutput"), + ]), el("span", { className: "m02-dfdg__gap" }), ...legend, sm("ZoomOut", () => setZoom(st.zoom / 1.2)), zoomLabel, sm("ZoomIn", () => setZoom(st.zoom * 1.2)), sm("Fit", fit), - sm("Arrange", () => { - for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y); - }), + ...(ro + ? [] + : [ + sm("Arrange", () => + Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)), + ), + ]), ], }); const root = el("div", { @@ -426,7 +434,7 @@ export function renderDiagram( st.pill = null; redraw(); } - if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; + if (ro || ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; if (selLink) on.unlink(selLink.id); else if (selBox) void askRemove(selBox); }); @@ -477,32 +485,6 @@ export function renderDiagram( const right = (dir === "out") === (side === "near"); return toWorld(right ? r.right : r.left, r.top + r.height / 2); }; - const add = ( - parent: Element, - tag: K, - attrs: Record, - ) => { - const n = document.createElementNS(SVG, tag); - for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); - parent.append(n); - return n; - }; - 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", { - 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)), - ); - return w; - }; const defs = add(svg, "defs", {}); const arrow = add(defs, "marker", { id: "m02-dfdg-arrow", @@ -657,7 +639,7 @@ export function renderDiagram( } function dragLink(ev: PointerEvent, from: CfPort): void { - if (ev.button !== 0 || spaceDown) return; + if (ev.button !== 0 || spaceDown || ro) return; ev.stopPropagation(); ev.preventDefault(); const pill = ev.currentTarget as HTMLElement; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index cf0616712..649aac97e 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`. + * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `M02_DF_Diagram.ts`. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -118,6 +118,10 @@ export interface DiagramOpts { trace?: { back?: CfStep[]; forward?: CfStep[] } | null; /** 끊긴 곳 — Eval breaks */ breaks?: CfBreak[]; + /** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */ + readOnly?: boolean; + /** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */ + fitKey?: number; } /* ── 알약 · 자리 ────────────────────────────────────────────────────── */ @@ -330,6 +334,25 @@ export function restParts( return { links, pills, boxes }; } +/** 고른 알약 한 줄 — 이름 · 지금 값 · 어디서(받는) / 어디로(내보내는) */ +export function pillInfo( + lg: CfLogic, + p: DfPillSel, + values: Record>, +): string { + const name = (e: CfPort): string => + `${lg.boxes.find((b) => b.id === e.box)?.name ?? ""} · ${e.name}`; + const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port; + const where = lg.links + .filter((l) => mine(p.dir === "in" ? l.to : l.from)) + .map((l) => name(p.dir === "in" ? l.from : l.to)) + .join(", "); + const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`; + return [p.port, now, where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""] + .filter(Boolean) + .join(" · "); +} + /* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */ /** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */ @@ -360,3 +383,37 @@ export function follow( window.addEventListener("pointermove", mv); window.addEventListener("pointerup", up); } + +/* ── 선 그림 도우미(SVG) ────────────────────────────────────────────── */ + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** SVG 마디 하나 붙이기 */ +export function svgAdd( + parent: Element, + tag: K, + attrs: Record, +): SVGElementTagNameMap[K] { + const n = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); + parent.append(n); + return n; +} + +/** 가운데 이름표 — 둥근 바탕 + 글 · 돌려주는 값 = 이름표 폭 */ +export function svgLabel(parent: Element, x: number, y: number, text: string, cls: string): number { + const tag = svgAdd(parent, "g", { class: cls }); + const box = svgAdd(tag, "rect", { rx: "4", ry: "4" }); + const t = svgAdd(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)), + ); + return w; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts new file mode 100644 index 000000000..6a90118bb --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -0,0 +1,341 @@ +/* ============================================================================= + * M02_DF_Paper.ts + * 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소 + * Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀. + * 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로). + * readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름). + * 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import * as api from "./M02_DF_Api"; +import { renderDiagram, type DfPillSel } from "./M02_DF_Diagram"; +import * as ev from "./M02_DF_Eval"; +import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; +import { renderFuncPanel } from "./M02_DF_FuncPanel"; +import { renderInputPanel } from "./M02_DF_InputPanel"; +import { renderLinkPanel } from "./M02_DF_LinkPanel"; +import * as model from "./M02_DF_Model"; +import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model"; +import { renderOutputPanel } from "./M02_DF_OutputPanel"; +import { renderPillPanel } from "./M02_DF_PillPanel"; +import { renderSummary } from "./M02_DF_Summary"; +import type { CfSel } from "./M02_DF_Verify"; +import { + detailFrame, + drawHead, + drawWarnings, + fetchValues, + loadLogics, + queriesOf, + type DfPick, +} from "./M02_DF_Entry_Detail"; +import "./M02_DF_Entry.css"; + +const TEXT = { + Undo: ["실행 취소", "Undo"], + Color: ["상자 색", "Box colour"], + BoxName: ["상자 이름", "Box name"], + NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"], + Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], + Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"], + ReadOnly: ["보기만 — 고치기는 시스템 관리자", "View only — editing is for system admins"], +} as const; + +const tp = (key: keyof typeof TEXT, v = ""): string => + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{v}", v); + +export interface PaperOpts { + logic: CfLogic; + readOnly?: boolean; + /** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */ + onChange(lg: CfLogic): void; + /** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */ + logics?(key: string): Promise; +} + +export interface Paper { + /** 다이어그램 자리 · 상세 내용 · 원가 · 검증 · 알림 줄 · 실행 취소 단추 */ + parts: { + diagram: HTMLElement; + detail: HTMLElement; + summary: HTMLElement; + note: HTMLElement; + undo: HTMLButtonElement; + }; + get(): CfLogic; + /** 밖에서 바꿈(고친 것 버리기 등) — 실행 취소 기록은 비움 */ + set(lg: CfLogic): void; + undo(): void; + /** 다이어그램을 다시 맞춤(자리를 옮겨 끼운 뒤) */ + refit(): void; + redraw(): void; +} + +export function createPaper(opts: PaperOpts): Paper { + const readOnly = !!opts.readOnly; + let lg = opts.logic; + let stack: CfLogic[] = []; + let pick: DfPick = null; + let values: CfValues = {}; + let valueSig = ""; + let logics: NonNullable = {}; + let logicSig = ""; + let logicNotes: string[] = []; + let notes: string[] = []; + let broken: CfBreak[] = []; + let result: CfEvalResult; + let fitKey = 0; + const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); + + const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); + const diagramHost = el("div", { className: "m02-dfp__diagram" }); + const summaryHost = el("div", { className: "m02-dfp__summary" }); + const colorBox = el("input", { + className: "m02-df__color-input", + attrs: { type: "color", "aria-label": tp("Color") }, + }); + colorBox.addEventListener("change", () => { + const id = pickedBox(); + if (id) edit(model.setColor(lg, id, colorBox.value)); + }); + const colorRow = el("label", { + className: "m02-df__color", + children: [el("span", { text: tp("Color") }), colorBox], + }); + const boxName = createInputField({ label: tp("BoxName"), compact: true }); + boxName.input.addEventListener("change", () => { + const box = lg.boxes.find((b) => b.id === pickedBox()); + if (!box) return; + const next = boxName.input.value.trim(); + const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next); + if (taken) note.textContent = tp("NameTaken", next); + if (!next || taken || next === box.name) return void (boxName.input.value = box.name); + edit(model.updateBox(lg, box.id, { name: next })); + }); + // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만 + const funcHost = el("div"); + const outHost = el("div"); + const inHost = el("div"); + const linkHost = el("div"); + const pillHost = el("div"); + const detail = detailFrame(); + detail.settings.append(boxName.root, colorRow); + // 보기만 — 판 안 칸 · 단추를 한 번에 막음(고르기 · 흐름 따라가기는 다이어그램에서 됨) + const lock = el("fieldset", { + className: "m02-dfp__lock", + children: [inHost, funcHost, outHost, linkHost, pillHost], + }); + lock.disabled = readOnly; + detail.body.append(lock); + if (readOnly) detail.root.prepend(el("p", { className: "m02-df__muted", text: tp("ReadOnly") })); + + const evalNow = (): CfEvalResult => { + try { + return ev.evalLogic(lg, values, { logics }); + } catch (e) { + const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; + return { ports: {}, outputs: {}, cost, errors: [api.cfPlain(e)] }; + } + }; + + /** 고침 — readOnly 면 무시 · 기록에 쌓고 onChange */ + const edit = (next: CfLogic): void => { + if (readOnly || next === lg) return; + stack = [...stack, lg]; + lg = next; + opts.onChange(lg); + redraw(); + }; + /** 검증 칸 예시값 — readOnly 에서도 됨(보기용 · onChange 안 부름) */ + const editExample = (next: CfLogic): void => { + if (!readOnly) return edit(next); + lg = next; + redraw(); + }; + const editFromDiagram = (next: CfLogic): void => { + edit(next); + setTimeout(redraw); + }; + + const refreshValues = (): void => { + const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null; + const sig = JSON.stringify(queriesOf(lg, pill, model.elementPort).map((a) => a.q)); + if (sig === valueSig) return; + valueSig = sig; + void fetchValues(api.cfValues, lg, pill, model.elementPort).then( + (got) => { + if (sig !== valueSig) return; + values = got.values; + notes = got.errors.length ? [tp("Values", got.errors.join(" · "))] : []; + redraw(); + }, + (e: unknown) => { + notes = [tp("Values", api.cfPlain(e))]; + redraw(); + }, + ); + }; + + const refreshLogics = (): void => { + const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); + if (sig === logicSig) return; + logicSig = sig; + const get = + opts.logics ?? (async (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : null)); + void loadLogics(lg, get).then((got) => { + if (sig !== logicSig) return; + logics = got.logics; + logicNotes = got.errors.length ? [tp("Logics", got.errors.join(" · "))] : []; + redraw(); + }); + }; + + const choose = (sel: CfSel): void => { + const box = lg.boxes.find((b) => b.id === sel.box); + pick = sel.link + ? { kind: "link", id: sel.link } + : box && sel.port + ? { kind: "pill", box: box.id, port: sel.port, dir: model.portDir(box, sel.port) } + : box + ? { kind: "box", id: box.id } + : null; + redraw(); + }; + + /** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */ + const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => { + try { + if (pick?.kind === "pill") { + const p = { box: pick.box, name: pick.port }; + return { back: model.traceBack(lg, p), forward: model.traceForward(lg, p) }; + } + const link = lg.links.find((l) => pick?.kind === "link" && l.id === pick.id); + if (link) + return { back: model.traceBack(lg, link.from), forward: model.traceForward(lg, link.to) }; + } catch { + /* 따라갈 수 없으면 칠하지 않음 */ + } + return null; + }; + + const diagramOn = { + select: (id: string | null, pill?: DfPillSel): void => { + pick = pill + ? { kind: "pill", ...pill } + : !id + ? null + : lg.links.some((l) => l.id === id) + ? { kind: "link", id } + : { kind: "box", id }; + setTimeout(redraw); + }, + move: (id: string, x: number, y: number): void => edit(model.moveBox(lg, id, x, y)), + link: (from: CfPort, to: CfPort): string | null => { + const got = model.link(lg, from, to); + if (got.why) return got.why; + editFromDiagram(got.lg); + return null; + }, + unlink: (id: string): void => editFromDiagram(model.unlink(lg, id)), + add: (kind: CfKind, x: number, y: number): void => { + const got = model.addBox(lg, kind, x, y); + pick = { kind: "box", id: got.box.id }; + editFromDiagram(got.lg); + }, + remove: (id: string): void => { + if (pickedBox() === id) pick = null; + editFromDiagram(model.removeBox(lg, id)); + }, + }; + + const drawPanel = (): void => { + const boxId = pickedBox(); + 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 || readOnly; + colorRow.hidden = !box; + boxName.root.hidden = !box || box.kind === "output"; + if (box && document.activeElement !== boxName.input) boxName.input.value = box.name; + linkHost.hidden = pick?.kind !== "link"; + pillHost.hidden = pick?.kind !== "pill"; + if (pick?.kind === "link") + renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics }); + if (pick?.kind === "pill") + renderPillPanel( + pillHost, + lg, + pick.box, + pick.port, + { pick: (id) => choose({ box: id }) }, + { values, logics }, + ); + drawWarnings(detail, pick, broken); + if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; + funcHost.hidden = box?.kind !== "function"; + outHost.hidden = box?.kind !== "output"; + inHost.hidden = box?.kind !== "input"; + if (box?.kind === "input") renderInputPanel(inHost, lg, box.id, { change: edit }); + if (box?.kind === "function") renderFuncPanel(funcHost, lg, box.id, { change: edit }); + if (box?.kind === "output") + renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); + }; + + const redraw = (): void => { + result = evalNow(); + refreshValues(); + refreshLogics(); + const linkId = pick?.kind === "link" ? pick.id : ""; + if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null; + try { + broken = ev.breaks(lg, values, { logics }); + } catch { + broken = result.breaks ?? []; + } + undo.disabled = readOnly || !stack.length; + renderDiagram(diagramHost, lg, diagramOn, { + values: result.ports, + selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null, + selectedPill: + pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null, + trace: traceNow(), + breaks: broken, + readOnly, + fitKey, + }); + drawPanel(); + renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values }); + note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n"); + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back || readOnly) return; + stack = stack.slice(0, -1); + lg = back; + opts.onChange(lg); + redraw(); + }; + const undo = createButton({ label: tp("Undo"), variant: "ghost", size: "sm", onClick: doUndo }); + + redraw(); + return { + parts: { diagram: diagramHost, detail: detail.root, summary: summaryHost, note, undo }, + get: () => lg, + set: (next) => { + lg = next; + stack = []; + pick = null; + fitKey += 1; + redraw(); + }, + undo: doUndo, + refit: () => { + fitKey += 1; + redraw(); + }, + redraw, + }; +} From 2e99091673df1888054171eeeccea79979d92d14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:27:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(m02-df):=2036-0b=20=EC=84=A0=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=97=AC=EB=9F=AC=20=EC=A4=84(=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B3=A0=20=C2=B7=20=EB=98=90=EB=8A=94)=20=C2=B7=20?= =?UTF-8?q?=EA=B0=92=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CfRule gate conds[{from · op · value}] · join 그리고/또는 · 옛 한 줄 꼴(from · op · value)도 읽음 — 옛 칸은 첫 조건과 같게 둠(makeGate) - gateConds · makeGate · valueChoices(변수 고르기 · 재료 규격 · 표 칸 값 · 수 · 함수 결과는 null) - 규칙 → 받는 칸 엑셀 식 IF(AND/OR(...),A1,NA()) · 선 이름표 「지반 = 토사 그리고 높이 ≥ 3 일 때만」 · 겹침 · 안 맞음은 조건 알약 묶음끼리 - 조건 값 열쇠 「상자|알약」 먼저(이름 겹침 막음) · M02_DF_Compute.py gate 같은 뜻(서버 = 화면 시험) - 시험 test_m02_df_model 30개 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/DataFlow/M02_DF_Compute.py | 41 +++++-- M02_MasterTemplete/DataFlow/M02_DF_Eval.ts | 22 +++- M02_MasterTemplete/DataFlow/M02_DF_Model.ts | 77 ++++++++++++- M02_MasterTemplete/DataFlow/M02_DF_Rule.ts | 31 ++++-- resources/tester/test_m02_df_model.py | 104 ++++++++++++++++++ 5 files changed, 249 insertions(+), 26 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py index 3abcdc73b..331a2a151 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -323,7 +323,7 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: return None, "오류" tests = [] for c in rule.get("conds") or []: - col, want = str(c.get("col")), conds.get((c.get("from") or {}).get("name")) + col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {}) if col in head: i = head.index(col) tests.append(lambda row, i=i, want=want: _same(row[i], want)) @@ -362,12 +362,13 @@ def apply_rule(rule: dict, value: Any, conds: dict) -> tuple[Any, str | None]: if kind == "lookup": return _lookup(rule, value, conds) if kind == "gate": - src = (rule.get("from") or {}).get("name") - return ( - (value, None) - if _compare(conds.get(src), rule.get("op"), rule.get("value")) - else (None, "통과 안 됨") - ) + rows, join = _gate_conds(rule) + hits = [ + _compare(_cond_of(conds, c.get("from") or {}), c.get("op"), c.get("value")) + for c in rows + ] + ok = any(hits) if join == "또는" else all(hits) + return (value, None) if ok else (None, "통과 안 됨") n = _num_of(value) if n is None: return (None, None) if value is None else (None, "오류") @@ -387,7 +388,23 @@ def _sources(rule: dict | None) -> list[dict]: return [] if rule.get("kind") == "lookup": return [c.get("from") or {} for c in rule.get("conds") or []] - return [rule.get("from") or {}] if rule.get("kind") == "gate" else [] + if rule.get("kind") == "gate": + return [c.get("from") or {} for c in _gate_conds(rule)[0]] + return [] + + +def _gate_conds(rule: dict) -> tuple[list[dict], str]: + """조건 통과 줄 · 잇는 말 — conds · join · 옛 한 줄 꼴은 줄 하나(Model gateConds).""" + rows = rule.get("conds") or [ + {"from": rule.get("from") or {}, "op": rule.get("op"), "value": rule.get("value")} + ] + return rows, rule.get("join") or "그리고" + + +def _cond_of(conds: dict, port: dict) -> Any: + """조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름(Rule condOf).""" + key = f"{port.get('box')}|{port.get('name')}" + return conds[key] if key in conds else conds.get(port.get("name")) # ── 계산(`M02_DF_Eval.evalLogic` 와 같은 차례) ───────────────────────────── @@ -462,9 +479,11 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N return v if v is None and rule.get("kind") in ("scale", "round"): return None - conds = { - p.get("name"): ports.get(p.get("box"), {}).get(p.get("name")) for p in _sources(rule) - } + conds = {} + for p in _sources(rule): + got_v = ports.get(p.get("box"), {}).get(p.get("name")) + conds[p.get("name")] = got_v + conds[f"{p.get('box')}|{p.get('name')}"] = got_v got, why = apply_rule(rule, v, conds) if why: blocked.add(ln["to"].get("box")) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts index a35d22cf9..038c1e9a7 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -16,10 +16,12 @@ import { depends, designInputs, elementPort, + gateConds, portsIn, portsOut, ruleSources, type CfBox, + type CfGateRule, type CfLink, type CfLogic, } from "./M02_DF_Model"; @@ -266,7 +268,10 @@ export function evalLogic( if (!l.rule) return v; if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null; const conds = Object.fromEntries( - ruleSources(l.rule).map((p) => [p.name, valueOf(p.box, p.name)]), + ruleSources(l.rule).flatMap((p) => [ + [p.name, valueOf(p.box, p.name)], + [`${p.box}|${p.name}`, valueOf(p.box, p.name)], + ]), ); const res = applyRule(l.rule, v, conds); if (l.rule.kind === "gate") passed.set(l.id, res.why === null); @@ -411,21 +416,26 @@ function gates( const groups = new Map(); for (const l of lg.links) if (l.rule?.kind === "gate") { - const k = `${l.rule.from.box}|${l.rule.from.name}|${l.from.box}|${l.from.name}`; + // 같은 알약에서 나가 같은 조건 알약들로 가르는 선끼리 — 여러 조건이면 조건 알약 묶음이 같을 때 + const srcs = ruleSources(l.rule) + .map((p) => `${p.box}|${p.name}`) + .sort() + .join("+"); + const k = `${srcs}|${l.from.box}|${l.from.name}`; groups.set(k, [...(groups.get(k) ?? []), l]); } for (const list of groups.values()) { if (list.length < 2) continue; const n = list.filter((l) => passed.get(l.id)).length; if (n === 1) continue; - const g = list[0].rule as Extract; - const fill = { box: name(g.from.box), pill: g.from.name }; + const g = gateConds(list[0].rule as CfGateRule).conds[0].from; + const fill = { box: name(g.box), pill: g.name }; const text = tl(n > 1 ? "Overlap" : "NoneMatch", fill); found.push({ kind: n > 1 ? "조건 겹침" : "조건 안 맞음", level: "문제", - box: g.from.box, - port: g.from.name, + box: g.box, + port: g.name, text, }); errors.push(text); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts index f8ee64e95..bee7c9010 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts @@ -126,9 +126,27 @@ export type CfGateOp = "=" | "≠" | "≥" | "≤" | ">" | "<"; /** 선 규칙(선 하나에 하나 · 없으면 그대로) — 받는 칸의 엑셀 수식으로 바뀌어 엔진 한 벌로 셈 * lookup = 보낸 표에서 조건 칸마다 맞출 알약 · 꺼낼 칸(범위 칸은 「이름·아래」 「이름·위」) * gate = 알약 값이 조건을 통과할 때만 보냄 · scale = × ÷ +% -% · round = 끝수 */ +/** 조건 통과의 조건 한 줄 */ +export interface CfGateCond { + from: CfPort; + op: CfGateOp; + value: number | string; +} + +/** 조건 통과 — conds 여러 줄을 join(그리고 · 또는)으로 · 옛 한 줄 꼴(from · op · value)도 읽음 + * from · op · value 는 첫 조건과 같게 둠(옛 화면 코드가 그대로 돌게 · makeGate 가 맞춰 줌) — conds 가 있으면 conds 가 이김 */ +export type CfGateRule = { + kind: "gate"; + conds?: CfGateCond[]; + join?: "그리고" | "또는"; + from: CfPort; + op: CfGateOp; + value: number | string; +}; + export type CfRule = | { kind: "lookup"; conds: { col: string; from: CfPort }[]; col: string } - | { kind: "gate"; from: CfPort; op: CfGateOp; value: number | string } + | CfGateRule | { kind: "scale"; op: "×" | "÷" | "+%" | "-%"; value: number } | ({ kind: "round" } & CfRound); @@ -357,10 +375,65 @@ export function portDir(box: CfBox, name: string): "in" | "out" { export function ruleSources(rule: CfRule | undefined): CfPort[] { if (!rule) return []; if (rule.kind === "lookup") return rule.conds.map((c) => c.from); - if (rule.kind === "gate") return [rule.from]; + if (rule.kind === "gate") return gateConds(rule).conds.map((c) => c.from); return []; } +/** 조건 통과의 조건 줄 · 잇는 말 — 옛 한 줄 꼴은 조건 하나 */ +export function gateConds(rule: CfGateRule): { conds: CfGateCond[]; join: "그리고" | "또는" } { + const conds = rule.conds?.length + ? rule.conds + : [{ from: rule.from, op: rule.op, value: rule.value }]; + return { conds, join: rule.join ?? "그리고" }; +} + +/** 조건 통과 만들기 — 옛 칸(from · op · value)은 첫 조건과 같게 */ +export function makeGate(conds: CfGateCond[], join: "그리고" | "또는" = "그리고"): CfGateRule { + const first = conds[0] ?? { from: { box: "", name: "" }, op: "=" as const, value: "" }; + return { + kind: "gate", + conds: clone(conds), + join, + from: { ...first.from }, + op: first.op, + value: first.value, + }; +} + +/** 알약이 가질 수 있는 값 목록 — 조건 값 고르기 · null = 숫자 칸(범위 검사) + * 입력 변수 고르기 = choices · 재료 알약 = 그 요소들의 규격 · 표 알약 = opts.col 칸 값들(opts.values 의 표 덩어리) + * · 함수 결과 · 다른 로직 출력 · 수 변수 = null */ +export function valueChoices( + lg: CfLogic, + port: CfPort, + opts: { col?: string; values?: Record> } = {}, +): (string | number)[] | null { + const box = boxOf(lg, port.box); + const inp = box?.input; + if (!box || box.kind !== "input" || !inp || inp.mode === "logic") return null; + const v = inp.variables.find((x) => x.name === port.name); + if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null; + if (port.name === TABLE_PORT && inp.table) { + const block = opts.values?.[box.id]?.[TABLE_PORT]; + if (!opts.col || !Array.isArray(block) || !Array.isArray(block[0])) return null; + const head = (block[0] as unknown[]).map(String); + const at = head.indexOf(opts.col); + if (at < 0) return null; + const seen: (string | number)[] = []; + for (const row of block.slice(1) as unknown[][]) { + const x = row[at]; + if ((typeof x === "number" || typeof x === "string") && x !== "" && !seen.includes(x)) + seen.push(x); + } + return seen.length ? seen : null; + } + const specs = inp.elements + .filter((e) => elementPort(e) === port.name && e.spec.trim()) + .map((e) => e.spec.trim()); + const uniq = [...new Set(specs)]; + return uniq.length ? uniq : null; +} + /** 상자 사이 기댐 — 선 + 선 규칙의 조건 알약 + 재료 조건 찾기의 알약(→ 그 상자) */ export function depends(lg: CfLogic): { from: string; to: string }[] { const out: { from: string; to: string }[] = []; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts index adf25c2fa..3e2233023 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts @@ -12,7 +12,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { colName } from "../../A00_Common/spreadsheet/spreadsheet_address"; import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import type { CfGateOp, CfRound, CfRule } from "./M02_DF_Model"; +import { gateConds, type CfGateOp, type CfRound, type CfRule } from "./M02_DF_Model"; type Scalar = number | string | boolean | null; @@ -27,6 +27,8 @@ export interface CfRuleResult { const TEXT = { Lookup: ["표에서 찾기", "Look up in table"], Gate: ["일 때만", "only when"], + And: ["그리고", "and"], + Or: ["또는", "or"], Places: ["소수 {n}자리", "{n} decimals"], Round: ["반올림", "round"], Up: ["올림", "round up"], @@ -57,8 +59,11 @@ export function ruleLabel(rule: CfRule): string { const conds = rule.conds.map((c) => `${c.col} = ${c.from.name}`).join(" · "); return `${tl("Lookup")}${conds ? ` (${conds})` : ""} → ${rule.col}`; } - case "gate": - return `${rule.from.name} ${rule.op} ${rule.value} ${tl("Gate")}`; + case "gate": { + const g = gateConds(rule); + const join = g.join === "또는" ? tl("Or") : tl("And"); + return `${g.conds.map((c) => `${c.from.name} ${c.op} ${c.value}`).join(` ${join} `)} ${tl("Gate")}`; + } case "scale": return rule.op === "+%" || rule.op === "-%" ? `${rule.op[0]}${rule.value}%` @@ -109,6 +114,10 @@ const lit = (v: number | string): string => /* ── 규칙 ───────────────────────────────────────────────────────────────── */ +/** 조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름 */ +const condOf = (conds: Record, p: { box: string; name: string }): unknown => + `${p.box}|${p.name}` in conds ? conds[`${p.box}|${p.name}`] : conds[p.name]; + /** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 이름 → 값(lookup · gate) */ export function applyRule( rule: CfRule, @@ -121,9 +130,17 @@ export function applyRule( if (a1) 칸.A1 = a1; let formula: string; if (rule.kind === "gate") { - const c1 = cellOf(conds[rule.from.name]); - if (c1) 칸.C1 = c1; - formula = `IF(C1${OP[rule.op]}${lit(rule.value)},A1,NA())`; + // 조건 값 = C1 · D1 · E1 … · 여러 줄은 AND / OR + const g = gateConds(rule); + const tests = g.conds.map((c, i) => { + const at = `${colName(2 + i)}1`; + const cv = cellOf(condOf(conds, c.from)); + if (cv) 칸[at] = cv; + return `${at}${OP[c.op]}${lit(c.value)}`; + }); + const test = + tests.length > 1 ? `${g.join === "또는" ? "OR" : "AND"}(${tests.join(",")})` : tests[0]; + formula = `IF(${test},A1,NA())`; } else if (rule.kind === "scale") { const v = rule.value; formula = @@ -169,7 +186,7 @@ function lookup( let bad = !out || rows < 1; rule.conds.forEach((c, i) => { const ref = `$${colName(W + 1 + i)}$1`; - const cv = cellOf(conds[c.from.name]); + const cv = cellOf(condOf(conds, c.from)); if (cv) 칸[`${colName(W + 1 + i)}1`] = cv; const same = at(c.col); const low = at(`${c.col}·아래`); diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index 4cfbee4d8..3d39c8e64 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -680,3 +680,107 @@ def test_만나는_상자는_살아_있는_선이_있으면_셈_서버와_같음 k: float(v) for k, v in screen_out.items() } assert [[b["kind"], b["level"], b["link"]] for b in srv["breaks"]] == screen_breaks + + +# ── 36-0b 선 조건 여러 줄(그리고 · 또는) · 값 목록 ───────────────────────────── +def test_조건_여러_줄_그리고_또는_글_식_서버같음() -> None: + lg = _ground() + two = { + "kind": "gate", + "conds": [ + {"from": {"box": "in-ground", "name": "지반"}, "op": "=", "value": "토사"}, + {"from": {"box": "in-ground", "name": "굴착량"}, "op": "≥", "value": 50}, + ], + "join": "그리고", + "from": {"box": "in-ground", "name": "지반"}, + "op": "=", + "value": "토사", + } + either = {**two, "join": "또는"} + cases = [ + (two, {"in-ground": {"지반": "토사", "굴착량": 100}}), + (two, {"in-ground": {"지반": "토사", "굴착량": 10}}), + (either, {"in-ground": {"지반": "암", "굴착량": 100}}), + (either, {"in-ground": {"지반": "암", "굴착량": 10}}), + ] + got = _run( + RULE + + f"const base = {json.dumps(lg, ensure_ascii=False)};\n" + + f"const cases = {json.dumps(cases, ensure_ascii=False)};\n" + "out(cases.map(([rule, vals]) => {\n" + " const lg = { ...base, links: base.links.map(l => l.id === 'l1' ? { ...l, rule } : l) };\n" + " const ev = E.evalLogic(lg, vals);\n" + " return [R.ruleLabel(rule), R.applyRule(rule, 1, { 지반: vals['in-ground'].지반, 굴착량: vals['in-ground'].굴착량 }).formula," + " ev.ports['fn-exc'], ev.breaks.filter(b => b.link === 'l1').map(b => b.kind)];\n" + "}));" + ) + labels = [g[0] for g in got] + assert labels[0] == "지반 = 토사 그리고 굴착량 ≥ 50 일 때만" + assert labels[2] == "지반 = 토사 또는 굴착량 ≥ 50 일 때만" + assert got[0][1] == 'IF(AND(C1="토사",D1>=50),A1,NA())' + assert got[2][1] == 'IF(OR(C1="토사",D1>=50),A1,NA())' + passed = [g[3] == [] for g in got] + assert passed == [True, False, True, False] + # 서버(Compute) = 화면(Eval) + for (rule, values), (_, _, ports, breaks) in zip(cases, got): + srv_lg = { + **lg, + "links": [{**ln, "rule": rule} if ln["id"] == "l1" else ln for ln in lg["links"]], + } + srv = _server(srv_lg, values) + exc = srv["ports"].get("fn-exc") or {} + assert {k: (None if v is None else float(v)) for k, v in exc.items()} == { + k: (None if v is None else float(v)) for k, v in ports.items() + } + assert [b["kind"] for b in srv["breaks"] if b.get("link") == "l1"] == breaks + + +def test_옛_한_줄_꼴과_makeGate() -> None: + got = _run( + RULE + + "const old = { kind: 'gate', from: { box: 'b1', name: '지반' }, op: '=', value: '토사' };\n" + "const made = M.makeGate([{ from: { box: 'b1', name: '지반' }, op: '≠', value: '암' }, { from: { box: 'b1', name: '높이' }, op: '<', value: 3 }], '또는');\n" + "out([M.gateConds(old), R.ruleLabel(old), made.from, made.op, made.value, M.ruleSources(made).map(p => p.name)," + " R.applyRule(made, 7, { 지반: '암', 높이: 2 }).value, R.applyRule(made, 7, { 지반: '암', 높이: 5 }).why]);" + ) + conds, label, first_from, first_op, first_value, sources, ok, blocked = got + assert conds == { + "conds": [{"from": {"box": "b1", "name": "지반"}, "op": "=", "value": "토사"}], + "join": "그리고", + } + assert label == "지반 = 토사 일 때만" + assert (first_from, first_op, first_value) == ({"box": "b1", "name": "지반"}, "≠", "암") + assert sources == ["지반", "높이"] + assert (ok, blocked) == (7, "통과 안 됨") + + +def test_값_목록() -> None: + got = _run( + PIPE + + "lg = M.updateBox(lg, dia.id, { input: { ...lg.boxes[0].input, variables: [...lg.boxes[0].input.variables," + " { name: '지반', type: 'choice', unit: '-', choices: ['토사', '암'], example: '토사' }] } });\n" + "lg = M.updateBox(lg, mat.id, { input: { ...lg.boxes[1].input, elements: lg.boxes[1].input.elements.map((e, i) => i === 1 ? { ...e, spec: '12mm' } : e) } });\n" + "r = add(lg, 'input'); lg = r.lg; const tb = r.box;\n" + "lg = M.updateBox(lg, tb.id, { input: { mode: 'group', group: '소요량', elements: [], variables: [], table: { key: 'QF1' } } });\n" + "const vals = { [tb.id]: { 표: [['돌', '석공'], ['깬돌', 0.2], ['견치돌', 0.3], ['깬돌', 0.25]] } };\n" + "const v = (box, name, opts) => M.valueChoices(lg, { box, name }, opts);\n" + "out([v(dia.id, '지반'), v(dia.id, '관경'), v(mat.id, '거푸집 자리'), v(mat.id, '흄관 단가')," + " v(tb.id, '표', { col: '돌', values: vals }), v(tb.id, '표', { col: '없음', values: vals }), v(tb.id, '표')," + " v(f.id, '재료값'), v('없는 상자', '지반')]);" + ) + assert got == [["토사", "암"], None, ["12mm"], None, ["깬돌", "견치돌"], None, None, None, None] + + +def test_조건_여러_줄_겹침_안맞음() -> None: + got = _run( + SPLIT + + "const both = (op1, v1) => M.makeGate([{ from: { box: v.id, name: '지반' }, op: op1, value: v1 }, { from: { box: v.id, name: '높이' }, op: '≥', value: 3 }]);\n" + "let g = M.setRule(lg, lg.links[0].id, both('=', '토사')).lg;\n" + "g = M.setRule(g, lg.links[1].id, both('≠', '암')).lg;\n" + "const over = E.breaks(g, V('토사')).filter(b => b.level === '문제').map(b => b.kind);\n" + "const none = E.breaks(g, V('암', 2)).filter(b => b.level === '문제').map(b => b.kind);\n" + "out([over, none]);" + ) + over, none = got + assert "조건 겹침" in over + assert "조건 안 맞음" in none