feat(804-12): 왼쪽 만약 식 = 결정된 식 + 근거 한 줄 · 고치면 굳음 + 표지(요구 695 · 696)

- 공식 줄 만약(IF) 식 = 지금 옵션 · 중간값으로 탄 갈래만(decide) · 만약이 낳은 0 · 1 접음 · 「a - -x」 → 「a + x」 · 셈은 그대로(판정 = 계산기 값)
- 바로 아래 근거 한 줄 — 옵션 비교 「지붕슬래브 「아님」 → 0」 · 수치 비교 「H = 9 > 7 → …」 · 규칙 보기 단추 없음(M01 도)
- 오른쪽 산출근거 = 같은 decide 한 벌(tex 의 만약 · 0 · 1 규칙을 걷고 decide 부름)
- 결정된 식을 고치면 그 글로 굳음(만약 사라짐 · 옵션 바꿔도 안 따라감) · 저장본에 만약이 있던 줄 = 「옵션 규칙이 풀렸음 · 되돌리려면 [되돌림]」 표지 · M01 · M02 같은 부품
- 1,518 전수 — 만약 든 388 문서 663 줄 · 결정된 식 값 = 원 식 = 셈 답 · 남은 만약 0 · 근거 1,062
- test_804_12 둘 · helper 「결정」 할일 · test_804_7 머리 단추 기대를 804-11 deleteButton 으로 고침(낡은 기대)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 16:04:16 +09:00
co-authored by Claude Opus 5.5
parent 9130741751
commit 7cde7acf1c
10 changed files with 264 additions and 17 deletions
+2
View File
@@ -63,6 +63,8 @@ export interface EditHooks {
pair: (loc: string) => void;
/** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */
take?: StackTake;
/** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */
before?: ReadonlyMap<string, unknown>;
}
const field = (
+29 -5
View File
@@ -18,10 +18,12 @@ import {
DocError,
rename,
splitId,
textOf,
varId,
type FormulaDoc,
} from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view";
import {
dropSyms,
freshId,
@@ -44,6 +46,8 @@ import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pi
import type { EditHooks } from "./line_stack_edit";
import { openTablePick } from "./line_stack_pick";
import { lt } from "./line_stack_text";
import { whyText } from "./line_stack_why";
import { createText } from "@ui/ui_template_text";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
type Block = Extract<StackLine, { 꼴: "공식" }>;
@@ -221,7 +225,13 @@ export function buildBlock(
.map((id) => {
const v = vars.get(id);
const link = v?.연결;
const expr = lines.get(id);
const raw = lines.get(id);
// 만약 식 = 결정된 식(지금 옵션 · 중간값으로 탄 갈래만 · 요구 695) — 고치면 그 글로 굳음(만약 사라짐 · 696)
const tree = raw !== undefined ? tryParse(raw) : null;
const iffy = !!tree && hasIf(tree);
const expr = iffy ? textOf(decide(tree!, vals)) : raw;
const was = h.before?.get(id);
const freed = !iffy && raw !== undefined && !!was && hasIf(tryParse(was) ?? ["num", 0, "0"]);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const tag =
link?.갈래 === "표" && !expr
@@ -311,10 +321,11 @@ export function buildBlock(
body = [
name,
"=",
...(expr
? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id), "="]
: []),
el("span", { className: "lstk-val", text: valText(id) }),
...(expr ? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id)] : []),
// 결정된 식이 수 하나면(만약이 수를 탄 줄) 값을 또 적지 않음
...(expr && iffy && tryParse(expr)?.[0] === "num"
? []
: [...(expr ? ["="] : []), el("span", { className: "lstk-val", text: valText(id) })]),
];
else
body = [
@@ -327,12 +338,25 @@ export function buildBlock(
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
: []),
];
const why = iffy
? whyText(tree!, vals, {
label: (x) => vars.get(x)?.이름 || splitId(x)[1] || x,
shown: shownText,
fmt: (x) => eqText(x),
})
: "";
const notes = [
...(why ? [createText(why)] : []),
...(freed ? [createText(lt("If_Freed"), "warning")] : []),
];
notes.forEach((n) => n.classList.add("lstk-wide", "lstk-why"));
const row = el("div", {
className: `lstk-frow ${open ? "is-open" : ""}`,
children: [
...body,
...(tag ? [tag] : []),
...(tools.length ? [el("span", { className: "lstk-rtools", children: tools })] : []),
...notes,
],
});
row.dataset.sym = id;
+2
View File
@@ -27,6 +27,7 @@ import { blankDoc, type FormulaDoc } from "../common_util/common_util_formula_do
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import { lineMap } from "../common_util/common_util_formula_doc_view";
import {
docOf,
stackOf,
@@ -326,6 +327,7 @@ export function createStackScreen(opt: StackScreenOptions) {
open: blocks,
pair: (loc) => pair(loc, "left"),
take: takeCtx(),
before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined,
}),
]
: []),
+5 -10
View File
@@ -11,7 +11,7 @@ import "../config/node_modules/katex/dist/katex.min.css";
import { eqText } from "@ui/ui_template_equation";
import { splitId, type Node } from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import { leafNum, takenOf, type Values } from "../common_util/common_util_formula_doc_view";
import { decide, hasIf, type Values } from "../common_util/common_util_formula_doc_view";
export interface TexCtx {
side: "sym" | "val";
@@ -23,6 +23,8 @@ export interface TexCtx {
place?: Record<string, string>;
/** 표 값 기호 화면 이름(요구 683 — 「Q_수량2^표」 → 「Q_작업반장」) */
alias?: Record<string, string>;
/** 이미 결정된 나무(다시 안 접음) */
decided?: boolean;
}
const HIDE = new Set(["반올림", "올림", "버림"]);
@@ -85,6 +87,8 @@ const paren = (t: string): string => `\\left(${t}\\right)`;
const miss = (t: string): string => `\\htmlClass{lstk-miss}{${t}}`;
export function tex(n: Node, c: TexCtx): string {
// 만약 = 탄 갈래만 · 만약이 낳은 0 · 1 접음 — 왼쪽 결정된 식과 같은 `decide` 한 벌(요구 695)
if (!c.decided && hasIf(n)) return tex(decide(n, c.vals), { ...c, decided: true });
const go = (x: Node): string => tex(x, c);
switch (n[0]) {
case "num":
@@ -114,21 +118,12 @@ export function tex(n: Node, c: TexCtx): string {
return `${prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1])}\\,\\%`;
case "call": {
const [, name, items] = n;
if (name === "만약") {
const pick = takenOf(n, c.vals);
if (pick) return go(items[pick]);
}
if (HIDE.has(name) && items.length >= 1 && items.length <= 2) return go(items[0]);
const fn = name === "최소" ? "\\min" : name === "최대" ? "\\max" : txt(name);
return `${fn}${paren(items.map(go).join(",\\,"))}`;
}
case "bin": {
const [, o, a, b] = n;
const na = leafNum(a, c.vals);
const nb = leafNum(b, c.vals);
if ((o === "+" || o === "-") && nb === 0) return go(a);
if ((o === "*" || o === "/") && nb === 1) return go(a);
if ((o === "+" && na === 0) || (o === "*" && na === 1)) return go(b);
if (o === "/" && !c.inline) return `\\dfrac{${go(a)}}{${go(b)}}`;
const mine = PREC[o];
const left = prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(go(a)) : go(a);
+5
View File
@@ -66,6 +66,11 @@ const TEXT = {
Close: ["닫기", "Close"],
Tbl: ["표", "Table"],
Direct: ["직접 입력", "Typed"],
If_Not: [" 아님", " not"],
If_Freed: [
"옵션 규칙이 풀렸음 · 되돌리려면 [되돌림]",
"Option rule released — use [Undo] to restore",
],
Direct_Hint: ["표 값을 손으로 고침 — 표 연결 끊김", "Table value typed by hand — table link off"],
Tbl_Missing: ["표를 못 읽음", "Table not found"],
Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"],
+54
View File
@@ -0,0 +1,54 @@
/* =============================================================================
* line_stack_why.ts
* 결정된 식 근거 한 줄(요구 695 · 696) — 탄 길 위 만약마다 「조건 → 탄 갈래」
* 옵션 비교 = 「지붕슬래브 「아님」 아님 → 17」 · 수치 비교 = 「H = 9 > 7 → …」 · 그 밖 = 조건 글 + 아님
* 판정 · 결정은 `common_util_formula_doc_view` (decide · whyOf) — 여기는 글만
* ========================================================================== */
import { textOf, type Node } from "../common_util/common_util_formula_doc";
import { whyOf, type Values } from "../common_util/common_util_formula_doc_view";
import { lt } from "./line_stack_text";
export interface WhyCtx {
/** 옵션 기호 → 화면 이름(변수 이름) */
label: (id: string) => string;
/** 식 글 → 화면 글(표 값 별칭) */
shown: (text: string) => string;
/** 값 → 글 */
fmt: (v: unknown) => string;
}
const OP: Record<string, string> = {
"<": "<",
"<=": "≤",
">": ">",
">=": "≥",
"==": "=",
"!=": "≠",
};
function condText(c: Node, yes: boolean, vals: Values, x: WhyCtx): string {
const no = lt("If_Not");
if (c[0] === "bin" && (c[1] === "==" || c[1] === "!=")) {
const [v, s] =
c[2][0] === "var" && c[3][0] === "str"
? [c[2], c[3]]
: c[3][0] === "var" && c[2][0] === "str"
? [c[3], c[2]]
: [null, null];
if (v && s && typeof vals[v[1]] === "string")
return `${x.label(v[1])} 「${s[1]}」${(c[1] === "==" ? !yes : yes) ? no : ""}`;
}
if (c[0] === "bin" && OP[c[1]]) {
const side = (n: Node): string =>
n[0] === "var" ? `${x.shown(n[1])} = ${x.fmt(vals[n[1]])}` : x.shown(textOf(n));
return `${side(c[2])} ${OP[c[1]]} ${side(c[3])}${yes ? "" : no}`;
}
return `${x.shown(textOf(c))}${yes ? "" : no}`;
}
/** 근거 한 줄 — 만약 없으면 빈 글 */
export const whyText = (n: Node, vals: Values, x: WhyCtx): string =>
whyOf(n, vals)
.map((w) => `${condText(w.cond, w.yes, vals, x)} → ${x.shown(textOf(w.to))}`)
.join(" · ");
+96 -1
View File
@@ -2,10 +2,11 @@
* common_util_formula_doc_view.ts
* 수식 문서 그림용 값(PLAN 802-3 · 802-4b-2 · 요구 663-1) — 화면 · DOM · fetch 없음(거울 시험이 Node 로 돌림)
* tryParse · evalNode(탄 갈래 값) · takenOf · leafNum · offZero / offLine(선택으로 꺼진 줄)
* decide · whyOf — 만약 = 탄 갈래만 · 만약이 낳은 0 더하기 · 1 곱하기 없앰(왼쪽 식 글 · 오른쪽 산출근거 한 벌 · 요구 695)
* 셈 정본은 계산기(`common_util_formula_doc_calc.ts`) — 여기는 그림 · 숨김 판정만
* ========================================================================== */
import { DocError, parse, type Node } from "./common_util_formula_doc";
import { DocError, parse, walk, type Node } from "./common_util_formula_doc";
import { binary, cut, isNum } from "./common_util_formula_doc_calc";
export type Values = Record<string, unknown>;
@@ -150,3 +151,97 @@ export function moneyScope(rows: unknown, i: number): Values {
});
return acc;
}
/** 결정된 식 — 지금 값으로 탄 갈래만 남긴 나무(모르는 조건의 만약은 그대로) · 셈 값은 원 식과 같음
* 아래에서 위로: 만약이 낳은 수(made)는 접음(-0 · 0 ÷ 100 · 1 - 0 → 1) · 0 더하기 · 빼기 · 1 곱하기 · 나누기 · 0 곱하기 없앰 ·
* 만약이 낳은 음수 부호는 위로 올려 「a - -x」 → 「a + x」 · 만약 없는 식 조각은 손대지 않음 */
export function decide(n: Node, vals: Values): Node {
const made = new WeakSet<Node>(); // 만약이 낳은 수
const sign = new WeakSet<Node>(); // 만약에서 온 「-x」
const num = (v: number): Node => {
const x: Node = ["num", v, String(Number(v.toPrecision(12)))];
made.add(x);
return x;
};
const isMade = (x: Node): x is ["num", number, string] => x[0] === "num" && made.has(x);
const neg = (x: Node): Node => {
if (x[0] === "num") return num(-x[1]);
if (x[0] === "neg") return x[1];
const y: Node = ["neg", x];
sign.add(y);
return y;
};
const go = (x: Node): Node => {
if (!hasIf(x)) return x;
switch (x[0]) {
case "call": {
const pick = takenOf(x, vals);
if (x[1] === "만약" && pick) {
const r = go(x[2][pick]);
return r[0] === "num" ? num(r[1]) : r[0] === "neg" ? neg(r[1]) : r;
}
return ["call", x[1], x[2].map(go)];
}
case "neg": {
const c = go(x[1]);
const fromIf = x[1][0] === "call" && x[1][1] === "만약"; // 「-만약(…)」 = 만약에서 온 부호
return fromIf || isMade(c) || sign.has(c) || c[0] === "neg" ? neg(c) : ["neg", c];
}
case "pct":
return ["pct", go(x[1])];
case "field":
return ["field", go(x[1]), x[2]];
case "bin": {
const o = x[1];
const a = go(x[2]);
const b = go(x[3]);
if (!"+-*/".includes(o) || o.length !== 1) return ["bin", o, a, b];
const ma = isMade(a);
const mb = isMade(b);
if (a[0] === "num" && b[0] === "num" && (ma || mb)) {
const v = binary(o, a[1], b[1]);
if (typeof v === "number" && Number.isFinite(v)) return num(v);
}
if ((o === "+" || o === "-") && mb && b[1] === 0) return a;
if (o === "+" && ma && a[1] === 0) return b;
if (o === "-" && ma && a[1] === 0) return neg(b);
if ((o === "*" || o === "/") && mb && b[1] === 1) return a;
if (o === "*" && ma && a[1] === 1) return b;
if (
(o === "*" && ((ma && a[1] === 0) || (mb && b[1] === 0))) ||
(o === "/" && ma && a[1] === 0)
)
return num(0);
if ((o === "*" || o === "/") && sign.has(a))
return neg(["bin", o, (a as ["neg", Node])[1], b]);
if (o === "-" && sign.has(b)) return ["bin", "+", a, (b as ["neg", Node])[1]];
if (o === "+" && sign.has(b)) return ["bin", "-", a, (b as ["neg", Node])[1]];
return ["bin", o, a, b];
}
default:
return x;
}
};
return go(n);
}
export const hasIf = (n: Node): boolean =>
[...walk(n)].some((x) => x[0] === "call" && x[1] === "만약");
/** 결정 근거 — 탄 길 위 만약마다 {조건, 참?, 탄 갈래(결정된 꼴)} · 안 탄 갈래 속 만약은 안 셈 */
export function whyOf(n: Node, vals: Values): { cond: Node; yes: boolean; to: Node }[] {
const out: { cond: Node; yes: boolean; to: Node }[] = [];
const visit = (x: Node): void => {
const pick = takenOf(x, vals);
if (pick) {
const items = (x as ["call", string, Node[]])[2];
out.push({ cond: items[0], yes: pick === 1, to: decide(items[pick], vals) });
return visit(items[pick]);
}
if (x[0] === "neg" || x[0] === "pct" || x[0] === "field") visit(x[1]);
else if (x[0] === "call") x[2].forEach(visit);
else if (x[0] === "bin") (visit(x[2]), visit(x[3]));
};
visit(n);
return out;
}
@@ -253,6 +253,31 @@ function directOf({ 문서 }) {
return { 전: before, 숫자: typed, 식: pk2.tableAlias(expr), 갈래: kinds, 문서: doc };
}
/* 695 결정된 식 — 만약 든 식 줄마다 결정된 식 값 = 원 식 값 = 셈 답 · 남은 만약 · 근거 수 */
const vw = util("common_util_formula_doc_view");
function decideOf({ 문서, 연결 }) {
const c = dc.createDocCache();
c.merge(연결);
const vals = calc.calcDoc(문서, c.links, {}).값 || {};
const out = [];
for (const l of 문서.식 || []) {
const t = vw.tryParse(l.식);
if (!t || !vw.hasIf(t)) continue;
const d = vw.decide(t, vals);
const a = vw.evalNode(t, vals);
const b = vw.evalNode(d, vals);
out.push({
기호: l.기호,
같음: a === undefined ? b === undefined : calc.same(a, b),
답: a === undefined || calc.same(a, vals[l.기호]),
남은만약: vw.hasIf(d),
근거: vw.whyOf(t, vals).length,
글: fd.textOf(d),
});
}
return out;
}
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const job = {
왕복: roundTrip,
@@ -266,5 +291,6 @@ const job = {
번지: placeOf,
별칭: aliasOf,
직접: directOf,
결정: decideOf,
}[input.할일];
process.stdout.write(JSON.stringify(input.문서들.map(job)));
@@ -0,0 +1,44 @@
"""804-12(요구 695 · 696) — 왼쪽 공식 줄 만약(IF) 식 = 결정된 식 + 근거 한 줄 · 오른쪽 산출근거와 같은 `decide` 한 벌.
① 1,518 전수 — 만약 든 식 줄마다 결정된 식 값 = 원 식 값 = 셈 답 · 조건을 아는 줄은 만약이 안 남음 · 근거 = 탄 만약 수
② 접기 규칙 — 만약이 낳은 수만 접음(-0 · 0 ÷ 100 · 1 - 0) · 「a - -x」 → 「a + x」 · 만약 없는 조각 그대로
③ 화면 — 결정된 식을 고치면 그 글로 굳음(만약 사라짐) · 저장본에 만약이 있던 줄 = 「옵션 규칙이 풀렸음」 표지 · 규칙 보기 단추 없음
"""
from __future__ import annotations
from pathlib import Path
from resources.tester.test_804_2_line_stack import _ts, world # noqa: F401 — 같은 셈 바탕
ROOT = Path(__file__).resolve().parents[2]
def test_결정된_식_전수_셈_같음(world) -> None: # noqa: F811
keys = sorted(world["cases"])
got = dict(zip(keys, _ts("결정", [world["cases"][k] for k in keys])))
lines = [(k, x) for k, xs in got.items() for x in xs]
assert len({k for k, _ in lines}) > 350 and len(lines) > 600 # 388 문서 · 663 줄 언저리
bad = [(k, x["기호"]) for k, x in lines if not (x["같음"] and x["답"])]
assert not bad, bad[:10]
left = [(k, x["기호"], x["글"]) for k, x in lines if x["남은만약"]]
assert len(left) < len(lines) * 0.05, left[:10] # 조건을 모르는 줄만(빈 값 등)
assert sum(x["근거"] for _, x in lines) >= len(lines) - len(left)
gc = {x["기호"]: x["글"] for x in got["GC001009"]}
assert (
gc["Q_시공"] == "Q_시공^표" and gc["Q_그물막인부"] == "0"
) # 기본 옵션 = 감률 0 · 할증 없음
assert "만약" not in "".join(x["글"] for x in got["GC000211"])
def test_화면_결정된_식_근거_굳음_표지() -> None:
rows = (ROOT / "A00_Common/line_stack_rows.ts").read_text(encoding="utf-8")
assert "const expr = iffy ? textOf(decide(tree!, vals)) : raw;" in rows
assert 'createText(lt("If_Freed"), "warning")' in rows
tex = (ROOT / "A00_Common/line_stack_tex.ts").read_text(encoding="utf-8")
assert "return tex(decide(n, c.vals), { ...c, decided: true });" in tex # 오른쪽 = 같은 decide
assert "takenOf" not in tex and "leafNum" not in tex
for rel in ("A00_Common/line_stack_text.ts", "A00_Common/line_stack_rows.ts"):
assert "규칙 보기" not in (ROOT / rel).read_text(encoding="utf-8")
screen = (ROOT / "A00_Common/line_stack_screen.ts").read_text(encoding="utf-8")
assert "before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined," in screen
@@ -144,7 +144,7 @@ def test_머리_단추_다섯_저장_한_번() -> None:
assert "await loading;" in _src("A00_Common/line_stack_screen.ts")
assert "await stack.discard();" in lab # [되돌림] = 둘 다
assert "n === 0 && !stack.dirty()" in lab
for t in ('tl("New")', "copyButton", 'tx("Bar_Delete")', "discardButton", "saveButton"):
for t in ('tl("New")', "copyButton", "deleteButton", "discardButton", "saveButton"):
assert t in lab.split("buttons: [")[1].split("]")[0]
text = _src("M01_MasterData/M01_MasterData_UI_Logic_Text.ts")
assert 'Bar_Discard: ["되돌림", "Revert"]' in text and "캐시 삭제" not in text