fix(845-2): sub2 단위 받은 뒤 표시 셋 — 「줄 따름」 = 고른 표 줄 단위 · 단위 TeX 글 안전(%) · 노임 원/일 × 인 = 원/인

- 「줄 따름」 표 값 = 지금 조건으로 고른 표 줄의 「단위」 칸(rowUnit · 화면 캐시 표 읽기) — 오른쪽 · 왼쪽 값 칸 · 수량 꼬리표
- 식 줄 결과 단위 = TeX 글 안전(「%」 가 주석으로 먹혀 「가산 = 0 %」 가 안 보이던 것)
- 단가 분모가 수량 단위와 같은 뜻이면 수량 글로(원/일 × 인 → 원/인 · ㎏ ↔ kg · ℓ ↔ L) · kWh = ㎾h · 기계 칸 전력 = ㎾h/hr(845-1 문서 단위와 같게)
- 왼쪽 만약이 수 하나로 접힌 식 줄 = 단위 남김
- 전수(845-1 단위 채움 뒤) 표시 탓 0 · 시험 test_845_2_unit_show 줄 따름 · % · 원/인 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 23:01:15 +09:00
co-authored by Claude Opus 5.5
parent 44411d6e69
commit 3a8ba840f4
8 changed files with 108 additions and 29 deletions
+7 -3
View File
@@ -47,7 +47,7 @@ import {
tableAlias,
unswap,
} from "../common_util/common_util_line_stack_pick";
import { varUnit } from "../common_util/common_util_line_stack_unit";
import { shownUnitOf, type TableLook } from "../common_util/common_util_line_stack_unit";
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
@@ -80,6 +80,8 @@ export interface EditHooks {
calc?: (doc: FormulaDoc, given: Record<string, unknown>) => DocAnswer;
/** 842-3 그 기계 줄 인력 · 잡품 목록(기본값 바탕) */
lists?: (given: Record<string, unknown>) => Record<string, unknown> | null;
/** 845-2 표 한 칸 읽기(캐시) — 「줄 따름」 값의 고른 줄 단위 */
look?: TableLook;
}
const field = (
@@ -325,8 +327,10 @@ function qtyBox(
// 838-3 공식 · 표 값 묶음에 이미 보이는 기호 = 줄 쪽 표 칩 걷음(묶음에서 고름) · 묶음에 없으면 줄에서 고름
const inBlock = h.items.some((it) => it.줄.some((l) => l.꼴 === "공식" && l.기호.includes(text)));
const tag = v && !inBlock ? sourceTag(h, text, v.연결) : null;
if (tag && isNum(val))
tag.title = `${eqText(val)}${varUnit(v) ? ` ${varUnit(v)}` : ""} — ${tag.title || tag.textContent}`;
if (tag && isNum(val)) {
const u = shownUnitOf(v, (h.answer.값 ?? {}) as Record<string, unknown>, h.look);
tag.title = `${eqText(val)}${u ? ` ${u}` : ""} — ${tag.title || tag.textContent}`;
}
return el("span", { className: "lstk-qty", children: tag ? [f, tag] : [f] });
}
+5 -4
View File
@@ -48,10 +48,10 @@ import {
machineUnit,
moneyUnits,
unitText,
varUnit,
shownUnitOf,
} from "../common_util/common_util_line_stack_unit";
import { partName } from "../common_util/common_util_machine_symbol_name";
import { katexNode, symTex, tex, valTex, type TexCtx } from "./line_stack_tex";
import { katexNode, symTex, tex, txt, valTex, type TexCtx } from "./line_stack_tex";
import { lt } from "./line_stack_text";
type Cols = Partial<Record<"sum" | (typeof COSTS)[number], number>>;
@@ -127,7 +127,8 @@ function rows(
const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만
const steps = lineMap(doc);
const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5)
const unit = (id: string): string => varUnit(vars.get(id)); // 845-2 단위 규칙 한 벌
const look = (p: Record<string, unknown>): unknown => h.cache.links.table(p);
const unit = (id: string): string => shownUnitOf(vars.get(id), vals, look); // 845-2 단위 규칙 한 벌(줄 따름 = 고른 표 줄 단위)
const alias = tableAlias(doc); // 표 값 기호 화면 이름(요구 683)
const machine = doc.부품?.갈래 === "기계경비식"; // 845-2 한 줄 분수 한 층(중간 줄로 나눔)
const taken = new Set([...vars.keys(), ...lines.keys(), ...Object.values(alias)]);
@@ -144,7 +145,7 @@ function rows(
more: Partial<TexCtx>,
): (string | HTMLElement)[] => {
const res = isNum(v)
? katexNode(`${code(id) ? String(v) : valTex(v)}\\ ${u ? `\\text{${u}}` : ""}`)
? katexNode(`${code(id) ? String(v) : valTex(v)}\\ ${u ? txt(u) : ""}`) // 단위 글 = TeX 안전(「%」 주석 · 「^」 첨자 막음)
: typeof v === "string" // 글 값(기계 원문번호 등 · 838-3) = 그 글 · 기계면 이름
? el("span", { text: machineText(v, () => h.redraw?.()) ?? v })
: missing(a, id);
+9 -5
View File
@@ -44,7 +44,7 @@ import {
pickCell,
tableAlias,
} from "../common_util/common_util_line_stack_pick";
import { unitText, varUnit } from "../common_util/common_util_line_stack_unit";
import { shownUnitOf, unitText } from "../common_util/common_util_line_stack_unit";
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import type { EditHooks } from "./line_stack_edit";
import { openTablePick } from "./line_stack_pick";
@@ -157,14 +157,16 @@ export function buildBlock(
const show = (id: string): string => alias[id] ?? id;
const shownText = (t: string): string => aliasText(t, alias);
const realText = (t: string): string => aliasText(t, alias, true);
/** 845-2 단위 한 벌 — 변수 단위 › 기계 칸 › 고른 표 줄 단위(줄 따름) */
const unitOf = (id: string): string => shownUnitOf(vars.get(id), vals, h.look);
const valText = (id: string): string => {
const v = vals[id];
const mach = machineText(v, () => h.change(() => undefined)); // 838-3 기계 원문번호 = 이름
if (mach) return mach;
if (isNum(v) || typeof v === "string") return `${eqText(v)} ${varUnit(vars.get(id))}`.trim();
if (isNum(v) || typeof v === "string") return `${eqText(v)} ${unitOf(id)}`.trim();
if (v && typeof v === "object") {
const key = vars.get(id)?.연결?.키; // 845-2 묶음 값 = 그 문서 결과 단위(원/시간 등)
const u = varUnit(vars.get(id)) || unitText(key ? h.subDoc(key)?.결과단위 : "");
const u = unitOf(id) || unitText(key ? h.subDoc(key)?.결과단위 : "");
return `${["노무비", "재료비", "경비"].map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`).join(" · ")} ${u}`.trim(); // prettier-ignore
}
return `— ${h.answer.빈값?.[id] ?? ""}`.trim();
@@ -340,7 +342,9 @@ export function buildBlock(
...(expr ? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id)] : []),
// 결정된 식이 수 하나면(만약이 수를 탄 줄) 값을 또 적지 않음
...(expr && iffy && tryParse(expr)?.[0] === "num"
? []
? unitOf(id) // 수 하나 = 값 다시 안 적고 단위만(845-2)
? [el("span", { className: "lstk-unit", text: unitOf(id) })]
: []
: [...(expr ? ["="] : []), el("span", { className: "lstk-val", text: valText(id) })]),
];
else
@@ -350,7 +354,7 @@ export function buildBlock(
input(shownText(valueOf), valText(id), "lstk-f-m", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
...(varUnit(v) ? [el("span", { className: "lstk-unit", text: varUnit(v) })] : []),
...(unitOf(id) ? [el("span", { className: "lstk-unit", text: unitOf(id) })] : []),
];
const why = iffy
? whyText(tree!, vals, {
+1
View File
@@ -377,6 +377,7 @@ export function createStackScreen(opt: StackScreenOptions) {
machine: machineCtx(),
calc: calcSub,
lists: (g) => listsOf((q) => cache.links.element(q), g),
look: (p) => cache.links.table(p),
before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined,
}),
]
+1 -1
View File
@@ -61,7 +61,7 @@ const PREC: Record<string, number> = {
const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9);
/** TeX 글 안전 — \text{} 안 */
const txt = (s: string): string => `\\text{${s.replace(/[\\{}$&#^_%~]/g, (c) => `\\${c}`)}}`;
export const txt = (s: string): string => `\\text{${s.replace(/[\\{}$&#^_%~]/g, (c) => `\\${c}`)}}`;
const word = (s: string): string =>
/^[A-Za-z]$/.test(s) ? s : /^[A-Za-z]+$/.test(s) ? `\\mathit{${s}}` : txt(s);
+40 -5
View File
@@ -5,14 +5,16 @@
* ㉢ 비목 · 금액 줄 = 「단가 원/단위 × (수량 식) 단위 = 금액 원」(`moneyUnits` — 수량이 식이면 괄호로 식 전체에 단위) ·
* 소계 · 합계 = 원(머리 [원/㎥] = 결과 하나당)
* ㉣ 무차원 = 빈 칸 ㉤ 율 = %(「%(인력품 대비)」 등 설명 괄호 뗌) ㉥ 왼쪽 값 칸 = 칸 바로 뒤 한 자리
* 메타 단위(「구분 따름」 · 「줄 따름」 · 「수(…)」 · 「원문 단위 없음」) = 단위 아님 → 안 그림 · 「10^-7」 = 10⁻⁷
* 메타 단위(「구분 따름」 · 「줄 따름」 · 「수(…)」 · 「원문 단위 없음」) = 단위 아님 → 안 그림 · 「10^-7」 = 10⁻⁷ · kWh = ㎾h
* 「줄 따름」 표 값 = 지금 조건으로 고른 표 줄의 「단위」 칸(`rowUnit`) · 단가 분모 = 같은 뜻이면 수량 단위 글(원/일 × 인 → 원/인)
* 기계 칸 연결(요소 `EQ:` 칸) 변수 = 변수 단위가 빈 칸이면 기계 표 칸 단위(`MACHINE_UNIT` — 기계 표 빌더 · 내 기계와 같은 글)
* 그리기만 — 자료 · 식 · 금액 불변 · 화면 없음(시험이 Node 로 돌림)
* ========================================================================== */
import type { DocVar, MoneyLine, Node } from "./common_util_formula_doc";
import { evalNode, tryParse, type Values } from "./common_util_formula_doc_view";
/** 기계 표 칸 단위(`build_기계_건설품셈.py` UNIT · `M02_MyMachine.py` UNITS 와 같은 글) */
/** 기계 표 칸 단위(`build_기계_건설품셈.py` UNIT · `M02_MyMachine.py` UNITS · 845-1 문서 단위와 같은 글) */
export const MACHINE_UNIT: Record<string, string> = {
가격: "천원",
내용시간: "시간",
@@ -22,7 +24,7 @@ export const MACHINE_UNIT: Record<string, string> = {
관리비계수: "10^-7/시간",
손료계수: "10^-7/시간",
주연료: "ℓ/hr",
전력: "kWh/hr",
전력: "㎾h/hr",
잡재료율: "%",
잡품: "%",
조종원: "인/일",
@@ -39,9 +41,17 @@ export const AMOUNT_UNIT = "원";
export function unitText(u: unknown): string {
const s = String(u ?? "").trim();
if (!s || /따름|원문 단위 없음/.test(s) || /^수(\(| 또는|$)/.test(s)) return "";
return s.replace(/^%\(.*\)$/, "%").replace(/10\^-7/g, "10⁻⁷");
return s
.replace(/^%\(.*\)$/, "%")
.replace(/10\^-7/g, "10⁻⁷")
.replace(/kWh/g, "㎾h");
}
/** 같은 뜻 다른 표기(단가 단위 분모 ↔ 수량 단위) — 노임 원/일 ↔ 인(인·일) · ㎏ ↔ kg · ℓ ↔ L */
const SAME: [string, string][] = [["일", "인"], ["㎏", "kg"], ["ℓ", "L"]]; // prettier-ignore
const same = (a: string, b: string): boolean =>
a === b || SAME.some(([x, y]) => (a === x && b === y) || (a === y && b === x));
/** 기계 칸 이름의 화면 단위 */
export const machineUnit = (col: string): string => unitText(MACHINE_UNIT[col]);
@@ -53,6 +63,29 @@ export function varUnit(v: DocVar | undefined): string {
return k?.갈래 === "요소" && /^EQ:/.test(String(k.키 ?? "")) && k.칸 ? machineUnit(k.칸) : "";
}
/** 표 한 칸 읽기(화면 캐시 `links.table`) */
export type TableLook = (p: Record<string, unknown>) => unknown;
/** 「줄 따름」(줄마다 다른 단위) 표 값 = 지금 조건으로 고른 표 줄의 「단위」 칸 · 못 읽으면 빈 글 */
export function rowUnit(v: DocVar | undefined, vals: Values, look?: TableLook): string {
const k = v?.연결;
if (!look || !k?.표 || !/따름/.test(String(v?.단위 ?? ""))) return "";
const 조건: Record<string, unknown> = {};
for (const [c, t] of Object.entries(k.조건 ?? {})) {
const n = tryParse(t);
if (n) 조건[c] = evalNode(n, vals);
}
try {
return unitText(look({ 표: k.표, 칸: "단위", 조건 }));
} catch {
return "";
}
}
/** 값 줄 단위 한 벌 — 변수 단위 › 기계 칸 › 고른 표 줄 단위 */
export const shownUnitOf = (v: DocVar | undefined, vals: Values, look?: TableLook): string =>
varUnit(v) || rowUnit(v, vals, look);
const HIDE = new Set(["반올림", "올림", "버림"]);
/** 한 덩이(기호 · 수 · 칸 — 감싸개 안 한 덩이 포함) */
export function isAtom(n: Node | null | undefined): boolean {
@@ -83,6 +116,8 @@ export function moneyUnits(
const q = has ? unitText(line.단위) : "";
const wrap = !!q && !!qty && !isAtom(qty);
const own = [sub?.결과단위, priceVar?.단위].map(unitText).find((u) => u.startsWith("원"));
const p = has && isAtom(price) ? (own ?? (q ? `${AMOUNT_UNIT}/${q}` : "")) : "";
// 단가 분모가 수량 단위와 같은 뜻이면 수량 단위 글로(「원/일 × 인」 대신 「원/인 × 인」)
const mine = own && q && same(own.slice(2), q) ? `${AMOUNT_UNIT}/${q}` : own;
const p = has && isAtom(price) ? (mine ?? (q ? `${AMOUNT_UNIT}/${q}` : "")) : "";
return { price: p, qty: q, wrap };
}
+1 -1
View File
@@ -278,7 +278,7 @@ function left(c) {
const root = edit.buildEdit({
doc, answer: a, items: ls.stackOf(doc), change: noop, table: async () => null, subDoc: sub,
name: () => "", open: new Set((doc.금액 || []).map((_, i) => `mctl:${i}`)), pair: noop,
machine: { scope: "x" }, calc: (d, g) => calc.calcDoc(d, cache.links, g, [], new Map(), {}), lists: () => null,
machine: { scope: "x" }, calc: (d, g) => calc.calcDoc(d, cache.links, g, [], new Map(), {}), lists: () => null, look: (p) => cache.links.table(p),
}); // prettier-ignore
const rows = [];
const walk = (n) => {
+44 -10
View File
@@ -56,7 +56,9 @@ def world():
}
)
names = [c["문서"].get("이름") for c in cases if c["기계"]]
got = _js({"할일": "전수", "문서들": cases, "보기": ["GC000088"], "기계이름": names})
got = _js(
{"할일": "전수", "문서들": cases, "보기": ["GC000088", "GC000006"], "기계이름": names}
)
return {g["키"]: g for g in got}
@@ -70,20 +72,35 @@ def test_전수_규칙_어긋남_0(world):
def test_GC000088_비목_줄_단위_자리(world):
rows = [r["t"] for r in world["GC000088"]["오른쪽"]]
labor = next(t for t in rows if t.startswith("노무비 : ") and "×" in t)
assert labor.startswith("노무비 : 59,020 원/시간 × ⟦\\left(1 \\times 8 \\div 560\\right)⟧ 시간 = ")
assert labor.startswith(
"노무비 : 59,020 원/시간 × ⟦\\left(1 \\times 8 \\div 560\\right)⟧ 시간 = "
)
assert labor.endswith(" 원")
assert any(t.startswith("소 계 : ") and t.endswith(" 원") for t in rows)
assert any(t.startswith("계 : ") and t.endswith(" 원") for t in rows)
# 펼친 굴착기 시간당 경비 — 겹분수 대신 중간 줄
sub = "\n".join(rows)
assert "⟦\\text{상여}⟧ = ⟦\\dfrac{\\text{상여분자}}{\\text{상여분모}}⟧ = ⟦\\dfrac{16}{12}⟧" in sub
assert "⟦\\text{휴지}⟧ = ⟦\\dfrac{\\text{휴지분자}}{\\text{휴지분모}}⟧ = ⟦\\dfrac{25}{20}⟧" in sub
assert (
"⟦\\text{상여}⟧ = ⟦\\dfrac{\\text{상여분자}}{\\text{상여분모}}⟧ = ⟦\\dfrac{16}{12}⟧" in sub
)
assert (
"⟦\\text{휴지}⟧ = ⟦\\dfrac{\\text{휴지분자}}{\\text{휴지분모}}⟧ = ⟦\\dfrac{25}{20}⟧" in sub
)
assert "\\dfrac{1}{8} \\times 1.333333 \\times 1.25⟧ = ⟦0.208333" in sub
assert "⟦\\text{손료계수}⟧ = " in sub and "= ⟦2{,}085\\ \\text{10⁻⁷/시간}⟧" in sub
assert "\\dfrac{142{,}069 \\times 1{,}000 \\times 2{,}085}{10{,}000{,}000}⟧ = ⟦29{,}621" in sub
assert "⟦\\text{가격}⟧ = 142,069 천원" in sub
assert "⟦\\text{직종}⟧ = 1048" in sub # 코드 값 = 쉼표 없음
assert "조종원 : ⟦283{,}297⟧ 원/인 × ⟦\\left(1 \\times 0.208333\\right)⟧ 인 = 59,020.2 원" in sub
assert (
"조종원 : ⟦283{,}297⟧ 원/인 × ⟦\\left(1 \\times 0.208333\\right)⟧ 인 = 59,020.2 원" in sub
)
assert "⟦\\text{가산}⟧ = ⟦0\\ \\text{\\%}⟧" in sub # % = TeX 글(주석으로 안 먹힘)
def test_줄_따름_고른_표_줄_단위(world):
rows = "\n".join(r["t"] for r in world["GC000006"]["오른쪽"])
assert "⟦Q_{\\text{비계공2}}⟧ = 3 인" in rows # 변수 단위 「줄 따름」 = 고른 줄(비계공) 단위 칸
assert "비계공 : ⟦281{,}939⟧ 원/인 × ⟦0.03⟧ 인 = 8,458.1 원" in rows # 노임 원/일 = 인 하루 값
def test_왼쪽_제어_구역_단위_오른쪽과_같은_글(world):
@@ -98,23 +115,40 @@ def test_단위_규칙_낱개():
got = _js(
{
"할일": "단위",
"글": ["구분 따름", "줄 따름", "수(단위는 줄의 단위 칸 따름)", "수", "원문 단위 없음",
"%(주연료비의)", "10^-7/시간", "㎥/hr", "인", ""], # fmt: skip
"글": [
"구분 따름",
"줄 따름",
"수(단위는 줄의 단위 칸 따름)",
"수",
"원문 단위 없음",
"%(주연료비의)",
"10^-7/시간",
"㎥/hr",
"인",
"",
], # fmt: skip
"변수": [
{"단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율"}},
{"단위": "%", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}},
{"단위": "", "연결": {"갈래": "요소", "키": "LB:{S_직종}"}},
{"단위": "kWh/hr"},
],
"금액": [
[{"단위": "시간", "수량": "Q × 8 ÷ Q2"}, ["var", "P"], ["bin", "/", ["var", "a"], ["var", "b"]], {"단위": ""}, {"결과단위": "원/시간"}], # fmt: skip
[{"단위": "인", "수량": "Q"}, ["var", "w"], ["var", "Q"], {"단위": ""}, None],
[
{"단위": "시간", "수량": "Q × 8 ÷ Q2"},
["var", "P"],
["bin", "/", ["var", "a"], ["var", "b"]],
{"단위": ""},
{"결과단위": "원/시간"},
], # fmt: skip
[{"단위": "인", "수량": "Q"}, ["var", "w"], ["var", "Q"], {"단위": "원/일"}, None],
[{"단위": "식"}, ["bin", "*", ["var", "a"], ["var", "b"]], None, None, None],
],
}
)
assert got["글"] == ["", "", "", "", "", "%", "10⁻⁷/시간", "㎥/hr", "인", ""]
assert got["변수"] == ["천원", "%", "%", ""]
assert got["변수"] == ["천원", "%", "%", "", "㎾h/hr"]
assert got["금액"] == [
{"price": "원/시간", "qty": "시간", "wrap": True},
{"price": "원/인", "qty": "인", "wrap": False},