Merge remote-tracking branches 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into sub_laptop_3
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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 = <K extends keyof SVGElementTagNameMap>(
|
||||
parent: Element,
|
||||
tag: K,
|
||||
attrs: Record<string, string>,
|
||||
) => {
|
||||
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;
|
||||
|
||||
@@ -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, Record<string, unknown>>,
|
||||
): 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<K extends keyof SVGElementTagNameMap>(
|
||||
parent: Element,
|
||||
tag: K,
|
||||
attrs: Record<string, string>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
@@ -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<string, CfLink[]>();
|
||||
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<CfLink["rule"], { kind: "gate" }>;
|
||||
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);
|
||||
|
||||
@@ -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, Record<string, unknown>> } = {},
|
||||
): (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 }[] = [];
|
||||
|
||||
@@ -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<CfLogic | null>;
|
||||
}
|
||||
|
||||
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<CfEvalOptions["logics"]> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>, 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}·아래`);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user