From fbb6902576fc47ca12fca6f18920c86a6a7bb9f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:29:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-df):=20=EB=8B=A4=EB=A5=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9E=85=EB=A0=A5=20=3D=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EB=A1=9C=EC=A7=81=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=9B=90=EB=AC=B8=20=C2=B7=20=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=C2=B7=20=EC=AA=BD=20=C2=B7=20=EC=88=9C?= =?UTF-8?q?=ED=99=98=20=EB=A7=89=EC=9D=8C=20=C2=B7=20=EA=B0=92=EC=9D=80=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M02_MasterTemplete/DataFlow/M02_DF_Api.ts | 19 ++ .../DataFlow/M02_DF_InputPanel_Logic.ts | 288 +++++++++++++++--- resources/tester/test_m02_df_input_logic.py | 230 ++++++++++++++ 3 files changed, 493 insertions(+), 44 deletions(-) create mode 100644 resources/tester/test_m02_df_input_logic.py 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_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index a59e4e3d4..cdc55845d 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -1,42 +1,83 @@ /* ============================================================================= * 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", + ], 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 +87,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 +130,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 +218,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"); } }; - 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 +345,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/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 # 못 읽으면 막지 않음