feat(b09): 산근 탭 보기 둘(표 · 호표별 카드) · 규격 칸 기계 · 직종 「이름 규격」(157-2 · 157-3)

- 157-2 탭 머리 단추 = 「표로 보기」 ↔ 「호표별 보기」 · 고른 보기 localStorage 기억(못 읽으면 표)
- 호표별 카드 = 표 줄 그대로 호표마다 묶음 · 머리 산근 n호표 · 공종 · 단위 · 합계 · 가름줄 없이 간격 · 긴 글 줄바꿈 · 줄 누름 이동 같음
- 157-3 산근 규격 글 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 원문번호는 풍선(두 보기 · 목록표)
- 시험 test_157_2_b09_sangeun_views — 두 보기 같은 값 · 단추 바꿈 · 기억 실패 때 표 · 이름 규격 · 풍선

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-09 21:21:37 +09:00
co-authored by Claude Opus 5.5
parent c08d321af4
commit d02c1d3f08
7 changed files with 530 additions and 26 deletions
@@ -0,0 +1,148 @@
"""157-2 B09 단가산출근거 탭 보기 둘 · 157-3 산근 규격 칸 「이름 규격」.
- 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 줄 객체 · 같은 차례
- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 표(A)
- 157-3 규격 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 번호는 풍선(tip)
"""
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Sangeun import spec_text # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
B09 = ROOT / "B09_Estimation"
def _node(js_body):
src = "".join(
IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in ("UI_Tabs", "UI_Tabs_Sangeun", "UI_Tabs_Sangeun_Cards")
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src + js_body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _block(key, no, spec="", spec_raw=""):
return {
"key": key, "no": no, "name": f"산근{no}", "unit": "m3", "spec": spec, "spec_raw": spec_raw,
"head": [f"산근{no} / m3", "산림품셈 적용"],
"lines": [
{"kind": "labor", "name": "보통인부", "ref": {"tab": "labor", "key": "LB2"},
"why": None, "labor": 2979.8, "material": 0.0, "expense": 0.0,
"text": ["보통인부 : 165,545 * 0.018 = 2,979.8 (긴 함수 " +"가" * 200 + ")"]},
{"kind": "machine", "name": "굴착기", "ref": {"tab": "machine", "key": "EQ1"},
"why": None, "labor": 3899.0, "material": 538.6, "expense": 912.8,
"text": ["굴착기 노무비", "굴착기 재료비", "굴착기 경비"]},
],
"labor": 6878, "material": 538, "expense": 912, "sum": 8328, "why": None,
"foot": "계 : 8,328",
} # fmt: skip
SHEETS = {"sg": [_block("SG:A", 1), _block("SG:B", 2)]}
def test_157_2_두_보기_같은_값():
out = _node(
f"\nconst t = buildSg({{ sheets: {json.dumps(SHEETS, ensure_ascii=False)} }});"
"\nconst cards = sgCards(t.rows);"
"\nconst flat = cards.flatMap((c) => [c.head, ...c.lines.map((l) => l.row)]);"
"\nconst ats = cards.flatMap((c) => [c.at, ...c.lines.map((l) => l.at)]);"
"\nconsole.log(JSON.stringify({ n: t.rows.length, keys: cards.map((c) => c.key),"
" same: flat.length === t.rows.length && flat.every((r, i) => r === t.rows[i]),"
" ats, heads: cards.map((c) => [c.head.kind, c.head.name, c.head.unit]),"
" links: cards.map((c) => c.lines.map((l) => t.link(l.at, 'kind')?.key ?? null)) }));"
)
assert out["keys"] == ["SG:A", "SG:B"]
assert out["same"] is True # 카드 줄 = 표 줄 객체 그대로 · 같은 차례
assert out["ats"] == list(range(out["n"]))
assert out["heads"] == [["제 1 호표", "산근1", "m3"], ["제 2 호표", "산근2", "m3"]]
# 머리 1 · 노무 1 · 중기 3 · 총계 1 — 줄 누름 이동은 표와 같은 link
assert out["links"][0] == [None, "LB2", "EQ1", "EQ1", "EQ1", None]
def test_157_2_단추_바꿈():
out = _node(
"\nconsole.log(JSON.stringify([sgViewLabel('table'), sgViewLabel('cards'),"
" flipSgView('table'), flipSgView(flipSgView('table'))]));"
)
assert out == ["호표별 보기", "표로 보기", "cards", "table"]
def test_157_2_기억_실패_때_표():
out = _node(
"\nconst bad = { getItem: () => { throw new Error('막힘'); },"
" setItem: () => { throw new Error('막힘'); } };"
"\nconst mem = new Map();"
"\nconst ok = { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v) };"
"\nwriteSgView('cards', bad);"
"\nconst junk = { getItem: () => 'x', setItem: () => {} };"
"\nconst a = [readSgView(bad), readSgView(null), readSgView(junk), readSgView(ok)];"
"\nwriteSgView('cards', ok);"
"\nconsole.log(JSON.stringify([...a, readSgView(ok), mem.get(SG_VIEW_KEY)]));"
)
assert out == ["table", "table", "table", "table", "cards", "cards"]
def test_157_2_화면_잇기():
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert 'tab === "sg"' in shell and "renderSgPanel(" in shell
cards = (B09 / "B09_Estimation_UI_Tabs_Sangeun_Cards.ts").read_text("utf-8")
assert "createCard(" in cards and "createButton(" in cards
css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8")
assert "white-space: pre-wrap" in css # 긴 산출내역 글 = 카드 안 줄바꿈
class _Whole:
numbers = {
"EQ": {"0201-0070": {"이름": "굴착기(무한궤도)", "규격": "0.6㎥"}},
"LB": {"1001": {"이름": "보통인부"}},
}
def test_157_3_규격_이름_규격():
given = {"기계": "0201-0070", "직종": "1001", "규격": 150, "모름": "9999-9999"}
shown, raw = spec_text(_Whole(), given)
assert shown == "기계=굴착기(무한궤도) 0.6㎥, 직종=보통인부, 규격=150, 모름=9999-9999"
assert raw == "기계=0201-0070, 직종=1001, 규격=150, 모름=9999-9999"
def test_157_3_번호는_풍선():
block = _block("SG:A", 1, "기계=굴착기(무한궤도) 0.6㎥", "기계=0201-0070")
plain = _block("SG:B", 2, "규격=150", "규격=150")
listing = [{k: b[k] for k in ("key", "no", "name", "spec", "spec_raw", "unit")}
for b in (block, plain)] # fmt: skip
sg_json = json.dumps([block, plain], ensure_ascii=False)
ls_json = json.dumps(listing, ensure_ascii=False)
out = _node(
f"\nconst sg = buildSg({{ sheets: {{ sg: {sg_json} }} }});"
f"\nconst ls = buildSgList({{ sheets: {{ sg_list: {ls_json} }} }});"
"\nconst heads = sg.rows.map((r, i) => [r, i])"
".filter(([r]) => !String(r.key).includes('|'));"
"\nconsole.log(JSON.stringify({"
" sg: heads.map(([r, i]) => [r.note, sg.tip(i, 'note') ?? null]),"
" ls: [0, 1].map((i) => [ls.rows[i].spec, ls.tip(i, 'spec') ?? null]),"
" other: ls.tip(0, 'name') ?? null }));"
)
assert out["sg"] == [
["기계=굴착기(무한궤도) 0.6㎥", "원문번호 기계=0201-0070"],
["규격=150", None],
]
assert out["ls"] == [
["기계=굴착기(무한궤도) 0.6㎥", "원문번호 기계=0201-0070"],
["규격=150", None],
]
assert out["other"] is None