From 4f12eb36114a677544896afa39dc787fbc1ea50f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:43:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EA=B2=80=EC=82=AC=20Check=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=E2=80=94=20=EB=B9=88=ED=8B=88=20=C2=B7=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=C2=B7=20=EA=B1=B8=EB=A6=B0=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EB=8C=80=EA=B0=92=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=ED=91=9C(PLAN=2035-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줄 찾기는 엔진 _match · find 규칙 그대로(범위규칙 넷 · 빈 칸 무관 · 위 끝 가장 작은 줄 · 결과 칸 있는 줄) - 시험 test_m01_lb_check.py 7 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/LogicBuilder/M01_LB_Check.ts | 274 ++++++++++++++++++ resources/tester/test_m01_lb_check.py | 290 ++++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Check.ts create mode 100644 resources/tester/test_m01_lb_check.py diff --git a/M01_MasterData/LogicBuilder/M01_LB_Check.ts b/M01_MasterData/LogicBuilder/M01_LB_Check.ts new file mode 100644 index 000000000..b62485477 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Check.ts @@ -0,0 +1,274 @@ +/* ============================================================================= + * M01_LB_Check.ts + * 로직 만들기 판 — 검사(정함 7 · 설계초안 4절) + * ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈) + * ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복 + * ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패 + * 시험표 줄은 sessionStorage 초안만(저장 안 함) + * + * 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 · + * 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈. + * DOM 없음. + * ========================================================================== */ + +import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; + +/* ── 맞출 모양(설계초안 7-1 의 쓰는 칸만) ─────────────────────────────── */ + +type Ref = + | { k: "input"; name: string } + | { k: "fixed"; value: number | string } + | { k: string; [x: string]: unknown }; + +export interface CheckLookup { + id: string; + table: string; + conds: { col: string; from: Ref }[]; + col: string; +} + +export interface CheckLogic { + inputs: LogicInput[]; + lookups: CheckLookup[]; +} + +type Cell = string | number | boolean | null | [number | null, number | null]; +export interface CheckTable { + 키?: string; + 범위규칙?: string; + 조건?: Record; + 값칸?: Record; + 줄: Record[]; +} + +/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */ + +const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); + +function match( + table: CheckTable, + row: Record, + cond: string, + want: unknown, +): boolean { + if (!(cond in row)) return true; // 빈 조건 칸 = 무관 + const have = row[cond] as Cell; + if (table.조건?.[cond] === "범위") { + if (!Array.isArray(have) || !isNum(want)) return false; + const [low, high] = have; + const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · "); + const rule = head.split(" (")[0]; + const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low); + if (rule === "이상·이하") return above(false) && (high === null || want <= high); + if (rule === "초과·이하" || rule === "이하") + return above(true) && (high === null || want <= high); + if (rule === "이상·미만") { + const tops = table.줄 + .map((r) => r[cond]) + .filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null) + .map((c) => c[1]); + const closed = + more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops); + return above(false) && (high === null || want < high || (closed && want === high)); + } + return false; // 모르는 범위규칙 — 엔진은 멈춤 + } + if (isNum(have) && isNum(want)) return have === want; + return typeof have === "string" && typeof want === "string" && have === want; +} + +/** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */ +export function findRows(table: CheckTable, conds: Record, want = ""): number[] { + let hits = table.줄 + .map((row, i) => ({ row, i })) + .filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v))); + const first = (table.범위규칙 ?? "").split(" · ")[0]; + if (hits.length > 1 && (first === "이하" || first === "이상·이하")) { + for (const c of Object.keys(conds)) { + if (table.조건?.[c] !== "범위") continue; + const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]); + if (!ups.length || ups.includes(null)) continue; + const low = Math.min(...(ups as number[])); + hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low); + } + } + if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row); + return hits.map((h) => h.i); +} + +/* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */ + +export interface Gap { + table: string; + /** 어느 표 조회에서 나왔나(화면 번호) */ + lookup: string; + conds: Record; + kind: "없음" | "중복"; + /** 중복이면 걸린 줄 차례 */ + rows: number[]; +} + +/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */ +function combos(lb: CheckLogic, lk: CheckLookup): Record[] | null { + const lists: [string, unknown[]][] = []; + for (const { col, from } of lk.conds) { + if (from.k === "fixed") lists.push([col, [from.value]]); + else if (from.k === "input") { + const spec = lb.inputs.find((x) => x.이름 === from.name); + if (!spec?.고르기?.length) return null; + lists.push([col, spec.고르기]); + } else return null; + } + let out: Record[] = [{}]; + for (const [col, values] of lists) + out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v }))); + return out; +} + +/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */ +export function gaps(lb: CheckLogic, tables: Record): Gap[] { + const out: Gap[] = []; + const seen = new Set(); + for (const lk of lb.lookups) { + const table = tables[lk.table] as CheckTable | undefined; + const sets = table?.줄 ? combos(lb, lk) : null; + if (!table || !sets) continue; + for (const conds of sets) { + const rows = findRows(table, conds, lk.col); + if (rows.length === 1) continue; + const tag = `${lk.table}|${lk.col}|${JSON.stringify(conds)}`; + if (seen.has(tag)) continue; + seen.add(tag); + out.push({ + table: lk.table, + lookup: lk.id, + conds, + kind: rows.length ? "중복" : "없음", + rows, + }); + } + } + return out; +} + +/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */ +export function gapSkipped(lb: CheckLogic, tables: Record): string[] { + return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id); +} + +/* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */ + +/** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */ +export type LbHit = number | null | "없음" | "중복"; + +export function lookupHits( + lb: CheckLogic, + tables: Record, + values: Record, +): Record { + const out: Record = {}; + for (const lk of lb.lookups) { + const table = tables[lk.table] as CheckTable | undefined; + const conds: Record = {}; + let known = !!table?.줄; + for (const { col, from } of lk.conds) { + if (from.k === "fixed") conds[col] = from.value; + else if (from.k === "input" && values[from.name as string] !== undefined) + conds[col] = values[from.name as string]; + else known = false; + } + if (!known || !table) { + out[lk.id] = null; + continue; + } + const rows = findRows(table, conds, lk.col); + out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음"; + } + return out; +} + +/* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */ + +export interface ExpectRow { + /** 예시값 */ + inputs: Record; + /** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */ + expect: Record; +} + +export interface ExpectResult { + ok: boolean; + got: Record; + /** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */ + why: string | null; +} + +/** 계산 답 → 볼 값 모음 */ +export function pickValues(ans: CalcAnswer): Record { + const got: Record = {}; + if (!ans.ok) return got; + if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"]; + if (isNum(ans.result)) got["결과"] = ans.result; + for (const line of ans.lines ?? []) { + if (isNum(line.수량)) got[line.이름] = line.수량; + if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액; + } + return got; +} + +/** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */ +export function sameAsExpect(got: number | undefined, expect: number): boolean { + if (!isNum(got)) return false; + const places = (String(expect).split(".")[1] ?? "").length; + return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9; +} + +type CalcFn = ( + row: LogicRow, + inputs: Record, + tables: Record, +) => Promise; + +/** 줄마다 시험 계산(차례로 · 하나씩) — `row` 는 블록을 글로 되돌린 로직 줄 · `calc` 는 LB Api 의 바로 계산 */ +export async function runExpect( + row: LogicRow, + tables: Record, + rows: ExpectRow[], + calc: CalcFn, +): Promise { + const out: ExpectResult[] = []; + for (const r of rows) { + try { + const ans = await calc(row, r.inputs, tables); + const got = pickValues(ans); + const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v)); + out.push({ ok, got, why: ans.ok ? null : ans.reason }); + } catch (e) { + out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) }); + } + } + return out; +} + +/* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */ + +const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`; + +export function loadExpectRows(logic: string): ExpectRow[] { + try { + const raw = sessionStorage.getItem(draftKey(logic)); + const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : []; + return Array.isArray(rows) ? rows : []; + } catch { + return []; + } +} + +export function saveExpectRows(logic: string, rows: ExpectRow[]): void { + try { + if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows)); + else sessionStorage.removeItem(draftKey(logic)); + } catch { + /* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */ + } +} diff --git a/resources/tester/test_m01_lb_check.py b/resources/tester/test_m01_lb_check.py new file mode 100644 index 000000000..58a88465f --- /dev/null +++ b/resources/tester/test_m01_lb_check.py @@ -0,0 +1,290 @@ +"""로직 만들기 판 — 검사(`M01_LB_Check.ts`)의 빈틈 · 중복 · 걸린 줄 · 기대값 맞춤. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 서버는 빈 흉내 · 순수 함수만). +견본 표 · 로직은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Check.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")); +Promise.all( + jobs.map(async (job) => { + if (job.op === "gaps") return m.gaps(job.lb, job.tables); + if (job.op === "skipped") return m.gapSkipped(job.lb, job.tables); + if (job.op === "hits") return m.lookupHits(job.lb, job.tables, job.values); + if (job.op === "find") return m.findRows(job.table, job.conds, job.want ?? ""); + if (job.op === "same") return m.sameAsExpect(job.got, job.expect); + if (job.op === "expect") { + const answers = job.answers; + let i = 0; + return m.runExpect({}, {}, job.rows, async () => answers[i++]); + } + return null; + }), +).then((out) => 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) + + +# 견본 표 ① — 돌쌓기 꼴: 뒷길이 × 돌 × 쌓기 · 「호박돌」 줄은 쌓기 칸이 비어 무관 +STONE = { + "키": "T1", + "조건": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"}, + "값칸": {"석공": "인"}, + "줄": [ + {"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15}, + {"뒷길이": 25, "돌": "깬잡석", "쌓기": "켜쌓기", "석공": 0.13}, + {"뒷길이": 25, "돌": "호박돌", "석공": 0.10}, + {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.22}, + {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.23}, # 같은 조합 두 줄 = 중복 + ], +} + +# 견본 표 ② — 범위 줄(이상·미만 · 마지막 칸 이하 포함) +HEIGHT = { + "키": "T2", + "범위규칙": "이상·미만 · 마지막 칸(4~6)은 이하 포함", + "조건": {"높이": "범위"}, + "값칸": {"증가율": "%"}, + "줄": [ + {"높이": [None, 2], "증가율": 0}, + {"높이": [2, 4], "증가율": 30}, + {"높이": [4, 6], "증가율": 40}, + ], +} + +# 견본 표 ③ — 범위 줄(이하 · 위 끝만 · 여러 줄이면 위 끝 가장 작은 줄) +UPTO = { + "키": "T3", + "범위규칙": "이하", + "조건": {"거리": "범위"}, + "값칸": {"값": ""}, + "줄": [{"거리": [None, 10], "값": 1}, {"거리": [None, 20], "값": 2}], +} + +TABLES = {"T1": STONE, "T2": HEIGHT, "T3": UPTO} + + +def lk(id_, table, conds, col): + return {"id": id_, "table": table, "conds": conds, "col": col} + + +LOGIC = { + "inputs": [ + {"이름": "뒷길이", "고르기": [25, 30]}, + {"이름": "돌", "고르기": ["깬잡석", "호박돌"]}, + {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, + {"이름": "높이", "고르기": [1, 3, 6, 7]}, + {"이름": "길이"}, # 숫자 입력 — 선택 목록 없음 + ], + "lookups": [ + lk( + "a", + "T1", + [ + {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, + {"col": "돌", "from": {"k": "input", "name": "돌"}}, + {"col": "쌓기", "from": {"k": "input", "name": "쌓기"}}, + ], + "석공", + ), + lk("b", "T2", [{"col": "높이", "from": {"k": "input", "name": "높이"}}], "증가율"), + lk("c", "T3", [{"col": "거리", "from": {"k": "input", "name": "길이"}}], "값"), + ], +} + + +def by(gaps, lookup): + return sorted( + ( + (g["kind"], json.dumps(g["conds"], ensure_ascii=False)) + for g in gaps + if g["lookup"] == lookup + ) + ) + + +def test_빈틈_중복_무관_빈칸(): + [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) + got = by(gaps, "a") + # 뒷길이 2 × 돌 2 × 쌓기 2 = 8 조합 + # 무관 빈칸 — 25 · 호박돌 은 쌓기 둘 다 한 줄로 걸림(빈틈 아님) + # 빈틈 — 30 · 깬잡석 · 켜쌓기 · 30 · 호박돌 · 골/켜 + # 중복 — 30 · 깬잡석 · 골쌓기(두 줄) + assert got == sorted( + [ + ( + "없음", + json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "켜쌓기"}, ensure_ascii=False), + ), + ( + "없음", + json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, ensure_ascii=False), + ), + ( + "없음", + json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "켜쌓기"}, ensure_ascii=False), + ), + ( + "중복", + json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기"}, ensure_ascii=False), + ), + ] + ) + dup = next(g for g in gaps if g["kind"] == "중복") + assert dup["rows"] == [3, 4] + + +def test_범위_줄_이상미만_마지막칸_이하포함(): + [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) + # 높이 1 → [null,2) · 3 → [2,4) · 6 → 마지막 칸 이하 포함 · 7 → 없음 + assert by(gaps, "b") == [("없음", json.dumps({"높이": 7}, ensure_ascii=False))] + out = run( + [ + {"op": "find", "table": HEIGHT, "conds": {"높이": 2}}, + {"op": "find", "table": HEIGHT, "conds": {"높이": 4}}, + {"op": "find", "table": HEIGHT, "conds": {"높이": 6}}, + ] + ) + assert out == [[1], [2], [2]] + + +def test_범위_이하_위끝_가장_작은_줄(): + out = run( + [ + {"op": "find", "table": UPTO, "conds": {"거리": 5}}, + {"op": "find", "table": UPTO, "conds": {"거리": 15}}, + {"op": "find", "table": UPTO, "conds": {"거리": 25}}, + ] + ) + assert out == [[0], [1], []] + + +def test_셀_수_없는_조회는_건너뜀(): + gaps, skipped = run( + [ + {"op": "gaps", "lb": LOGIC, "tables": TABLES}, + {"op": "skipped", "lb": LOGIC, "tables": TABLES}, + ] + ) + assert by(gaps, "c") == [] + assert skipped == ["c"] + + +def test_예시값으로_걸린_줄(): + [hits] = run( + [ + { + "op": "hits", + "lb": LOGIC, + "tables": TABLES, + "values": {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "높이": 3, "길이": 15}, + } + ] + ) + assert hits == {"a": "중복", "b": 1, "c": 1} + [hits] = run( + [ + { + "op": "hits", + "lb": LOGIC, + "tables": TABLES, + "values": {"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, + } + ] + ) + assert hits == {"a": "없음", "b": None, "c": None} # 높이 · 길이 예시값 없음 = 모름 + + +def test_고정값_조건(): + lb = { + "inputs": [], + "lookups": [ + lk( + "f", + "T1", + [ + {"col": "돌", "from": {"k": "fixed", "value": "호박돌"}}, + {"col": "뒷길이", "from": {"k": "fixed", "value": 25}}, + ], + "석공", + ), + lk("g", "T1", [{"col": "돌", "from": {"k": "calc", "name": "x"}}], "석공"), + ], + } + gaps, hits = run( + [ + {"op": "gaps", "lb": lb, "tables": TABLES}, + {"op": "hits", "lb": lb, "tables": TABLES, "values": {}}, + ] + ) + assert gaps == [] + assert hits == {"f": 2, "g": None} + + +def test_기대값_시험표(): + answers = [ + { + "ok": True, + "lines": [{"이름": "석공", "수량": 0.26, "금액": 52437.06}], + "sums": {"계": 52437.06}, + "middle": {}, + }, + { + "ok": True, + "lines": [{"이름": "석공", "수량": 0.25, "금액": 50000}], + "sums": {"계": 50000}, + "middle": {}, + }, + {"ok": False, "reason": "표에 줄이 없음"}, + ] + rows = [ + {"inputs": {"뒷길이": 30}, "expect": {"석공": 0.26, "계": 52437}}, + {"inputs": {"뒷길이": 35}, "expect": {"석공": 0.26}}, + {"inputs": {"뒷길이": 99}, "expect": {"계": 1}}, + ] + [res] = run([{"op": "expect", "rows": rows, "answers": answers}]) + assert [r["ok"] for r in res] == [True, False, False] + assert res[0]["got"]["석공 금액"] == 52437.06 + assert res[2]["why"] == "표에 줄이 없음" + same = run( + [ + {"op": "same", "got": 52437.4, "expect": 52437}, + {"op": "same", "got": 52437.6, "expect": 52437}, + {"op": "same", "got": 0.264, "expect": 0.26}, + ] + ) + assert same == [True, False, True] From f62689dff1c6db7989a9eec926f870a78fc31f5d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:47:53 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EC=99=BC=EC=AA=BD=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20Source=20=C2=B7=20Check=20=EB=A5=BC=20LB=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=97=90=20=EB=A7=9E=EC=B6=A4(PLAN=2035-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renderSource: 표 이름 · 원문번호 · 기준 · 격자 · 역할 칩 · 걸린 줄 · 해당 없음/중복 · 칸 고침(on.edit) · 주 · 사용처 - Check: runExpect(lb, tables, rows) · 같은 조건 두 칸 조회는 빈틈 한 번 - 시험 test_m01_lb_check.py 9 통과 · LogicBuilder 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/LogicBuilder/M01_LB_Check.ts | 56 ++- M01_MasterData/LogicBuilder/M01_LB_Source.css | 196 +++++++++ M01_MasterData/LogicBuilder/M01_LB_Source.ts | 371 ++++++++++++++++++ resources/tester/test_m01_lb_check.py | 92 +++++ 4 files changed, 683 insertions(+), 32 deletions(-) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Source.css create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Source.ts diff --git a/M01_MasterData/LogicBuilder/M01_LB_Check.ts b/M01_MasterData/LogicBuilder/M01_LB_Check.ts index b62485477..075c8a9f4 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Check.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Check.ts @@ -11,26 +11,9 @@ * DOM 없음. * ========================================================================== */ -import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; - -/* ── 맞출 모양(설계초안 7-1 의 쓰는 칸만) ─────────────────────────────── */ - -type Ref = - | { k: "input"; name: string } - | { k: "fixed"; value: number | string } - | { k: string; [x: string]: unknown }; - -export interface CheckLookup { - id: string; - table: string; - conds: { col: string; from: Ref }[]; - col: string; -} - -export interface CheckLogic { - inputs: LogicInput[]; - lookups: CheckLookup[]; -} +import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { lbCalcLater, type LbTables } from "./M01_LB_Api"; +import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks"; type Cell = string | number | boolean | null | [number | null, number | null]; export interface CheckTable { @@ -109,7 +92,7 @@ export interface Gap { } /** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */ -function combos(lb: CheckLogic, lk: CheckLookup): Record[] | null { +function combos(lb: LbLogic, lk: LbLookup): Record[] | null { const lists: [string, unknown[]][] = []; for (const { col, from } of lk.conds) { if (from.k === "fixed") lists.push([col, [from.value]]); @@ -126,7 +109,7 @@ function combos(lb: CheckLogic, lk: CheckLookup): Record[] | nu } /** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */ -export function gaps(lb: CheckLogic, tables: Record): Gap[] { +export function gaps(lb: LbLogic, tables: Record): Gap[] { const out: Gap[] = []; const seen = new Set(); for (const lk of lb.lookups) { @@ -136,7 +119,7 @@ export function gaps(lb: CheckLogic, tables: Record): Gap[] { for (const conds of sets) { const rows = findRows(table, conds, lk.col); if (rows.length === 1) continue; - const tag = `${lk.table}|${lk.col}|${JSON.stringify(conds)}`; + const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`; if (seen.has(tag)) continue; seen.add(tag); out.push({ @@ -152,7 +135,7 @@ export function gaps(lb: CheckLogic, tables: Record): Gap[] { } /** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */ -export function gapSkipped(lb: CheckLogic, tables: Record): string[] { +export function gapSkipped(lb: LbLogic, tables: Record): string[] { return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id); } @@ -162,7 +145,7 @@ export function gapSkipped(lb: CheckLogic, tables: Record): str export type LbHit = number | null | "없음" | "중복"; export function lookupHits( - lb: CheckLogic, + lb: LbLogic, tables: Record, values: Record, ): Record { @@ -173,8 +156,8 @@ export function lookupHits( let known = !!table?.줄; for (const { col, from } of lk.conds) { if (from.k === "fixed") conds[col] = from.value; - else if (from.k === "input" && values[from.name as string] !== undefined) - conds[col] = values[from.name as string]; + else if (from.k === "input" && values[from.name] !== undefined) + conds[col] = values[from.name]; else known = false; } if (!known || !table) { @@ -226,16 +209,25 @@ export function sameAsExpect(got: number | undefined, expect: number): boolean { type CalcFn = ( row: LogicRow, inputs: Record, - tables: Record, + tables: LbTables, ) => Promise; -/** 줄마다 시험 계산(차례로 · 하나씩) — `row` 는 블록을 글로 되돌린 로직 줄 · `calc` 는 LB Api 의 바로 계산 */ +/** LB Api 계산을 한 번 기다림 — 차례로 부르므로 「최신만」 에 안 걸림 */ +const calcOnce: CalcFn = (row, inputs, tables) => + new Promise((resolve, reject) => + lbCalcLater(row, inputs, tables, (ans, plain) => + ans ? resolve(ans) : reject(new Error(plain ?? "")), + ), + ); + +/** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */ export async function runExpect( - row: LogicRow, - tables: Record, + lb: LbLogic, + tables: LbTables, rows: ExpectRow[], - calc: CalcFn, + calc: CalcFn = calcOnce, ): Promise { + const row = writeLogic(lb); const out: ExpectResult[] = []; for (const r of rows) { try { diff --git a/M01_MasterData/LogicBuilder/M01_LB_Source.css b/M01_MasterData/LogicBuilder/M01_LB_Source.css new file mode 100644 index 000000000..0d90c4871 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Source.css @@ -0,0 +1,196 @@ +/* 로직 만들기 판 — 왼쪽 원문 */ +.m01-lbsrc { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; + font-size: var(--text-body-sm); +} + +.m01-lbsrc p { + margin: 0; +} + +.m01-lbsrc__muted { + color: var(--color-text-muted); +} + +.m01-lbsrc__muted:empty { + display: none; +} + +.m01-lbsrc__preview { + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-warning) 14%, var(--color-canvas)); + color: var(--color-text); +} + +.m01-lbsrc__table { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + min-width: 0; +} + +.m01-lbsrc__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); +} + +.m01-lbsrc__users { + margin-left: auto; + padding: 0; + border: 0; + background: none; + color: var(--color-accent); + font: inherit; + cursor: pointer; + text-decoration: underline; +} + +.m01-lbsrc__warn { + color: var(--color-danger); + font-weight: 600; +} + +/* 엑셀 같은 격자 */ +.m01-lbsrc__grid { + max-height: 22rem; + overflow: auto; + border: 1px solid var(--color-border); +} + +.m01-lbsrc__grid table { + border-collapse: collapse; + width: 100%; +} + +.m01-lbsrc__grid th, +.m01-lbsrc__grid td { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + white-space: nowrap; + text-align: left; +} + +.m01-lbsrc__grid thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-paper); + color: var(--color-text-secondary); + font-weight: 500; + vertical-align: top; +} + +.m01-lbsrc__grid thead th span { + display: block; +} + +.m01-lbsrc__grid .m01-lbsrc__no { + width: 2.5rem; + background: var(--color-paper); + color: var(--color-text-muted); + text-align: right; +} + +.m01-lbsrc__grid td { + cursor: cell; +} + +.m01-lbsrc__grid td.is-value { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m01-lbsrc__grid td.is-col { + font-weight: 700; +} + +.m01-lbsrc__grid tr.is-hit td { + background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); +} + +.m01-lbsrc__grid td.is-edited { + box-shadow: inset 0 0 0 2px var(--color-accent); +} + +.m01-lbsrc__grid td.is-bad { + box-shadow: inset 0 0 0 2px var(--color-danger); +} + +.m01-lbsrc__input { + width: 100%; + min-width: 4rem; + padding: 0 var(--spacing-4); + border: 1px solid var(--color-accent); + font: inherit; + background: var(--color-canvas); + color: var(--color-text); +} + +/* 칸 역할 칩 — 조건 파랑 · 고정 회색 · 결과 초록 · 무관 점선 */ +.m01-lbsrc__chip { + margin-top: var(--spacing-4); + padding: 0 var(--spacing-8); + border: 1px solid currentColor; + border-radius: 999px; + background: transparent; + font: inherit; + font-size: var(--text-caption); + cursor: pointer; +} + +.m01-lbsrc__chip.is-cond { + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 12%, transparent); +} + +.m01-lbsrc__chip.is-fixed { + color: var(--color-text-secondary); +} + +.m01-lbsrc__chip.is-result { + color: var(--color-success, #2f6b4f); + background: color-mix(in srgb, var(--color-success, #2f6b4f) 12%, transparent); +} + +.m01-lbsrc__chip.is-free { + color: var(--color-text-muted); + border-style: dashed; +} + +.m01-lbsrc__notes summary { + cursor: pointer; + color: var(--color-text-secondary); +} + +.m01-lbsrc__notes ol { + margin: var(--spacing-4) 0 0; + padding-left: var(--spacing-16); + list-style: none; + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-lbsrc__notes li.is-used { + font-weight: 600; +} + +.m01-lbsrc__tag { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-4); + border-radius: 4px; + background: color-mix(in srgb, var(--color-accent) 14%, transparent); + color: var(--color-accent); + font-size: var(--text-caption); + font-weight: 500; +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Source.ts b/M01_MasterData/LogicBuilder/M01_LB_Source.ts new file mode 100644 index 000000000..8a16e89a9 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Source.ts @@ -0,0 +1,371 @@ +/* ============================================================================= + * M01_LB_Source.ts + * 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절) + * 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과) + * · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처. + * 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { tableUsers, type LbTables } from "./M01_LB_Api"; +import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks"; +import type { LbHit } from "./M01_LB_Check"; +import "./M01_LB_Source.css"; + +const TEXT = { + Preview: [ + "표 고침은 미리 보기 — 저장은 결정 뒤", + "Table edits are a preview — saving comes later", + ], + Users: ["사용처", "Used by"], + UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"], + UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"], + UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"], + Loading: ["불러오는 중", "Loading"], + Missing: ["표를 불러오지 못함", "Couldn't load the table"], + NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"], + DupRow: [ + "중복 — 예시값에 맞는 줄이 둘 이상", + "Duplicate — more than one row fits the sample values", + ], + Notes: ["주", "Notes"], + NoteUsed: ["계산에 쓰임", "Used in the calculation"], + Cond: ["조건", "Condition"], + Fixed: ["고정", "Fixed"], + Result: ["결과", "Result"], + Free: ["무관", "Any"], + RoleHint: ["눌러 역할 바꿈", "Tap to change the role"], + EditHint: [ + "칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소", + "Tap a cell to edit · Enter to finish · Esc to cancel", + ], + Edited: ["고친 칸", "Edited cell"], +} as const; + +const ts = (key: keyof typeof TEXT, n?: number): string => + TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? "")); + +export type LbRole = "조건" | "고정" | "결과"; +type Cell = string | number | boolean | null | [number | null, number | null] | undefined; + +interface SourceTable { + 키?: string; + 이름?: string; + 원문번호?: string; + 기준?: string; + 범위규칙?: string; + 조건?: Record; + 값칸?: Record; + 주?: string[]; + 용도?: { 로직키?: string[] }; + 줄: Record[]; +} + +export interface SourceOn { + edit(e: TableEdit): void; + role(table: string, col: string, role: LbRole): void; +} + +/* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */ + +const isRange = (v: unknown): v is [number | null, number | null] => + Array.isArray(v) && v.length === 2; + +/** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */ +export function showCell(v: Cell): string { + if (v === null || v === undefined) return ""; + if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`; + return String(v); +} + +/** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */ +export function parseCell(text: string, before: Cell): TableEdit["value"] | null { + const t = text.trim(); + const num = (s: string): number | null | undefined => { + const c = s.replace(/,/g, "").trim(); + if (!c) return null; + return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined; + }; + if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) { + const parts = t.split(/[~~]/); + if (parts.length !== 2) return null; + const [a, b] = parts.map(num); + if (a === undefined || b === undefined || (a === null && b === null)) return null; + if (a !== null && b !== null && a > b) return null; + return [a, b]; + } + if (!t) return null; + const n = num(t); + if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) { + return typeof n === "number" ? n : null; + } + return t; +} + +/* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */ + +/** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */ +export function roleOf(lookups: LbLookup[], col: string): LbRole | null { + for (const lk of lookups) { + if (lk.col === col) return "결과"; + const c = lk.conds.find((x) => x.col === col); + if (c) return c.from.k === "fixed" ? "고정" : "조건"; + } + return null; +} + +const NEXT: Record = { 조건: "고정", 고정: "결과", 결과: "조건" }; + +/** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */ +export function usedNotes(row: unknown): Map> { + const out = new Map>(); + const add = (where: string, mark: string): void => { + const set = out.get(where) ?? new Set(); + set.add(mark); + out.set(where, set); + }; + const walk = (v: unknown): void => { + if (typeof v === "string") { + for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]); + } else if (Array.isArray(v)) v.forEach(walk); + else if (v && typeof v === "object") Object.values(v).forEach(walk); + }; + walk(row); + return out; +} + +/** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */ +function hitsOf( + key: string, + lookups: LbLookup[], + hits: Record, +): { rows: Set; none: boolean; dup: boolean } { + const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null); + return { + rows: new Set(got.filter((h): h is number => typeof h === "number")), + none: got.includes("없음"), + dup: got.includes("중복"), + }; +} + +/* ── 그리기 ─────────────────────────────────────────────────────── */ + +/** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */ +const originals = new Map(); + +function snapshot(key: string, table: SourceTable, cols: string[]): string[][] { + const had = originals.get(key); + if (had && had.length === table.줄.length) return had; + const shot = table.줄.map((r) => cols.map((c) => showCell(r[c]))); + originals.set(key, shot); + return shot; +} + +function roleChip( + key: string, + col: string, + role: LbRole | null, + isCond: boolean, + on: SourceOn, +): HTMLElement { + const shown = role ?? (isCond ? null : undefined); + if (shown === undefined) return el("span"); + const label = shown + ? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result") + : ts("Free"); + const chip = el("button", { + className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`, + text: label, + attrs: { type: "button", title: ts("RoleHint") }, + }); + chip.addEventListener("click", (ev) => { + ev.stopPropagation(); + on.role(key, col, shown ? NEXT[shown] : "조건"); + }); + return chip; +} + +function editCell( + td: HTMLTableCellElement, + before: Cell, + done: (v: TableEdit["value"]) => void, +): void { + if (td.querySelector("input")) return; + const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } }); + input.value = showCell(before); + const text = td.textContent ?? ""; + td.replaceChildren(input); + input.focus(); + input.select(); + let closed = false; + const close = (save: boolean): void => { + if (closed) return; + closed = true; + const value = save ? parseCell(input.value, before) : null; + const same = value !== null && showCell(value) === showCell(before); + td.textContent = value === null || same ? text : showCell(value); + td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text); + if (value !== null && !same) done(value); + }; + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") close(true); + if (ev.key === "Escape") close(false); + ev.stopPropagation(); + }); + input.addEventListener("blur", () => close(true)); +} + +function renderTable( + key: string, + table: SourceTable | undefined, + lb: LbLogic, + hits: Record, + notes: Map>, + on: SourceOn, +): HTMLElement { + const lookups = lb.lookups.filter((lk) => lk.table === key); + if (!table?.줄) { + return el("section", { + className: "m01-lbsrc__table", + children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })], + }); + } + const conds = Object.keys(table.조건 ?? {}); + const vals = Object.keys(table.값칸 ?? {}); + const cols = [...conds, ...vals]; + const orig = snapshot(key, table, cols); + const hit = hitsOf(key, lookups, hits); + + const users = (table.용도?.로직키 ?? []).length; + const userBtn = el("button", { + className: "m01-lbsrc__users", + text: users ? ts("UsersN", users) : ts("UsersNone"), + attrs: { type: "button" }, + }); + const userList = el("p", { className: "m01-lbsrc__muted" }); + userBtn.addEventListener("click", () => { + userList.textContent = ts("Loading"); + tableUsers(key) + .then((list) => { + userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone"); + userList.textContent = list.map((u) => u.이름).join(" · "); + }) + .catch(() => (userList.textContent = ts("UsersFail"))); + }); + + const head = el("tr", { + children: [ + el("th", { className: "m01-lbsrc__no", text: "" }), + ...cols.map((c) => { + const unit = table.값칸?.[c]; + return el("th", { + className: vals.includes(c) ? "is-value" : "", + children: [ + el("span", { text: unit ? `${c} (${unit})` : c }), + roleChip(key, c, roleOf(lookups, c), conds.includes(c), on), + ], + }); + }), + ], + }); + const body = table.줄.map((row, i) => { + const tr = el("tr", { + className: hit.rows.has(i) ? "is-hit" : "", + children: [ + el("td", { className: "m01-lbsrc__no", text: String(i + 1) }), + ...cols.map((c, j) => { + const td = el("td", { + className: [ + vals.includes(c) ? "is-value" : "", + lookups.some((lk) => lk.col === c) ? "is-col" : "", + showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "", + ].join(" "), + text: showCell(row[c]), + }); + td.addEventListener("click", () => + editCell(td, row[c], (value) => { + td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]); + on.edit({ table: key, row: i, col: c, value }); + }), + ); + return td; + }), + ], + }); + return tr; + }); + + const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]); + const noteList = (table.주 ?? []).map((n) => { + const used = marks.has(n.trim().charAt(0)); + return el("li", { + className: used ? "is-used" : "", + children: [ + el("span", { text: n }), + ...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []), + ], + }); + }); + + return el("section", { + className: "m01-lbsrc__table", + children: [ + el("div", { + className: "m01-lbsrc__head", + children: [ + el("strong", { text: table.이름 ?? "" }), + el("span", { + className: "m01-lbsrc__muted", + text: [table.원문번호, table.기준].filter(Boolean).join(" · "), + }), + userBtn, + ], + }), + userList, + ...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []), + ...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []), + el("div", { + className: "m01-lbsrc__grid", + children: [ + el("table", { + children: [el("thead", { children: [head] }), el("tbody", { children: body })], + }), + ], + }), + ...(noteList.length + ? [ + el("details", { + className: "m01-lbsrc__notes", + attrs: marks.size ? { open: "" } : {}, + children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })], + }), + ] + : []), + ], + }); +} + +/** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */ +export function renderSource( + host: HTMLElement, + lb: LbLogic, + tables: LbTables, + hits: Record, + on: SourceOn, +): void { + const keys = [...new Set(lb.lookups.map((lk) => lk.table))]; + const notes = usedNotes(lb.doc?.row); + host.replaceChildren( + el("div", { + className: "m01-lbsrc", + children: [ + el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }), + el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }), + ...keys.map((k) => + renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on), + ), + ], + }), + ); +} diff --git a/resources/tester/test_m01_lb_check.py b/resources/tester/test_m01_lb_check.py index 58a88465f..a8fbeb80d 100644 --- a/resources/tester/test_m01_lb_check.py +++ b/resources/tester/test_m01_lb_check.py @@ -288,3 +288,95 @@ def test_기대값_시험표(): ] ) assert same == [True, False, True] + + +SOURCE_UI = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Source.ts" + +RUNNER_UI = 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((job) => { + if (job.op === "parse") return m.parseCell(job.text, job.before); + if (job.op === "notes") return [...m.usedNotes(job.row)].map(([k, v]) => [k, [...v]]); + if (job.op === "role") return m.roleOf(job.lookups, job.col); + return null; +}); +process.stdout.write(JSON.stringify(out)); +""" + + +def run_ui(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER_UI, str(ROOT), str(SOURCE_UI)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_원문_칸_고친_글_읽기(): + out = run_ui( + [ + {"op": "parse", "text": "0.3", "before": 0.26}, + {"op": "parse", "text": "1,200", "before": 1000}, + {"op": "parse", "text": "abc", "before": 0.26}, # 수 칸에 글 — 못 읽음 + {"op": "parse", "text": "3~4", "before": [2, 3]}, + {"op": "parse", "text": "~5", "before": [None, 4]}, + {"op": "parse", "text": "7~", "before": [6, None]}, + {"op": "parse", "text": "5~3", "before": [3, 5]}, # 거꾸로 — 못 읽음 + {"op": "parse", "text": "깬돌", "before": "깬잡석"}, + {"op": "parse", "text": "25", "before": "25"}, # 글 칸은 글 그대로 + {"op": "parse", "text": "", "before": 1}, + ] + ) + assert out == [0.3, 1200, None, [3, 4], [None, 5], [7, None], None, "깬돌", "25", None] + + +def test_계산에_쓰인_주와_칸_역할(): + [notes] = run_ui( + [ + { + "op": "notes", + "row": { + "중간": [{"이름": "증가율", "출처": "산림품셈 13-4-1 주③"}], + "비고": "주① 참고", + }, + } + ] + ) + assert sorted(notes) == [["*", ["①"]], ["13-4-1", ["③"]]] + lookups = [ + { + "id": "a", + "table": "T1", + "col": "석공", + "conds": [ + {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, + {"col": "돌", "from": {"k": "fixed", "value": "깬잡석"}}, + ], + } + ] + out = run_ui( + [ + {"op": "role", "lookups": lookups, "col": "석공"}, + {"op": "role", "lookups": lookups, "col": "뒷길이"}, + {"op": "role", "lookups": lookups, "col": "돌"}, + {"op": "role", "lookups": lookups, "col": "쌓기"}, + ] + ) + assert out == ["결과", "조건", "고정", None]