diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index 62f736436..2c2250eef 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -63,6 +63,8 @@ export interface EditHooks { pair: (loc: string) => void; /** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */ take?: StackTake; + /** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */ + before?: ReadonlyMap; } const field = ( diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index 4ce169010..68e4c2abc 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -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 = (v: T): T => JSON.parse(JSON.stringify(v)) as T; type Block = Extract; @@ -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; diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index e91568663..2d1b6f594 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -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, }), ] : []), diff --git a/A00_Common/line_stack_tex.ts b/A00_Common/line_stack_tex.ts index cc20596de..e8cefd828 100644 --- a/A00_Common/line_stack_tex.ts +++ b/A00_Common/line_stack_tex.ts @@ -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; /** 표 값 기호 화면 이름(요구 683 — 「Q_수량2^표」 → 「Q_작업반장」) */ alias?: Record; + /** 이미 결정된 나무(다시 안 접음) */ + 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); diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index af62fec9f..202430619 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -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"], diff --git a/A00_Common/line_stack_why.ts b/A00_Common/line_stack_why.ts new file mode 100644 index 000000000..6722a8627 --- /dev/null +++ b/A00_Common/line_stack_why.ts @@ -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 = { + "<": "<", + "<=": "≤", + ">": ">", + ">=": "≥", + "==": "=", + "!=": "≠", +}; + +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(" · "); diff --git a/common_util/common_util_formula_doc_view.ts b/common_util/common_util_formula_doc_view.ts index 1d5e4e712..74ca5f40b 100644 --- a/common_util/common_util_formula_doc_view.ts +++ b/common_util/common_util_formula_doc_view.ts @@ -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; @@ -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(); // 만약이 낳은 수 + const sign = new WeakSet(); // 만약에서 온 「-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; +} diff --git a/resources/tester/helper_804_2_line_stack.cjs b/resources/tester/helper_804_2_line_stack.cjs index dea001fe6..03273e738 100644 --- a/resources/tester/helper_804_2_line_stack.cjs +++ b/resources/tester/helper_804_2_line_stack.cjs @@ -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))); diff --git a/resources/tester/test_804_12_decided_if.py b/resources/tester/test_804_12_decided_if.py new file mode 100644 index 000000000..bfd516efe --- /dev/null +++ b/resources/tester/test_804_12_decided_if.py @@ -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 diff --git a/resources/tester/test_804_7_line_stack_fix.py b/resources/tester/test_804_7_line_stack_fix.py index ce4128b9b..0f9e54a32 100644 --- a/resources/tester/test_804_7_line_stack_fix.py +++ b/resources/tester/test_804_7_line_stack_fix.py @@ -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