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 b385ae6d7..9dd41038a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -20,6 +20,7 @@ import { follow, depLines, isRest, + restParts, linkLabel, midOf, pillsOf, @@ -86,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 }); @@ -160,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: { diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index 5181c80ed..c96fe7fe4 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -306,6 +306,29 @@ export function depPath(a: Pt, b: Pt, side: "left" | "right"): string { /** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — 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)); + const outs = Object.values(values[b.id] ?? {}); + const hit = rests.some((k) => !k.link && k.box === b.id); + if (allRest || (hit && outs.every(empty))) boxes.add(b.id); + } + return { links, pills, boxes }; +} + /* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */ /** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */ diff --git a/resources/tester/test_m02_df_diagram_parts.py b/resources/tester/test_m02_df_diagram_parts.py index 8f2cf9eae..f5842893e 100644 --- a/resources/tester/test_m02_df_diagram_parts.py +++ b/resources/tester/test_m02_df_diagram_parts.py @@ -46,6 +46,10 @@ const out = jobs.map((job) => { 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; }); @@ -269,3 +273,34 @@ def test_기댐_선_끝이_알약과_이름표에_붙음(): 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"]