feat(714-3): M01 텍스트 수식 입력 단추 — 줄이 쓰는 시험 입력(표 찾기 행 열쇠 · 이름 조각)을 값 식 아래 단추로 · 누르면 그 자리 고르기/글 칸 · 바꾸면 시험 계산 입력 고침 + 다시 셈 · 정본 · 캐시 안 바뀜

- Formula.ts usedInputs(순수) · inputChip(부품 상자 단추 · 고르기 · 입력 칸)
- Detail → Formula 에 입력 · 값 · onInput 넘김 · LogicLab syncLeft(loud) = 흐름 예시값도 같은 길 · 줄 수 그대로(627)
- Logic_Api TextPiece 에 이름 조각 값 칸
- 새 지킴 시험 test_714_3_formula_inputs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 15:49:52 +09:00
co-authored by Claude Opus 5.5
parent f8c628552c
commit 915feb436e
7 changed files with 185 additions and 6 deletions
+3 -3
View File
@@ -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 = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(): Record<string, Draft> {
@@ -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<string, string>): void => {
const syncLeft = (o: Opened, ex: Record<string, string>, 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();
@@ -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 =
@@ -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,
@@ -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<string, string>;
onInput?: (name: string, value: string) => void;
}
/* ── 순수 셈: 줄이 쓰는 입력 ── */
/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */
export function usedInputs(
line: Pick<TextLine, "조각" | "요소조각">,
names: string[],
): [string, string][] {
const out = new Map<string, string>();
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<HTMLElement>("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", {
@@ -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"],
@@ -124,6 +124,8 @@ export type CalcAnswer =
export interface TextPiece {
kind: "요소" | "표" | "로직" | "값" | "기호" | "글";
글: string;
/** 이름 조각(`kind: "값"` · 글 = 이름)이 가진 그 값 — 714-3 입력 단추 */
값?: string | number;
참조?: {
테이블?: string;
키: string;
@@ -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