diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 7af76d2a3..e32601f29 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -99,7 +99,6 @@ const calcRow = (row: LogicRow | null): LogicRow | null => { const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); - const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { @@ -257,6 +256,7 @@ export async function mountM01LogicLab( values: current.values, ownHost: current.ownHost, onChange: touch, + onInput: (name, v) => syncLeft(current, { [name]: v }, true), calcHost: calc, flowHost: flowOf(current.row) ? current.flowHost : undefined, admin, @@ -332,11 +332,11 @@ export async function mountM01LogicLab( }; /** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */ - const syncLeft = (o: Opened, ex: Record): void => { + const syncLeft = (o: Opened, ex: Record, loud = false): void => { const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름)); const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v); if (!changed.length) return; - quiet = true; + quiet = !loud; // 텍스트 수식 고침(loud)은 흐름 예시값에도 — 시험 계산 칸 고침과 같은 길 for (const [k, v] of changed) o.values[k] = v; quiet = false; drawCalc(); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index b67d95ca6..ed14e9e49 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -82,6 +82,8 @@ export interface DetailContext { flowHost?: HTMLElement; /** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */ admin?: boolean; + /** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */ + onInput?: (name: string, value: string) => void; } type Group = "labor" | "material" | "cost" | "other"; @@ -511,7 +513,8 @@ function hoBox(ctx: DetailContext): HTMLElement { /** 상세 화면 넷을 `host` 에 쌓음 */ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { const formula = el("div", { className: "m01lab__formula" }); - buildFormula(formula, { text: ctx.text }); + const fx = { inputs: ctx.row.입력, values: ctx.values, onInput: ctx.onInput }; + buildFormula(formula, { text: ctx.text, ...fx }); // 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림 const active = document.activeElement; const kept = diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css index 3a69e8091..0b09d07e7 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css @@ -37,6 +37,18 @@ padding: var(--spacing-4); } +/* 714-3 줄이 쓰는 입력 — 누르면 그 자리 고르기/글 칸 */ +.m01lab-fx__vars { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); + margin-top: var(--spacing-4); +} + +.m01lab-fx__var .ui-field { + min-width: 6rem; +} + @media (max-width: 600px) { .m01lab-fx, .m01lab-fx__head, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index bec7c9a5d..d14afae32 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -3,10 +3,13 @@ * 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음) * 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이 * 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게 + * 714-3 — 줄이 쓰는 입력(표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 → + * 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8) * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; -import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api"; +import { createButton, el } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; @@ -17,6 +20,77 @@ export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean }; export interface FormulaContext { text: PreviewTextAnswer | null; + /** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */ + inputs?: LogicInput[]; + values?: Record; + onInput?: (name: string, value: string) => void; +} + +/* ── 순수 셈: 줄이 쓰는 입력 ── */ +/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */ +export function usedInputs( + line: Pick, + names: string[], +): [string, string][] { + const out = new Map(); + for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) { + const row = p.kind === "표" ? p.참조?.행 : null; + if (row && typeof row === "object") + for (const [k, v] of Object.entries(row)) + if (names.includes(k) && !out.has(k)) out.set(k, String(v)); + if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글)) + out.set(p.글, String(p.값)); + } + return [...out]; +} +/* ── 순수 셈 끝 ── */ + +/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */ +function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement { + const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } }); + const chip = createButton({ + label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`, + variant: "ghost", + size: 24, + onClick: (ev) => { + ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게 + let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게 + const done = (v: string | null): void => { + if (over) return; + over = true; + if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim()); + else wrap.replaceChildren(chip); + }; + let field: HTMLElement; + if (spec.고르기?.length) { + const pick = createSelectField({ + options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + value: shown, + size: 24, + ariaLabel: spec.이름, + onChange: (v) => done(v), + }); + pick.select.addEventListener("blur", () => done(null)); + field = pick.root; + } else { + const box = createInputField({ value: shown, size: 24 }); + box.input.setAttribute("aria-label", spec.이름); + box.input.addEventListener("keydown", (k) => { + if (k.isComposing) return; + if (k.key === "Enter") done(box.input.value); + if (k.key === "Escape") (k.preventDefault(), done(null)); + }); + box.input.addEventListener("blur", () => done(box.input.value)); + field = box.root; + } + field.addEventListener("click", (e) => e.stopPropagation()); + wrap.replaceChildren(field); + field.querySelector("select, input")?.focus(); + }, + }); + chip.title = tl("Formula_Var"); + wrap.append(chip); + return wrap; } const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text }); @@ -47,6 +121,27 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { ], }); const rows: HTMLElement[] = []; + const specs = ctx.onInput ? (ctx.inputs ?? []) : []; + const vars = (line: TextLine): HTMLElement[] => { + const used = usedInputs( + line, + specs.map((x) => x.이름), + ); + if (!used.length || !ctx.onInput) return []; + const onInput = ctx.onInput; + return [ + el("div", { + className: "m01lab-fx__vars", + children: used.map(([name, was]) => + inputChip( + specs.find((x) => x.이름 === name)!, + ctx.values?.[name] || was, + (v) => onInput(name, v), + ), + ), + }), + ]; + }; const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false const light = (pair: string, on: boolean): void => rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on)); @@ -64,6 +159,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { children: calculated ? [ el("div", { text: `${line.이름} : ${line.글}` }), + ...vars(line), ...(line.까닭 ? [ el("div", { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 8ecee9c54..52b4329de 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -61,6 +61,10 @@ const TEXT = { Modal_Close: ["닫기", "Close"], Formula_Left: ["변수 이름 식", "Formula with names"], Formula_Right: ["값이 들어간 식", "Formula with values"], + Formula_Var: [ + "누르면 시험 계산 입력을 고침 · 다시 셈(정본 안 바뀜)", + "Click to change the trial input and recalculate (source unchanged)", + ], Modal_Sum: ["한 줄 풀이", "In one line"], Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"], Modal_UnitPrice: ["단가", "Unit price"], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 379832f60..6890e0432 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -124,6 +124,8 @@ export type CalcAnswer = export interface TextPiece { kind: "요소" | "표" | "로직" | "값" | "기호" | "글"; 글: string; + /** 이름 조각(`kind: "값"` · 글 = 이름)이 가진 그 값 — 714-3 입력 단추 */ + 값?: string | number; 참조?: { 테이블?: string; 키: string; diff --git a/resources/tester/test_714_3_formula_inputs.py b/resources/tester/test_714_3_formula_inputs.py new file mode 100644 index 000000000..8b527c437 --- /dev/null +++ b/resources/tester/test_714_3_formula_inputs.py @@ -0,0 +1,62 @@ +"""714-3 텍스트 수식 입력 단추 지킴 시험 — 줄이 쓰는 입력 고르기(순수 함수 · node) + 이음 글. + +`M01_MasterData_UI_LogicLab_Formula.ts` 「순수 셈」 구간만 떼어 돌림(화면 · 서버 없음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +M01 = Path(__file__).resolve().parents[2] / "M01_MasterData" +FX = M01 / "M01_MasterData_UI_LogicLab_Formula.ts" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _run(body: str) -> object: + text = FX.read_text(encoding="utf-8") + pure = text[text.index("/* ── 순수 셈: 줄이 쓰는 입력 ── */") : text.index("/* ── 순수 셈 끝 ── */")] + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", pure + "\n" + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_표_행_열쇠와_이름_조각(): + got = _run( + """ +const line = { + 조각: [ + { kind: "표", 글: "25 · 깬잡석", 참조: { 키: "QF1", 행: { 뒷길이: 25, 돌: "깬잡석", 줄번호: 3 } } }, + { kind: "기호", 글: "*" }, + { kind: "값", 글: "두께", 값: "20" }, + { kind: "값", 글: "0.009" }, + { kind: "값", 글: "뒷길이", 값: "99" }, + ], + 요소조각: [{ kind: "표", 글: "x", 참조: { 키: "QF2", 행: 4 } }], +}; +console.log(JSON.stringify(usedInputs(line, ["뒷길이", "돌", "두께", "쌓기"]))); +""" + ) + # 처음 나온 값 · 입력 아닌 열쇠(줄번호) · 이름 없는 수(0.009) · 행 번호(4) 는 뺌 + assert got == [["뒷길이", "25"], ["돌", "깬잡석"], ["두께", "20"]] + + +def test_빈_줄(): + assert _run('console.log(JSON.stringify(usedInputs({}, ["a"])))') == [] + + +def test_이음_시험_입력만_고침(): + lab = (M01 / "M01_MasterData_UI_LogicLab.ts").read_text(encoding="utf-8") + detail = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8") + # 텍스트 수식 고침 = 시험 계산 입력 + 다시 셈(흐름 예시값과 같은 길) · 캐시(drafts)에 안 들어감 + assert "onInput: (name, v) => syncLeft(current, { [name]: v }, true)" in lab + assert "onInput: ctx.onInput" in detail + fx = FX.read_text(encoding="utf-8") + assert "touch" not in fx and "drafts" not in fx