diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 583cc173d..87ec2cc7b 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -22,6 +22,7 @@ import { createCalcEngine } from "./spreadsheet_graph"; import { createGrid } from "./spreadsheet_grid"; import { createHistory } from "./spreadsheet_history"; import { attachKeys } from "./spreadsheet_keys"; +import type { ConditionOptions } from "./spreadsheet_condition"; import type { LinkOptions, Links } from "./spreadsheet_links"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; @@ -62,6 +63,8 @@ export interface SpreadsheetOptions { /** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 · * 알약 막대만. 다른 화면 모양은 그대로 */ preview?: boolean; + /** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */ + conditions?: ConditionOptions; } export interface SpreadsheetHandle { @@ -165,6 +168,10 @@ export function createSpreadsheet( grid.render(); select(ctx.selection); loadLinks(); + if (opts.conditions && !readOnly) + void import("./spreadsheet_condition").then((m) => { + if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!)); + }); function notify(message: string): void { toast.textContent = message; diff --git a/A00_Common/spreadsheet/spreadsheet_condition.css b/A00_Common/spreadsheet/spreadsheet_condition.css new file mode 100644 index 000000000..d98c625e3 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condition.css @@ -0,0 +1,61 @@ +.ss-cond-menu { + position: fixed; + z-index: 10000; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.ss-cond { + min-width: min(46rem, 96vw); +} + +.ss-cond-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) 0; +} + +.ss-cond-label { + min-width: 5em; + font-weight: var(--font-weight-bold); +} + +.ss-cond-pick { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.ss-cond-cell, +.ss-cond-value { + width: 7em; +} + +.ss-cond .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.ss-cond-note { + margin: 0 0 var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); +} + +.ss-cond-formula { + flex: 1 1 20em; + overflow-wrap: anywhere; + color: var(--color-text-secondary); +} + +.ss-cond-foot { + margin-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} diff --git a/A00_Common/spreadsheet/spreadsheet_condition.ts b/A00_Common/spreadsheet/spreadsheet_condition.ts new file mode 100644 index 000000000..628fd3d4f --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condition.ts @@ -0,0 +1,367 @@ +/* ============================================================================= + * spreadsheet_condition.ts + * 엑셀 「조건 만들기」 — 칸 우클릭 → 줄 판: 만약 [칸·알약] [비교] [값] → [칸·알약·값] · 아니면 만약 … · 아니면 → 고른 칸에 IF 식(중첩 IF) + * 엑셀로 쓴 IF 도 같은 줄로 다시 엶(`formulaToDraft`) · 못 바꾸는 식은 「식 그대로 고침」 안내 + * 부르는 쪽이 `conditions` 선택을 줄 때만 붙음(`attachCondition` · 늦게 불러옴) → 그 밖 화면은 그대로 + * 앞 절반(DraftToFormula · formulaToDraft)은 DOM 을 모름 — 시험이 이 구역만 떼어 Node 로 돌림 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; +import { toA1 } from "./spreadsheet_address"; +import { + COND_OPS, + CELL, + draftToFormula, + formulaToDraft, + type CondBranch, + type CondDraft, + type CondOp, + type CondOperand, +} from "./spreadsheet_condition_text"; +import "./spreadsheet_condition.css"; + +/** 부르는 쪽이 주는 것 — valuesFor(a1) = 그 칸에 올 수 있는 값 목록(없으면 null → 값 칸) · pills = 이름 붙은 칸 */ +export interface ConditionOptions { + valuesFor(a1: string): (string | number)[] | null; + pills?(): { name: string; cell: string }[]; +} + +export { COND_OPS, draftToFormula, formulaToDraft }; +export type { CondBranch, CondDraft, CondOp, CondOperand }; + +/* ── 글 ─────────────────────────────────────────────────────────────────── */ + +const TEXT = { + Menu: ["조건 만들기", "Make a condition"], + Title: ["조건 만들기", "Make a condition"], + If: ["만약", "If"], + ElseIf: ["아니면 만약", "Else if"], + Then: ["→", "→"], + Otherwise: ["아니면 →", "Otherwise →"], + AddElseIf: ["+ 아니면 만약", "+ Else if"], + Remove: ["지움", "Remove"], + Make: ["만들기", "Make"], + Cancel: ["취소", "Cancel"], + Close: ["닫기", "Close"], + CellPick: ["칸 직접", "A cell"], + ValuePick: ["값", "A value"], + Cell: ["칸 주소", "Cell address"], + Value: ["값", "Value"], + BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"], + Target: ["식이 들어갈 칸", "Formula goes into"], + AsIs: [ + "이 식은 조건 줄로 바꿀 수 없음 — 식 그대로 고침", + "This formula can't be shown as lines — edit the formula as it is", + ], +} as const; + +const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/* ── 줄 판 ───────────────────────────────────────────────────────────────── */ + +const OWN = "\u0001cell"; +const OWN_VALUE = "\u0001value"; + +function select( + options: { value: string; text: string }[], + value: string, + onPick: (v: string) => void, + aria: string, +): HTMLSelectElement { + const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } }); + for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text })); + s.value = value; + s.addEventListener("change", () => onPick(s.value)); + return s; +} + +/** 칸 고르기 — 이름 붙은 칸(알약) 아니면 칸 주소 직접 */ +function cellPick( + opts: ConditionOptions, + value: string, + onChange: (cell: string) => void, + aria: string, +): HTMLElement { + const pills = opts.pills?.() ?? []; + const hit = pills.find((p) => p.cell === value); + const options = [ + ...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })), + { value: OWN, text: tc("CellPick") }, + ]; + const box = el("span", { className: "ss-cond-pick" }); + const sel = select(options, hit ? value : OWN, (v) => v !== OWN && onChange(v), aria); + box.append(sel); + if (!hit) { + const input = el("input", { + className: "ui-input ui-input--sm ss-cond-cell", + attrs: { type: "text", "aria-label": tc("Cell"), placeholder: "B2" }, + }); + input.value = value; + input.addEventListener("change", () => { + const m = CELL.exec(input.value.trim().toUpperCase()); + input.classList.toggle("is-bad", !m); + input.title = m ? "" : tc("BadCell"); + if (m) onChange(`${m[1]}${m[2]}`); + }); + box.append(input); + } + return box; +} + +/** 값 쪽 고르기 — 칸 · 알약 · 값(목록이 있으면 고르기) */ +function operandPick( + opts: ConditionOptions, + o: CondOperand, + hint: string | null, + onChange: (o: CondOperand) => void, + aria: string, +): HTMLElement { + const box = el("span", { className: "ss-cond-pick" }); + const pills = opts.pills?.() ?? []; + const isPill = o.kind === "cell" && pills.some((p) => p.cell === o.cell); + const options = [ + ...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })), + { value: OWN, text: tc("CellPick") }, + { value: OWN_VALUE, text: tc("ValuePick") }, + ]; + const mode = o.kind === "value" ? OWN_VALUE : isPill ? o.cell : OWN; + box.append( + select( + options, + mode, + (v) => { + if (v === OWN_VALUE) onChange({ kind: "value", value: 0 }); + else if (v === OWN) onChange({ kind: "cell", cell: "B2" }); + else onChange({ kind: "cell", cell: v }); + }, + aria, + ), + ); + if (o.kind === "cell" && !isPill) { + const input = el("input", { + className: "ui-input ui-input--sm ss-cond-cell", + attrs: { type: "text", "aria-label": tc("Cell") }, + }); + input.value = o.cell; + input.addEventListener("change", () => { + const m = CELL.exec(input.value.trim().toUpperCase()); + input.classList.toggle("is-bad", !m); + if (m) onChange({ kind: "cell", cell: `${m[1]}${m[2]}` }); + }); + box.append(input); + } + if (o.kind === "value") { + const list = hint ? opts.valuesFor(hint) : null; + if (list && list.length) { + const key = (v: string | number): string => `${typeof v}:${v}`; + box.append( + select( + list.map((v) => ({ value: key(v), text: String(v) })), + key(o.value), + (k) => { + const hitv = list.find((v) => key(v) === k); + if (hitv !== undefined) onChange({ kind: "value", value: hitv }); + }, + tc("Value"), + ), + ); + } else { + const input = el("input", { + className: "ui-input ui-input--sm ss-cond-value", + attrs: { type: "text", "aria-label": tc("Value") }, + }); + input.value = String(o.value); + input.addEventListener("change", () => { + const t = input.value.trim(); + onChange({ kind: "value", value: t !== "" && Number.isFinite(Number(t)) ? Number(t) : t }); + }); + box.append(input); + } + } + return box; +} + +function openJob( + ctx: SpreadsheetContext, + opts: ConditionOptions, + a1: string, + draft: CondDraft | null, + existing: string | null, +): void { + openModal({ + title: `${tc("Title")} — ${a1}`, + closeLabel: tc("Close"), + dialogClass: "ss-cond", + mount: (body, close) => { + let d: CondDraft = draft ?? { + branches: [ + { + left: "B1", + op: "=", + right: { kind: "value", value: "" }, + then: { kind: "value", value: 0 }, + }, + ], + otherwise: { kind: "value", value: 0 }, + }; + if (existing !== null && !draft) { + body.append( + el("p", { className: "ss-cond-note", text: tc("AsIs") }), + el("code", { className: "ss-cond-formula", text: `=${existing}` }), + ); + return; + } + const draw = (): void => { + const set = (next: CondDraft): void => ((d = next), draw()); + const rows = d.branches.map((b, i) => { + const edit = (patch: Partial): void => + set({ ...d, branches: d.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) }); + return el("div", { + className: "ss-cond-row", + children: [ + el("span", { className: "ss-cond-label", text: tc(i ? "ElseIf" : "If") }), + cellPick(opts, b.left, (cell) => edit({ left: cell }), tc("If")), + select( + COND_OPS.map((o) => ({ value: o, text: o })), + b.op, + (v) => edit({ op: v as CondOp }), + "op", + ), + operandPick(opts, b.right, b.left, (right) => edit({ right }), tc("Value")), + tc("Then"), + operandPick(opts, b.then, null, (then) => edit({ then }), tc("Then")), + d.branches.length > 1 + ? createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: () => set({ ...d, branches: d.branches.filter((_, j) => j !== i) }), + }) + : "", + ], + }); + }); + const last = d.branches[d.branches.length - 1]; + body.replaceChildren( + ...rows, + el("div", { + className: "ss-cond-row", + children: [ + createButton({ + label: tc("AddElseIf"), + variant: "ghost", + size: "sm", + onClick: () => + set({ + ...d, + branches: [ + ...d.branches, + { + left: last.left, + op: "=", + right: { kind: "value", value: "" }, + then: { kind: "value", value: 0 }, + }, + ], + }), + }), + ], + }), + el("div", { + className: "ss-cond-row", + children: [ + el("span", { className: "ss-cond-label", text: tc("Otherwise") }), + operandPick( + opts, + d.otherwise, + null, + (otherwise) => set({ ...d, otherwise }), + tc("Otherwise"), + ), + ], + }), + el("div", { + className: "ss-cond-row ss-cond-foot", + children: [ + el("code", { + className: "ss-cond-formula", + text: `${tc("Target")} ${a1}: =${draftToFormula(d)}`, + }), + createButton({ + label: tc("Make"), + variant: "filled", + size: "sm", + onClick: () => { + const sheet = ctx.sheet(); + const { 값: _v, ...keep } = sheet.칸[a1] ?? {}; + ctx.dispatch({ + 종류: "칸", + 시트: sheet.id, + 칸: { [a1]: { ...keep, 식: draftToFormula(d) } }, + }); + close(); + }, + }), + createButton({ label: tc("Cancel"), variant: "ghost", size: "sm", onClick: close }), + ], + }), + ); + }; + draw(); + }, + }); +} + +/** 칸 우클릭에 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음 */ +export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle { + let menu: HTMLElement | null = null; + const close = (): void => { + menu?.remove(); + menu = null; + document.removeEventListener("pointerdown", away, true); + }; + const away = (e: Event): void => { + if (menu && !menu.contains(e.target as Node)) close(); + }; + const onContext = (e: MouseEvent): void => { + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + if (!hit || hit.kind !== "cell" || ctx.readOnly) return; + e.preventDefault(); + e.stopPropagation(); + close(); + const a1 = toA1(hit.r, hit.c); + const sheet = ctx.sheet(); + const cell = sheet.칸[a1]; + menu = el("div", { className: "ss-cond-menu" }); + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + menu.append( + createButton({ + label: tc("Menu"), + variant: "ghost", + size: "sm", + onClick: () => { + close(); + const existing = cell?.식 ?? null; + const draft = existing !== null ? formulaToDraft(existing) : null; + openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null); + }, + }), + ); + document.body.append(menu); + document.addEventListener("pointerdown", away, true); + }; + ctx.root.addEventListener("contextmenu", onContext, true); + return { + root: null, + refresh() {}, + destroy() { + ctx.root.removeEventListener("contextmenu", onContext, true); + close(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_condition_text.ts b/A00_Common/spreadsheet/spreadsheet_condition_text.ts new file mode 100644 index 000000000..24e26fdbb --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condition_text.ts @@ -0,0 +1,121 @@ +/* ============================================================================= + * spreadsheet_condition_text.ts + * 「조건 만들기」 의 글 쪽(DOM 없음) — 줄들 ↔ IF 식 글 · 바깥 쉼표 가름 · 시험이 이 파일만 떼어 Node 로 돌림 + * ========================================================================== */ + +export type CondOp = "=" | "≠" | "≥" | "≤" | ">" | "<"; +export type CondOperand = + { kind: "cell"; cell: string } | { kind: "value"; value: number | string }; +export interface CondBranch { + /** 비교하는 칸 */ + left: string; + op: CondOp; + right: CondOperand; + then: CondOperand; +} +export interface CondDraft { + branches: CondBranch[]; + otherwise: CondOperand; +} + +export const XL_OP: Record = { + "=": "=", + "≠": "<>", + "≥": ">=", + "≤": "<=", + ">": ">", + "<": "<", +}; +export const FROM_XL: Record = { + "=": "=", + "<>": "≠", + ">=": "≥", + "<=": "≤", + ">": ">", + "<": "<", +}; +export const COND_OPS: CondOp[] = ["=", "≠", "≥", "≤", ">", "<"]; + +export const CELL = /^\$?([A-Z]{1,3})\$?(\d+)$/; + +/* ── 글 ↔ 줄 (DOM 없음) ───────────────────────────────────────────────── */ + +export const operandText = (o: CondOperand): string => + o.kind === "cell" + ? o.cell + : typeof o.value === "number" + ? String(o.value) + : `"${o.value.replace(/"/g, '""')}"`; + +/** 줄들 → 식 글(앞 = 뺌) — 아니면 만약 은 안쪽 IF */ +export function draftToFormula(d: CondDraft): string { + return d.branches.reduceRight( + (rest, b) => + `IF(${b.left}${XL_OP[b.op]}${operandText(b.right)},${operandText(b.then)},${rest})`, + operandText(d.otherwise), + ); +} + +/** 바깥 쉼표로 가름 — 따옴표 · 괄호 안은 건너뜀 */ +export function splitArgs(s: string): string[] { + const out: string[] = []; + let depth = 0; + let quote = false; + let from = 0; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '"') quote = !quote; + else if (quote) continue; + else if (ch === "(") depth++; + else if (ch === ")") depth--; + else if (ch === "," && depth === 0) { + out.push(s.slice(from, i)); + from = i + 1; + } + } + out.push(s.slice(from)); + return out; +} + +export function parseOperand(raw: string): CondOperand | null { + const t = raw.trim(); + const cell = CELL.exec(t); + if (cell) return { kind: "cell", cell: `${cell[1]}${cell[2]}` }; + if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: "value", value: Number(t) }; + const q = /^"((?:[^"]|"")*)"$/.exec(t); + return q ? { kind: "value", value: q[1].replace(/""/g, '"') } : null; +} + +/** 바깥 괄호 하나로 감싼 IF( … ) 의 안쪽 글 — 아니면 null */ +export function ifBody(f: string): string | null { + const t = f.trim(); + if (!/^IF\(/i.test(t) || !t.endsWith(")")) return null; + let depth = 0; + let quote = false; + for (let i = 2; i < t.length; i++) { + if (t[i] === '"') quote = !quote; + else if (quote) continue; + else if (t[i] === "(") depth++; + else if (t[i] === ")" && --depth === 0 && i !== t.length - 1) return null; + } + return t.slice(3, -1); +} + +/** 식 글(앞 = 뺌) → 줄들 — 이 판이 만드는 꼴(칸 비교 · 단순 값 · 아니면 쪽 중첩)만 읽고 나머지는 null */ +export function formulaToDraft(formula: string): CondDraft | null { + const body = ifBody(formula); + if (body === null) return null; + const args = splitArgs(body); + if (args.length !== 3) return null; + const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(args[0]); + if (!m) return null; + const left = parseOperand(m[1]); + const right = parseOperand(m[3]); + const then = parseOperand(args[1]); + if (!left || left.kind !== "cell" || !right || !then) return null; + const branch: CondBranch = { left: left.cell, op: FROM_XL[m[2]], right, then }; + const plain = parseOperand(args[2]); + if (plain) return { branches: [branch], otherwise: plain }; + const rest = formulaToDraft(args[2]); + return rest ? { branches: [branch, ...rest.branches], otherwise: rest.otherwise } : null; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 5842ec45e..2e599bb42 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -72,6 +72,9 @@ export interface DetailContext { onChange: () => void; /** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */ calcHost: HTMLElement; + /** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 · + * 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */ + flowHost?: HTMLElement; } type Group = "labor" | "material" | "cost" | "other"; @@ -482,8 +485,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { : null; host.replaceChildren( infoBox(ctx), - hoBox(ctx), - section(tl("Formula_Title"), [formula]), + ...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]), section(tl("Calc_Title"), [ctx.calcHost]), ); const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts new file mode 100644 index 000000000..a9652b42b --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_FlowRows.ts + * 흐름 로직의 왼쪽 「단가산출 상세」 줄 만들기(DOM 없음 · 순수) — 흐름도 출력 상자를 비용 종류별로 묶어 + * 줄(이름 · 단위 · 값) · 소계 · 계 · 함수 상자별 결과 값. 값은 DataFlow 계산 결과(`CfEvalResult`)를 읽기만 함. + * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7. 시험 = resources/tester/test_m01_logic_flowview.py + * ========================================================================== */ + +import type { CfEvalResult } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; +import type { CfBox, CfCost, CfLogic, CfRound } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { roundBy, showRounded } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; + +export type FlowGroupKey = "labor" | "material" | "cost" | "plain"; + +/** 비용 종류 → 묶음(인력 = 노무비 · 재료 = 재료비 · 경비) · 값(비용 아님)은 따로 */ +export const GROUP_OF: Record = { + 노무비: "labor", + 재료비: "material", + 경비: "cost", + 값: "plain", +}; +export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"]; +const COST_OF: Record, "노무비" | "재료비" | "경비"> = { + labor: "노무비", + material: "재료비", + cost: "경비", +}; + +export interface FlowLine { + box: string; + name: string; + unit: string; + value: number | string | boolean | null; + /** 보이는 글 — 숫자는 그 출력의 끝수 자리대로 */ + text: string; +} + +export interface FlowGroup { + key: FlowGroupKey; + lines: FlowLine[]; + /** 비용 묶음만 — 합계 끝수를 적용한 소계 */ + sub: number | null; + subText: string; +} + +export interface FlowFunc { + box: string; + name: string; + unit: string; + value: unknown; + text: string; +} + +export interface FlowRows { + groups: FlowGroup[]; + /** 합계 끝수(기본 1원 미만 버림)를 적용한 계 */ + total: number; + totalText: string; + funcs: FlowFunc[]; + errors: string[]; +} + +const plain = (v: unknown): string => + v === null || v === undefined || Array.isArray(v) + ? "—" + : typeof v === "number" + ? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }) + : String(v); + +/** 줄 모으기 — round.item = 출력 기본 끝수 · round.total = 합계 기본 끝수 */ +export function flowRows( + lg: CfLogic, + ev: CfEvalResult, + round: { item: CfRound; total: CfRound }, +): FlowRows { + const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output"); + const groups = GROUP_ORDER.map((key): FlowGroup => { + const mine = outs.filter((b) => GROUP_OF[b.output?.cost ?? "값"] === key); + const lines = mine.map((b): FlowLine => { + const v = ev.outputs[b.id] ?? null; + const r = b.output?.round ?? (key === "plain" ? undefined : round.item); + return { + box: b.id, + name: b.name, + unit: b.output?.unit ?? b.unit, + value: v, + text: typeof v === "number" ? showRounded(v, r) : plain(v), + }; + }); + const raw = key === "plain" ? null : ev.cost[COST_OF[key]]; + const sub = raw === null ? null : roundBy(round.total, raw); + return { + key, + lines, + sub, + subText: sub === null ? "" : showRounded(sub, round.total), + }; + }).filter((g) => g.lines.length); + const total = roundBy(round.total, ev.cost.계); + const funcs = lg.boxes + .filter((b) => b.kind === "function" && b.func) + .map((b): FlowFunc => { + const out = b.func!.output; + const v = ev.ports[b.id]?.[out.name]; + return { + box: b.id, + name: b.name, + unit: out.unit || b.unit, + value: v, + text: + typeof v === "number" + ? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }) + : plain(v), + }; + }); + return { + groups, + total, + totalText: showRounded(total, round.total), + funcs, + errors: ev.errors, + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css new file mode 100644 index 000000000..4a4f282e8 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css @@ -0,0 +1,30 @@ +.m01flow__group { + margin: 0 0 var(--spacing-12); +} + +.m01flow__block { + margin: 0 0 var(--spacing-8); +} + +.m01flow__func { + margin: 0 0 var(--spacing-4); + color: var(--color-text-body); + font-weight: 600; +} + +.m01flow__branch { + margin: 0 0 0 var(--spacing-16); + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01flow__branch--now { + color: var(--color-text-body); + font-weight: 600; +} + +.m01flow__warn { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-body-sm); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts new file mode 100644 index 000000000..87b9d9de2 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts @@ -0,0 +1,207 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_FlowView.ts + * 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다 + * 식 두 줄 — sub2 `formulaLines` 가 있으면 그것 · 없으면 함수 이름 · 결과 값만). 기본정보 · 시험 계산은 Detail 이 둠. + * 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름. + * 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름. + * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { + evalLogic, + type CfEvalOptions, + type CfEvalResult, + type CfValues, +} from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; +import type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; +import { + flowRows, + type FlowFunc, + type FlowGroup, + type FlowGroupKey, +} from "./M01_MasterData_UI_LogicLab_FlowRows"; +import "./M01_MasterData_UI_LogicLab_FlowView.css"; + +const TEXT = { + Detail: ["단가산출 상세", "Unit-price detail"], + Formula: ["텍스트 수식", "Text formulas"], + Labor: ["인력 · 노무비", "Labor · labor cost"], + Material: ["재료 · 재료비", "Material · material cost"], + Cost: ["경비", "Expense"], + Plain: ["값(비용 아님)", "Values (not costs)"], + HName: ["이름", "Name"], + HUnit: ["단위", "Unit"], + HValue: ["값", "Value"], + Sub: ["소계", "Subtotal"], + Total: ["계", "Total"], + TotalNote: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"], + None: ["출력 상자가 없음", "No output box"], + NoFunc: ["함수 상자가 없음", "No function box"], +} as const; + +type TextKey = keyof typeof TEXT; +const tf = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; +const GROUP_TITLE: Record = { + labor: "Labor", + material: "Material", + cost: "Cost", + plain: "Plain", +}; + +/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */ +interface FormulaLine { + name: string; + named: string; + valued: string; + branches?: { when: string; then: string; now: boolean }[]; +} +type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[]; +const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>( + "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts", +); +const loadFormula = async (): Promise => { + const load = Object.values(FORMULA)[0]; + return load ? ((await load()).formulaLines ?? null) : null; +}; + +const section = (title: string, body: HTMLElement[]): HTMLElement => + el("section", { + className: "m01-logic__section m01lab__box", + children: [ + el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title })] }), + ...body, + ], + }); + +const groupTable = (g: FlowGroup): HTMLElement => { + const sub = + g.sub === null + ? [] + : [ + el("tr", { + className: "m01lab__sub", + children: [ + el("td", { text: tf("Sub"), attrs: { colspan: "2" } }), + el("td", { className: "m01-logic__money", text: g.subText }), + ], + }), + ]; + return el("div", { + className: "m01flow__group", + children: [ + el("h4", { text: tf(GROUP_TITLE[g.key]) }), + el("div", { + className: "m01-logic__scroll", + children: [ + el("table", { + className: "m01-logic__grid", + children: [ + el("thead", { + children: [ + el("tr", { + children: [tf("HName"), tf("HUnit"), tf("HValue")].map((h) => + el("th", { text: h }), + ), + }), + ], + }), + el("tbody", { + children: [ + ...g.lines.map((l) => + el("tr", { + children: [ + el("td", { text: l.name }), + el("td", { text: l.unit }), + el("td", { className: "m01-logic__money", text: l.text }), + ], + }), + ), + ...sub, + ], + }), + ], + }), + ], + }), + ], + }); +}; + +/** 함수 상자 하나의 글 줄 — 식 보기가 있으면 두 줄(+ 갈래) · 없으면 이름 = 값 */ +function funcBlock(f: FlowFunc, lines: FormulaLine[] | null): HTMLElement { + const unit = f.unit && f.unit !== "-" ? ` ${f.unit}` : ""; + const head = el("p", { className: "m01flow__func", text: `${f.name} = ${f.text}${unit}` }); + if (!lines?.length) return el("div", { className: "m01flow__block", children: [head] }); + return el("div", { + className: "m01flow__block", + children: [ + head, + ...lines.flatMap((l) => [ + el("p", { className: "m01lab__expr", text: `${l.name} = ${l.named}` }), + el("p", { className: "m01lab__expr", text: `${l.name} = ${l.valued}` }), + ...(l.branches ?? []).map((b) => + el("p", { + className: `m01flow__branch${b.now ? " m01flow__branch--now" : ""}`, + text: `${b.now ? "▶ " : ""}${b.when} → ${b.then}`, + }), + ), + ]), + ], + }); +} + +/** 왼쪽 「지금 내용」 — 단가산출 상세 + 텍스트 수식을 `host` 에 그림(`values` = 입력 값 · 다시 부르면 다시 그림) */ +export function buildFlowView( + host: HTMLElement, + lg: CfLogic, + values: CfValues, + opts: { logics?: CfEvalOptions["logics"]; result?: CfEvalResult } = {}, +): void { + let ev: CfEvalResult; + try { + ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics }); + } catch (e) { + host.replaceChildren( + section(tf("Detail"), [el("p", { className: "m01-logic__muted", text: String(e) })]), + ); + return; + } + const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() }); + const detail = section(tf("Detail"), [ + ...(rows.groups.length + ? rows.groups.map(groupTable) + : [el("p", { className: "m01-logic__muted", text: tf("None") })]), + el("p", { + className: "m01lab__total", + attrs: { "data-sum": String(rows.total) }, + text: `${tf("Total")} ${rows.totalText}`, + }), + el("p", { className: "m01-logic__muted", text: tf("TotalNote") }), + ...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })), + ]); + const bodies = rows.funcs.map((f) => funcBlock(f, null)); + const text = section( + tf("Formula"), + bodies.length ? bodies : [el("p", { className: "m01-logic__muted", text: tf("NoFunc") })], + ); + host.replaceChildren(detail, text); + // sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함 + void loadFormula().then((fn) => { + if (!fn || !bodies.length) return; + const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func); + const merged = { ...ev.ports, ...ev.arrived } as CfValues; + text.replaceChildren( + text.firstElementChild!, + ...rows.funcs.map((f, i) => { + try { + return funcBlock(f, fn(boxes[i], merged)); + } catch { + return bodies[i]; + } + }), + ); + }); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts index 35596a0c9..3e7775ebd 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -206,6 +206,25 @@ export async function cfLogicValue( return send("/logic-value", { key, designs }); } +/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true · + * notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */ +export async function cfLogicFlow( + file: string, + key: string, +): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, { + credentials: "include", + }); + } catch { + throw new CfError(0, ""); + } + const data = (await response.json().catch(() => ({}))) as { detail?: unknown }; + if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText); + return data as { flow: CfLogic; converted: boolean; notes: string[] }; +} + /** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */ export async function cfDesignInputs(key: string): Promise { const qs = new URLSearchParams({ key }).toString(); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts b/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts new file mode 100644 index 000000000..1e8eac043 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts @@ -0,0 +1,135 @@ +/* ============================================================================= + * M02_DF_CellTable.ts + * 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기 + * 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfBox, CfLogic } from "./M02_DF_Model"; +import { portText } from "./M02_DF_Detail"; + +const TEXT = { + Title: ["칸 연결 표", "Cell connections"], + Cell: ["칸", "Cell"], + Pill: ["연결 알약", "Pill"], + Kind: ["종류", "Kind"], + Line: ["이어진 선", "Line"], + In: ["입력", "Input"], + Var: ["변수", "Variable"], + Out: ["결과", "Result"], + None: ["아직 연결된 칸이 없음", "No cell is connected yet"], + Unplaced: ["칸 없음", "no cell"], + NoLine: ["-", "-"], + Release: ["풀기", "Release"], + BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"], +} as const; + +const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +export type CellKind = "in" | "var" | "out"; + +export interface CellTableHandlers { + /** 표에서 칸을 바꿈 — key = 알약 이름 */ + move(kind: CellKind, key: string, cell: string): void; + /** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */ + release(kind: CellKind, key: string): void; +} + +interface Row { + kind: CellKind; + key: string; + cell: string; + line: string; +} + +function rowsOf(lg: CfLogic, box: CfBox): Row[] { + const f = box.func; + if (!f) return []; + const into = (port: string): string => { + const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port); + return l ? portText(lg, l.from) : tt("NoLine"); + }; + const out = (): string => { + const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name); + return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine"); + }; + return [ + ...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })), + ...f.variables.map((v): Row => ({ + kind: "var", + key: v.name, + cell: v.cell ?? "", + line: tt("NoLine"), + })), + { kind: "out", key: f.output.name, cell: f.output.cell, line: out() }, + ]; +} + +/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */ +export function renderCellTable( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + readOnly: boolean, + handlers: CellTableHandlers, +): void { + host.classList.add("m02-dfcells"); + const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key); + const head = el("tr", { + children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) => + el("th", { text: t }), + ), + }); + const body = rows.map((r) => { + const input = el("input", { + className: "ui-input ui-input--sm m02-dfcells__cell", + attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") }, + }); + input.value = r.cell; + input.readOnly = readOnly; + input.addEventListener("change", () => { + const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim()); + input.classList.toggle("is-bad", !m); + input.title = m ? "" : tt("BadCell"); + if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`); + }); + return el("tr", { + className: `m02-dfcells__row is-${r.kind}`, + children: [ + el("td", { children: [input] }), + el("td", { text: r.key }), + el("td", { + children: [ + el("span", { + className: "m02-dfcells__kind", + text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"), + }), + ], + }), + el("td", { text: r.line }), + el("td", { + children: + readOnly || (r.kind !== "var" && !r.cell) + ? [] + : [ + createButton({ + label: tt("Release"), + variant: "ghost", + size: "sm", + onClick: () => handlers.release(r.kind, r.key), + }), + ], + }), + ], + }); + }); + host.replaceChildren( + el("strong", { className: "m02-dfcells__title", text: tt("Title") }), + el("table", { + className: "m02-dfcells__table", + children: [el("thead", { children: [head] }), el("tbody", { children: body })], + }), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index e74e30fa7..ae84fe5e1 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -320,7 +320,7 @@ export function renderDiagram( return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 }; }); const r = view.getBoundingClientRect(); - Object.assign(st, fitView(rects, { w: r.width, h: r.height })); + Object.assign(st, fitView(rects, { w: r.width, h: r.height }, 40, opts.fitMin)); applyView(); drawLines(); }; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index 649aac97e..49052241f 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -122,6 +122,8 @@ export interface DiagramOpts { readOnly?: boolean; /** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */ fitKey?: number; + /** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */ + fitMin?: number; } /* ── 알약 · 자리 ────────────────────────────────────────────────────── */ @@ -193,13 +195,14 @@ export function fitView( rects: { x: number; y: number; w: number; h: number }[], view: { w: number; h: number }, pad = 40, + min = FIT_MIN, ): { zoom: number; panX: number; panY: number } { if (!rects.length) return { zoom: 1, panX: 0, panY: 0 }; const x0 = Math.min(...rects.map((r) => r.x)) - pad; const y0 = Math.min(...rects.map((r) => r.y)) - pad; const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad; const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad; - const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5))); + const zoom = clampZoom(Math.max(min, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5))); /** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */ const place = (size: number, from: number, to: number): number => Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css index 05228f379..9058ccdb2 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css @@ -70,7 +70,7 @@ /* 시작 화면 */ .m02-df__start { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); gap: var(--spacing-24); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts index e6a60b0e8..0cd240ba0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts @@ -47,12 +47,11 @@ const loadMods = () => import("./M02_DF_FuncPanel"), import("./M02_DF_OutputPanel"), import("./M02_DF_Summary"), - import("./M02_DF_Sample"), import("./M02_DF_InputPanel"), import("./M02_DF_PillPanel"), import("./M02_DF_LinkPanel"), import("./M02_DF_Standards"), - ]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({ + ]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({ model, ev, api, @@ -60,7 +59,6 @@ const loadMods = () => func, output, summary, - sample, input, pill, line, @@ -78,7 +76,6 @@ const TEXT = { Start_New: ["새로 만들기", "New"], Start_Mine: ["내 로직", "My logics"], Start_MineNone: ["아직 없음", "None yet"], - Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"], Name: ["로직 이름", "Logic name"], NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], Unit: ["결과 단위", "Result unit"], @@ -217,7 +214,6 @@ function openDataFlow(): void { paper = null; strip.replaceChildren(); const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] }); - const sampleHost = el("div", { className: "m02-df__list" }); body.replaceChildren( el("div", { className: "m02-df__start", @@ -232,28 +228,10 @@ function openDataFlow(): void { mineHost, ], }), - el("section", { - className: "m02-df__start-col", - children: [el("h3", { text: te("Start_Samples") }), sampleHost], - }), ], }), ); const M = await mods; - sampleHost.replaceChildren( - ...M.sample.sampleList().map((s) => - listRow( - s.name, - s.resultUnit, - () => - run(async (M2) => { - const lg = M2.sample.sampleLoad(s.id); - if (lg) await showPaper(M2, lg, false); - }), - s.note, - ), - ), - ); try { const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample); mineHost.replaceChildren( @@ -379,7 +357,7 @@ function openDataFlow(): void { if (sig === logicSig) return; logicSig = sig; const get = async (key: string): Promise => - key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key); + key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null); void loadLogics(lg, get).then((got) => { if (sig !== logicSig) return; logics = got.logics; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts b/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts new file mode 100644 index 000000000..8d9581eb4 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts @@ -0,0 +1,284 @@ +/* ============================================================================= + * M02_DF_FormulaView.ts + * 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 · + * IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀 + * 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; +import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text"; +import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { CfBox } from "./M02_DF_Model"; + +export type CellScalar = number | string | boolean | null; + +export interface FormulaBranch { + when: string; + then: string; + /** 지금 타는 갈래 */ + now: boolean; +} + +export interface FormulaLine { + /** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */ + name: string; + /** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */ + named: string; + /** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */ + valued: string; + /** IF 면 만약 / 아니면 줄 */ + branches?: FormulaBranch[]; +} + +const TEXT = { + Title: ["식 보기", "Formulas"], + Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"], + Else: ["아니면", "Otherwise"], + If: ["만약", "If"], + Now: ["지금", "now"], +} as const; + +const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */ + +const REF = /(? string): string { + return text + .split(/("(?:[^"]|"")*")/) + .map((p, i) => (i % 2 ? p : fn(p))) + .join(""); +} + +const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, ""); + +/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */ +export function cellNames(box: CfBox): Record { + const f = box.func; + const out: Record = {}; + if (!f) return out; + for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port; + for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name; + if (f.output.cell) out[upper(f.output.cell)] = f.output.name; + return out; +} + +const refsIn = (formula: string): string[] => { + const found: string[] = []; + outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m))); + return found; +}; + +const OPS: [RegExp, string][] = [ + [/\*/g, " × "], + [/\//g, " ÷ "], + [/<>/g, " ≠ "], + [/>=/g, " ≥ "], + [/<=/g, " ≤ "], + [/(?≠≥≤])=/g, " = "], + [/\+/g, " + "], + [/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "], +]; + +/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */ +function pretty(text: string): string { + return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p)) + .replace(/\s{2,}/g, " ") + .trim(); +} + +const fmt = (n: number): string => + Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +function showScalar(v: CellScalar | undefined): string { + if (v === undefined || v === null) return "0"; + if (typeof v === "number") return fmt(v); + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + return `"${v}"`; +} + +/** 이름으로 쓴 식 */ +export function namedText(formula: string, names: Record): string { + return pretty( + outside(formula, (p) => + p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`), + ), + ); +} + +/** 값 넣은 식(값이 문자면 따옴표) */ +export function valuedText(formula: string, values: Record): string { + return pretty( + outside(formula, (p) => + p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])), + ), + ); +} + +/* ── 갈래 ───────────────────────────────────────────────────────────────── */ + +const num = (v: CellScalar | undefined): number | null => { + if (typeof v === "number") return v; + if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v); + return null; +}; + +/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */ +function holds(cond: string, values: Record): boolean | null { + const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond); + if (!m) return null; + const read = (t: string): CellScalar | undefined => { + const x = t.trim(); + const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x); + if (cell) return values[`${cell[1]}${cell[2]}`]; + const q = /^"((?:[^"]|"")*)"$/.exec(x); + if (q) return q[1].replace(/""/g, '"'); + return Number.isFinite(Number(x)) ? Number(x) : undefined; + }; + const a = read(m[1]); + const b = read(m[3]); + if (a === undefined || b === undefined) return null; + const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)]; + switch (m[2]) { + case "=": + return l === r; + case "<>": + return l !== r; + case ">=": + return l >= r; + case "<=": + return l <= r; + case ">": + return l > r; + default: + return l < r; + } +} + +/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */ +function branchesOf( + formula: string, + names: Record, + values: Record, +): FormulaBranch[] | undefined { + const out: FormulaBranch[] = []; + let rest = formula; + let taken = false; + for (;;) { + const body = ifBody(rest); + if (body === null) break; + const a = splitArgs(body); + if (a.length !== 3) return undefined; + const ok = holds(a[0], values) === true && !taken; + if (ok) taken = true; + out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok }); + rest = a[2]; + } + if (!out.length) return undefined; + out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken }); + return out; +} + +/* ── 줄 ─────────────────────────────────────────────────────────────────── */ + +/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */ +export function formulaLines(box: CfBox, values: Record): FormulaLine[] { + const f = box.func; + if (!f?.output.cell) return []; + const names = cellNames(box); + const cells = f.sheet.칸; + const order: string[] = []; + const seen = new Set(); + const visit = (a1: string): void => { + const key = upper(a1); + if (seen.has(key)) return; + seen.add(key); + const formula = cells[key]?.식; + if (formula === undefined) return; + for (const r of refsIn(formula)) visit(r); + order.push(key); + }; + visit(f.output.cell); + return order.map((key) => { + const formula = cells[key].식 as string; + const result = showScalar(values[key]); + const branches = branchesOf(formula, names, values); + return { + name: names[key] ?? key, + named: namedText(formula, names), + valued: `${valuedText(formula, values)} = ${result}`, + ...(branches ? { branches } : {}), + }; + }); +} + +/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */ +export function cellValuesOf( + box: CfBox, + arrived: Record | undefined, +): Record { + const f = box.func; + if (!f) return {}; + const sheet = structuredClone(f.sheet); + const scalar = (v: unknown): number | string | boolean | undefined => + typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined; + const put = (cell: string, v: number | string | boolean | undefined): void => { + if (!parseA1(cell) || v === undefined) return; + sheet.칸[upper(cell)] = { 값: v }; + }; + for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port])); + for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example); + const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); + const out: Record = {}; + for (const [key, cell] of Object.entries(sheet.칸)) { + const c: CalcValue | undefined = calc[sheet.id]?.[key]; + out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null; + } + return out; +} + +function fromCalc(v: CalcValue): CellScalar { + if ("수" in v) return Number(v.수); + if ("글" in v) return v.글; + if ("참" in v) return v.참; + return v.오류; +} + +/* ── 그리기 ─────────────────────────────────────────────────────────────── */ + +/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */ +export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void { + host.classList.add("m02-df-formula"); + host.replaceChildren( + el("strong", { className: "m02-df-formula__title", text: tv("Title") }), + ...(lines.length + ? lines.map((l) => + el("div", { + className: "m02-df-formula__line", + children: [ + el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }), + el("div", { className: "m02-df-formula__valued", text: l.valued }), + ...(l.branches + ? [ + el("ul", { + className: "m02-df-formula__branches", + children: l.branches.map((b, i) => + el("li", { + className: b.now ? "is-now" : "", + text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`, + }), + ), + }), + ] + : []), + ], + }), + ) + : [el("p", { className: "m02-df-formula__empty", text: tv("Empty") })]), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts new file mode 100644 index 000000000..6d302fea5 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * M02_DF_FuncExtra.ts + * 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것 + * 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { showToast } from "@ui/ui_template_elements"; +import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition"; +import { renderCellTable } from "./M02_DF_CellTable"; +import { evalSafe, type DetailOptions } from "./M02_DF_Detail"; +import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView"; +import { TEXT } from "./M02_DF_FuncText"; +import { valueChoices, type CfFunc, type CfLogic } from "./M02_DF_Model"; + +export interface ExtraContext { + lg: CfLogic; + boxId: string; + detail: DetailOptions; + readOnly: boolean; + place(port: string, cell: string): void; + setOutput(cell: string): void; + patch(make: (f: CfFunc) => CfFunc): void; + used(f: CfFunc): Map; +} + +const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0]; +const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId); + +/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */ +export function drawFormula(host: HTMLElement, x: ExtraContext): void { + const box = boxOf(x); + if (!box?.func) return; + const ev = evalSafe(x.lg, x.detail); + renderFormulaView(host, formulaLines(box, cellValuesOf(box, ev?.arrived?.[x.boxId]))); +} + +/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */ +export function drawCells(host: HTMLElement, x: ExtraContext): void { + const box = boxOf(x); + if (!box?.func) return; + renderCellTable(host, x.lg, box, x.readOnly, { + move: (kind, key, cell) => { + if (kind === "in") return x.place(key, cell); + if (kind === "out") return x.setOutput(cell); + if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error"); + x.patch((f) => ({ + ...f, + variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)), + })); + }, + release: (kind, key) => + x.patch((f) => + kind === "in" + ? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) } + : kind === "var" + ? { ...f, variables: f.variables.filter((v) => v.name !== key) } + : { ...f, output: { ...f.output, cell: "" } }, + ), + }); +} + +/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸 */ +export function conditionsOf(x: ExtraContext): ConditionOptions { + return { + valuesFor: (a1) => { + const f = boxOf(x)?.func; + const at = a1.toUpperCase(); + const v = f?.variables.find((y) => y.cell?.toUpperCase() === at); + if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null; + const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port; + const link = port + ? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port) + : undefined; + return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null; + }, + pills: () => { + const f = boxOf(x)?.func; + return [ + ...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })), + ...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })), + ]; + }, + }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css index 7497c4a10..77a5471f1 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css @@ -111,11 +111,11 @@ button.m02-dfpanel-chip:focus-visible { } .m02-dfpanel-chip.is-out { - border-color: #f59e0b; + border-color: #27ae60; } .m02-dfpanel-chip.is-var { - border-color: #22a06b; + border-color: #d99a00; } /* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */ @@ -133,11 +133,11 @@ button.m02-dfpanel-chip:focus-visible { } .m02-dfpanel .aislo-cell[data-cf-kind="var"] { - box-shadow: inset 0 0 0 2px #22a06b; + box-shadow: inset 0 0 0 2px #d99a00; } .m02-dfpanel .aislo-cell[data-cf-kind="out"] { - box-shadow: inset 0 0 0 2px #f59e0b; + box-shadow: inset 0 0 0 2px #27ae60; } .m02-dfpanel .aislo-cell[data-cf-kind]::after { @@ -170,3 +170,112 @@ button.m02-dfpanel-chip:focus-visible { outline: 2px dashed var(--color-danger); outline-offset: -2px; } + +/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */ +.m02-dfpanel-body { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--spacing-12); +} + +.m02-dfpanel-body > .m02-dfpanel-sheet { + flex: 1 1 28em; + min-width: 0; +} + +.m02-dfcells { + flex: 0 1 24em; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-dfcells__title { + font-weight: var(--font-weight-bold); +} + +.m02-dfcells__table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.m02-dfcells__table th, +.m02-dfcells__table td { + padding: var(--spacing-4); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m02-dfcells__cell { + width: 5em; +} + +.m02-dfcells .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.m02-dfcells__kind { + padding: 0 var(--spacing-8); + border: 2px solid; + border-radius: var(--radius-pills); + white-space: nowrap; +} + +.m02-dfcells__row.is-in .m02-dfcells__kind { + border-color: #3b82f6; +} + +.m02-dfcells__row.is-var .m02-dfcells__kind { + border-color: #d99a00; +} + +.m02-dfcells__row.is-out .m02-dfcells__kind { + border-color: #27ae60; +} + +/* 식 보기 */ +.m02-df-formula { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m02-df-formula__title { + font-weight: var(--font-weight-bold); +} + +.m02-df-formula__line { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: var(--spacing-4); + border-top: 1px dashed var(--color-border); +} + +.m02-df-formula__valued { + color: var(--color-text-secondary); +} + +.m02-df-formula__branches { + margin: 0; + padding-left: var(--spacing-16); + list-style: none; +} + +.m02-df-formula__branches .is-now { + font-weight: var(--font-weight-bold); +} + +.m02-df-formula__empty { + margin: 0; + color: var(--color-text-muted); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts index 45e3b2be5..17f9cb13a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts @@ -14,6 +14,8 @@ import type { Workbook, } from "../../A00_Common/spreadsheet/spreadsheet"; import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model"; +import type { DetailOptions } from "./M02_DF_Detail"; +import { conditionsOf, drawCells, drawFormula, type ExtraContext } from "./M02_DF_FuncExtra"; import { createRoundRow } from "./M02_DF_RoundRow"; import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText"; @@ -37,6 +39,8 @@ interface State { lg: CfLogic; on: Handlers; readOnly: boolean; + /** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */ + detail: DetailOptions; handle: SpreadsheetHandle | null; /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ snap: CalcValues; @@ -48,6 +52,8 @@ interface State { suggest: number | null; parts: { head: HTMLElement; + formula: HTMLElement; + cells: HTMLElement; got: HTMLElement; vars: HTMLElement; bar: HTMLElement; @@ -201,6 +207,20 @@ function problems(st: State): string[] { return out; } +/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */ + +/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */ +const extra = (st: State): ExtraContext => ({ + lg: st.lg, + boxId: st.boxId, + detail: st.detail, + readOnly: st.readOnly, + place: (port, cell) => placePort(st, port, cell), + setOutput: (cell) => setOutput(st, cell), + patch: (make) => patchFunc(st, make), + used: (f) => usedCells(f), +}); + /* ------------------------------ 칸 위 표시 ------------------------------ */ function applyMarks(st: State): void { @@ -563,6 +583,7 @@ async function mountSheet(st: State): Promise { preview: true, tabs: false, readOnly: st.readOnly, + conditions: conditionsOf(extra(st)), onChange: (b) => onBook(st, b), onSelect: (s) => { st.cell = s.칸; @@ -593,13 +614,14 @@ export function renderFuncPanel( lg: CfLogic, boxId: string, on: Handlers, - opts: { readOnly?: boolean } = {}, + opts: DetailOptions & { readOnly?: boolean } = {}, ): void { const old = states.get(host); const readOnly = !!opts.readOnly; if (old && old.boxId === boxId && old.readOnly === readOnly) { old.lg = lg; old.on = on; + old.detail = opts; const f = funcOf(old); if (old.handle && f && sheetJson(f) !== old.json) { old.json = sheetJson(f); @@ -615,6 +637,8 @@ export function renderFuncPanel( } const parts: State["parts"] = { head: el("div", { className: "m02-dfpanel-head" }), + formula: el("div"), + cells: el("div"), got: el("div", { className: "m02-dfpanel-row" }), vars: el("div", { className: "m02-dfpanel-vars" }), bar: el("div", { className: "m02-dfpanel-bar" }), @@ -626,6 +650,7 @@ export function renderFuncPanel( lg, on, readOnly, + detail: opts, handle: null, snap: {}, json: "", @@ -639,12 +664,22 @@ export function renderFuncPanel( drawGot(st); drawVars(st); drawBar(st); + drawFormula(st.parts.formula, extra(st)); + drawCells(st.parts.cells, extra(st)); applyMarks(st); }, }; states.set(host, st); host.classList.add("m02-dfpanel"); - host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet); + host.replaceChildren( + parts.head, + parts.formula, + parts.got, + parts.vars, + parts.bar, + parts.msg, + el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }), + ); parts.msg.hidden = true; st.redraw(); void mountSheet(st); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index a59e4e3d4..3ac1c5507 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -1,42 +1,87 @@ /* ============================================================================= * M02_DF_InputPanel_Logic.ts - * 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면 - * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) + * 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면 + * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길 + * 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름 * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; -import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api"; -import type { CfLogic } from "./M02_DF_Model"; -import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api"; +import type { CfLogic, CfLogicRef } from "./M02_DF_Model"; +import { + filterLogics, + logicBooks, + logicChapters, + logicRefOf, + masterRef, + pageRange, + setKind, + setLogicInput, + wouldCycle, + type MasterLogicBrief, +} from "./M02_DF_InputPanel_Build"; const TEXT = { Help: [ - "저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀", - "Use a saved logic or sample as an input", + "마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀", + "Use any master logic or a logic of mine as an input", ], - Search: ["로직 이름으로 찾기", "Search by name"], + Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"], + Book: ["원문", "Source"], + Chapter: ["장", "Chapter"], + All: ["전체", "All"], Loading: ["찾는 중…", "Searching…"], None: ["찾는 로직 없음", "No logic found"], - Sample: ["견본", "Sample"], + Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"], + Prev: ["이전", "Prev"], + Next: ["다음", "Next"], Mine: ["내가 만든 것", "Mine"], + Blocked: ["읽을 수 없음", "Unreadable"], Chosen: ["고른 로직", "Chosen logic"], - Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"], + Converted: [ + "옛 로직을 흐름도로 바꿔 읽음 — 파일은 안 바뀜", + "Old logic read as a flow — the file is unchanged", + ], + Notes: ["못 옮긴 곳", "Not carried over"], + Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"], Designs: [ - "받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)", + "받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)", "Pills it takes — its design inputs", ], NoOut: ["출력 상자가 없음", "No output box"], NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"], Change: ["다른 로직 고르기", "Pick another"], Remove: ["빼기", "Remove"], - Self: ["이 로직 자신은 못 씀", "Can't use itself"], + Self: ["이 로직 자신은 못 고름", "Can't pick itself"], + Cycle: [ + "돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름", + "That would loop — a logic that uses this one can't be picked", + ], + NoFlow: [ + "이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음", + "Can't read this logic's flow yet", + ], Failed: ["불러오지 못함", "Could not load"], + Wait: ["값 셈 중…", "Calculating…"], } as const; type TKey = keyof typeof TEXT; -const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string; +const t = (key: TKey, v: Record = {}): string => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, String(x)), + TEXT[key][currentLanguageIndex] as string, + ); + +const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); const plain = (e: unknown): string => { try { @@ -46,6 +91,40 @@ const plain = (e: unknown): string => { } }; +const PAGE = 12; +const MINE = "흐름도"; + +/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */ +let cache: Promise | null = null; +const loadAll = (): Promise => + (cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])]) + .then(([master, mine]) => [ + ...mine + .filter((i) => !i.sample) + .map((i): MasterLogicBrief => ({ + file: MINE, + 구분: MINE, + 상세구분: t("Mine"), + 키: i.key, + 원문번호: "", + 이름: i.name, + 결과단위: i.resultUnit, + })), + ...master, + ]) + .catch((e: unknown) => ((cache = null), Promise.reject(e)))); + +/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */ +const readNotes = new Map(); + +/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */ +async function flowOf( + item: Pick, +): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> { + if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] }; + return cfLogicFlow(item.file, item.키); +} + /** 다른 로직 입력 판 */ export function renderLogicInput( host: HTMLElement, @@ -55,32 +134,86 @@ export function renderLogicInput( ): void { const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic; if (ref) return void showChosen(host, lg, boxId, ref, on); + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m02-dfip__list" }); - let all: CfListItem[] = []; - const draw = (): void => { - const word = q.input.value.trim().toLowerCase(); - const rows = all.filter( - (i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)), + const all = { value: "", text: t("All") }; + let book = ""; + let chapter = ""; + let page = 1; + let items: MasterLogicBrief[] = []; + const bookBox = createSelectField({ + label: t("Book"), + options: [all], + value: "", + compact: true, + onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()), + }); + const chapterBox = createSelectField({ + label: t("Chapter"), + options: [all], + value: "", + compact: true, + onChange: (v) => ((chapter = v), (page = 1), draw()), + }); + const fillChapters = (): void => + chapterBox.setOptions( + [all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))], + chapter, ); + const list = el("div", { className: "m02-dfip__list" }); + const info = el("span", { className: "m02-dfip__muted" }); + const prev = createButton({ + label: t("Prev"), + variant: "ghost", + size: "sm", + onClick: () => ((page -= 1), draw()), + }); + const next = createButton({ + label: t("Next"), + variant: "ghost", + size: "sm", + onClick: () => ((page += 1), draw()), + }); + + const draw = (): void => { + const rows = filterLogics( + items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌 + { text: q.input.value, book, chapter }, + ); + const range = pageRange(rows.length, page, PAGE); + page = Math.min(page, range.pages); + const shown = rows.slice((page - 1) * PAGE, page * PAGE); + info.textContent = t("Total", { + n: rows.length, + a: range.from, + b: range.to, + p: page, + m: range.pages, + }); + prev.disabled = page <= 1; + next.disabled = page >= range.pages; list.replaceChildren( - ...(rows.length - ? rows.map((i) => { + ...(shown.length + ? shown.map((i) => { const b = el("button", { className: "m02-dfip__row", - attrs: { type: "button" }, + attrs: { + type: "button", + ...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}), + }, children: [ el("span", { className: "m02-dfip__name", children: [ - el("strong", { text: i.name }), + el("strong", { text: i.이름 }), + el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }), el("span", { className: "m02-dfip__tag", - text: i.sample ? t("Sample") : t("Mine"), + text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분, }), ], }), - el("span", { className: "m02-dfip__muted", text: i.resultUnit }), + el("span", { className: "m02-dfip__muted", text: i.결과단위 }), ], }); b.addEventListener("click", () => void pick(i)); @@ -89,56 +222,114 @@ export function renderLogicInput( : [el("p", { className: "m02-dfip__muted", text: t("None") })]), ); }; - const pick = async (i: CfListItem): Promise => { + + /** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */ + const loadForCycle = async (key: string): Promise => { + const it = items.find((x) => x.키 === key); + return it ? (await flowOf(it)).flow : null; + }; + + const pick = async (i: MasterLogicBrief): Promise => { try { - const other = await cfGet(i.key); - const next = setLogicInput(lg, boxId, logicRefOf(i.key, other)); + if (i.키 === lg.key) return void showToast(t("Self"), "error"); + if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error"); + const got = await flowOf(i); + readNotes.set(i.키, { converted: got.converted, notes: got.notes }); + const picked = got.converted + ? masterRef(i.키, got.flow, true, i.결과단위) + : { ...logicRefOf(i.키, got.flow), name: i.이름 }; + const done = setLogicInput(lg, boxId, picked); // 기본 이름이면 고른 로직 이름으로 on.change({ - ...next, - boxes: next.boxes.map((b) => - b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b, + ...done, + boxes: done.boxes.map((b) => + b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b, ), }); } catch (e) { - showToast(plain(e), "error"); + showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error"); } }; - q.input.addEventListener("input", draw); + + let timer = 0; + q.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => ((page = 1), draw()), 200); + }); list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); - host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list); - void cfList().then( - (items) => ((all = items), draw()), + host.replaceChildren( + el("p", { className: "m02-dfip__muted", text: t("Help") }), + el("div", { className: "m02-dfip__bar", children: [bookBox.root, chapterBox.root, q.root] }), + info, + list, + el("div", { className: "m02-dfip__chips", children: [prev, next] }), + ); + void loadAll().then( + (rows) => { + items = rows; + bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], ""); + fillChapters(); + draw(); + }, (e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })), ); } -/** 고른 로직 — 내보내는 알약 · 받는 알약 */ +/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */ function showChosen( host: HTMLElement, lg: CfLogic, boxId: string, - ref: NonNullable["logic"], + ref: CfLogicRef, on: { change(lg: CfLogic): void }, ): void { - if (!ref) return; - const pillLine = (p: { name: string; unit: string }): HTMLElement => + const read = readNotes.get(ref.key); + const values = new Map(); + const errors = el("p", { className: "m02-dfip__muted is-bad" }); + const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement => el("div", { className: "m02-dfip__item", children: [ - el("span", { className: "m02-dfip__name", text: p.name }), - el("span", { className: "m02-dfip__muted", text: p.unit || "-" }), + el("span", { className: "m02-dfip__name", text: name }), + ...(valueNode + ? [valueNode] + : extra + ? [el("span", { className: "m02-dfip__muted", text: extra })] + : []), + el("span", { className: "m02-dfip__muted", text: unit || "-" }), ], }); host.replaceChildren( el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }), + ...(read?.converted + ? [ + el("p", { className: "m02-dfip__muted", text: t("Converted") }), + ...(read.notes.length + ? [ + el("details", { + children: [ + el("summary", { text: `${t("Notes")} · ${read.notes.length}` }), + ...read.notes.map((n) => el("p", { text: n })), + ], + }), + ] + : []), + ] + : []), el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }), ...(ref.outputs.length - ? ref.outputs.map(pillLine) + ? ref.outputs.map((o) => { + const v = el("strong", { className: "m02-dfip__value", text: "—" }); + values.set(o.name, v); + return line(o.name, o.unit, v); + }) : [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]), + errors, el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }), ...(ref.designs.length - ? ref.designs.map(pillLine) + ? ref.designs.map((d) => + line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")), + ) : [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]), el("div", { className: "m02-dfip__chips", @@ -158,4 +349,17 @@ function showChosen( ], }), ); + // 값 — 서버 계산 길(받는 알약 예시값으로) + errors.textContent = t("Wait"); + void cfLogicValue(ref.key, ref.examples ?? {}).then( + (r) => { + for (const [name, node] of values) { + const v = r.outputs[name] ?? (r.cost as unknown as Record)[name]; + if (typeof v === "number") node.textContent = fmt(v); + else if (typeof v === "string") node.textContent = v; + } + errors.textContent = r.errors.join(" · "); + }, + (e) => (errors.textContent = plain(e)), + ); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts index 5f08dcf38..f8efa089d 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts @@ -9,8 +9,12 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import { + gateConds, + makeGate, portsOut, reaches, + valueChoices, + type CfGateCond, type CfGateOp, type CfLogic, type CfPort, @@ -77,6 +81,12 @@ const TEXT = { ], WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"], Count: ["선", "Line"], + And: ["그리고", "and"], + Or: ["또는", "or"], + If: ["조건", "If"], + AddLine: ["+ 조건 줄", "+ Condition line"], + Join: ["잇는 말", "Join with"], + OutRange: ["범위 {n} 밖", "Outside the range {n}"], Range: ["{n} (범위)", "{n} (range)"], } as const; @@ -92,6 +102,7 @@ interface Ctx { linkId: string; readOnly: boolean; headers: string[]; + detail: DetailOptions; change(lg: CfLogic): void; } @@ -240,28 +251,122 @@ function lookupEditor(ctx: Ctx, rule: Extract): HTML return out; } +/** 보낸 알약이 입력 상자의 숫자 변수면 그 범위 */ +function rangeOf(ctx: Ctx, p: CfPort): [number, number] | null { + const v = ctx.lg.boxes + .find((b) => b.id === p.box) + ?.input?.variables.find((x) => x.name === p.name); + return v?.type === "number" && v.range ? v.range : null; +} + +/** 값 칸 — 목록이 있으면 고르기 · 없으면 숫자 칸(범위 밖이면 한 줄 알림) */ +function gateValue(ctx: Ctx, c: CfGateCond, onPick: (v: number | string) => void): HTMLElement[] { + const list = valueChoices(ctx.lg, c.from, { values: ctx.detail.values }); + if (list?.length) { + const key = (v: string | number): string => `${typeof v}:${v}`; + const options = list.map((v) => ({ value: key(v), text: String(v) })); + return [ + select( + ctx, + options, + key(c.value), + (k) => { + const hit = list.find((v) => key(v) === k); + if (hit !== undefined) onPick(hit); + }, + tk("Value"), + ), + ]; + } + const range = rangeOf(ctx, c.from); + const out: HTMLElement[] = [numberBox(ctx, c.value, onPick, tk("Value"))]; + const n = Number(c.value); + if (range && Number.isFinite(n) && (n < range[0] || n > range[1])) { + out.push( + el("span", { + className: "m02-dflink-warn", + text: `⚠ ${tk("OutRange", `${range[0]} ~ ${range[1]}`)}`, + }), + ); + } + return out; +} + function gateEditor(ctx: Ctx, rule: Extract): HTMLElement[] { - const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); - return [ - el("p", { className: "m02-dflink-note", text: tk("GateHint") }), - row( - select( - ctx, - pillOptions(ctx), - toKey(rule.from), - (v) => put({ from: fromKey(v) }), - tk("Match"), + const g = gateConds(rule); + const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join)); + const edit = (i: number, patch: Partial): void => + put(g.conds.map((c, j) => (j === i ? { ...c, ...patch } : c))); + const out: HTMLElement[] = [el("p", { className: "m02-dflink-note", text: tk("GateHint") })]; + g.conds.forEach((c, i) => { + out.push( + row( + el("span", { + className: "m02-dflink-label", + text: i ? tk(g.join === "그리고" ? "And" : "Or") : tk("If"), + }), + select( + ctx, + pillOptions(ctx), + toKey(c.from), + (v) => { + const from = fromKey(v); + edit(i, { + from, + value: valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0, + }); + }, + tk("Match"), + ), + select( + ctx, + GATE_OPS.map((o) => ({ value: o, text: o })), + c.op, + (v) => edit(i, { op: v as CfGateOp }), + tk("Kinds"), + ), + ...gateValue(ctx, c, (v) => edit(i, { value: v })), + ctx.readOnly || g.conds.length < 2 + ? "" + : createButton({ + label: tk("Remove"), + variant: "ghost", + size: "sm", + onClick: () => put(g.conds.filter((_, j) => j !== i)), + }), ), - select( - ctx, - GATE_OPS.map((o) => ({ value: o, text: o })), - rule.op, - (v) => put({ op: v as CfGateOp }), - tk("Kinds"), + ); + }); + if (!ctx.readOnly) { + const first = g.conds[0]; + out.push( + row( + createButton({ + label: tk("AddLine"), + variant: "ghost", + size: "sm", + onClick: () => + put([...g.conds, { from: first?.from ?? { box: "", name: "" }, op: "=", value: 0 }]), + }), + ...(g.conds.length > 1 + ? [ + el("span", { className: "m02-dflink-label", text: tk("Join") }), + select( + ctx, + [ + { value: "그리고", text: tk("And") }, + { value: "또는", text: tk("Or") }, + ], + g.join, + (v) => put(g.conds, v as "그리고" | "또는"), + tk("Join"), + ), + ] + : []), ), - numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")), - ), - ]; + ); + } + return out; } function scaleEditor(ctx: Ctx, rule: Extract): HTMLElement[] { @@ -323,7 +428,7 @@ function roundEditor( const DEFAULT: Record, (from: CfPort) => CfRule> = { lookup: () => ({ kind: "lookup", conds: [], col: "" }), - gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }), + gate: (from) => makeGate([{ from, op: "≥", value: 0 }]), scale: () => ({ kind: "scale", op: "×", value: 1 }), round: () => ({ kind: "round", places: 0, method: "반올림" }), }; @@ -345,14 +450,23 @@ export function renderLinkPanel( Array.isArray(table) && Array.isArray(table[0]) ? table[0].map((h) => String(h ?? "")).filter(Boolean) : []; - const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change }; + const ctx: Ctx = { + lg, + linkId, + readOnly: !!opts.readOnly, + headers, + detail: opts, + change: on.change, + }; const rule = link.rule; const kind: Kind = rule?.kind ?? "none"; const parts: HTMLElement[] = [ el("div", { className: "m02-dflink-head", children: [ - el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }), + el("strong", { + text: `${portText(lg, link.from)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${portText(lg, link.to)}`, + }), el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }), ], }), diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.css b/M02_MasterTemplete/DataFlow/M02_DF_Panel.css new file mode 100644 index 000000000..f9ffd4fc8 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.css @@ -0,0 +1,151 @@ +/* 오른쪽 흐름도 패널 — 부르는 쪽 칸 안에서 100% */ +.m02-dfp { + position: relative; + display: flex; + width: 100%; + min-width: 0; + height: 100%; + border-left: 1px solid var(--color-border); + background: var(--color-canvas); +} + +.m02-dfp__open { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + padding: var(--spacing-8) var(--spacing-12) var(--spacing-12); +} + +.m02-dfp__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-dfp__title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m02-dfp__tools { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} + +/* 좁은 패널 — 다이어그램 판 높이를 패널에 맞춤 */ +.m02-dfp .m02-dfdg__view { + height: max(320px, 45vh); +} + +.m02-dfp__lock { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.m02-dfp__cost > summary { + cursor: pointer; + font-weight: 600; + padding: var(--spacing-4) 0; +} + +/* 왼쪽 가장자리 끌기 */ +.m02-dfp__grip { + position: absolute; + top: 0; + bottom: 0; + left: -4px; + z-index: 2; + width: 8px; + cursor: col-resize; + touch-action: none; +} + +.m02-dfp__grip:hover, +.m02-dfp__grip:focus-visible, +.m02-dfp.is-resizing .m02-dfp__grip { + background: color-mix(in srgb, var(--color-accent) 35%, transparent); + outline: none; +} + +.m02-dfp.is-resizing { + user-select: none; +} + +/* 접기 = 얇은 띠 */ +.m02-dfp__strip { + width: 100%; + padding: var(--spacing-12) 0; + border: 0; + background: var(--color-surface); + color: var(--color-text-secondary); + font: inherit; + writing-mode: vertical-rl; + cursor: pointer; +} + +.m02-dfp__strip:hover, +.m02-dfp__strip:focus-visible { + color: var(--color-accent); +} + +.m02-dfp__strip[hidden], +.m02-dfp__open[hidden], +.m02-dfp__grip[hidden] { + display: none; +} + +/* 크게 보기 — 화면 전체 */ +.m02-dfp-full { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12) var(--spacing-16); + overflow: auto; + background: var(--color-canvas); + outline: none; +} + +.m02-dfp-full__head { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.m02-dfp-full__head strong { + flex: 1; + font-size: var(--text-body); +} + +.m02-dfp-full__diagram .m02-dfdg__view { + height: max(420px, 60vh); +} + +.m02-dfp-full__lower { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: var(--spacing-16); + align-items: start; +} + +.m02-dfp-full__lower h3 { + margin: 0 0 var(--spacing-8); + font-size: var(--text-body); +} + +@media (max-width: 900px) { + .m02-dfp-full__lower { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts new file mode 100644 index 000000000..de5d49f87 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts @@ -0,0 +1,269 @@ +/* ============================================================================= + * M02_DF_Panel.ts + * 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand } + * 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) → + * 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange. + * 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage · + * 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로). + * 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfLogic } from "./M02_DF_Model"; +import { createPaper } from "./M02_DF_Paper"; +import "./M02_DF_Panel.css"; + +const TEXT = { + Big: ["크게 보기", "Full screen"], + Fold: ["접기", "Collapse"], + Strip: ["흐름도 ▸", "Flow ▸"], + StripTitle: ["흐름도 펴기", "Open the flow panel"], + Close: ["닫기", "Close"], + Detail: ["상세 내용", "Details"], + Cost: ["원가 · 검증", "Cost · check"], + Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"], +} as const; + +const tf = (key: keyof typeof TEXT): string => + TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; + +export interface FlowPanelOpts { + logic: CfLogic; + readOnly?: boolean; + title: string; + /** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */ + onChange(lg: CfLogic): void; + /** 다른 로직 입력이 부르는 로직 */ + logics?(key: string): Promise; + /** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */ + onResize?(px: number): void; +} + +export interface FlowPanel { + /** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */ + set(lg: CfLogic): void; + destroy(): void; + /** 크게 보기 열기 */ + expand(): void; +} + +/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */ + +export const PANEL_MIN = 320; +export const STRIP_WIDTH = 40; +/** 좁은 패널에서 저절로 맞출 때 가장 작은 확대 */ +export const PANEL_FIT_MIN = 0.25; +const WIDTH_KEY = "m02.dfpanel.width"; +const FOLD_KEY = "m02.dfpanel.folded"; + +/** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */ +export function dragWidth(start: number, startX: number, x: number, viewport: number): number { + const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85)); + return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x)))); +} + +/** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */ +export function storedWidth(raw: string | null): number | null { + const n = Number(raw); + return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null; +} + +const readStore = (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; +const writeStore = (key: string, value: string): void => { + try { + localStorage.setItem(key, value); + } catch { + /* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */ + } +}; + +/* ── 패널 ───────────────────────────────────────────────────────────── */ + +export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel { + /** 크게 보기 화면 — 열려 있을 때만 */ + let full: HTMLElement | null = null; + const paper = createPaper({ + logic: opts.logic, + readOnly: opts.readOnly, + onChange: opts.onChange, + logics: opts.logics, + // 좁은 패널은 「한눈에」 가 되게 더 작게 · 크게 보기는 글이 읽히게 90% 아래로 안 내림 + fitMin: () => (full ? 0.9 : PANEL_FIT_MIN), + }); + const { diagram, detail, summary, note, undo } = paper.parts; + let width = storedWidth(readStore(WIDTH_KEY)); + let folded = readStore(FOLD_KEY) === "1"; + const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth)); + + const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") => + createButton({ label, size: "sm", variant, onClick }); + + /* 펼친 패널 */ + const costBox = el("details", { + className: "m02-dfp__cost", + attrs: { open: "" }, + children: [el("summary", { text: tf("Cost") })], + }); + const tools = el("div", { className: "m02-dfp__tools", children: [undo] }); + const open = el("div", { + className: "m02-dfp__open", + children: [ + el("header", { + className: "m02-dfp__head", + children: [ + el("strong", { className: "m02-dfp__title", text: opts.title }), + sm(tf("Big"), () => expand()), + sm(tf("Fold"), () => setFolded(true)), + ], + }), + tools, + note, + diagram, + detail, + costBox, + ], + }); + costBox.append(summary); + + /* 접힌 띠 */ + const strip = el("button", { + className: "m02-dfp__strip", + text: tf("Strip"), + attrs: { type: "button", title: tf("StripTitle"), "aria-label": tf("StripTitle") }, + }); + strip.addEventListener("click", () => setFolded(false)); + + /* 왼쪽 가장자리 — 끌기 · 화살표 키 */ + const grip = el("div", { + className: "m02-dfp__grip", + attrs: { + role: "separator", + "aria-orientation": "vertical", + "aria-label": tf("Grip"), + tabindex: "0", + }, + }); + const setWidth = (px: number, save: boolean): void => { + width = px; + if (save) writeStore(WIDTH_KEY, String(px)); + tell(); + }; + grip.addEventListener("pointerdown", (ev) => { + if (ev.button !== 0) return; + ev.preventDefault(); + const start = root.getBoundingClientRect().width; + const startX = ev.clientX; + root.classList.add("is-resizing"); + const move = (m: PointerEvent): void => + setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false); + const up = (u: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + root.classList.remove("is-resizing"); + setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true); + paper.refit(); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + grip.addEventListener("keydown", (ev) => { + const step = ev.shiftKey ? 80 : 20; + const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0; + if (!d) return; + ev.preventDefault(); + const now = root.getBoundingClientRect().width; + setWidth(dragWidth(now, 0, -d, window.innerWidth), true); + }); + + const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] }); + root.addEventListener("keydown", (ev) => { + if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { + if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; + ev.preventDefault(); + paper.undo(); + } + }); + + const setFolded = (on: boolean): void => { + folded = on; + writeStore(FOLD_KEY, on ? "1" : "0"); + root.classList.toggle("is-folded", on); + open.hidden = on; + strip.hidden = !on; + grip.hidden = on; + tell(); + if (!on) paper.refit(); + }; + + /* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */ + const expand = (): void => { + if (full) return; + const close = sm(tf("Close"), () => shrink()); + full = el("div", { + className: "m02-dfp-full", + attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" }, + children: [ + el("header", { + className: "m02-dfp-full__head", + children: [el("strong", { text: opts.title }), undo, close], + }), + note, + el("div", { className: "m02-dfp-full__diagram", children: [diagram] }), + el("div", { + className: "m02-dfp-full__lower", + children: [ + el("section", { children: [el("h3", { text: tf("Detail") }), detail] }), + el("section", { children: [el("h3", { text: tf("Cost") }), summary] }), + ], + }), + ], + }); + full.addEventListener("keydown", (ev) => { + if (ev.key === "Escape" && !ev.defaultPrevented) shrink(); + if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { + if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; + ev.preventDefault(); + paper.undo(); + } + }); + document.body.append(full); + full.focus(); + paper.refit(); + }; + const shrink = (): void => { + if (!full) return; + full.remove(); + full = null; + tools.append(undo); + costBox.append(summary); + // 머리 · 실행 취소 줄 뒤에 판을 처음 차례대로 되돌려 끼움 + open.replaceChildren( + open.firstElementChild as HTMLElement, + tools, + note, + diagram, + detail, + costBox, + ); + paper.refit(); + }; + + host.replaceChildren(root); + setFolded(folded); + + return { + set: (lg) => paper.set(lg), + destroy: () => { + shrink(); + root.remove(); + }, + expand, + }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts index 6a90118bb..85146e7f4 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -53,6 +53,8 @@ export interface PaperOpts { onChange(lg: CfLogic): void; /** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */ logics?(key: string): Promise; + /** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */ + fitMin?(): number; } export interface Paper { @@ -304,6 +306,7 @@ export function createPaper(opts: PaperOpts): Paper { breaks: broken, readOnly, fitKey, + fitMin: opts.fitMin?.(), }); drawPanel(); renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values }); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts index 5341b5a83..5101ad946 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -102,3 +102,8 @@ export function showRounded(v: number, r?: CfRound): string { maximumFractionDigits: Math.max(0, r.places), }); } + +/** 원가 합계 기본 끝수(표를 읽어 오기 전에는 계금 1원 미만 버림) — 화면이 바로 쓰는 값 */ +let totalNow: CfRound = { places: 0, method: "버림" }; +void defaultTotalRound().then((r) => (totalNow = r)); +export const totalRoundNow = (): CfRound => totalNow; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts b/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts deleted file mode 100644 index 96bda504e..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* ============================================================================= - * M02_DF_Sample.ts - * 단가산출 흐름도 견본 — 처음 열었을 때 바로 보고 따라 만들 견본(`_sample/*.json` · 손으로 짬) - * ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증(직렬) · 품 × 노임(병렬) → 노무비 - * ② 지반 가르기 — 같은 굴착량이 「조건 통과」 선 둘(지반 = 토사 / ≠ 토사)로 굴삭기 · 브레이커에 감 → 경비 - * ③ 흄관 관경 조건 찾기 — 재료 자리(조건 찾기 · 규격에 관경 포함) → 본수 × 단가 → 재료비 - * ④ 다른 로직 잇기 — 작업량 로직(⑤) 출력 → 장비 시간 → 경비 - * ⑤ 덤프트럭 시간당 작업량(값) — ④ 가 부르는 다른 로직 - * 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호). - * 입력 값(노임 · 표 · 재료)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음. - * 모양 = `M02_DF_Model` CfLogic(선 규칙 · 재료 방식 · 다른 로직 입력 포함) · 알약 이름 규칙 — 정함 - * `docs/raw/M01_쉬운단가/흐름도_정함.md` 1 · 4~7 · 12. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import type { CfLogic } from "./M02_DF_Model"; - -const FILES = import.meta.glob("./_sample/*.json", { eager: true, import: "default" }); - -/** 견본마다 한 줄 설명 — [한, 영] */ -const NOTE: Record = { - masonry: [ - "변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비", - "Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost", - ], - ground: [ - "지반이 토사면 굴삭기로 · 아니면 브레이커로 — 조건 통과 선 둘 → 경비", - "Soil goes to the excavator, rock to the breaker — two gate lines → expense", - ], - hume: [ - "관경으로 흄관 품목을 찾음(재료 자리 · 규격에 관경 포함) → 본수 × 단가 → 재료비", - "Pipe size picks the hume pipe item (spec contains size) → count × price → material cost", - ], - linked: [ - "작업량 로직의 출력을 받아 장비 시간 → 경비", - "Takes the output of a productivity logic → machine hours → expense", - ], - value: [ - "q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)", - "q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)", - ], -}; - -/** 보여 줄 차례 */ -const ORDER = ["masonry", "ground", "hume", "linked", "value"]; - -export interface CfSampleItem { - /** 파일 이름(확장자 뺌) */ - id: string; - name: string; - resultUnit: string; - /** 쉬운 말 한 줄 */ - note: string; -} - -const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, ""); -const byId = (): Map => - new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg])); - -/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */ -export function sampleList(): CfSampleItem[] { - const all = byId(); - const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length); - return [...all.entries()] - .sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name)) - .map(([id, lg]) => ({ - id, - name: lg.name, - resultUnit: lg.resultUnit, - note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "", - })); -} - -/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */ -export function sampleLoad(id: string): CfLogic | null { - const lg = byId().get(id); - return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null; -} diff --git a/M02_MasterTemplete/CostFlow/_sample/masonry.json b/resources/tester/fixtures_df/costflow/masonry.json similarity index 100% rename from M02_MasterTemplete/CostFlow/_sample/masonry.json rename to resources/tester/fixtures_df/costflow/masonry.json diff --git a/M02_MasterTemplete/CostFlow/_sample/rate.json b/resources/tester/fixtures_df/costflow/rate.json similarity index 100% rename from M02_MasterTemplete/CostFlow/_sample/rate.json rename to resources/tester/fixtures_df/costflow/rate.json diff --git a/M02_MasterTemplete/CostFlow/_sample/value.json b/resources/tester/fixtures_df/costflow/value.json similarity index 100% rename from M02_MasterTemplete/CostFlow/_sample/value.json rename to resources/tester/fixtures_df/costflow/value.json diff --git a/M02_MasterTemplete/DataFlow/_sample/ground.json b/resources/tester/fixtures_df/ground.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/ground.json rename to resources/tester/fixtures_df/ground.json diff --git a/M02_MasterTemplete/DataFlow/_sample/hume.json b/resources/tester/fixtures_df/hume.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/hume.json rename to resources/tester/fixtures_df/hume.json diff --git a/M02_MasterTemplete/DataFlow/_sample/linked.json b/resources/tester/fixtures_df/linked.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/linked.json rename to resources/tester/fixtures_df/linked.json diff --git a/M02_MasterTemplete/DataFlow/_sample/masonry.json b/resources/tester/fixtures_df/masonry.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/masonry.json rename to resources/tester/fixtures_df/masonry.json diff --git a/M02_MasterTemplete/DataFlow/_sample/value.json b/resources/tester/fixtures_df/value.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/value.json rename to resources/tester/fixtures_df/value.json diff --git a/resources/tester/test_m01_logic_flowview.py b/resources/tester/test_m01_logic_flowview.py new file mode 100644 index 000000000..67a4d35ea --- /dev/null +++ b/resources/tester/test_m01_logic_flowview.py @@ -0,0 +1,184 @@ +"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 · +화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.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 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => 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) => m.flowRows(job.lg, job.ev, job.round)); +process.stdout.write(JSON.stringify(out)); +setTimeout(() => process.exit(0), 50); +""" + +ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}} + + +def box(id_, kind, name, unit="원", **extra): + return { + "id": id_, + "kind": kind, + "name": name, + "color": "#000", + "unit": unit, + "x": 0, + "y": 0, + **extra, + } + + +def out_box(id_, name, cost, unit="원", round_=None): + output = {"cost": cost, "unit": unit} + if round_: + output["round"] = round_ + return box(id_, "output", name, unit, output=output) + + +def func_box(id_, name, out_name, unit="시간"): + func = { + "sheet": {"id": "s-" + id_, "이름": name, "칸": {}}, + "inputs": [], + "variables": [], + "output": {"cell": "B1", "name": out_name, "unit": unit}, + } + return box(id_, "function", name, unit, func=func) + + +def logic(boxes): + return { + "key": None, + "name": "시험", + "resultUnit": "원/㎡", + "boxes": boxes, + "links": [], + "userElements": [], + } + + +def result(outputs, cost, ports=None, errors=None): + return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []} + + +def run(jobs: list[dict]) -> list: + node = "node" + done = subprocess.run( + [node, "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs), + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +@pytest.fixture(scope="module") +def sample(): + lg = logic( + [ + out_box("o1", "인건비", "노무비"), + out_box("o2", "재료값", "재료비"), + out_box("o3", "시간당 작업량", "값", "㎥/시간"), + out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}), + func_box("f1", "운반 시간", "시간", "시간"), + ] + ) + ev = result( + {"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239}, + { + "노무비": 3216.69, + "재료비": 100.04, + "경비": 1.239, + "값들": {"시간당 작업량": 12.5}, + "계": 3317.969, + }, + ports={"f1": {"시간": 6.024}}, + ) + return run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + + +def group(rows, key): + return next(g for g in rows["groups"] if g["key"] == key) + + +def test_group_order_and_costs_only(sample): + assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"] + + +def test_line_uses_output_round(sample): + # 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음) + assert group(sample, "labor")["lines"][0]["text"] == "3,216.6" + assert group(sample, "material")["lines"][0]["text"] == "100" + # 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림) + assert group(sample, "cost")["lines"][0]["text"] == "1.24" + + +def test_plain_value_is_not_rounded_and_has_no_subtotal(sample): + plain = group(sample, "plain") + assert plain["lines"][0]["text"] == "12.5" + assert plain["lines"][0]["unit"] == "㎥/시간" + assert plain["sub"] is None + + +def test_subtotal_and_total_floor_to_won(sample): + assert group(sample, "labor")["sub"] == 3216 + assert group(sample, "labor")["subText"] == "3,216" + assert sample["total"] == 3317 + assert sample["totalText"] == "3,317" + + +def test_function_rows(sample): + assert sample["funcs"] == [ + {"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"} + ] + + +def test_empty_group_is_hidden(): + lg = logic([out_box("o1", "인건비", "노무비")]) + ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10}) + rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + assert [g["key"] for g in rows["groups"]] == ["labor"] + assert rows["total"] == 10 + + +def test_missing_value_shows_dash(): + lg = logic([out_box("o1", "인건비", "노무비")]) + ev = result( + {"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"] + ) + rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + assert group(rows, "labor")["lines"][0]["text"] == "—" + assert rows["errors"] == ["끊김"] diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py index 2acb47268..00d6fb661 100644 --- a/resources/tester/test_m02_cf_sample.py +++ b/resources/tester/test_m02_cf_sample.py @@ -1,4 +1,4 @@ -"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`. +"""M02 단가산출 다이어그램 견본(35-7) 시험 — 시험 견본 `resources/tester/fixtures_df/costflow/*.json` · `M02_CF_Sample.ts`. · 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) · 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진 @@ -21,7 +21,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow" SAMPLE_TS = FOLDER / "M02_CF_Sample.ts" -SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip +SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df" / "costflow").glob("*.json"))} # fmt: skip HOOKS = r""" import { readFileSync } from "node:fs"; diff --git a/resources/tester/test_m02_df_formula_view.py b/resources/tester/test_m02_df_formula_view.py new file mode 100644 index 000000000..d015c2a9e --- /dev/null +++ b/resources/tester/test_m02_df_formula_view.py @@ -0,0 +1,180 @@ +"""단가산출 흐름도 「식 보기」(`M02_DF_FormulaView.ts`) · 엑셀 「조건 만들기」 글 쪽(`spreadsheet_condition_text.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(엔진 포함)는 이어 트랜스파일 · 화면 부품(@ui 의 elements · locale · modal)은 빈 흉내 · 엔진이 쓰는 @ui/sheet 는 그대로. +견본 함수 상자는 이 시험 안에 둠. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +VIEW = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_FormulaView.ts" +TEXT = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_condition_text.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, viewFile, textFile] = 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(".") || id.startsWith("@ui/sheet/")) { + const p = id.startsWith("@ui/sheet/") + ? path.join(root, "ui_template", "sheet", id.slice("@ui/sheet/".length)) + : 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 v = load(viewFile); +const t = load(textFile); +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => { + if (job.op === "lines") { + const values = v.cellValuesOf(job.box, job.arrived); + return { values, lines: v.formulaLines(job.box, values) }; + } + if (job.op === "toFormula") return t.draftToFormula(job.draft); + if (job.op === "toDraft") return t.formulaToDraft(job.formula); + 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(VIEW), str(TEXT)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def func_box(cells: dict, inputs: list, variables: list, out_cell: str, out_name: str) -> dict: + return { + "id": "f1", + "kind": "function", + "name": "석공 품", + "color": "", + "unit": "인", + "x": 0, + "y": 0, + "func": { + "sheet": {"id": "s1", "이름": "석공 품", "칸": cells}, + "inputs": inputs, + "variables": variables, + "output": {"cell": out_cell, "name": out_name, "unit": "인"}, + }, + } + + +def var(name: str, cell: str, example) -> dict: + return {"name": name, "type": "number", "unit": "-", "example": example, "cell": cell} + + +# 석공량(받는 알약 B1) · 높이(변수 B2) → B3 = 석공량 × (1 + 높이 ÷ 100) +PLAIN = func_box( + { + "A1": {"값": "석공량"}, + "B3": {"식": "B1*(1+B2/100)"}, + }, + [{"port": "석공량", "cell": "B1"}], + [var("높이", "B2", 4)], + "B3", + "석공 인", +) + + +def test_이름_식과_값_식(): + (got,) = run([{"op": "lines", "box": PLAIN, "arrived": {"석공량": 0.15}}]) + assert got["values"]["B3"] == pytest.approx(0.156) + (line,) = got["lines"] + assert line["name"] == "석공 인" + assert line["named"] == "석공량 × (1 + 높이 ÷ 100)" + assert line["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156" + assert "branches" not in line + + +def test_중간_칸이_먼저_결과_칸이_마지막(): + box = func_box( + {"B3": {"식": "B1*B2"}, "B4": {"식": "B3+1"}}, + [{"port": "석공량", "cell": "B1"}], + [var("높이", "B2", 4)], + "B4", + "합", + ) + (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 2}}]) + assert [x["name"] for x in got["lines"]] == ["B3", "합"] # 연결 없는 칸은 주소 그대로 + assert got["lines"][1]["named"] == "B3 + 1" + assert got["lines"][1]["valued"] == "8 + 1 = 9" + + +@pytest.mark.parametrize( + ("ground", "now"), + [("토사", [True, False]), ("암", [False, True])], +) +def test_IF_줄과_지금_갈래(ground, now): + box = func_box( + {"B3": {"식": 'IF(B4="토사",B1*2,B1*3)'}}, + [{"port": "석공량", "cell": "B1"}], + [{"name": "지반", "type": "choice", "unit": "-", "example": ground, "cell": "B4"}], + "B3", + "결과", + ) + (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 10}}]) + (line,) = got["lines"] + assert [b["now"] for b in line["branches"]] == now + assert line["branches"][0]["when"] == '지반 = "토사"' + assert line["branches"][0]["then"] == "석공량 × 2" + assert line["branches"][1] == {"when": "아니면", "then": "석공량 × 3", "now": now[1]} + assert line["valued"].endswith("= 20" if ground == "토사" else "= 30") + + +def test_조건_만들기_식과_다시_줄(): + draft = { + "branches": [ + { + "left": "B1", + "op": "=", + "right": {"kind": "value", "value": "토사"}, + "then": {"kind": "cell", "cell": "C1"}, + }, + { + "left": "B1", + "op": "≥", + "right": {"kind": "value", "value": 3}, + "then": {"kind": "value", "value": 0.5}, + }, + ], + "otherwise": {"kind": "value", "value": 0}, + } + (formula,) = run([{"op": "toFormula", "draft": draft}]) + assert formula == 'IF(B1="토사",C1,IF(B1>=3,0.5,0))' + (back,) = run([{"op": "toDraft", "formula": formula}]) + assert back == draft + + +@pytest.mark.parametrize("formula", ["SUM(B1:B2)", "IF(B1+1>2,1,2)", "IF(B1>2,B2*2,0)"]) +def test_못_바꾸는_식은_null(formula): + (back,) = run([{"op": "toDraft", "formula": formula}]) + assert back is None diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py new file mode 100644 index 000000000..cc91ee13d --- /dev/null +++ b/resources/tester/test_m02_df_input_logic.py @@ -0,0 +1,230 @@ +"""단가산출 흐름도 입력 판 — 다른 로직 입력 = 마스터 로직 고르기(순수 부분 · `M02_DF_InputPanel_Build.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림. 견본 로직 줄 · 흐름은 이 시험 안에 둠(마스터 내용에 기대지 않음). +""" + +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_InputPanel_Build.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 js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const m = box.exports; +(async () => { + const jobs = JSON.parse(fs.readFileSync(0, "utf8")); + const out = []; + for (const j of jobs) { + if (j.name === "wouldCycle") { + const graph = j.args[2]; + out.push(await m.wouldCycle(j.args[0], j.args[1], async (k) => graph[k] ?? null)); + } else out.push(await m[j.name](...j.args)); + } + process.stdout.write(JSON.stringify(out)); +})(); +""" + + +def call(name: str, *args): + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout)[0] + + +def row(key, name, book, chapter, no="", unit="원/㎡", **more): + return { + "file": f"로직_{book}.json", + "구분": book, + "상세구분": chapter, + "키": key, + "원문번호": no, + "이름": name, + "결과단위": unit, + **more, + } + + +ITEMS = [ + row("GF000219", "돌쌓기 메쌓기(인력)", "산림품셈", "13장 구조물", "13-4-1"), + row("GF000220", "돌쌓기 찰쌓기(인력)", "산림품셈", "13장 구조물", "13-4-2"), + row("GF000025", "떼붙임(재배잔디)", "산림품셈", "05장 녹화", "5-12"), + row("GC000051", "보행자 안전통로", "건설품셈", "01장 공통", "1-3-9", "원/m"), + row("DF-0001", "내 흄관 로직", "흐름도", "내가 만든 것", "", "원/m"), +] + + +def keys(items): + return [i["키"] for i in items] + + +def test_찾기는_띄어쓰기로_나눈_말을_모두_포함(): + got = call("filterLogics", ITEMS, {"text": "돌쌓기 메쌓기"}) + assert keys(got) == ["GF000219"] + assert keys(call("filterLogics", ITEMS, {"text": "돌쌓기"})) == ["GF000219", "GF000220"] + assert keys(call("filterLogics", ITEMS, {"text": "돌 쌓기메쌓기"})) == [ + "GF000219" + ] # 띄어쓰기는 가리지 않음 + assert keys(call("filterLogics", ITEMS, {"text": "13-4"})) == [ + "GF000219", + "GF000220", + ] # 원문번호 + assert keys(call("filterLogics", ITEMS, {"text": "gc000051"})) == [ + "GC000051" + ] # 번호 · 대소문자 + assert call("filterLogics", ITEMS, {"text": ""}) == ITEMS + + +def test_원문_장으로_거름(): + assert keys(call("filterLogics", ITEMS, {"text": "", "book": "건설품셈"})) == ["GC000051"] + got = call("filterLogics", ITEMS, {"text": "", "book": "산림품셈", "chapter": "05장 녹화"}) + assert keys(got) == ["GF000025"] + assert call("filterLogics", ITEMS, {"text": "메쌓기", "book": "건설품셈"}) == [] + + +def test_원문_장_목록은_나온_차례(): + assert call("logicBooks", ITEMS) == ["산림품셈", "건설품셈", "흐름도"] + assert call("logicChapters", ITEMS, "산림품셈") == ["13장 구조물", "05장 녹화"] + assert call("logicChapters", ITEMS, "") == [ + "13장 구조물", + "05장 녹화", + "01장 공통", + "내가 만든 것", + ] + + +def box(id_, kind, name, **more): + return { + "id": id_, + "kind": kind, + "name": name, + "color": "#fff", + "unit": "-", + "x": 0, + "y": 0, + **more, + } + + +FLOW = { + "key": "GF000219", + "name": "돌쌓기 메쌓기(인력)", + "resultUnit": "원/㎡", + "userElements": [], + "links": [], + "boxes": [ + box( + "b1", + "input", + "입력", + input={ + "mode": "variable", + "elements": [], + "variables": [ + {"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30}, + { + "name": "돌", + "type": "choice", + "unit": "", + "choices": ["깬돌", "견치돌"], + "example": "깬돌", + }, + ], + }, + ), + box("o1", "output", "노무비", unit="원/㎡", output={"cost": "노무비", "unit": "원/㎡"}), + ], +} + + +def test_흐름_로직_고르기는_출력_상자와_설계_때_정할_것(): + ref = call("masterRef", "GF000219", FLOW, False, "원/㎡") + assert ref["key"] == "GF000219" + assert ref["outputs"] == [{"name": "노무비", "unit": "원/㎡"}] + assert ref["designs"] == [{"name": "뒷길이", "unit": "㎝"}, {"name": "돌", "unit": "-"}] + assert ref["examples"] == {"뒷길이": 30, "돌": "깬돌"} + + +def test_옛_로직_변환은_노무비_재료비_경비_계_넷(): + ref = call("masterRef", "GF000219", FLOW, True, "원/㎡") + assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"] + assert {o["unit"] for o in ref["outputs"]} == {"원/㎡"} + assert [d["name"] for d in ref["designs"]] == ["뒷길이", "돌"] # 받는 알약은 흐름 그대로 + + +def test_이어지지_않은_다른_로직_받는_알약도_설계_때_정할_것(): + inner = { + "mode": "logic", + "elements": [], + "variables": [], + "logic": { + "key": "GF000250", + "outputs": [{"name": "작업량", "unit": "㎥/시간"}], + "designs": [{"name": "지역", "unit": "-"}, {"name": "기계", "unit": "-"}], + "examples": {"지역": "서울"}, + }, + } + flow = {**FLOW, "boxes": [*FLOW["boxes"], box("b2", "input", "작업량", input=inner)]} + flow["links"] = [ + {"id": "l1", "from": {"box": "b1", "name": "돌"}, "to": {"box": "b2", "name": "기계"}} + ] + ref = call("logicRefOf", "X", flow) + names = [d["name"] for d in ref["designs"]] + assert "지역" in names and "기계" not in names # 선이 이어진 받는 알약은 뺌 + assert ref["examples"]["지역"] == "서울" + + +def test_자기_자신과_돌고_도는_로직은_막음(): + graph = { + "A": { + "boxes": [ + box( + "b", + "input", + "x", + input={"mode": "logic", "elements": [], "variables": [], "logic": {"key": "B"}}, + ) + ] + }, + "B": { + "boxes": [ + box( + "b", + "input", + "x", + input={ + "mode": "logic", + "elements": [], + "variables": [], + "logic": {"key": "SELF"}, + }, + ) + ] + }, + "C": {"boxes": []}, + } + assert call("wouldCycle", "SELF", "SELF", graph) is True # 자기 자신 + assert call("wouldCycle", "SELF", "A", graph) is True # A → B → SELF + assert call("wouldCycle", "SELF", "C", graph) is False + assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음 + assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음 diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index 3d39c8e64..10c2c925d 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -646,7 +646,9 @@ def test_쉬는_갈래와_문제_무게() -> None: def _ground() -> dict: - return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8")) + return json.loads( + (ROOT / "resources" / "tester" / "fixtures_df" / "ground.json").read_text(encoding="utf-8") + ) def _server(lg: dict, values: dict) -> dict: diff --git a/resources/tester/test_m02_df_panel.py b/resources/tester/test_m02_df_panel.py new file mode 100644 index 000000000..0356e18d8 --- /dev/null +++ b/resources/tester/test_m02_df_panel.py @@ -0,0 +1,83 @@ +"""오른쪽 흐름도 패널(`M02_DF_Panel.ts`) 순수 부분 — 끌어서 정한 너비 · 저장해 둔 너비. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 판은 빈 흉내 · 순수 함수만). +""" + +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_Panel.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 js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const stub = new Proxy({}, { get: () => () => undefined }); +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((j) => + j.op === "drag" ? m.dragWidth(j.start, j.startX, j.x, j.viewport) + : j.op === "stored" ? m.storedWidth(j.raw) + : j.op === "consts" ? [m.PANEL_MIN, m.STRIP_WIDTH] + : 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 test_왼쪽_가장자리_끌기_너비(): + [consts, wider, narrower, floor, ceil] = run( + [ + {"op": "consts"}, + # 왼쪽으로 100 끌면 넓어짐 + {"op": "drag", "start": 480, "startX": 1000, "x": 900, "viewport": 1600}, + # 오른쪽으로 끌면 좁아짐 + {"op": "drag", "start": 480, "startX": 1000, "x": 1060, "viewport": 1600}, + # 너무 좁게 — 가장 작은 너비에서 멈춤 + {"op": "drag", "start": 480, "startX": 1000, "x": 1400, "viewport": 1600}, + # 너무 넓게 — 화면의 85% 에서 멈춤 + {"op": "drag", "start": 480, "startX": 1000, "x": -2000, "viewport": 1600}, + ] + ) + min_width, strip = consts + assert (min_width, strip) == (320, 40) + assert wider == 580 and narrower == 420 + assert floor == min_width and ceil == 1360 + + +def test_저장해_둔_너비(): + got = run( + [ + {"op": "stored", "raw": "640"}, + {"op": "stored", "raw": "640.6"}, + {"op": "stored", "raw": None}, + {"op": "stored", "raw": "abc"}, + {"op": "stored", "raw": "100"}, + ] + ) + assert got == [640, 641, None, None, None] diff --git a/resources/tester/test_m02_df_sample.py b/resources/tester/test_m02_df_sample.py index 11bc2f9af..342436f79 100644 --- a/resources/tester/test_m02_df_sample.py +++ b/resources/tester/test_m02_df_sample.py @@ -1,4 +1,4 @@ -"""M02 단가산출 흐름도 견본(35-9) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`. +"""M02 단가산출 흐름도 견본(35-9) 시험 — 시험 견본 `resources/tester/fixtures_df/*.json`(36-7 · 화면 견본 목록은 지움). 견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value) · 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) @@ -6,7 +6,7 @@ · 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음 · 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 · 입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133 -· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀 +· 파일 안 씀 """ from __future__ import annotations @@ -14,7 +14,6 @@ from __future__ import annotations import json import math import re -import subprocess from decimal import Decimal from pathlib import Path @@ -22,8 +21,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow" -SAMPLE_TS = FOLDER / "M02_DF_Sample.ts" -SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip +SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df").glob("*.json"))} # fmt: skip NAMES = ["masonry", "ground", "hume", "linked", "value"] BOX_COLOR = dict( re.findall( @@ -34,40 +32,6 @@ BOX_COLOR = dict( ) ) -HOOKS = r""" -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { stripTypeScriptTypes } from "node:module"; -export async function load(url, ctx, next) { - if (!url.endsWith(".ts")) return next(url, ctx); - let src = readFileSync(fileURLToPath(url), "utf8"); - src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples"); - src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { - const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); - return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; - }); - return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; -} -""" - - -def _node(body: str) -> object: - files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()} - script = ( - "import { register } from 'node:module';\n" - f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" - "globalThis.__stub = { currentLanguageIndex: 0 };\n" - f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n" - f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n" - "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body - ) - result = subprocess.run( - ["node", "--no-warnings", "--input-type=module", "-e", script], - capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr[-2000:] - return json.loads(result.stdout) - # ── 알약 · 선 ───────────────────────────────────────────────────────────── def _ports_out(box: dict) -> list[str]: @@ -215,16 +179,6 @@ def test_견본_흐름_규칙_방식() -> None: } -def test_견본_목록_열기() -> None: - got = _node( - "out({ list: S.sampleList(), one: S.sampleLoad('ground'), none: S.sampleLoad('없음') });" - ) - assert [x["id"] for x in got["list"]] == NAMES - assert all(x["note"] for x in got["list"]) - assert got["one"]["key"] is None and got["one"]["links"] == SAMPLES["ground"]["links"] - assert got["none"] is None - - # ── 값(내부 확인) ───────────────────────────────────────────────────────── @pytest.fixture(scope="module") def master():