From 8f169f5fe2392d2388827b686599df9749c75629 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:13:18 +0900 Subject: [PATCH 01/38] =?UTF-8?q?feat(B09):=20=EB=B0=95=EC=8A=A4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=8F=84=EA=B5=AC=20=EC=A4=84=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EC=95=88=EB=82=B4=20=EA=B8=80=20=EC=A4=84?= =?UTF-8?q?=EC=9E=84=ED=91=9C=20+=20=ED=98=B8=EB=B2=84=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B0=99=EC=9D=80=20=EA=B0=80=EB=A1=9C=20?= =?UTF-8?q?=EC=A4=84=20=EB=86=92=EC=9D=B4=20=EA=B0=99=EA=B2=8C(200-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B09_Estimation/B09_Estimation_UI_Page.css | 16 +++ .../B09_Estimation_UI_Tabs_Groups.ts | 18 ++- resources/tester/helper_b09_group_cards.py | 4 +- resources/tester/test_185_2_b09_view_bar.py | 5 +- .../tester/test_200_1_b09_box_tool_line.py | 118 ++++++++++++++++++ ui_template/ui_template_group_cards.css | 5 +- ui_template/ui_template_group_cards.ts | 5 +- 7 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 resources/tester/test_200_1_b09_box_tool_line.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 9f9a4b97a..27be4e313 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -50,6 +50,22 @@ white-space: nowrap; } +/* 200-1 묶음 탭 도구 줄 — 안내 글 = 남는 자리 한 줄 · 넘치면 줄임표(전문 = 공용 호버) */ +.b09-estimation__bar { + flex-wrap: nowrap; + align-items: center; +} + +.b09-estimation__line-hint { + flex: 1 1 0; + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-caption); +} + /* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */ .b09-estimation .ui-data-table__table tbody tr.is-lv1 td { font-size: var(--text-body); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 0bc6e9f87..666f0ea42 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -7,10 +7,12 @@ * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 + * 도구 줄(200-1) = 안내 글 + 가로 열 수 + 보기 단추 한 줄 · 안내 글 길면 줄임표 + 공용 호버로 전문 * ========================================================================== */ import type { DataRow, DataTable } from "@ui/ui_template_data_table"; import { createButton } from "@ui/ui_template_elements"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { clampColumns, createGroupCards, @@ -174,10 +176,18 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine /* ── 그리기 ───────────────────────────────────────────────────────────── */ +/** 안내 글 = 도구 줄 한 칸(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */ +function lineHint(hint: HTMLElement | null): HTMLElement | null { + if (!hint) return null; + hint.classList.add("b09-estimation__line-hint"); + attachHoverInfo(hint, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); + return hint; +} + /** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ const chosen = new Map(); -/** 묶음 탭 — 카드 = 안내 + 카드 판(보기 단추는 가로 열 수 줄) · 표 = 머리(안내 + 보기 단추) + 표 */ +/** 묶음 탭 — 카드 = 카드 판(안내 · 가로 열 수 · 보기 단추 한 줄) · 표 = 머리(안내 + 보기 단추 한 줄) + 표 */ export function renderGroupPanel(o: { tab: TabId; spec: TabTable; @@ -188,6 +198,7 @@ export function renderGroupPanel(o: { redraw: () => void; }): HTMLElement[] { const view = chosen.get(o.tab) ?? readGroupView(o.tab); + const lead = lineHint(o.hint); const toggle = createButton({ label: viewLabel(view), variant: "ghost", @@ -210,14 +221,15 @@ export function renderGroupPanel(o: { focus: at, money: numberFormat(o.spec, "sum"), qty: numberFormat(o.spec, "qty"), + lead, // 200-1 안내 글도 같은 줄 actions: [toggle], // 185-2 가로 열 수 단추와 한 줄 }); cards.dataset.b09Table = o.tab; - return [...(o.hint ? [o.hint] : []), cards]; + return [cards]; } const bar = document.createElement("div"); bar.className = "b09-estimation__bar"; - bar.append(...(o.hint ? [o.hint] : []), toggle); + bar.append(...(lead ? [lead] : []), toggle); const range = pickRange(o.spec.rows, o.focus); const table = o.makeTable((i) => pickClass(range, i)); if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 45027fdd4..11887582b 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -15,7 +15,7 @@ COMMON = "ui_template/ui_template_group_cards.ts" TABS = "B09_Estimation/B09_Estimation_UI_Tabs.ts" GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts" -# 기억(b_page_state) 대신 Map · 단추(createButton) 대신 가짜 button · 157-3 원문번호 풍선(raw) 짝 +# 기억(b_page_state) 대신 Map · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝 STUBS = r""" const kept = new Map(); const readState = (name) => (kept.has(name) ? JSON.parse(kept.get(name)) : null); @@ -27,6 +27,8 @@ const createButton = (o) => { if (o.onClick) b.addEventListener("click", o.onClick); return b; }; +const hoverCalls = []; +const attachHoverInfo = (root, text, selector) => { hoverCalls.push({ root, text, selector }); return () => {}; }; """ RAW = r""" diff --git a/resources/tester/test_185_2_b09_view_bar.py b/resources/tester/test_185_2_b09_view_bar.py index 7ddc65174..f28188e2d 100644 --- a/resources/tester/test_185_2_b09_view_bar.py +++ b/resources/tester/test_185_2_b09_view_bar.py @@ -51,9 +51,10 @@ def test_카드_보기_단추가_가로_열수_줄에(): assert out["n"] == 1 # 안내 글 없으면 카드 판 하나뿐 -def test_카드_보기_안내_글은_카드_위에_따로(): +def test_카드_보기_안내_글은_같은_도구_줄에(): + # 200-1 — 안내 글이 카드 위 따로 줄에서 도구 줄 안으로(상세 = test_200_1) out = _panel("cards", HINT) - assert out["n"] == 2 and out["first"] == "hint" + assert out["n"] == 1 and out["first"] == "ui-group-cards" assert out["bar"] == "ui-group-cards__bar" diff --git a/resources/tester/test_200_1_b09_box_tool_line.py b/resources/tester/test_200_1_b09_box_tool_line.py new file mode 100644 index 000000000..57d9e08fc --- /dev/null +++ b/resources/tester/test_200_1_b09_box_tool_line.py @@ -0,0 +1,118 @@ +"""200-1 B09 박스보기 도구 줄 한 줄 · 카드 같은 가로 줄 높이 같게. + +- 안내 글 · 가로 열 수 단추 · 보기 단추 = 같은 줄(카드 보기 = `ui-group-cards__bar` · 표 보기 = `b09-estimation__bar`) +- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 +- 카드 격자 = 행 늘림(`align-items: stretch`) · 카드 = 세로 flex · 몸통이 늘어 합계 줄이 아래 +""" + +import json +import re +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 resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 + +B09 = "B09_Estimation/B09_Estimation_UI_Tabs_" +DG = {"id": "DG1", "no": 1, "name": "찰쌓기", "unit": "m2", "lines": [], "sum": 0} +LONG = "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가" +HINT = ( + "(() => { const h = document.createElement('p');" + f" h.className = 'ui-general-block__empty'; h.textContent = '{LONG}'; return h; }})()" +) + + +def _block(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def _panel(view: str, hint: str): + """고른 보기 · 안내 글로 그린 뒤 도구 줄 구성 · 호버 기록.""" + return run( + [COMMON, TABS, f"{B09}Labor.ts", GROUPS], + f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" + f"\nchosen.set('dg', '{view}');" + f"\nconst hint = {hint};" + "\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}," + " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };" + "\nconst out = renderGroupPanel(o);" + "\nconst btn = out.flatMap((e) => (e.dataset.b09GroupView ? [e] : e.all('[data-b09-group-view]')))[0];" + "\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar');" + "\nconst h = hint ? bar.all('.b09-estimation__line-hint')[0] : null;" + "\nconst call = hoverCalls[0];" + "\nconst wide = call ? call.text({ scrollWidth: 900, clientWidth: 300, textContent: 'T' }) : null;" + "\nconst fit = call ? call.text({ scrollWidth: 300, clientWidth: 300, textContent: 'T' }) : null;" + "\nconsole.log(JSON.stringify({ n: out.length, bar: bar.className," + " cols: bar.all('[data-group-cols]').length, hint: !!h, hintInBar: h ? bar.contains(h) : false," + " hintCls: h ? h.className : '', hover: hoverCalls.length, hoverIsHint: call ? call.root === h : false," + " wide, fit, outer: out.map((e) => e.className) }));", + RAW, + ) + + +def test_카드_보기_안내_글이_단추와_같은_줄(): + out = _panel("cards", HINT) + assert out["n"] == 1 # 안내 글 따로 줄 없음 · 카드 판 하나 + assert out["bar"] == "ui-group-cards__bar" + assert out["hint"] and out["hintInBar"] # 열 수 단추 · 보기 단추와 같은 도구 줄 안 + assert out["cols"] == 4 + assert "b09-estimation__line-hint" in out["hintCls"] + + +def test_표_보기_안내_글이_보기_단추와_같은_줄(): + out = _panel("table", HINT) + assert out["n"] == 2 # 머리 줄 + 표 + assert out["bar"] == "b09-estimation__bar" + assert out["hint"] and out["hintInBar"] + + +def test_안내_글_잘렸을_때만_공용_호버로_전문(): + for view in ("cards", "table"): + out = _panel(view, HINT) + assert out["hover"] == 1 and out["hoverIsHint"] + assert out["wide"] == "T" # 잘림(scrollWidth > clientWidth) = 전문 + assert out["fit"] is None # 다 보이면 안 띄움 + + +def test_안내_글_없으면_호버도_없음(): + for view in ("cards", "table"): + out = _panel(view, "null") + assert out["hint"] is False and out["hover"] == 0 + + +def test_CSS_안내_글_줄임표_한_줄(): + page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + hint = _block(page, ".b09-estimation__line-hint") + for need in ( + "overflow: hidden", + "text-overflow: ellipsis", + "white-space: nowrap", + "min-width: 0", + ): + assert need in hint, need + assert "flex: 1 1 0" in hint + # 표 보기 머리 줄 = 줄바꿈 없음 · 같은 높이 가운데 + bars = re.findall(r"\.b09-estimation__bar\s*\{([^}]*)\}", page) + assert any("flex-wrap: nowrap" in b and "align-items: center" in b for b in bars) + + +def test_CSS_카드_같은_줄_높이_같게(): + css = (ROOT / "ui_template" / "ui_template_group_cards.css").read_text("utf-8") + grid = _block(css, ".ui-group-cards__grid") + assert "align-items: stretch" in grid and "align-items: start" not in grid + card = _block(css, ".ui-group-card") + assert "display: flex" in card and "flex-direction: column" in card + assert "flex: 1 1 auto" in _block(css, ".ui-group-card__body") # 합계 줄 = 카드 아래 + + +def test_잇기(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert "lead," in groups and "actions: [toggle]" in groups + assert "attachHoverInfo(hint," in groups + cards = (ROOT / COMMON).read_text("utf-8") + assert "if (opts.lead) bar.append(opts.lead);" in cards diff --git a/ui_template/ui_template_group_cards.css b/ui_template/ui_template_group_cards.css index 58499235f..ed4f4f584 100644 --- a/ui_template/ui_template_group_cards.css +++ b/ui_template/ui_template_group_cards.css @@ -43,11 +43,13 @@ .ui-group-cards__grid { display: grid; grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr)); - align-items: start; + align-items: stretch; /* 200-1 같은 가로 줄 = 높이 같음 */ gap: var(--spacing-8); } .ui-group-card { + display: flex; + flex-direction: column; min-width: 0; padding: var(--spacing-4) var(--spacing-8); background-color: var(--color-surface-raised); @@ -87,6 +89,7 @@ } .ui-group-card__body { + flex: 1 1 auto; /* 내용 적은 카드도 몸통이 늘어 합계 줄이 아래에 */ overflow-x: auto; } diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 65923f767..4eb6cc64b 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -5,7 +5,7 @@ * 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) / * 합계 줄 = 노무 · 재료 · 경비 · 계 · 줄 금액 = 합계만(비목 셋 = 풍선) * 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽) - * 접기 없음 — 묶음 모두 펼친 박스 + * 접기 없음 — 묶음 모두 펼친 박스 · 같은 가로 줄 카드 = 높이 같음(200-1 · 합계 줄은 아래 붙음) * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선 * (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`) * ========================================================================== */ @@ -65,6 +65,8 @@ export interface GroupCardsOptions { focus?: number; money?: (value: number | null | undefined) => string; qty?: (value: number | null | undefined) => string; + /** 머리 띠 가운데 안내 글(200-1 · 단추와 한 줄 — 늘어나는 칸) */ + lead?: HTMLElement | null; /** 머리 띠 끝 단추(예 [표로 보기]) */ actions?: HTMLElement[]; } @@ -209,6 +211,7 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { }); pick.append(...colButtons); bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`)); + if (opts.lead) bar.append(opts.lead); const tail = document.createElement("div"); tail.className = "ui-group-cards__actions"; tail.append(...(opts.actions ?? [])); From 84ae5100f825262385ce7175a69e1d9edb91271f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:15:05 +0900 Subject: [PATCH 02/38] =?UTF-8?q?feat(B09):=20=EB=B0=95=EC=8A=A4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B9=B4=EB=93=9C=20=EC=A0=9C=EB=AA=A9=20=EC=A4=84?= =?UTF-8?q?=20=C2=B7=20=ED=95=A9=EA=B3=84=20=EC=A4=84=20=EB=B0=94=ED=83=95?= =?UTF-8?q?=EC=83=89=20=EA=B5=AC=EB=B6=84(=EB=B0=9D=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EC=96=B4=EB=91=90=EC=9B=80=20=ED=85=8C=EB=A7=88=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0)(200-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../tester/test_200_1_b09_box_tool_line.py | 82 +++++++++++++++++++ ui_template/ui_template_group_cards.css | 7 +- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_200_1_b09_box_tool_line.py b/resources/tester/test_200_1_b09_box_tool_line.py index 57d9e08fc..eea635ee6 100644 --- a/resources/tester/test_200_1_b09_box_tool_line.py +++ b/resources/tester/test_200_1_b09_box_tool_line.py @@ -116,3 +116,85 @@ def test_잇기(): assert "attachHoverInfo(hint," in groups cards = (ROOT / COMMON).read_text("utf-8") assert "if (opts.lead) bar.append(opts.lead);" in cards + + +# ── 200-3 카드 제목 줄 · 합계 줄 바탕색(밝음 · 어두움) ───────────────────────── + + +def _theme_vars(dark: bool) -> dict: + """테마 CSS 의 `:root`(밝음) · 어두움 덮어쓰기 토큰 → 한 벌.""" + css = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") + light = dict(re.findall(r"(--[\w-]+):\s*([^;]+);", _block(css, ":root"))) + if dark: + light.update(re.findall(r"(--[\w-]+):\s*([^;]+);", _block(css, ':root[data-theme="dark"]'))) + return light + + +def _rgb(token: str, theme: dict) -> tuple: + """토큰 → (r, g, b) · var() 별칭 따라감 · 16진수만.""" + val = theme[token].split("/*")[0].strip() + while m := re.fullmatch(r"var\((--[\w-]+)\)", val): + val = theme[m.group(1)].split("/*")[0].strip() + assert re.fullmatch(r"#[0-9a-fA-F]{6}", val), (token, val) + return tuple(int(val[i : i + 2], 16) for i in (1, 3, 5)) + + +def _mix(spec: str, theme: dict) -> tuple: + """`color-mix(in srgb, var(A) N%, var(B))` → 섞은 (r, g, b).""" + m = re.fullmatch( + r"color-mix\(in srgb,\s*var\((--[\w-]+)\)\s*(\d+)%,\s*var\((--[\w-]+)\)\)", spec + ) + assert m, spec + a, n, b = _rgb(m.group(1), theme), int(m.group(2)) / 100, _rgb(m.group(3), theme) + return tuple(a[i] * n + b[i] * (1 - n) for i in range(3)) + + +def _lum(c: tuple) -> float: + ch = [ + (v / 255) / 12.92 if v / 255 <= 0.03928 else (((v / 255) + 0.055) / 1.055) ** 2.4 for v in c + ] + return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2] + + +def _contrast(a: tuple, b: tuple) -> float: + hi, lo = sorted((_lum(a), _lum(b)), reverse=True) + return (hi + 0.05) / (lo + 0.05) + + +def _tokens() -> tuple: + css = (ROOT / "ui_template" / "ui_template_group_cards.css").read_text("utf-8") + root = _block(css, ".ui-group-cards") + head = re.search(r"--group-head-bg:\s*([^;]+);", root) + foot = re.search(r"--group-foot-bg:\s*([^;]+);", root) + assert head and foot + return css, head.group(1), foot.group(1) + + +def test_카드_제목_줄과_합계_줄_바탕색_토큰(): + css, head, foot = _tokens() + assert head != foot + assert "background-color: var(--group-head-bg)" in _block(css, ".ui-group-card__head") + assert "background-color: var(--group-foot-bg)" in _block(css, ".ui-group-card__foot") + for spec in (head, foot): # 고른 값 없이 테마 변수만(색 고정값 없음) + assert "#" not in spec and "rgb(" not in spec + + +def test_밝음_어두움_두_색이_다르고_글_대비_유지(): + _, head, foot = _tokens() + for dark in (False, True): + theme = _theme_vars(dark) + h, f = _mix(head, theme), _mix(foot, theme) + assert sum(abs(h[i] - f[i]) for i in range(3)) >= 40, (dark, h, f) # 서로 다른 색 + card = _rgb("--color-surface-raised", theme) + assert sum(abs(f[i] - card[i]) for i in range(3)) >= 40 # 카드 바탕과도 구분 + for color in ("--color-text", "--color-text-secondary", "--color-accent"): + assert _contrast(_rgb(color, theme), h) >= 4.5, (dark, "head", color) + for color in ("--color-text", "--color-text-body", "--color-text-secondary"): + assert _contrast(_rgb(color, theme), f) >= 4.5, (dark, "foot", color) + + +def test_어두움_테마_값(): + # 어두움 테마에서 쓰는 토큰이 실제로 어두움 값으로 덮임(밝음 값 그대로가 아님) + light, dark = _theme_vars(False), _theme_vars(True) + for token in ("--color-surface", "--color-surface-raised", "--color-accent"): + assert _rgb(token, light) != _rgb(token, dark), token diff --git a/ui_template/ui_template_group_cards.css b/ui_template/ui_template_group_cards.css index ed4f4f584..677c99a50 100644 --- a/ui_template/ui_template_group_cards.css +++ b/ui_template/ui_template_group_cards.css @@ -1,6 +1,9 @@ /* 묶음 카드 공용 양식(171-2) — 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽. 색은 테마 변수만. */ .ui-group-cards { + /* 200-3 제목 줄 = 표 머리색 계열(옅게) · 합계 줄 = 강조색 더 짙게 — 둘 다 테마 변수에서 섞음(밝음 · 어두움 같이) */ + --group-head-bg: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface-raised)); + --group-foot-bg: color-mix(in srgb, var(--color-accent) 15%, var(--color-surface-raised)); display: flex; flex-direction: column; gap: var(--spacing-8); @@ -68,7 +71,8 @@ flex-wrap: wrap; align-items: baseline; column-gap: var(--spacing-8); - padding: 2px 0; + padding: 2px var(--spacing-4); + background-color: var(--group-head-bg); border-bottom: 1px solid var(--color-border); font-weight: var(--font-weight-bold); } @@ -148,6 +152,7 @@ justify-content: flex-end; column-gap: var(--spacing-8); padding: 2px var(--spacing-4); + background-color: var(--group-foot-bg); border-top: 1px solid var(--color-border); font-variant-numeric: tabular-nums; } From c9cd2812dc50cb6a3eac59851071fef14aeff8c7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:17:50 +0900 Subject: [PATCH 03/38] =?UTF-8?q?feat(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EA=B8=80=20=EC=99=BC=EC=AA=BD=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=C2=B7=20=EB=A0=88=EB=B2=A8=20=3D=20=EC=99=BC?= =?UTF-8?q?=EC=AA=BD=20=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=20=EB=8F=84=EA=B5=AC=20=EC=A4=84(=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EB=84=B7=20=C2=B7=20Tab=20=C2=B7=20Shift+Tab=20?= =?UTF-8?q?=C2=B7=20Alt+=E2=86=91=E2=86=93=20=C2=B7=20=EC=95=88=EB=82=B4)?= =?UTF-8?q?=20(200-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Page.css | 57 +++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 8 +- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 129 +++++++++++ .../tester/test_200_2_b09_bill_level_bar.py | 213 ++++++++++++++++++ 4 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts create mode 100644 resources/tester/test_200_2_b09_bill_level_bar.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 9f9a4b97a..1b24e484c 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -88,6 +88,63 @@ background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); } +/* 200-2 설계내역서 레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸 1em) · 글자 크기 한 벌(굵기 = 묶음 · 제목) · 글 칸 왼쪽 맞춤 */ +.b09-estimation [data-b09-table="bill"] tbody tr { + --b09-lv: 0; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv2 { + --b09-lv: 1; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv3 { + --b09-lv: 2; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv4 { + --b09-lv: 3; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv5 { + --b09-lv: 4; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-lv6 { + --b09-lv: 5; +} + +.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td { + font-size: inherit; +} + +.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td:first-child { + padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em); +} + +.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td.is-edit:first-child { + padding-left: 0; +} + +.b09-estimation [data-b09-table="bill"] tbody td:first-child .ui-data-table__input { + padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em); +} + +.b09-estimation [data-b09-table="bill"] .ui-data-table__input:not([data-k="qty"]) { + text-align: left; +} + +/* 200-2 레벨 도구 줄 — 단추 넷 + 안내 한 줄 */ +.b09-estimation__level { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.b09-estimation__level .b09-estimation__hint { + margin: 0; +} + /* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */ .b09-estimation .ui-data-table__table tbody tr.is-pick td { --pick-t: inset 0 0 0 0 transparent; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index cc619ea1f..fbc860214 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -52,6 +52,7 @@ import { } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { buildSumExpense, buildSumLabor, @@ -249,8 +250,11 @@ export function renderB09Estimation(root: HTMLElement): void { ...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, redraw }), ); } - const table = makeTable(); - panel.replaceChildren(...(hint ? [hint] : []), table.root); + // 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 + const bill = (spec as BillTable).menu ? (spec as BillTable) : null; + const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined); + const level = bill ? levelBar(table.root, bill) : null; + panel.replaceChildren(...(hint ? [hint] : []), ...(level ? [level] : []), table.root); if (focus !== undefined) { const index = rowIndex(spec.rows, focus); if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts new file mode 100644 index 000000000..ca070d200 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -0,0 +1,129 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Bill_Level.ts + * B09 설계내역서 레벨 도구 줄(PLAN 200-2) — 표 위 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · 키 · 안내 한 줄. + * + * 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음 + * 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) + * 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂) + * 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import { createButton } from "@ui/ui_template_elements"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; + +/** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */ +const ACTS: [string, string][] = [ + ["→ 들여쓰기", "들여쓰기 →"], + ["← 내어쓰기", "내어쓰기 ←"], + ["↑ 위로", "위로 ↑"], + ["↓ 아래로", "아래로 ↓"], +]; + +const GUIDE = "레벨 — 줄을 누르고 단추 · 키 Tab 들여 · Shift+Tab 내어 · Alt+↑↓ 옮김"; +const GUIDE_MORE = + "레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " + + "들여쓰기 = 바로 위 묶음 줄 아래로 · 내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " + + "위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " + + "키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " + + "묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭"; + +let picked: string | null = null; +/** 지금 그린 도구 줄 — 키가 부름 */ +let live: { + root: HTMLElement; + bar: HTMLElement; + run: (label: string) => void; + sync: () => void; +} | null = null; +let keysOn = false; + +/** 고른 줄 class — Shell 줄 class 곁값 */ +export const pickedClass = (row: DataRow | undefined): string | null => + row && picked !== null && String(row.key) === picked ? "is-focus" : null; + +/** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */ +export function menuActs(table: BillTable, index: number): Map void> { + const items = index >= 0 && table.menu ? table.menu(index) : []; + return new Map(items.filter((x): x is [string, () => void] => Array.isArray(x))); +} + +/** 키 → 메뉴 글 · 편집 중 · 다른 키 = null */ +export function keyAct( + e: { key: string; shiftKey: boolean; altKey: boolean; ctrlKey?: boolean }, + editing: boolean, +): string | null { + if (editing || e.ctrlKey) return null; + if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1]; + if (!e.altKey || e.shiftKey) return null; + return e.key === "ArrowUp" ? ACTS[2][1] : e.key === "ArrowDown" ? ACTS[3][1] : null; +} + +const editing = (at: Element | null): boolean => + /^(INPUT|TEXTAREA|SELECT)$/.test(at?.tagName ?? "") || + !!(at as HTMLElement | null)?.isContentEditable; + +function listenKeys(): void { + if (keysOn) return; + keysOn = true; + document.addEventListener("keydown", (event) => { + if (!live?.root.isConnected || picked === null) return; + const at = document.activeElement; + if (at && at !== document.body && !live.root.contains(at) && !live.bar.contains(at)) return; + // Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂 + if (event.key === "Escape") { + if (!editing(at)) { + picked = null; + return live.sync(); + } + (at as HTMLElement).blur(); + return live.root.focus(); + } + const label = keyAct(event, editing(at)); + if (!label) return; + event.preventDefault(); + live.run(label); + }); +} + +/** 표 위 레벨 도구 줄 — 표 root 에 줄 누름(고름)을 붙임 */ +export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { + const index = (): number => + picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked); + const run = (label: string): void => menuActs(table, index()).get(label)?.(); + const buttons = ACTS.map(([text, label]) => { + const button = createButton({ + label: text, + variant: "ghost", + size: "sm", + onClick: () => run(label), + }); + button.dataset.b09Level = label; + return button; + }); + const guide = document.createElement("span"); + guide.className = "b09-estimation__hint"; + guide.textContent = GUIDE; + attachHoverInfo(guide, GUIDE_MORE); + const bar = document.createElement("div"); + bar.className = "b09-estimation__level"; + bar.append(...buttons, guide); + const sync = (): void => { + const acts = menuActs(table, index()); + ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label))); + for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus"); + root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus"); + }; + root.addEventListener("click", (event) => { + const tr = (event.target as Element).closest("tr[data-i]"); + const row = tr ? table.rows[Number(tr.dataset.i)] : undefined; + if (!row) return; + picked = String(row.key); + sync(); + }); + sync(); + live = { root, bar, run, sync }; + listenKeys(); + return bar; +} diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py new file mode 100644 index 000000000..b2e0936b5 --- /dev/null +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -0,0 +1,213 @@ +"""200-2 B09 설계내역서 레벨 글 · 레벨 도구 줄 — 글 칸 왼쪽 맞춤 · 레벨 = 공종 · 명칭 칸 padding 들여쓰기 · +글자 크기 한 벌 · 단추 넷(고른 줄 · 못 하면 disabled) · 키 셋(Tab · Shift+Tab · Alt+↑↓) · 편집 중엔 키 안 먹음. + +- 단추 · 키 = 우클릭 메뉴(billMenu) 같은 항목을 부름 — 줄 꼴 · 저장 꼴은 184-3 시험 그대로 +- 표 · 문서 = 가짜 DOM(줄 tr · 글 칸 input) · 다시 그림 = buildBill + levelBar 다시 +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import ROWS, _node, _ts # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" + +BAR_JS = """ +class FakeEl { + constructor(tag) { + this.tagName = tag.toUpperCase(); this.kids = []; this.dataset = {}; this.cls = new Set(); + this.on = {}; this.parent = null; this.isConnected = true; this.disabled = false; this.textContent = ""; + } + set className(v) { this.cls = new Set(v.split(" ").filter(Boolean)); } + get classList() { const s = this.cls; return { add: (c) => s.add(c), remove: (c) => s.delete(c) }; } + append(...kids) { for (const k of kids) { k.parent = this; this.kids.push(k); } } + addEventListener(type, f) { (this.on[type] ??= []).push(f); } + fire(type, ev) { for (const f of this.on[type] ?? []) f(ev); } + contains(x) { for (let n = x; n; n = n.parent) if (n === this) return true; return false; } + all() { return this.kids.flatMap((k) => [k, ...k.all()]); } + closest() { for (let n = this; n; n = n.parent) if (n.tagName === "TR") return n; return null; } + querySelectorAll() { return this.all().filter((n) => n.tagName === "TR" && n.cls.has("is-focus")); } + querySelector(sel) { const i = sel.match(/data-i="(-?\\d+)"/)[1]; return this.all().find((n) => n.dataset.i === i) ?? null; } + focus() { doc.activeElement = this; } + blur() { doc.activeElement = doc.body; } +} +const doc = new FakeEl("#document"); +doc.body = new FakeEl("body"); +doc.activeElement = doc.body; +doc.createElement = (t) => new FakeEl(t); +globalThis.document = doc; +const hovers = []; +function createButton(o) { + const b = new FakeEl("button"); + b.label = o.label; b.variant = o.variant; + b.addEventListener("click", o.onClick); + return b; +} +function attachHoverInfo(el, text) { hovers.push([el.textContent, text]); } + +let user = {}; +let table, root, bar; +const draw = () => { + table = buildBill({ sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, + get user() { return user; }, setUser: (u) => (user = u) }); + if (root) root.isConnected = false; + if (root?.contains(doc.activeElement)) doc.activeElement = doc.body; // 지운 요소 초점 = body + root = new FakeEl("div"); + table.rows.forEach((r, i) => { + const tr = new FakeEl("tr"); tr.dataset.i = String(i); + const td = new FakeEl("td"); const input = new FakeEl("input"); + td.append(input); tr.append(td); root.append(tr); + }); + bar = levelBar(root, table); +}; +const at = (key) => table.rows.findIndex((r) => r.key === key); +const tr = (key) => root.kids[at(key)]; +const pick = (key) => root.fire("click", { target: tr(key).kids[0] }); +const buttons = () => bar.kids.filter((k) => k.tagName === "BUTTON"); +const off = () => buttons().map((b) => b.disabled); +const order = () => (user.bill?.rows ?? []).map((e) => [e.key, e.parent ?? null]); +const key = (k, more = {}) => { + let stopped = false; + doc.fire("keydown", { key: k, shiftKey: false, altKey: false, ...more, preventDefault: () => (stopped = true) }); + return stopped; +}; +const out = {}; +draw(); +out.labels = buttons().map((b) => [b.label, b.dataset.b09Level, b.variant]); +out.guide = bar.kids.at(-1).textContent; +out.hover = hovers.length; +out.none = off(); +pick("earth:4"); +out.earth4 = off(); +out.focus = [tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")]), + pickedClass(table.rows[at("earth:5")])]; +buttons()[3].fire("click"); +out.down = order(); +user = {}; draw(); +out.kept = [off(), tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")])]; +root.focus(); +out.tabStop = key("Tab"); +out.tabNoop = order(); +out.shiftTab = [key("Tab", { shiftKey: true }), order()]; +user = {}; draw(); +pick("earth:5"); +root.focus(); +out.altUp = [key("ArrowUp", { altKey: true }), order()]; +user = {}; draw(); pick("earth:4"); +out.altDown = [key("ArrowDown", { altKey: true }), order()]; +user = {}; draw(); pick("earth:4"); +doc.activeElement = tr("earth:4").kids[0].kids[0]; +out.editing = [key("Tab", { shiftKey: true }), key("ArrowDown", { altKey: true }), order()]; +out.esc = [key("Escape"), doc.activeElement === root]; +out.afterEsc = [key("Tab", { shiftKey: true }), order().length > 0]; +user = {}; draw(); pick("earth:4"); +doc.activeElement = new FakeEl("input"); +out.outside = [key("Tab", { shiftKey: true }), order()]; +root.focus(); +key("Escape"); +out.unpick = [off(), tr("earth:4").cls.has("is-focus"), key("Tab")]; +out.keyAct = [ + keyAct({ key: "Tab", shiftKey: false, altKey: false }, false), + keyAct({ key: "Tab", shiftKey: true, altKey: false }, false), + keyAct({ key: "ArrowUp", shiftKey: false, altKey: true }, false), + keyAct({ key: "ArrowDown", shiftKey: false, altKey: true }, false), + keyAct({ key: "Tab", shiftKey: false, altKey: false }, true), + keyAct({ key: "ArrowUp", shiftKey: false, altKey: false }, false), + keyAct({ key: "Tab", shiftKey: false, altKey: false, ctrlKey: true }, false), +]; +console.log(JSON.stringify(out)); +""" + +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Level"] +IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓" + + +def _bar(): + body = BAR_JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + return json.loads(_node(_ts(SRC) + body)) + + +def _keys(order): + return [k for k, _ in order] + + +def test_단추_넷_고른_줄_못_하면_흐림(): + out = _bar() + assert out["labels"] == [["→ 들여쓰기", IN, "ghost"], ["← 내어쓰기", OUT, "ghost"], + ["↑ 위로", UP, "ghost"], ["↓ 아래로", DOWN, "ghost"]] # fmt: skip + assert "Tab" in out["guide"] and "Shift+Tab" in out["guide"] and "Alt+↑↓" in out["guide"] + assert out["hover"] == 1 # 안내 한 줄 = 공용 호버로 자세히 + assert out["none"] == [True] * 4 # 고른 줄 없음 = 모두 흐림 + # earth:4 = 1.1 첫 줄 — 들여(위 형제 없음) · 위로 = 못 함 · 내어 · 아래로 = 됨(184-3 메뉴와 같음) + assert out["earth4"] == [True, False, True, False] + assert out["focus"] == [True, "is-focus", None] + # [↓ 아래로] = 메뉴 「아래로 ↓」 와 같은 결과 + keys = _keys(out["down"]) + assert keys.index("earth:5") + 1 == keys.index("earth:4") + # 다시 그려도 고른 줄 · 단추 상태 남음 + assert out["kept"] == [[True, False, True, False], True, "is-focus"] + + +def test_키_셋_편집_중엔_안_먹음(): + out = _bar() + assert out["tabStop"] is True and out["tabNoop"] == [] # 못 하는 들여쓰기 = 바뀜 없음 + stopped, order = out["shiftTab"] + assert stopped and ("earth:4", "sec:1") in [tuple(x) for x in order] # 내어쓰기 + for name in ("altUp", "altDown"): + stopped, order = out[name] + keys = _keys(order) + assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name + # 글 칸 편집 중 = 키 안 먹음(기본 동작 그대로) · Esc = 편집 끝(표에 초점) → 키 먹음 + assert out["editing"] == [False, False, []] + assert out["esc"] == [False, True] + assert out["afterEsc"] == [True, True] + # 초점이 표 · 도구 줄 밖 = 안 먹음 · Esc(편집 아님) = 고름 풂 → 흐림 · Tab 은 기본 동작 + assert out["outside"] == [False, []] + assert out["unpick"] == [[True] * 4, False, False] + assert out["keyAct"] == [IN, OUT, UP, DOWN, None, None, None] + + +def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌(): + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + bill = '.b09-estimation [data-b09-table="bill"]' + # 레벨마다 한 칸(1em) — 변수로 공종 · 명칭 칸(글 칸이면 글 상자) 왼쪽 padding + for lv in range(2, 7): + assert f"{bill} tbody tr.is-lv{lv} {{\n --b09-lv: {lv - 1};" in css.replace("\r", "") + pad = "padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);" + assert re.search( + re.escape(f'{bill} tbody tr[class*="is-lv"] td:first-child {{') + r"\s*" + re.escape(pad), + css, + ) + assert re.search( + re.escape(f"{bill} tbody td:first-child .ui-data-table__input {{") + + r"\s*" + + re.escape(pad), + css, + ) + assert re.search( + re.escape(f'{bill} tbody tr[class*="is-lv"] td.is-edit:first-child {{') + + r"\s*padding-left: 0;", + css, + ) + # 글 칸 왼쪽 맞춤(수량만 오른쪽) · 글자 크기 = 표 한 벌(182 16/14/13px 덮음 · 굵기만) + assert re.search( + re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;", + css, + ) + assert re.search( + re.escape(f'{bill} tbody tr[class*="is-lv"] td {{') + r"\s*font-size: inherit;", css + ) + assert css.index("200-2 설계내역서 레벨") > css.index("182 설계내역서 레벨") # 뒤에 와서 덮음 + assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css) + + +def test_셸_배선(): + src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src + assert "...(level ? [level] : []), table.root" in src + lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") + assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수 From 694ca5fee3f8c1349c2200c20dc3d3705fd07920 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:21:14 +0900 Subject: [PATCH 04/38] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2053~60=20=C2=B7=20=EC=A1=B0=EC=82=AC=20AB(=EC=A0=9C=EB=B9=84?= =?UTF-8?q?=EC=9C=A8=20=EB=8C=80=EC=A1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 8 +++ docs/raw/요구_1010_2/조사_AB_제비율대조.md | 79 ++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 docs/raw/요구_1010_2/조사_AB_제비율대조.md diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 9cd35f849..c7e8c1d58 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -73,3 +73,11 @@ 50. 대시보드의 구조물옵션 정의가 제대로 연결안됨. 51. B08의 구조물 집계표에서 미연결 상태표시는 행의 좌측에 하나의 램프로 표기, 미지정 열을 필요없어 보임. 52. B08의 페이지들을 보면 합계가 나오는 행들을 보면 초기에는 행들이 추가되다가 특정 컨테이너 높이가 되면 합계 행을 고정하고 내부를 스크롤 방식으로 변경하고 있음. 이방법 자체는 좋은데 그렇다면 컨테이너의 높이제약을 현재 브라우져가 스크롤이 안되는 지점을 기준으로 적용하는게 조작 편의성이 좋아보임. 그럼 행들이 추가됨에 따라 밀리더라도 스크롤은 페이지스크롤은 없을테니 +53. B09의 박스보기 버튼과 가로 숫자 버튼, 그리고 그위의 계산들에 대한 안내는 같은행으로 변경 +54. 박스보기의 경우 지정된 카드 행들은 높이를 같게 변경 +55. 설계내역서의 공종 및 명칭의 글자들은 좌측 맞춤이고 좌측에 들여쓰기로 레벨이 표현되는게 맞아보임. 그리고 레벨 제어를 어떻게 하는지 모르겠음. +56. 박스보기에서 제목과 합계 정보열의 색은 다른색으로 지정하는게 테마고려하여 선택 +57. B08/B09의 테이블 양식이 에서 레벨이 필요한 정보들은 설계내역서의 레벨지정 양식을 따라가되 제어가 안되게 반영하면 일원화되지 않을까? 그리고 셀에 같은 값으로 표현되는 행들은 결국 병합인것같은데 맞지 않아? +58. 제비율표는 일력하는 공사원가계산서와 총괄설계내역서의 내용에서 요율을 원문 제비율표를 보고 누락이 된게 없는지 봐줘. (조사만) +59. 현재 B08/B09의 테이블 컨테이너의 양식을 보면 제일상단에 경고줄이 있어 이걸제일하단으로 옮겨줘. 이때 박스보기와 가로 카드수 정의는 보조탭 행에 우측맞춤으로 이동 +60. 다음으로 B09의 좌측패널의 원가 단가 컨테이너의 이름을 데이터 점검으로 이름을 변경하고 내부의 쓸대없는 설명은 삭제, 단가 갱신버튼의 제목을 업데이트, 업데이트가 필요한경우 버튼에 오버레이 마크 추가, 상세 페이지의 연결점검 탭은 데이터 점검 컨테이너 내부로 이동 diff --git a/docs/raw/요구_1010_2/조사_AB_제비율대조.md b/docs/raw/요구_1010_2/조사_AB_제비율대조.md new file mode 100644 index 000000000..104228015 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AB_제비율대조.md @@ -0,0 +1,79 @@ +# 조사 AB — 제비율 대조 (요구 58 · 조사만) + +대상 = B09 총괄설계내역서 · 공사원가계산서 탭의 요율 줄 ↔ 원문 조달청 제비율표(2026.04.13판, 토목 갈래) · 예정가격작성기준 별표2 · 실무 엑셀(기번3 2024 · 소광 2025 · 거창 2026). + +## 1. 한눈 결론 + +- 요율 값 자체는 맞음 — 프로그램이 자동으로 고르는 13줄(간접노무비~부가세)의 값 · 밑수 · 구간이 원문표와 모두 같음. +- 큰 누락 넷 — 퇴직공제부금비 · 임금채권부담금 · 석면분담금 · 하도급대금지급보증수수료. 줄 이름만 있고 늘 빈칸이며 소계에도 안 들어감(M01 표엔 값이 있는데 셈이 안 씀). +- 총괄 줄 자체가 없는 것 둘 — 폐기물처리비(실무 거창 · 소광에 있고, 09-07 사용자 확정 「필요」) · 공사손해보험료(원문 서식에 있음, 가입 대상일 때만). +- 조건이 다른 것 — 연금 요율을 「오늘 날짜의 해」로 고름(설계 연도 칸 없음) · 조달수수료율 자동값 없음(빈칸 = 0) · 적용 조건(1억 이상 등) 확인 없음. +- 실무 엑셀 차례는 기번3(2024) 꼴과 같음. 2026 실무(거창)에 비하면 퇴직공제 · 공사이행보증 · 폐기물처리비 줄이 빠짐. + +## 2. 대조 표 + +프로그램 근거 약칭 — `CT` = `B09_Estimation/B09_Estimation_Calc_Total.py`(짝 `.ts` 같은 줄 구성) · `RT` = `B09_Estimation/B09_Estimation_Rates.py` · `UI` = `B09_Estimation/B09_Estimation_UI_Tabs_Total.ts` · `M01` = `resources/master_data/요율_조달청제비율.json` · 원문 = `현행_20260413.md` 줄. + +| 원문 비목 | 원문 요율 · 밑수 · 조건 | 프로그램 | 프로그램 근거 | 메모 | +|---|---|---|---|---| +| 간접노무비 (원문 8-31) | 직노 × 18.9~21.0% · 직접공사비 5구간 × 공기 4구간 · 토목 | 있음 · 같음 | CT:34,179 · RT:136 | 직접공사비 = 가.순공사비계 · 공기 = 사용자 칸(일) · 183/365/1095일 경계 맞음 | +| 산재보험료 (원문 88) | 노무비 × 3.56% · 모든 건설공사 | 있음 · 같음 | CT:35,181 · RT:112 | 밑수 = 직노 + 간노 | +| 고용보험료 (원문 91-105) | 노무비 × 1.01~1.57% · **추정금액** 등급 | 있음 · 밑수 조금 다름 | CT:36,182 · RT:127 | 등급을 **추정가격** 칸으로 고름. 임도 규모는 둘 다 7등급 이하 1.01% → 금액 차 없음 | +| 건강보험료 (원문 85) | 직노 × 3.595% · 공기 1개월 이상 | 있음 · 같음 | CT:38,183 · RT:112 | 1개월 조건 확인 없음(임도는 늘 넘음) | +| 노인장기요양 (원문 86) | 건강보험료 × 13.14% | 있음 · 같음 | CT:39,184 · RT:112 | | +| 연금보험료 (원문 87) | 직노 × 4.75%(2026) · 해마다 오름(2027 5.00 …) | 있음 · **조건 다름** | CT:40,185,158 · RT:155-159 | 해 = 오늘 날짜의 해 → 2026 설계를 2027년에 다시 [저장]하면 5.00%로 바뀜 | +| 산업안전보건관리비 (원문 107-125) | (재료비 + 직노 + 도급자설치 관급) × 3.15% / 2.53% + 330만 / 2.60% · 관급 있으면 A · B(× 1.2) 작은 쪽 · 총공사금액 2천만 이상 | 있음 · 조건 일부 다름 | CT:40,186-192 · RT:139-154 | B안도 A 대상액 구간의 요율 · 기초액을 씀(고시는 「관급 뺀 대상액으로 산출」 → 5억 · 50억 경계 근처에서만 차이) · 2천만 조건 확인 없음 · 1000억↑ 구간은 뺌(임도 밖) | +| 기타경비 (원문 33-58) | (재료비 + 노무비) × 5.5~8.1% · 직접공사비 × 공기 | 있음 · 같음 | CT:41,193 · RT:137 | | +| 환경보전비 (원문 127-143) | 직접공사비 × 0.9(도로) / 0.8(그 밖의 토목) · 추정금액 1억 이상 | 있음 · 조건 확인 없음 | CT:42,194 · RT:160-169 · UI:156-162 | 기본 = 그 밖 0.8 · 누르면 도로 0.9(지식DB도 미확정) · 1억 미만 공사에도 붙음 | +| 건설기계대여대금 지급보증 (원문 171-173) | 직접공사비 × 0.4%(종합 토목) · 전문 0.1~0.68% | 있음 · 같음 | CT:43,195 · RT:115-126 | 거창 실무 0.1%(전문) — 사용자 칸으로 바꿀 수 있음 | +| **퇴직공제부금비** (원문 152-154) | 직노 × 2.3% · 추정금액 1억 이상 | **줄만 · 늘 빈칸 · 소계 안 듦** | CT:46-52 · UI:169-173 · M01:1027 | ★ 누락. 거창 2026 실무 계상(5,603,907원). 원가계산서 탭엔 줄도 없음(UI:236-257) | +| **임금채권부담금** (원문 150) | 노무비 × 0.09% · 모든 건설공사 | **줄만** | CT:48 · M01:1028 | ★ 누락 (2026.03.13 추가 항목) | +| **석면분담금** (원문 149) | 노무비 × 0.006% · 모든 건설공사 | **줄만** | CT:49 · M01:1029 | ★ 누락 (〃) | +| **하도급대금지급보증수수료** (원문 158-169) | 직접공사비 × 0.081%(추정가격 50억 미만) … · 전문공사 제외 | **줄만** | CT:50 · M01:1031 | ★ 누락. 임도를 종합공사로 내면 붙음 | +| 공사이행보증수수료 (원문 175-185) | 직공비 구간식 × 공기(년) · 추정가격 300억 이상 등 의무 대상만 | 줄만 | CT:51 · M01:1110 | 임도는 통상 비대상 → 빈칸이 맞음. 원가계산서 탭엔 줄 없음 | +| 일반관리비 (원문 60-70) | (재 + 노 + 경) × 8.0%(50억 미만) / 6.5 / 5.0 / 4.5 · 추정가격 | 있음 · 같음 | CT:55,197 · RT:128-134 | 원문 md 표는 병합 칸이라 「5억~50억」이 비어 보이나 실무 「26. 1. 1.」 시트로 50억 미만 = 8.0 확인 → M01 해석 맞음 | +| 이윤 (원문 72-79) | (노 + 경 + 일반관리비) × 15 / 12 / 10 / 9% · 추정가격 · 상한 15%(예정가격작성기준 §21) | 있음 · 같음 | CT:56,199-200 · RT:135 | 사용자가 15% 넘게 적어도 막지 않음 | +| 부가가치세 | 총원가 × 10% | 있음 · 같음 | CT:57,202-203 · RT:114 | | +| 조달수수료 (조달수수료 고시 22-33) | 관급 순자재대 × 0.54%(단가계약 · 10억 이하) | 있음 · **자동값 없음** | CT:206-210 · UI:40 | 빈칸 시작 = 0 → 관급이 있어도 수수료 0. M01에 줄 없음 | +| 공사손해보험료 (예정가격작성기준 §22 · 별표2) | 가입 대상 총원가(+ 관급) × 발주기관이 정한 율 | **없음** | — | 조달청 제비율표 밖 · 가입 대상일 때만 · 실무 셋 모두 없음 | +| 폐기물처리비 (실무 거창 · 소광) | 요율 아님 · 실비 | **없음** | — | 거창 = 관급 뒤(총원가 밖) · 소광 = 이윤 뒤(총원가 안) — 자리 갈림 | +| 간접재료비 · 작업설 | 손입력 | 있음 | UI:41-42 | | + +### 2-2. 실무 엑셀 차례와 비교 + +| 실무 | 경비 줄 차례 | 프로그램과 차이 | +|---|---|---| +| 기번3 2024 총괄 49~67 (STmate) | 간노 · 산재 · 고용 · 건강 · 요양 · 연금 · 안전 · 기타경비 · 환경 · 건설기계 → 나.소계 · 일반관리비 · 이윤 · 부가세 · 관급 | **같음** (CT:33-58이 이 차례를 그대로 따름) | +| 소광 2025 원가계산서 (STmate) | 기번3과 같음 + 이윤 뒤 「폐기물처리」 | 폐기물처리 줄 없음 | +| 거창 2026 원가계산서 (26.1.1판) | … 연금 · 요양 · **퇴직공제** · 건설기계 · 안전(A · B 두 줄) · 환경 · **공사이행보증** · 기타경비 · … 관급 뒤 **폐기물처리비** | 퇴직공제 값 · 공사이행보증 줄(원가계산서) · 폐기물처리비 · 안전 비채택 줄(프로그램은 산식 글로만 A · B 표시 UI:191-193) | + +- 원문 4종(퇴직공제 · 임금채권 · 석면 · 하도급)은 실무 셋 중 거창에 퇴직공제만 있음 — 실무를 따라도, 원문을 따라도 프로그램에서 빠진 상태. + +## 3. 누락 · 다름 목록과 고칠 거리 + +| # | 무엇 | 왜 문제 | 고칠 거리 | 크기 | +|---|---|---|---|---| +| A | 법정 비목 4개 셈 안 함 (퇴직공제 · 임금채권 · 석면 · 하도급) | 원문상 모든(또는 1억 이상) 임도에 붙는 돈이 빠짐. 직노 2.4억 공사(거창)면 퇴직공제만 약 560만 원 + 일반관리비 · 이윤 · 부가세 몫 | CURRENT_ONLY → RATE_LINES로 옮김 · `rate_table` 에 4줄(값 · 밑수는 M01에 이미 있음) · 원가계산서 줄 · 짝 TS · 거울 시험 | 중 | +| B | 폐기물처리비 줄 없음 | 사용자가 「필요」로 확정한 줄. 실무 두 벌에 있음 | 손입력 줄 하나 · **자리(총원가 안/밖) 사용자 협의 먼저** | 소~중 | +| C | 조달수수료율 자동값 없음 | 관급자재가 있는데 칸을 안 채우면 관급자재대가 수수료만큼 적게 나옴 | M01에 조달수수료 줄(0.54%) 추가 후 자동 · 또는 기본값 | 소 | +| D | 연금 요율 해 = 오늘 해 | 해를 넘겨 다시 저장하면 요율이 저절로 바뀜 | 프로젝트 기준 연도(설계 · 입찰 연도) 칸으로 고름 | 소 | +| E | 적용 조건 확인 없음 (환경보전비 · 퇴직공제 추정금액 1억 · 안전 2천만 · 건강 · 연금 1개월) | 소규모 공사에서 0 이어야 할 줄이 붙음 | M01 조건 칸(이미 있음 M01:113,954,1027)을 셈이 읽음 | 소 | +| F | 안전관리비 B안 구간 | 관급을 넣어 대상액이 5억 · 50억을 넘을 때 B안 요율이 원문과 다를 수 있음 | B안은 관급 뺀 대상액으로 구간 다시 고름 | 소 | +| G | 공사손해보험료 줄 없음 | 가입 대상 공사일 때 넣을 자리 없음 | 손입력 줄(율 · 기본 빈칸) | 소 | +| H | 고용보험 등급 밑수 = 추정가격 | 원문은 추정금액. 임도 규모에선 금액 차 없음 | 메모만 · 또는 추정금액 칸 | 소 | +| I | M01 처리규칙 글 「추정가격 빈칸이면 직접공사비로 수렴」 ↔ 코드는 빈칸이면 자동 없음(CT:93-95) | 글과 코드가 어긋남 → 추정가격을 안 적으면 일반관리비 · 이윤 · 고용 빈칸 | 글을 고치거나 수렴을 넣음(협의) | 소 | +| J | 이윤 15% · 일반관리비 상한 확인 없음 | 사용자 값이 상한을 넘어도 그대로 | 넘으면 출처 칸에 경고 글 | 소 | + +- 요율 값 오류 · 밑수 오류는 못 찾음 (자동 고르는 13줄 전부 원문과 같음). +- 원문은 조달청 발주 기준(「참고자료」 문구) — 지자체 · 산림청 발주용 별도 제비율은 지식DB에 없음. 지식DB는 근거 · 후보 가이드 — 위 고칠 거리의 값 · 자리는 사용자 협의로 확정. + +## 4. 근거 문서 + +- 원문 제비율 — `resources/knowledge/original/행정규칙/토목공사원가계산 제비율 적용기준/현행_20260413.md` (원본 xlsx 같은 폴더 `첨부/`) +- 원가 서식 · 비목 정의 — `resources/knowledge/original/행정규칙/(계약예규) 예정가격작성기준/현행_20260825.md` §19~22 · `별표/별표2_공사원가계산서.pdf` +- 안전관리비 A · B — `resources/knowledge/original/행정규칙/건설업 산업안전보건관리비 계상 및 사용기준/현행_20250212.md` 44줄 +- 조달수수료 — `resources/knowledge/original/행정규칙/조달수수료 고시/현행_20250701.md` 22-33줄 +- 지식DB 정리 — `resources/knowledge/technical_info/01_임도/05_원가정보/제비율.md` · `원가계산_체계.md` · `original/실무문서/_종합비교/01_원가_제비율.md` +- 실무 엑셀 — 기번3 `2024년 간선임도(기번3-울진.대흥)(공통)/…xlsx` (공사원가계산서 · 총괄설계내역서 49~67) · 소광 `25년 산불진화임도(기번8)(울진소광)/…xlsx` (공사원가계산서) · 거창 `01.산불거창(거창북상병곡)/01.내역서/…xlsx` (원가계산서 · 「26. 1. 1.」 시트) +- 이전 결정 — `docs/raw/plans/2026-09-08_plan_B08_실무자료_해독기록.md` 8-13 · 8-14 (퇴직공제 · 공사이행보증 · 폐기물처리비 「필요」 사용자 확정 · 법정 4종 발견) +- 프로그램 — `B09_Estimation/B09_Estimation_Calc_Total.py` · `.ts` · `B09_Estimation_Rates.py` · `B09_Estimation_UI_Tabs_Total.ts` · `B09_Estimation_Store.py` 41-52 · `resources/master_data/요율_조달청제비율.json` From 7cd083a2d7053ffc50f65924b892235f54e5ac0b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:25:06 +0900 Subject: [PATCH 05/38] =?UTF-8?q?feat(B09):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=E3=80=8C=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=90=EA=B2=80?= =?UTF-8?q?=E3=80=8D=20=EB=AC=B6=EC=9D=8C=20=E2=80=94=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=C2=B7=20=EC=84=A4=EB=AA=85=20=EC=A7=80=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?[=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8]=20=C2=B7=20=EB=82=A1?= =?UTF-8?q?=EC=9D=8C=20=EC=A0=90=20=EB=B0=B0=EC=A7=80=20=C2=B7=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=A0=90=EA=B2=80=20=ED=83=AD=EC=9D=84=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EB=AA=A9=EB=A1=9D=EC=9C=BC=EB=A1=9C(203-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 묶음 이름 「원가 단가」 → 「데이터 점검」 · 굳힌 시각 안내 글 지움(공용 호버로) - [단가 갱신] → [업데이트] · 서버 book_stale(지금 M01 로 굳힌 책과 견줌) 까닭 있으면 점 배지 - 「연결 점검」 탭 = 좌측 목록(누르면 그 탭 그 줄 · 까닭 = 공용 호버) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서 - test_203_1 새로 · test_144 · test_181_1 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B09_Estimation/B09_Estimation_Prices.py | 18 +++ B09_Estimation/B09_Estimation_Router.py | 2 + B09_Estimation/B09_Estimation_UI_Check.ts | 93 +++++++++++++++ B09_Estimation/B09_Estimation_UI_Page.css | 31 +++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 24 ++-- B09_Estimation/B09_Estimation_UI_Tabs.ts | 41 +------ B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 4 +- .../B09_Estimation_UI_Tabs_Sangeun.ts | 2 +- .../B09_Estimation_UI_Tabs_Summary.ts | 2 +- resources/tester/test_144_b09_skeleton.py | 5 +- resources/tester/test_181_1_b09_tab_groups.py | 31 +++-- resources/tester/test_203_1_b09_data_check.py | 112 ++++++++++++++++++ 12 files changed, 290 insertions(+), 75 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Check.ts create mode 100644 resources/tester/test_203_1_b09_data_check.py diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 87badd217..e8dd8d916 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -15,6 +15,7 @@ from __future__ import annotations +import json from datetime import datetime from decimal import Decimal from typing import Any @@ -197,6 +198,23 @@ def refresh_book(keys: list[str]) -> dict[str, Any]: } +def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: + """203-1 [업데이트] 배지 — 지금 M01 로 새로 굳힌 책(`refresh_book`)과 견줌 · 다른 것 한 줄(같으면 None).""" + if book is None: + return "단가 아직 안 굳힘" + fresh = json.loads(json.dumps(refresh_book(keys), ensure_ascii=False)) # 저장 꼴(JSON)로 견줌 + rows = book.get("rows") or {} + new = sum(key not in rows for key in fresh["rows"]) + changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items()) + parts = [ + f"새 단가 줄 {new}" if new else "", + f"M01 에서 바뀐 단가 {changed}" if changed else "", + "운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "", + "환율 바뀜" if book.get("fx") != fresh["fx"] else "", + ] + return " · ".join(p for p in parts if p) or None + + def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]: """[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘.""" if book is None: diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index b4a7d7185..0c36b0627 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -121,6 +121,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: "inputs": inputs, "user": user, "book": {"made": book.get("made")} if book else None, + # 203-1 [업데이트] 배지 까닭(지금 M01 과 다른 것 한 줄 · 같으면 None) + "stale": await asyncio.to_thread(prices.book_stale, book, keys), "sheets": sheets, "rate_candidates": rate_candidates(), "rate_table": rate_table(), diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts new file mode 100644 index 000000000..5bb00b5e9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B09_Estimation_UI_Check.ts + * B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [업데이트] · 연결 점검(옛 「연결 점검」 탭 내용). + * + * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) + * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 + * 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 + * ========================================================================== */ + +import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; + +export interface CheckItem { + link: Link; + tab: string; + name: string; + why: string; +} + +/** 빠진 연결 줄 · 알림 글(서버 알림 + M01 에 없는 키) */ +export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; notes: string[] } { + const gaps: Row[] = sheets.gaps || []; + const items = gaps.map((g) => ({ + link: { tab: g.tab as TabId, key: g.key }, + tab: tabName(g.tab), + name: String(g.name ?? ""), + why: String(g.why ?? ""), + })); + const notes: string[] = [...(payload.notes || [])]; + const missing: string[] = payload.inputs?.missing || []; + if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); + return { items, notes }; +} + +/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */ +export function refreshTip(payload: Row | null): string { + const made = payload?.book?.made; + const when = made ? `단가 굳힘 ${String(made).replace("T", " ")}` : "단가 아직 안 굳힘"; + const stale = payload?.stale; + return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; +} + +export interface CheckPanel { + /** 묶음 안 연결 점검 자리 */ + list: HTMLElement; + draw: (payload: Row, sheets: Row) => void; +} + +export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => void): CheckPanel { + let payload: Row | null = null; + let items: CheckItem[] = []; + attachHoverInfo(refresh, () => refreshTip(payload)); + const list = document.createElement("div"); + list.className = "b09-check"; + attachHoverInfo(list, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); + + const draw = (next: Row, sheets: Row): void => { + payload = next; + refresh.classList.toggle("is-pending", Boolean(next.stale)); + const made = checkItems(sheets, next); + items = made.items; + const head = document.createElement("p"); + head.className = "b09-check__head"; + head.textContent = `연결 없음 ${items.length}곳`; + const rows = document.createElement("div"); + rows.className = "ui-sidebar-list"; + rows.append( + ...items.map((item, i) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = "ui-sidebar-row"; + row.dataset.i = String(i); + const name = document.createElement("span"); + name.textContent = item.name; + const tab = document.createElement("span"); + tab.className = "b09-check__tab"; + tab.textContent = item.tab; + row.append(name, tab); + row.addEventListener("click", () => goto(item.link)); + return row; + }), + ); + const notes = made.notes.map((text) => { + const p = document.createElement("p"); + p.className = "b09-check__note"; + p.textContent = text; + return p; + }); + list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes); + }; + return { list, draw }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index f5fbd8357..60c580bb2 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -209,3 +209,34 @@ .b09-estimation .ui-data-table__table tbody td.is-edited { background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); } + +/* 203-1 좌측 「데이터 점검」 — [업데이트] 필요 = 단추 위 점 배지(B05 [3D 업데이트] 꼴) */ +.b09-options .ui-btn.is-pending { + position: relative; + border-color: var(--color-warning); +} + +.b09-options .ui-btn.is-pending::after { + content: ""; + position: absolute; + top: 4px; + right: 4px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-warning); +} + +/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */ +.b09-check__head, +.b09-check__note { + margin: var(--spacing-8) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b09-check__tab { + flex: none; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index fbc860214..4d027b121 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -4,7 +4,7 @@ * * 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄 * 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) + - * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices + * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [업데이트] = POST …/prices * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * ========================================================================== */ @@ -31,9 +31,9 @@ import { computeEstimation } from "./B09_Estimation_Calc_Bill"; import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; import { computeTotal } from "./B09_Estimation_Calc_Total"; import { createTabBars } from "./B09_Estimation_UI_TabBar"; +import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { - buildCheck, rowIndex, TABS, type Link, @@ -70,7 +70,6 @@ function L(key: keyof typeof ui_locales): string { const LATER: Record = {}; const BUILDERS: Partial> = { - check: buildCheck, dg_list: buildDaegaList, dg: buildDaegaBlocks, bill: buildBill, @@ -152,7 +151,7 @@ export function renderB09Estimation(root: HTMLElement): void { let payload: Row | null = null; let user: Row = {}; let sheets: Row = {}; - let current: TabId = "check"; + let current: TabId = TABS[0][0]; // 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar` const bars = createTabBars((tab) => show(tab)); @@ -163,11 +162,10 @@ export function renderB09Estimation(root: HTMLElement): void { body.className = "b09-estimation"; body.append(bars.top, bars.sub, panel); - const made = document.createElement("p"); - made.className = "b09-estimation__hint"; - const recompute = (): void => { - if (payload) sheets = computeSheets(payload, user); + if (!payload) return; + sheets = computeSheets(payload, user); + check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지 }; const setUser = (next: Row): void => { @@ -266,9 +264,6 @@ export function renderB09Estimation(root: HTMLElement): void { const draft = projectId && !fromServer ? readState("b09-estimation", projectId) : null; user = draft ?? next.user ?? {}; recompute(); - made.textContent = next.book?.made - ? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로` - : "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘"; show(current); }; @@ -297,12 +292,13 @@ export function renderB09Estimation(root: HTMLElement): void { }); save.dataset.b09Save = ""; const refresh = createButton({ - label: "단가 갱신", + label: "업데이트", variant: "ghost", onClick: () => void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."), }); refresh.dataset.b09Refresh = ""; + const check = createCheckPanel(refresh, (link) => show(link.tab, link.key)); // 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역 const reset = createButton({ label: "내역 초기화", @@ -322,8 +318,8 @@ export function renderB09Estimation(root: HTMLElement): void { section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; const heading = document.createElement("h3"); heading.className = "ui-collapsible__title"; - heading.textContent = "원가 단가"; - section.append(heading, made, refresh); + heading.textContent = "데이터 점검"; + section.append(heading, refresh, check.list); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; actions.append(save, reset); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 8f68f8ead..5f6eca99d 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -2,9 +2,10 @@ * B09_Estimation_UI_Tabs.ts * B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례). * - * 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷 + * 탭 = 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷 * 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`) - * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만 + * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴만 + * 연결 점검(옛 첫 탭) = 좌측 「데이터 점검」 묶음(203-1 `…_UI_Check`) * ========================================================================== */ import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; @@ -12,7 +13,6 @@ import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; export type TabId = - | "check" | "cost" | "total" | "bill" @@ -34,7 +34,6 @@ export type TabId = /** 엑셀 탭 차례 — [id, 탭 이름] */ export const TABS: Array<[TabId, string]> = [ - ["check", "연결 점검"], ["cost", "공사원가계산서"], ["total", "총괄설계내역서"], ["bill", "설계내역서"], @@ -59,7 +58,6 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ - ["연결 점검", ["check"]], ["공사원가계산서", ["cost"]], ["총괄설계내역서", ["total"]], ["설계내역서", ["bill"]], @@ -156,36 +154,3 @@ export function parseNumber(value: string): number | null | undefined { export function rowIndex(rows: DataRow[], key: string): number { return rows.findIndex((row) => row.key === key); } - -/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */ - -export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => { - const gaps: Row[] = sheets.gaps || []; - const rows: DataRow[] = gaps.map((g, i) => ({ - no: i + 1, - key: `${g.tab}|${g.key}|${i}`, - tab: tabName(g.tab), - name: g.name, - why: g.why, - })); - const notes: string[] = [...(payload.notes || [])]; - const missing: string[] = payload.inputs?.missing || []; - if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); - return { - columns: [ - qty("no", "번호", 0), - text("tab", "탭", 150), - text("name", "명칭", 260), - text("why", "까닭", 320), - ], - rows, - frozen: 1, - hint: - `연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` + - (notes.length ? ` · ${notes.join(" · ")}` : ""), - link: (i) => { - const g = gaps[i]; - return g ? { tab: g.tab as TabId, key: g.key } : null; - }, - }; -}; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index e16abf10f..f1a978755 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -51,14 +51,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) => priceList( sheets.labor || [], "단가(원/인)", - `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`, + `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`, ); export const buildExpense: TabBuild = ({ sheets, payload }) => priceList( sheets.expense || [], "기준가(천원)", - `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`, + `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`, ); function madeText(payload: Row): string { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 1cf80dc9f..f98ab1cfa 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -1,7 +1,7 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Sangeun.ts * B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun` - * (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈). + * (서버 단독 · 이 탭엔 조작 없음 · [저장] · [업데이트] 때 서버가 다시 셈). * * 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록 * 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 · diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index 27d21fa0b..5fd4fba08 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -24,7 +24,7 @@ const HINT = /** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */ const hint = (payload: Row, extra = ""): string => { const n = (payload.summary || {}).unlinked || 0; - return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(연결 점검 탭)` : ""}`; + return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`; }; /** 줄이 없을 때 표 위 맨 앞 안내 */ diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py index 5c652cd11..7b4409486 100644 --- a/resources/tester/test_144_b09_skeleton.py +++ b/resources/tester/test_144_b09_skeleton.py @@ -1,6 +1,6 @@ """144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기. -- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김) +- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 좌측 「데이터 점검」 묶음 203-1 · INITIAL 안 옮김) - 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함 - 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표 · B08 자재만 줄 · 출처 빈칸 @@ -45,8 +45,7 @@ def test_탭_차례가_엑셀과_같다(): source.index("export const TABS") : source.index("];", source.index("export const TABS")) ] names = re.findall(r'\["\w+", "([^"]+)"\]', block) - assert names[0] == "연결 점검" - assert names[1:] == EXCEL_TABS + assert names == EXCEL_TABS # 203-1 「연결 점검」 탭 = 좌측 묶음으로 옮김 def test_길_셋(): diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py index c1e288330..659be45e6 100644 --- a/resources/tester/test_181_1_b09_tab_groups.py +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -1,6 +1,6 @@ -"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. +"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. -- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 +- 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 - 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴) """ @@ -18,7 +18,6 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts" # 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례 EXPECTED = [ - ["연결 점검", ["check"]], ["공사원가계산서", ["cost"]], ["총괄설계내역서", ["total"]], ["설계내역서", ["bill"]], @@ -31,7 +30,7 @@ EXPECTED = [ ] -def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩(): +def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩(): out = run( [TABS], "\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t)," @@ -39,11 +38,11 @@ def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩(): ) assert out["groups"] == EXPECTED flat = [t for _, tabs in out["groups"] for t in tabs] - assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19 + assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 18 # 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리 firsts = list(dict.fromkeys(out["of"])) assert firsts == [g[0] for g in EXPECTED] - assert len(firsts) == 10 + assert len(firsts) == 9 def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭(): @@ -61,26 +60,26 @@ const view = () => ({ }); bars.draw('dg'); const a = view(); -bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 +bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 bars.sub.children[0].click(); // 일위대가목록표 bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐 const b = view(); bars.draw('sum_material'); bars.draw('dg'); -bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) -bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭 +bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) +bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭 bars.draw('cost'); const c = view(); -bars.top.children[0].click(); // 연결 점검 = 단독 위 탭 +bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭 bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음 console.log(JSON.stringify({ a, b, c, picks })); """, ) a, b, c = out["a"], out["b"], out["c"] - assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [ - "연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가" + assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [ + "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출" ] # fmt: skip - assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5 + assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5 assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false") assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]] assert not a["blank"] and a["role"] == "tablist" @@ -89,11 +88,11 @@ console.log(JSON.stringify({ a, b, c, picks })); ["자재단가대비표", "true", "compare"], ["재료비수량금액집계표", "false", "sum_material"], ] # fmt: skip - assert b["top"][8][2] == "true" and b["top"][4][2] == "false" + assert b["top"][7][2] == "true" and b["top"][3][2] == "false" # 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개 assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1 - assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None - assert out["picks"] == ["dg_list", "sum_material", "machine", "check"] + assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None + assert out["picks"] == ["dg_list", "sum_material", "machine", "total"] def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙(): diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py new file mode 100644 index 000000000..2beecbcb5 --- /dev/null +++ b/resources/tester/test_203_1_b09_data_check.py @@ -0,0 +1,112 @@ +"""203-1 B09 좌측 「데이터 점검」 묶음(사용자 60번). + +- 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버) +- [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending) +- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서 +""" + +import json +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 import B09_Estimation_Prices as prices # noqa: E402 +from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402 + +CHECK = "B09_Estimation/B09_Estimation_UI_Check.ts" +SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + + +def test_묶음_이름_단추_글_설명_없음(): + assert 'heading.textContent = "데이터 점검";' in SHELL and "원가 단가" not in SHELL + assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL + # 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검 + assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL + assert "section.append(heading, refresh, check.list);" in SHELL + assert "check.draw(payload, sheets);" in SHELL + + +def test_연결_점검_탭_없음_첫_탭_공사원가계산서(): + out = run( + [TABS], + "\nconsole.log(JSON.stringify({ ids: TABS.map(([t]) => t), names: TABS.map(([, n]) => n)," + " groups: TAB_GROUPS.map(([n]) => n), first: TABS[0][0] }));", + ) + assert "check" not in out["ids"] and "연결 점검" not in out["names"] + out["groups"] + assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서" + assert "let current: TabId = TABS[0][0];" in SHELL + assert "buildCheck" not in SHELL and "check:" not in SHELL + + +def test_좌측_목록_이동_호버_배지(): + out = run( + [TABS, CHECK], + """ +const moves = []; +const refresh = document.createElement("button"); +const panel = createCheckPanel(refresh, (link) => moves.push(link)); +const sheets = { gaps: [ + { tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" }, + { tab: "material", key: "MT9", name: "모래", why: "단가 없음" }, +] }; +const payload = { book: { made: "2026-10-01T09:00:00" }, stale: "M01 에서 바뀐 단가 2", + notes: ["B08 자재 집계표 못 읽음"], inputs: { missing: ["EQ1", "LB2"] } }; +panel.draw(payload, sheets); +const view = () => ({ + pending: refresh.classList.contains("is-pending"), + texts: panel.list.children.map((c) => c.text), + rows: (panel.list.children[1]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text]), +}); +const a = view(); +const rowsBox = panel.list.children[1]; +rowsBox.children[1].click(); +const tipRefresh = hoverCalls.find((h) => h.root === refresh).text(); +const rowHover = hoverCalls.find((h) => h.root === panel.list); +const tipRow = rowHover.text(rowsBox.children[0]); +panel.draw({ book: null, stale: null }, { gaps: [] }); +const b = view(); +const tipEmpty = refreshTip({ book: null, stale: "단가 아직 안 굳힘" }); +console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, selector: rowHover.selector, tipEmpty })); +""", + ) + a, b = out["a"], out["b"] + assert a["pending"] and not b["pending"] + assert a["texts"][0] == "연결 없음 2곳" + assert a["rows"] == [ + ["0", "ui-sidebar-row", "찰쌓기일위대가표"], + ["1", "ui-sidebar-row", "모래재료비목록표"], + ] + assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"] + assert out["moves"] == [{"tab": "material", "key": "MT9"}] + assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]" + assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"] + assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"] + assert b["texts"] == ["연결 없음 0곳"] and b["rows"] == [] + assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"] + + +def test_배지_조건_서버_book_stale(): + keys = ["EQ000004"] # 불도저 · 조종원 · 경유(test_144 와 같은 견본) + book = json.loads(json.dumps(prices.refresh_book(keys), ensure_ascii=False)) + assert prices.book_stale(book, keys) is None # 방금 굳힌 책 = 업데이트 필요 없음 + assert prices.book_stale(None, keys) == "단가 아직 안 굳힘" + first, *rest = list(book["rows"]) + old = {**book, "rows": {k: book["rows"][k] for k in rest}, "fx": []} + old["rows"][rest[0]] = {**old["rows"][rest[0]], "price": -1} + old["driver_factor"] = {"num": 1, "den": 1, "text": "1/1"} + assert prices.book_stale(old, keys) == ( + "새 단가 줄 1 · M01 에서 바뀐 단가 1 · 운전사 계수 바뀜 · 환율 바뀜" + ) + router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8") + assert '"stale": await asyncio.to_thread(prices.book_stale, book, keys),' in router + + +def test_배지_꼴_경고_색_토큰(): + css = (ROOT / "B09_Estimation/B09_Estimation_UI_Page.css").read_text("utf-8") + block = css[css.index(".b09-options .ui-btn.is-pending::after") :] + block = block[: block.index("}")] + assert "position: absolute" in block and "border-radius: 50%" in block + assert "background: var(--color-warning)" in block From d1f7a0d76efea8b6f811b3e0f70729b8edf907be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:25:18 +0900 Subject: [PATCH 06/38] =?UTF-8?q?feat(B08=C2=B7B09):=20=ED=91=9C=20?= =?UTF-8?q?=EC=B9=B8=20=EC=95=88=EB=82=B4=20=C2=B7=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EC=A4=84=20=EB=A7=A8=20=EC=95=84=EB=9E=98=20=C2=B7=20=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EA=B0=80=EB=A1=9C=20?= =?UTF-8?q?=EC=97=B4=20=EC=88=98=20=EB=B3=B4=EC=A1=B0=20=ED=83=AD=20?= =?UTF-8?q?=EC=A4=84=20=EC=98=A4=EB=A5=B8=EC=AA=BD(202-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 4 +- B08_Quantity/B08_Quantity_UI_Material.ts | 4 +- B08_Quantity/B08_Quantity_UI_Page.css | 2 +- B08_Quantity/B08_Quantity_UI_Summary.ts | 3 +- B09_Estimation/B09_Estimation_UI_Page.css | 24 +-- B09_Estimation/B09_Estimation_UI_Shell.ts | 14 +- B09_Estimation/B09_Estimation_UI_TabBar.ts | 13 +- .../B09_Estimation_UI_Tabs_Groups.ts | 20 +- resources/tester/helper_202_1_fill_below.cjs | 84 +++++++++ .../tester/test_143_3_b08_material_cards.py | 6 +- resources/tester/test_171_3_b09_group_tabs.py | 16 +- resources/tester/test_181_1_b09_tab_groups.py | 2 +- resources/tester/test_181_2_b09_box_pick.py | 2 +- resources/tester/test_185_2_b09_view_bar.py | 43 +++-- .../tester/test_200_1_b09_box_tool_line.py | 46 ++--- .../tester/test_202_1_table_hint_bottom.py | 172 ++++++++++++++++++ ui_template/ui_template_group_cards.ts | 20 +- 17 files changed, 376 insertions(+), 99 deletions(-) create mode 100644 resources/tester/helper_202_1_fill_below.cjs create mode 100644 resources/tester/test_202_1_table_hint_bottom.py diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index da26b1d54..f8c318187 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -152,13 +152,13 @@ export function note(text: string): HTMLElement { return p; } -/** 표 한 장 — 공용 컨테이너(카드) 안에 설명 · 공용 표 · 아래 줄 */ +/** 표 한 장 — 공용 컨테이너(카드) 안에 공용 표 · 아래 줄 · 맨 아래 안내 · 경고 줄(202-1) */ export function tableCard( info: HTMLElement[], table: HTMLElement, after: HTMLElement[] = [], ): HTMLElement { - return createCard({ body: [...info, table, ...after] }); + return createCard({ body: [table, ...after, ...info] }); } function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow { diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 5316e7f84..0f148cd02 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -356,7 +356,7 @@ export async function renderMaterialSheet( const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시 concreteNote.dataset.b08MaterialConcreteWhy = ""; if (sheet === "concrete") notes.append(concreteNote); - toolbar.append(notes, save); + toolbar.append(save); let built = BUILD[sheet](sheets, overrides); const table = createDataTable({ @@ -395,7 +395,7 @@ export async function renderMaterialSheet( concreteNote.hidden = !concreteNote.textContent; }; redraw(); - host.replaceChildren(createCard({ body: [toolbar, table.root, excel] })); + host.replaceChildren(createCard({ body: [toolbar, table.root, excel, notes] })); // 202-1 안내 · 경고 = 맨 아래 onOptionsChange(host, () => { sheets = calc(); redraw(); diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index f1c338629..cf2275cd9 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -28,7 +28,7 @@ .b08-material__toolbar { display: flex; align-items: flex-start; - justify-content: space-between; + justify-content: flex-end; gap: var(--spacing-16); margin-bottom: var(--spacing-8); } diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index b54932b76..ba9a28b1e 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -281,8 +281,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin ) : note(L("B08_Quantity_Summary_NoStation")); let table = draw(); - parts.push(table); - host.replaceChildren(createCard({ body: parts })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3) + host.replaceChildren(createCard({ body: [table, ...parts] })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3) · 안내 · 경고 = 표 아래(202-1) onOptionsChange(host, () => { const next = draw(); table.replaceWith(next); diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 27be4e313..3b0313338 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -36,30 +36,30 @@ overflow-wrap: anywhere; } -/* 171-3 묶음 탭 머리 — 안내 글 + 보기 단추 */ -.b09-estimation__bar { +/* 202-1 보조 탭 줄 오른쪽 도구(박스보기 · 가로 열 수) — 빈 보조 줄에서도 보임 · 단추 · 글 한 줄 */ +.b09-estimation__tools { display: flex; - align-items: flex-start; - justify-content: space-between; + flex: none; + flex-wrap: nowrap; + align-items: center; gap: var(--spacing-8); + margin-left: auto; } -/* 185-2 보기 단추 글 한 줄 */ -.b09-estimation__bar .ui-btn { +.b09-estimation__tools .ui-btn, +.b09-estimation__tools .ui-group-cards__label { flex: none; white-space: nowrap; } -/* 200-1 묶음 탭 도구 줄 — 안내 글 = 남는 자리 한 줄 · 넘치면 줄임표(전문 = 공용 호버) */ -.b09-estimation__bar { - flex-wrap: nowrap; - align-items: center; +/* 202-1 안내 줄 = 표 · 카드 판 아래(묶음 탭은 한 줄 줄임표 · 전문 = 공용 호버) */ +.b09-estimation .ui-data-table + .ui-general-block__empty, +.b09-estimation .ui-group-cards + .ui-general-block__empty { + margin: var(--spacing-8) 0 0; } .b09-estimation__line-hint { - flex: 1 1 0; min-width: 0; - margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index cc619ea1f..8b06c0a4d 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -184,6 +184,7 @@ export function renderB09Estimation(root: HTMLElement): void { const show = (tab: TabId, focus?: string): void => { current = tab; bars.draw(tab); + bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1) if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); const build = BUILDERS[tab]; @@ -246,11 +247,20 @@ export function renderB09Estimation(root: HTMLElement): void { if (isGroupTab(tab)) { const redraw = (): void => show(tab); return void panel.replaceChildren( - ...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, redraw }), + ...renderGroupPanel({ + tab, + spec, + hint, + focus, + goto, + makeTable, + redraw, + setTools: bars.setTools, + }), ); } const table = makeTable(); - panel.replaceChildren(...(hint ? [hint] : []), table.root); + panel.replaceChildren(table.root, ...(hint ? [hint] : [])); // 202-1 안내 줄 = 표 아래 if (focus !== undefined) { const index = rowIndex(spec.rows, focus); if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); diff --git a/B09_Estimation/B09_Estimation_UI_TabBar.ts b/B09_Estimation/B09_Estimation_UI_TabBar.ts index c80caaf7e..3ce0e2a2f 100644 --- a/B09_Estimation/B09_Estimation_UI_TabBar.ts +++ b/B09_Estimation/B09_Estimation_UI_TabBar.ts @@ -5,6 +5,7 @@ * 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움) * 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭 * 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐 + * 보기 단추 · 가로 열 수(202-1) = 아래 줄 오른쪽 맞춤 자리(`setTools`) · 빈 줄에서도 보임 * ========================================================================== */ import { createButton } from "@ui/ui_template_elements"; @@ -15,6 +16,8 @@ export interface TabBars { sub: HTMLElement; /** 이 탭이 켜진 꼴로 두 줄을 다시 그림 */ draw: (tab: TabId) => void; + /** 아래 줄 오른쪽 도구(박스보기 · 가로 열 수) — 빈 목록 = 비움 */ + setTools: (tools: HTMLElement[]) => void; } function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) { @@ -35,6 +38,8 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars { top.setAttribute("role", "tablist"); const sub = document.createElement("div"); sub.className = "b09-estimation__tabs"; + const tools = document.createElement("div"); + tools.className = "b09-estimation__tools"; const lastSeen = new Map(); const draw = (tab: TabId): void => { @@ -53,11 +58,11 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars { const single = picked.length === 1; sub.classList.toggle("b09-estimation__tabs--blank", single); sub.setAttribute("role", single ? "presentation" : "tablist"); - sub.setAttribute("aria-hidden", String(single)); if (single) { const ghost = createButton({ label: tabName(picked[0]), variant: "ghost", size: "sm" }); ghost.tabIndex = -1; - return void sub.replaceChildren(ghost); + ghost.setAttribute("aria-hidden", "true"); // 도구는 보이는 줄이라 줄 전체를 숨기지 않음 + return void sub.replaceChildren(ghost, tools); } sub.replaceChildren( ...picked.map((id) => { @@ -65,7 +70,9 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars { button.dataset.b09Tab = id; return button; }), + tools, ); }; - return { top, sub, draw }; + const setTools = (items: HTMLElement[]): void => tools.replaceChildren(...items); + return { top, sub, draw, setTools }; } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 666f0ea42..313a30d7a 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -7,7 +7,8 @@ * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 - * 도구 줄(200-1) = 안내 글 + 가로 열 수 + 보기 단추 한 줄 · 안내 글 길면 줄임표 + 공용 호버로 전문 + * 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 + * 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1) * ========================================================================== */ import type { DataRow, DataTable } from "@ui/ui_template_data_table"; @@ -176,7 +177,7 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine /* ── 그리기 ───────────────────────────────────────────────────────────── */ -/** 안내 글 = 도구 줄 한 칸(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */ +/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */ function lineHint(hint: HTMLElement | null): HTMLElement | null { if (!hint) return null; hint.classList.add("b09-estimation__line-hint"); @@ -187,7 +188,7 @@ function lineHint(hint: HTMLElement | null): HTMLElement | null { /** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ const chosen = new Map(); -/** 묶음 탭 — 카드 = 카드 판(안내 · 가로 열 수 · 보기 단추 한 줄) · 표 = 머리(안내 + 보기 단추 한 줄) + 표 */ +/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */ export function renderGroupPanel(o: { tab: TabId; spec: TabTable; @@ -196,6 +197,7 @@ export function renderGroupPanel(o: { goto: (link: Link | null) => void; makeTable: (rowClass?: (index: number) => string | null) => DataTable; redraw: () => void; + setTools: (tools: HTMLElement[]) => void; }): HTMLElement[] { const view = chosen.get(o.tab) ?? readGroupView(o.tab); const lead = lineHint(o.hint); @@ -221,17 +223,15 @@ export function renderGroupPanel(o: { focus: at, money: numberFormat(o.spec, "sum"), qty: numberFormat(o.spec, "qty"), - lead, // 200-1 안내 글도 같은 줄 - actions: [toggle], // 185-2 가로 열 수 단추와 한 줄 + actions: [toggle], + placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽 }); cards.dataset.b09Table = o.tab; - return [cards]; + return [cards, ...(lead ? [lead] : [])]; } - const bar = document.createElement("div"); - bar.className = "b09-estimation__bar"; - bar.append(...(lead ? [lead] : []), toggle); + o.setTools([toggle]); const range = pickRange(o.spec.rows, o.focus); const table = o.makeTable((i) => pickClass(range, i)); if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); - return [bar, table.root]; + return [table.root, ...(lead ? [lead] : [])]; } diff --git a/resources/tester/helper_202_1_fill_below.cjs b/resources/tester/helper_202_1_fill_below.cjs new file mode 100644 index 000000000..73fd59feb --- /dev/null +++ b/resources/tester/helper_202_1_fill_below.cjs @@ -0,0 +1,84 @@ +/* 202-1 — 표 칸 아래로 간 안내 줄 몫이 창 끝까지 셈(`ui_template_data_table.ts` fillViewport)에 들어가는지 단위검증 헬퍼. + * 카드 본문 = [표 칸][아래 줄][안내 줄 · 안내 줄 …](B08 `tableCard` 꼴) 가짜 DOM 에서 돌리고 JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +const win = { innerHeight: 900, scrollY: 0, addEventListener() {}, removeEventListener() {} }; +const observers = []; +class RO { + constructor(fn) { + this.fn = fn; + observers.push(this); + } + observe() {} + disconnect() {} +} +const el = (name, css = {}, height = 0) => ({ + name, + css, + height, + top: 0, + parentElement: null, + nextElementSibling: null, + isConnected: true, + style: {}, + getBoundingClientRect() { + return { top: this.top, height: this.height }; + }, +}); +function link(parent, ...kids) { + kids.forEach((k, i) => { + k.parentElement = parent; + k.nextElementSibling = kids[i + 1] ?? null; + }); + return parent; +} +const sty = (n) => ({ + rowGap: n.css.rowGap ?? "normal", + paddingBottom: n.css.paddingBottom ?? "0px", + borderBottomWidth: n.css.borderBottomWidth ?? "0px", + marginBottom: n.css.marginBottom ?? "0px", +}); +const box = { exports: {} }; +new Function("exports", "module", "require", "window", "document", "getComputedStyle", "ResizeObserver", js)( + box.exports, + box, + (p) => (p.endsWith(".css") ? {} : require(p)), + win, + { body: null }, + sty, + RO, +); +const m = box.exports; + +// 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [root][avg 20][info 30][hint 40] +const body = el("body", { paddingBottom: "8px" }); +const card = el("card", { paddingBottom: "12px", rowGap: "8px" }); +const root = el("root"); +const avg = el("avg", {}, 20); +const info = el("info", {}, 30); +const hint = el("hint", {}, 40); +link(body, card); +link(card, root, avg, info, hint); +root.top = 200; + +m.fillViewport(root); +observers.forEach((o) => o.fn()); +const out = { first: parseFloat(root.style.maxHeight) }; +out.below = m.spaceBelow(root); +out.belowExpected = 3 * 8 + 20 + 30 + 40 + 12 + 8; // 줄 사이 8 셋 · 아래 줄 · 안내 둘 · 카드 · 본문 여백 +out.bottom = root.top + out.first + out.below; // 문서 맨 아래 = 창 아래 끝(페이지 스크롤 0) +out.viewport = win.innerHeight; + +// 안내 줄이 두 줄로 늘어남 → 표 칸이 그만큼 줄어듦 +hint.height = 60; +observers.forEach((o) => o.fn()); +out.afterHintGrow = parseFloat(root.style.maxHeight); +out.firstMinusGrow = out.first - 20; +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_143_3_b08_material_cards.py b/resources/tester/test_143_3_b08_material_cards.py index b21cffa34..5253fcfa4 100644 --- a/resources/tester/test_143_3_b08_material_cards.py +++ b/resources/tester/test_143_3_b08_material_cards.py @@ -27,5 +27,7 @@ def test_자재_셋_값_없는_줄_숨김(): def test_표_넷_공용_카드_안(): - assert "createCard({ body: [toolbar, table.root, excel] })" in MATERIAL # + 엑셀 알림(160-4) - assert "createCard({ body: parts })" in SUMMARY + assert ( + "createCard({ body: [toolbar, table.root, excel, notes] })" in MATERIAL + ) # + 엑셀 알림(160-4) · 안내 = 아래(202-1) + assert "createCard({ body: [table, ...parts] })" in SUMMARY diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index aaee869e7..4ac6abbc9 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -142,21 +142,21 @@ def test_화면_보기_바꿈_누름_이동(): out = run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" - "\nconst gone = []; let redraws = 0; const focused = [];" + "\nconst gone = []; let redraws = 0; const focused = []; const calls = [];" "\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: (l) => gone.push(l)," - " redraw: () => redraws++, makeTable: () => ({ root: document.createElement('table')," + " redraw: () => redraws++, setTools: (t) => calls.push(t), makeTable: () => ({ root: document.createElement('table')," " focusRow: (i) => focused.push(i) }) };" "\nconst [view] = renderGroupPanel(o);" - "\nconst btn = view.querySelector('[data-b09-group-view]');" + "\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);" "\nconst label = btn.textContent;" "\nview.querySelector('[data-at=\"2\"]').click();" "\nview.querySelector('[data-at=\"0\"]').click();" "\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');" "\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');" "\nbtn.click();" - "\nconst [bar2, view2] = renderGroupPanel(o);" + "\nconst [view2] = renderGroupPanel(o);" "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," - " label2: bar2.children[0].textContent, tag2: view2.tagName, focused," + " label2: calls[1].find((e) => e.dataset.b09GroupView).textContent, tag2: view2.tagName, focused," " kept: Object.fromEntries(kept) }));", RAW, ) @@ -171,7 +171,11 @@ def test_화면_보기_바꿈_누름_이동(): def test_잇기(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "if (isGroupTab(tab)) {" in shell and "renderGroupPanel({ tab, spec," in shell + assert ( + "if (isGroupTab(tab)) {" in shell + and "renderGroupPanel({" in shell + and "setTools: bars.setTools" in shell + ) state = (ROOT / "A00_Common" / "b_page_state.ts").read_text("utf-8") assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state assert '"b09-group-cols": { bucket: "pref", scope: "global" },' in state diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py index c1e288330..1752de606 100644 --- a/resources/tester/test_181_1_b09_tab_groups.py +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -55,7 +55,7 @@ const bars = createTabBars((t) => picks.push(t)); const view = () => ({ top: bars.top.children.map((b) => [b.textContent, b.className, b.attrs['aria-selected'], b.dataset.b09Group, b.dataset.b09Tab ?? null]), - sub: bars.sub.children.map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]), + sub: bars.sub.children.filter((b) => !b.classList.contains('b09-estimation__tools')).map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]), blank: bars.sub.classList.contains('b09-estimation__tabs--blank'), role: bars.sub.attrs.role, }); diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py index 05d14ce34..f078bc23f 100644 --- a/resources/tester/test_181_2_b09_box_pick.py +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -49,7 +49,7 @@ def test_표_보기_줄_class_넘김(): [COMMON, TABS, GROUPS], f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};" "\nlet extra = null;" - "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}," + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}, setTools: () => {}," " makeTable: (e) => { extra = e; return { root: document.createElement('table')," " focusRow: () => {} }; } };" "\nchosen.set('dg', 'table');" diff --git a/resources/tester/test_185_2_b09_view_bar.py b/resources/tester/test_185_2_b09_view_bar.py index f28188e2d..4aebd6501 100644 --- a/resources/tester/test_185_2_b09_view_bar.py +++ b/resources/tester/test_185_2_b09_view_bar.py @@ -1,7 +1,7 @@ -"""185-2 B09 묶음 탭 도구 줄 — 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄. +"""185-2 B09 묶음 탭 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄. -- 카드 보기 = 보기 바꿈 단추가 가로 열 수 줄(`ui-group-cards__bar`) 안 · 표 보기 = 머리 줄(`b09-estimation__bar`) -- 단추 · 라벨 = `white-space: nowrap` · 도구 줄 = 줄바꿈 없음(`flex-wrap: nowrap`) +- 보기 바꿈 단추 · 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만 +- 단추 · 라벨 = `white-space: nowrap` · 도구 묶음 = 줄바꿈 없음(`flex-wrap: nowrap`) """ import json @@ -27,40 +27,43 @@ def _block(css: str, selector: str) -> str: def _panel(view: str, hint: str): - """고른 보기 · 안내 글로 그린 뒤 보기 바꿈 단추가 든 줄 · 그 줄의 열 수 단추.""" + """고른 보기 · 안내 글로 그린 뒤 도구 묶음(보기 바꿈 단추 · 열 수 단추) · 판 안 머리 띠.""" return run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" f"\nchosen.set('dg', '{view}');" + "\nconst tools = [];" f"\nconst o = {{ tab: 'dg', spec, hint: {hint}, goto: () => {{}}, redraw: () => {{}}," + " setTools: (t) => tools.push(...t)," " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };" "\nconst out = renderGroupPanel(o);" - "\nconst top = out.find((e) => e.all('[data-b09-group-view]').length || e.dataset.b09GroupView);" - "\nconst btn = top.dataset.b09GroupView ? top : top.querySelector('[data-b09-group-view]');" - "\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar') ?? top;" + "\nconst btn = tools.find((e) => e.dataset.b09GroupView);" + "\nconst bar = out[0].all('.ui-group-cards__bar')[0] ?? null;" "\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn.textContent," - " bar: bar.className, cols: bar.all('[data-group-cols]').map((b) => b.dataset.groupCols) }));", + " cols: tools.flatMap((e) => e.all('[data-group-cols]')).map((b) => b.dataset.groupCols)," + " barBtn: bar ? bar.all('[data-b09-group-view]').length : null," + " barCols: bar ? bar.all('[data-group-cols]').length : null }));", RAW, ) -def test_카드_보기_단추가_가로_열수_줄에(): +def test_카드_보기_단추가_열수_단추와_같은_도구_묶음에(): out = _panel("cards", "null") - assert out["bar"] == "ui-group-cards__bar" and out["label"] == "표로 보기" - assert out["cols"] == ["1", "2", "3", "4"] # 같은 줄에 열 수 단추 넷 + assert out["label"] == "표로 보기" + assert out["cols"] == ["1", "2", "3", "4"] # 같은 묶음에 열 수 단추 넷 + assert out["barBtn"] == 0 and out["barCols"] == 0 # 카드 판 머리 띠에는 없음 assert out["n"] == 1 # 안내 글 없으면 카드 판 하나뿐 -def test_카드_보기_안내_글은_같은_도구_줄에(): - # 200-1 — 안내 글이 카드 위 따로 줄에서 도구 줄 안으로(상세 = test_200_1) +def test_카드_보기_안내_글은_카드_판_아래에(): + # 202-1 — 안내 글 = 카드 판 아래(상세 = test_202_1) out = _panel("cards", HINT) - assert out["n"] == 1 and out["first"] == "ui-group-cards" - assert out["bar"] == "ui-group-cards__bar" + assert out["n"] == 2 and out["first"] == "ui-group-cards" -def test_표_보기_단추는_머리_줄(): +def test_표_보기_단추는_도구_묶음에(): out = _panel("table", "null") - assert out["bar"] == "b09-estimation__bar" and out["label"] == "박스보기" + assert out["label"] == "박스보기" and out["n"] == 1 assert out["cols"] == [] # 표 보기 = 열 수 단추 없음 @@ -73,5 +76,9 @@ def test_CSS_한_줄_nowrap(): ) assert "white-space: nowrap" in item and "flex: none" in item page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") - assert "white-space: nowrap" in _block(page, ".b09-estimation__bar .ui-btn") + assert "flex-wrap: nowrap" in _block(page, ".b09-estimation__tools") + tools = _block( + page, ".b09-estimation__tools .ui-btn,\n.b09-estimation__tools .ui-group-cards__label" + ) + assert "white-space: nowrap" in tools assert "actions: [toggle]" in (ROOT / GROUPS).read_text("utf-8") diff --git a/resources/tester/test_200_1_b09_box_tool_line.py b/resources/tester/test_200_1_b09_box_tool_line.py index eea635ee6..e2ac75c0f 100644 --- a/resources/tester/test_200_1_b09_box_tool_line.py +++ b/resources/tester/test_200_1_b09_box_tool_line.py @@ -1,8 +1,8 @@ -"""200-1 B09 박스보기 도구 줄 한 줄 · 카드 같은 가로 줄 높이 같게. +"""200-1 B09 박스보기 안내 글 줄임표 + 호버 · 카드 같은 가로 줄 높이 같게. -- 안내 글 · 가로 열 수 단추 · 보기 단추 = 같은 줄(카드 보기 = `ui-group-cards__bar` · 표 보기 = `b09-estimation__bar`) -- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 +- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김) - 카드 격자 = 행 늘림(`align-items: stretch`) · 카드 = 세로 flex · 몸통이 늘어 합계 줄이 아래 +- 카드 제목 줄 · 합계 줄 바탕색(200-3) """ import json @@ -32,43 +32,36 @@ def _block(css: str, selector: str) -> str: def _panel(view: str, hint: str): - """고른 보기 · 안내 글로 그린 뒤 도구 줄 구성 · 호버 기록.""" + """고른 보기 · 안내 글로 그린 뒤 안내 글 자리 · 호버 기록.""" return run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" f"\nchosen.set('dg', '{view}');" f"\nconst hint = {hint};" - "\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}," + "\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}, setTools: () => {}," " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };" "\nconst out = renderGroupPanel(o);" - "\nconst btn = out.flatMap((e) => (e.dataset.b09GroupView ? [e] : e.all('[data-b09-group-view]')))[0];" - "\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar');" - "\nconst h = hint ? bar.all('.b09-estimation__line-hint')[0] : null;" + "\nconst last = out[out.length - 1];" + "\nconst inBar = hint ? out.some((e) => e.all('.ui-group-cards__bar').some((b) => b.contains(hint))) : false;" "\nconst call = hoverCalls[0];" "\nconst wide = call ? call.text({ scrollWidth: 900, clientWidth: 300, textContent: 'T' }) : null;" "\nconst fit = call ? call.text({ scrollWidth: 300, clientWidth: 300, textContent: 'T' }) : null;" - "\nconsole.log(JSON.stringify({ n: out.length, bar: bar.className," - " cols: bar.all('[data-group-cols]').length, hint: !!h, hintInBar: h ? bar.contains(h) : false," - " hintCls: h ? h.className : '', hover: hoverCalls.length, hoverIsHint: call ? call.root === h : false," - " wide, fit, outer: out.map((e) => e.className) }));", + "\nconsole.log(JSON.stringify({ n: out.length, hintLast: !!hint && last === hint, inBar," + " hintCls: hint ? hint.className : '', hover: hoverCalls.length," + " hoverIsHint: call ? call.root === hint : false, wide, fit, first: out[0].tagName }));", RAW, ) -def test_카드_보기_안내_글이_단추와_같은_줄(): +def test_카드_보기_안내_글은_카드_판_아래(): out = _panel("cards", HINT) - assert out["n"] == 1 # 안내 글 따로 줄 없음 · 카드 판 하나 - assert out["bar"] == "ui-group-cards__bar" - assert out["hint"] and out["hintInBar"] # 열 수 단추 · 보기 단추와 같은 도구 줄 안 - assert out["cols"] == 4 + assert out["n"] == 2 and out["hintLast"] and not out["inBar"] # 카드 판 + 안내 글(맨 아래) assert "b09-estimation__line-hint" in out["hintCls"] -def test_표_보기_안내_글이_보기_단추와_같은_줄(): +def test_표_보기_안내_글은_표_아래(): out = _panel("table", HINT) - assert out["n"] == 2 # 머리 줄 + 표 - assert out["bar"] == "b09-estimation__bar" - assert out["hint"] and out["hintInBar"] + assert out["n"] == 2 and out["first"] == "TABLE" and out["hintLast"] # 표 + 안내 글 def test_안내_글_잘렸을_때만_공용_호버로_전문(): @@ -82,7 +75,7 @@ def test_안내_글_잘렸을_때만_공용_호버로_전문(): def test_안내_글_없으면_호버도_없음(): for view in ("cards", "table"): out = _panel(view, "null") - assert out["hint"] is False and out["hover"] == 0 + assert out["n"] == 1 and out["hintLast"] is False and out["hover"] == 0 def test_CSS_안내_글_줄임표_한_줄(): @@ -95,10 +88,6 @@ def test_CSS_안내_글_줄임표_한_줄(): "min-width: 0", ): assert need in hint, need - assert "flex: 1 1 0" in hint - # 표 보기 머리 줄 = 줄바꿈 없음 · 같은 높이 가운데 - bars = re.findall(r"\.b09-estimation__bar\s*\{([^}]*)\}", page) - assert any("flex-wrap: nowrap" in b and "align-items: center" in b for b in bars) def test_CSS_카드_같은_줄_높이_같게(): @@ -112,10 +101,9 @@ def test_CSS_카드_같은_줄_높이_같게(): def test_잇기(): groups = (ROOT / GROUPS).read_text("utf-8") - assert "lead," in groups and "actions: [toggle]" in groups - assert "attachHoverInfo(hint," in groups + assert "attachHoverInfo(hint," in groups and "placeTools: o.setTools" in groups cards = (ROOT / COMMON).read_text("utf-8") - assert "if (opts.lead) bar.append(opts.lead);" in cards + assert "opts.lead" not in cards and "if (opts.placeTools) {" in cards # ── 200-3 카드 제목 줄 · 합계 줄 바탕색(밝음 · 어두움) ───────────────────────── diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py new file mode 100644 index 000000000..8847b62e6 --- /dev/null +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -0,0 +1,172 @@ +"""202-1 B08 · B09 표 칸 — 안내 · 경고 줄 = 맨 아래 · 박스보기 · 가로 열 수 = 보조 탭 줄 오른쪽. + +- B09 안내 줄 = 표 · 카드 판 아래(마지막 자식) · 묶음 탭 도구(보기 단추 · 열 수) = 아래 탭 줄 오른쪽 · 단독 탭 빈 줄에서도 +- B08 안내 · 경고 줄 = 표 · 아래 줄 뒤 맨 아래(토공 · 운반 시트 · 자재 · 구조물 집계) +- 창 끝까지 셈(199-1) = 아래로 간 안내 줄 몫 포함 +""" + +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 resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 + +B09 = "B09_Estimation/B09_Estimation_UI_" +BAR = f"{B09}TabBar.ts" +DG = {"id": "DG1", "no": 1, "name": "찰쌓기", "unit": "m2", "lines": [], "sum": 0} + + +def _block(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +# ── B09 보조 탭 줄 오른쪽 도구 ────────────────────────────────────────────── + + +def test_도구_자리_보조_줄_오른쪽_빈_줄에서도_보임(): + out = run( + [TABS, BAR], + """ +const bars = createTabBars(() => {}); +const mk = (n) => { const b = document.createElement('button'); b.textContent = n; return b; }; +const snap = () => ({ + kids: bars.sub.children.map((c) => (c.classList.contains('b09-estimation__tools') ? 'TOOLS' : c.textContent)), + last: bars.sub.children[bars.sub.children.length - 1].children.map((x) => x.textContent), +}); +bars.draw('dg'); +bars.setTools([mk('가'), mk('나')]); +const a = snap(); +bars.draw('compare'); // 다른 묶음으로 옮겨도 도구 자리는 그대로 마지막 +const b = snap(); +bars.draw('cost'); // 단독 탭 = 빈 보조 줄 +const c = snap(); +const ghost = bars.sub.children[0]; +bars.setTools([]); +const d = snap(); +console.log(JSON.stringify({ + a: a.kids, aLast: a.last, + b: b.kids.slice(-1), bLast: b.last, + c: c.kids, cLast: c.last, + blank: bars.sub.classList.contains('b09-estimation__tabs--blank'), + subHidden: bars.sub.attrs['aria-hidden'] ?? null, ghostHidden: ghost.attrs['aria-hidden'], + dLast: d.last.length, +})); +""", + ) + tools = "TOOLS" + assert out["a"] == ["일위대가목록표", "일위대가표", tools] # 아래 탭 뒤 오른쪽 자리 + assert out["aLast"] == ["가", "나"] + assert out["b"] == [tools] and out["bLast"] == ["가", "나"] # 탭을 옮겨도 도구는 그대로 + assert out["blank"] and len(out["c"]) == 2 and out["c"][1] == tools # 숨은 단추 + 도구 + assert out["cLast"] == ["가", "나"] + assert out["subHidden"] is None and out["ghostHidden"] == "true" # 줄 전체가 아니라 숨은 단추만 + assert out["dLast"] == 0 # 비움 + + +def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): + """쉘이 하는 대로 — 줄 그림 → 묶음 판 그림(`setTools` = 보조 줄 도구) → 단추는 줄에, 판에는 없음.""" + out = run( + [COMMON, TABS, f"{B09}Tabs_Labor.ts", GROUPS, BAR], + f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" + "\nconst bars = createTabBars(() => {});" + "\nconst hint = document.createElement('p');" + "\nconst res = {};" + "\nfor (const view of ['cards', 'table']) {" + "\n chosen.set('dg', view);" + "\n bars.draw('dg'); bars.setTools([]);" + "\n const panel = renderGroupPanel({ tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}," + " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} })," + " setTools: bars.setTools });" + "\n const tools = bars.sub.children[bars.sub.children.length - 1];" + "\n res[view] = { inRow: tools.all('[data-b09-group-view]').map((b) => b.textContent)," + " cols: tools.all('[data-group-cols]').length," + " inPanel: panel.flatMap((e) => [e, ...e.all('[data-b09-group-view]'), ...e.all('[data-group-cols]')])" + ".filter((e) => e.dataset.b09GroupView || e.dataset.groupCols).length," + " hintLast: panel[panel.length - 1] === hint, n: panel.length };" + "\n}" + "\nconsole.log(JSON.stringify(res));", + RAW, + ) + assert out["cards"]["inRow"] == ["표로 보기"] and out["cards"]["cols"] == 4 + assert out["table"]["inRow"] == ["박스보기"] and out["table"]["cols"] == 0 + for view in ("cards", "table"): + assert out[view]["inPanel"] == 0 # 판 안에는 단추 없음 + assert out[view]["hintLast"] and out[view]["n"] == 2 # 판 + 안내 줄(맨 아래) + + +def test_잇기_쉘_안내_줄_아래_도구_비움(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "panel.replaceChildren(table.root, ...(hint ? [hint] : []))" in shell + assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음 + assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell) + assert "setTools: bars.setTools" in shell + + +def test_CSS_도구_오른쪽_맞춤_빈_줄_숨김_규칙에_안_걸림(): + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + tools = _block(css, ".b09-estimation__tools") + assert "margin-left: auto" in tools and "flex-wrap: nowrap" in tools + hidden = _block(css, ".b09-estimation__tabs--blank > .ui-btn") + assert "visibility: hidden" in hidden # 직계 단추만 — 도구 묶음은 안 숨김 + assert ".b09-estimation__bar" not in css # 옛 머리 줄(안내 + 단추) 풂 + + +# ── B08 안내 · 경고 줄 맨 아래 ────────────────────────────────────────────── + + +def test_B08_표_카드_차례_표_아래줄_안내(): + src = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text("utf-8") + fn = re.search(r"export function tableCard\(.*?\n}\n", src, re.S) + assert fn + code = fn.group(0).replace("export ", "") + js = ( + "const createCard = ({ body }) => body;\n" + + code + + "\nconsole.log(JSON.stringify(tableCard(['i1', 'i2'], 'T', ['a1', 'a2'])));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=js, + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == ["T", "a1", "a2", "i1", "i2"] # 표 · 아래 줄 · 안내 줄 + + +def test_B08_자재_구조물_안내_줄_표_아래(): + material = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text("utf-8") + assert "createCard({ body: [toolbar, table.root, excel, notes] })" in material + assert "toolbar.append(save);" in material # 저장 단추만 위 줄(안내는 아래) + summary = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text("utf-8") + assert "createCard({ body: [table, ...parts] })" in summary + css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text("utf-8") + assert "justify-content: flex-end" in _block(css, ".b08-material__toolbar") + + +# ── 창 끝까지 셈(199-1)에 아래 줄 몫 ──────────────────────────────────────── + + +def test_창_끝까지_셈에_아래_안내_줄_포함(): + proc = subprocess.run( + ["node", str(ROOT / "resources" / "tester" / "helper_202_1_fill_below.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + r = json.loads(proc.stdout) + assert r["below"] == r["belowExpected"] == 134 # 아래 줄 · 안내 줄 둘 + 여백 + assert r["bottom"] == r["viewport"] # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0 + assert r["afterHintGrow"] == r["firstMinusGrow"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦 diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 4eb6cc64b..976100317 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -65,10 +65,10 @@ export interface GroupCardsOptions { focus?: number; money?: (value: number | null | undefined) => string; qty?: (value: number | null | undefined) => string; - /** 머리 띠 가운데 안내 글(200-1 · 단추와 한 줄 — 늘어나는 칸) */ - lead?: HTMLElement | null; /** 머리 띠 끝 단추(예 [표로 보기]) */ actions?: HTMLElement[]; + /** 주면 가로 열 수 · `actions` 를 머리 띠에 안 넣고 이 함수로 넘김(202-1 부르는 쪽 보조 탭 줄 오른쪽) */ + placeTools?: (tools: HTMLElement[]) => void; } export const GROUP_COLUMNS = [1, 2, 3, 4] as const; @@ -210,12 +210,16 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { return b; }); pick.append(...colButtons); - bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`)); - if (opts.lead) bar.append(opts.lead); - const tail = document.createElement("div"); - tail.className = "ui-group-cards__actions"; - tail.append(...(opts.actions ?? [])); - bar.append(tail); + const count = span("ui-group-cards__count", `묶음 ${opts.groups.length}`); + if (opts.placeTools) { + bar.append(count); + opts.placeTools([pick, ...(opts.actions ?? [])]); + } else { + const tail = document.createElement("div"); + tail.className = "ui-group-cards__actions"; + tail.append(...(opts.actions ?? [])); + bar.append(pick, count, tail); + } root.append(bar, grid); grid.addEventListener("click", (event) => { From 00067310f16ac85371706dd6390dab4c2aa3a1ac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:26:59 +0900 Subject: [PATCH 07/38] =?UTF-8?q?feat(ui,B08,B09):=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=ED=91=9C=20level(=EB=B3=B4=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0)=20=C2=B7=20mergeSame(?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B0=92=20=EC=84=B8=EB=A1=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9)=20=EC=98=B5=EC=85=98=20=C2=B7=20B08=20=C2=B7=20B09?= =?UTF-8?q?=20=ED=91=9C=20=EC=A0=81=EC=9A=A9=20(201-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레벨: 설계내역서 · 총괄 · 원가계산서 · 일위대가표 · 단가산출근거 · 중기사용료 = 공용 표 level (옛 is-lvN · is-indent · is-flat · 앞 빈칸 들여쓰기 갈음) - 병합: B08 토공집계표(구분 · 규격) · 콘크리트소요(구분) · 자재 총괄(구분) / B09 원가계산서(구분 · 비목) · 총괄(공종) · 기초자료(구분) · 단가산출근거(공종) · 연결 점검(탭) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- .../B08_Quantity_UI_Earthwork_Sheets.ts | 1 + B08_Quantity/B08_Quantity_UI_Material.ts | 13 +- B09_Estimation/B09_Estimation_UI_Page.css | 85 +---------- B09_Estimation/B09_Estimation_UI_Shell.ts | 8 +- B09_Estimation/B09_Estimation_UI_Tabs.ts | 13 +- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 7 +- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 5 +- .../B09_Estimation_UI_Tabs_Labor.ts | 6 +- .../B09_Estimation_UI_Tabs_Sangeun.ts | 9 +- .../B09_Estimation_UI_Tabs_Total.ts | 23 +-- resources/tester/test_155_b09_tabs.py | 26 ++-- resources/tester/test_182_b09_bill_level.py | 8 +- .../tester/test_185_1_b09_total_level.py | 36 +++-- .../tester/test_200_2_b09_bill_level_bar.py | 29 +--- .../tester/test_201_1_table_level_merge.py | 136 ++++++++++++++++++ ui_template/ui_template_data_table.css | 21 +++ ui_template/ui_template_data_table.ts | 74 +++++++++- 17 files changed, 327 insertions(+), 173 deletions(-) create mode 100644 resources/tester/test_201_1_table_level_merge.py diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index 420100cc8..e7acf989c 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -40,6 +40,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable) note: row.note || " ", })), frozen: 1, + mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침) caption: "토공집계표", fillViewport: true, }); diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 5316e7f84..f59d4983b 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -109,6 +109,8 @@ interface Table { editable: (index: number, key: string) => boolean; /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ target: (index: number, key: string) => [Record | Cells, string, string | null]; + /** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */ + mergeSame?: string[]; } function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { @@ -218,10 +220,10 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string { function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows, total } = s.concrete; const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침 - const rowsOut: DataRow[] = keep.map((at, i) => { + const rowsOut: DataRow[] = keep.map((at) => { const row = rows[at]; const out: DataRow = { - name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name, + name: row.name, // 위 줄과 같으면 세로 병합(201-1) spec: row.spec, qty: row.qty, }; @@ -256,6 +258,7 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { rows: rowsOut, total: totalRow, frozen: 2, + mergeSame: ["name"], editable: (_i, key) => key.startsWith("per:"), target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], }; @@ -275,10 +278,10 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { numberColumn({ key: "amount", label: "합계", width: 96 }), { key: "note", label: "비고", width: 96 }, ], - rows: keep.map((at, i) => { + rows: keep.map((at) => { const row = rows[at]; return { - group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group, + group: row.group, // 위 줄과 같으면 세로 병합(201-1) spec: row.spec, unit: row.unit, qty: row.qty, @@ -295,6 +298,7 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { }), total: null, frozen: 2, + mergeSame: ["group"], editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, target: (i) => [o.surcharge, rows[keep[i]].key, null], @@ -364,6 +368,7 @@ export async function renderMaterialSheet( rows: built.rows, total: built.total, frozen: built.frozen, + mergeSame: built.mergeSame, empty: "", caption: BASIS, fillViewport: true, diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index f5fbd8357..508978622 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -66,85 +66,17 @@ font-size: var(--text-caption); } -/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */ -.b09-estimation .ui-data-table__table tbody tr.is-lv1 td { - font-size: var(--text-body); -} - -.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td { - font-size: var(--text-body-sm); -} - -.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td { - font-size: 13px; -} - +/* 182 설계내역서 묶음 줄 굵게 · 레벨 들여쓰기 = 공용 표 level 옵션(201-1) */ .b09-estimation .ui-data-table__table tbody tr.is-group td { font-weight: var(--font-weight-bold); } -.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child { - padding-left: calc(var(--spacing-8) + 1em); -} - -.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child { - padding-left: calc(var(--spacing-8) + 2em); -} - -.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child { - padding-left: calc(var(--spacing-8) + 3em); -} - -.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child { - padding-left: calc(var(--spacing-8) + 4em); -} - /* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */ .b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child { background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); } -/* 200-2 설계내역서 레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸 1em) · 글자 크기 한 벌(굵기 = 묶음 · 제목) · 글 칸 왼쪽 맞춤 */ -.b09-estimation [data-b09-table="bill"] tbody tr { - --b09-lv: 0; -} - -.b09-estimation [data-b09-table="bill"] tbody tr.is-lv2 { - --b09-lv: 1; -} - -.b09-estimation [data-b09-table="bill"] tbody tr.is-lv3 { - --b09-lv: 2; -} - -.b09-estimation [data-b09-table="bill"] tbody tr.is-lv4 { - --b09-lv: 3; -} - -.b09-estimation [data-b09-table="bill"] tbody tr.is-lv5 { - --b09-lv: 4; -} - -.b09-estimation [data-b09-table="bill"] tbody tr.is-lv6 { - --b09-lv: 5; -} - -.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td { - font-size: inherit; -} - -.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td:first-child { - padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em); -} - -.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td.is-edit:first-child { - padding-left: 0; -} - -.b09-estimation [data-b09-table="bill"] tbody td:first-child .ui-data-table__input { - padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em); -} - +/* 200-2 설계내역서 글 칸 왼쪽 맞춤(수량만 오른쪽) */ .b09-estimation [data-b09-table="bill"] .ui-data-table__input:not([data-k="qty"]) { text-align: left; } @@ -192,19 +124,6 @@ font-weight: var(--font-weight-bold); } -/* 185-1 총괄 · 원가계산서 레벨 — 글자 크기 = 위 is-lvN · 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 */ -.b09-estimation .ui-data-table__table tbody tr[class*="is-lv"] td.is-flat:first-child { - padding-left: var(--spacing-8); -} - -.b09-estimation .ui-data-table__table tbody tr.is-lv2 td.is-indent { - padding-left: calc(var(--spacing-8) + 1em); -} - -.b09-estimation .ui-data-table__table tbody tr.is-lv3 td.is-indent { - padding-left: calc(var(--spacing-8) + 2em); -} - /* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */ .b09-estimation .ui-data-table__table tbody td.is-edited { background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index fbc860214..13f1ca8df 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -20,7 +20,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { createDataTable, type DataTable } from "@ui/ui_template_data_table"; +import { cellColumn, createDataTable, type DataTable } from "@ui/ui_template_data_table"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; @@ -210,6 +210,8 @@ export function renderB09Estimation(root: HTMLElement): void { ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null : spec.rowClass, cellClass: spec.cellClass, + level: spec.level, + mergeSame: spec.mergeSame, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); @@ -223,7 +225,7 @@ export function renderB09Estimation(root: HTMLElement): void { if (spec.choices) made.root.addEventListener("click", (event) => { const cell = (event.target as Element).closest("tr[data-i] td"); - const column = cell ? spec.columns[cell.cellIndex] : undefined; + const column = cell ? spec.columns[cellColumn(cell)] : undefined; const items = cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key); if (!cell || !items?.length) return; @@ -235,7 +237,7 @@ export function renderB09Estimation(root: HTMLElement): void { attachHoverInfo( made.root, (cell) => { - const column = spec.columns[(cell as HTMLTableCellElement).cellIndex]; + const column = spec.columns[cellColumn(cell as HTMLTableCellElement)]; return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key); }, "tr[data-i] td", diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 8f68f8ead..cc2ade95e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -8,7 +8,7 @@ * ========================================================================== */ import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; -import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; export type TabId = @@ -112,10 +112,20 @@ export interface TabTable { cellClass?: (index: number, key: string) => string | null | undefined; /** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */ choices?: (index: number, key: string) => MapContextMenuItem[] | null; + /** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */ + level?: DataTableOptions["level"]; + /** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */ + mergeSame?: string[]; } export type TabBuild = (ctx: TabContext) => TabTable | string; +/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ +export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ + key, + of: (i) => (top(i) ? 1 : 2), +}); + /* ── 열 꼴 ────────────────────────────────────────────────────────────── */ export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({ @@ -179,6 +189,7 @@ export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => { text("why", "까닭", 320), ], rows, + mergeSame: ["tab"], frozen: 1, hint: `연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` + diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index e16abf10f..601a03de9 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -19,6 +19,7 @@ import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; import { + blockLevel, money, parseNumber, qty, @@ -102,6 +103,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 2, + mergeSame: ["part"], hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표", link: (i) => { const row = rows[i]; @@ -344,7 +346,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null); rows.push({ key: `${b.key}|${ln.name}`, - name: ` ${ln.name}`, + name: ln.name, spec: ln.spec, qty: ln.qty, unit: ln.unit, @@ -361,7 +363,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { } rows.push({ key: `${b.key}|합계`, - name: " 합 계", + name: "합 계", sum: b.sum, labor: b.labor, material: b.material, @@ -388,5 +390,6 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { frozen: 1, hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭", link: (i) => links[i] ?? null, + level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")), }; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index c14b71c14..e29e0b762 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -7,7 +7,7 @@ * 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개 * 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값 * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) - * 레벨 = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) + * 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level` * 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴 * ========================================================================== */ @@ -93,8 +93,9 @@ export const buildBill: TabBuild = (ctx): BillTable => { const r = rows[i]; if (!r) return null; const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : ""; - return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" "); + return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null; }, + level: { key: "name", of: (i) => rows[i]?.level as number | undefined }, menu: (i) => billMenu(ctx, raw, i), onCell: (i, key) => { if (key === "connect") openLinkMenu(ctx, raw, i); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index e50dfece3..50b500e2f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -12,6 +12,7 @@ import type { DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; import { + blockLevel, money, qty, text, @@ -25,7 +26,7 @@ import { raw } from "./B09_Estimation_UI_Tabs_Sangeun"; const EMPTY = "연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]"; /** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */ -const CHILD = " ㄴ "; +const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1) const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0"; const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김"; /** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */ @@ -122,7 +123,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { titles.add(rows.length); rows.push({ key: `${b.id}|합계`, - name: " 합 계", + name: "합 계", sum: b.sum, labor: b.labor, material: b.material, @@ -154,6 +155,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`, link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), + level: blockLevel("name", (i) => titles.has(i)), tip: (i: number, key: string) => key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 1cf80dc9f..2223ee401 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -13,6 +13,7 @@ import type { DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; import { + blockLevel, money, qty, text, @@ -99,7 +100,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { null, true, ); - head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` })); + head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: line })); (b.lines as Row[]).forEach((ln, n) => { const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null; const kind = @@ -115,8 +116,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { push( { key: `${b.key}|${n}${part ? `|${part}` : ""}`, - kind: k === 0 ? kind : "", - text: ` ${texts[k] ?? fallback}`, + kind, // 같은 공종 줄 = 세로 병합(201-1) + text: texts[k] ?? fallback, sum: PARTS.some((p) => at(p) === null) ? null : PARTS.reduce((s, p) => s + (at(p) as number), 0), @@ -162,6 +163,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { notes(sheets), link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), + level: blockLevel("text", (i) => titles.has(i)), + mergeSame: ["kind"], tip: (i: number, key: string) => key === "note" ? raw(rows[i]?.note, rows[i]?.note_raw) : null, }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 0614ea532..96a3691aa 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -8,8 +8,8 @@ * 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · * 엑셀에 없는 현행 비목 = 줄만(빈칸) * 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로 - * 레벨(185-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → 182 is-lvN 글자 크기 · - * 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 · 1단 = 굵게(is-title) + * 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → + * 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title) * 총괄 = 가.~바. 소계 1 · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3 * ========================================================================== */ @@ -77,13 +77,12 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un ctx.setUser({ ...ctx.user, estimate }); } -/** 185-1 레벨 꼴 — 줄 class · 칸 class(들여쓰기 = 명칭 칸 · 앞 칸 그대로) */ -const levelClass = (r: DataRow | undefined): string | null => - r ? `is-lv${r.level}${r.level === 1 ? " is-title" : ""}` : null; -const indentClass = - (first: string, name: string) => - (_i: number, key: string): string | null => - key === name ? "is-indent" : key === first ? "is-flat" : null; +/** 185-1 레벨 꼴 — 1단 = 굵게 · 들여쓰기 = 공용 표 level(명칭 칸) */ +const levelClass = (r: DataRow | undefined): string | null => (r?.level === 1 ? "is-title" : null); +const nameLevel = (rows: DataRow[]) => ({ + key: "name", + of: (i: number) => rows[i]?.level as number | undefined, +}); function rateText(formula: string, rate: Num): string { return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); @@ -210,7 +209,8 @@ export const buildTotal: TabBuild = (ctx): TabTable => { rows, frozen: 2, rowClass: (i) => levelClass(rows[i]), - cellClass: indentClass("part", "name"), + level: nameLevel(rows), + mergeSame: ["part"], hint: "요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " + "2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " + @@ -290,7 +290,8 @@ export const buildCost: TabBuild = (ctx): TabTable => { rows, frozen: 3, rowClass: (i) => levelClass(rows[i]), - cellClass: indentClass("group", "name"), + level: nameLevel(rows), + mergeSame: ["group", "part"], hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), diff --git a/resources/tester/test_155_b09_tabs.py b/resources/tester/test_155_b09_tabs.py index 4f58a2581..a59f43219 100644 --- a/resources/tester/test_155_b09_tabs.py +++ b/resources/tester/test_155_b09_tabs.py @@ -52,16 +52,18 @@ def test_155_2_일위대가표_제목_굵게_하위_ㄴ(): out = _node( f"\nconst t = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(BLOCK)}] }} }});" "\nconsole.log(JSON.stringify({ names: t.rows.map((r) => r.name)," - " cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));" + " cls: t.rows.map((_, i) => t.rowClass(i) ?? null)," + " lv: [t.level.key, ...t.rows.map((_, i) => t.level.of(i))] }));" ) assert out["names"] == [ "제 1 호표 찰쌓기", - " ㄴ 건설표준품셈 7-1-2", - " ㄴ 석공", - " ㄴ 뒷채움", - " 합 계", - ] + "ㄴ 건설표준품셈 7-1-2", + "ㄴ 석공", + "ㄴ 뒷채움", + "합 계", + ] # 201-1 들여쓰기 = 공용 표 level(앞 빈칸 없앰) assert out["cls"] == ["is-title", None, None, None, "is-title"] + assert out["lv"] == ["name", 1, 2, 2, 2, 1] SG = { @@ -92,12 +94,12 @@ def test_155_5_단가산출근거_엑셀_꼴_칸(): rows = out["rows"] assert [(r.get("kind"), r["text"]) for r in rows] == [ ("제 1 호표", "뒷채움 / m3"), - (None, " 산림품셈 13-3-2 적용"), - (None, " 규격 : 150 mm"), - ("노무", " 보통인부 : 165,545 * 0.018 = 2,979.8"), - ("중기", " 굴착기 노무비 : 55,700 * 0.07 = 3,899.0"), - ("", " 굴착기 재료비 : 7,695 * 0.07 = 538.6"), - ("", " 굴착기 경비 : 13,041 * 0.07 = 912.8"), + (None, "산림품셈 13-3-2 적용"), + (None, "규격 : 150 mm"), + ("노무", "보통인부 : 165,545 * 0.018 = 2,979.8"), + ("중기", "굴착기 노무비 : 55,700 * 0.07 = 3,899.0"), + ("중기", "굴착기 재료비 : 7,695 * 0.07 = 538.6"), # 201-1 같은 공종 = 세로 병합 + ("중기", "굴착기 경비 : 13,041 * 0.07 = 912.8"), ("총 계", "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328"), ] assert [r.get("sum") for r in rows[3:7]] == [2979.8, 3899.0, 538.6, 912.8] diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index f9f68a211..8b938d94d 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -281,6 +281,7 @@ const out = { connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect), link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")], cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))), + lv: [table.level.key, ...["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.level.of(at(k)))], edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")], }; table.onCell(at("hand:a"), "connect"); @@ -309,7 +310,12 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): # 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음 assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함 assert out["cls"] == [ - "is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip + "is-title", + "is-group is-hand", + "is-hand", + None, + ] # fmt: skip 201-1 레벨 = 공용 표 level + assert out["lv"] == ["name", 1, 3, 4, 3] assert out["edit"] == [True, False] # 묶음 줄 = 이름만 picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"] # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 diff --git a/resources/tester/test_185_1_b09_total_level.py b/resources/tester/test_185_1_b09_total_level.py index e6011ee2b..6608e4ae1 100644 --- a/resources/tester/test_185_1_b09_total_level.py +++ b/resources/tester/test_185_1_b09_total_level.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 줄 class(182 is-lvN) · 칸 class · CSS. +"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 1단 굵게 · 공용 표 level 옵션(201-1). 레벨 = 화면이 정함(줄 짜임이 `B09_Estimation_UI_Tabs_Total.ts` 에 있음 · 셈 짝은 값만 냄 → 거울 없음). 총괄 = 가.~바. 소계 1(굵게) · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3. -들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로. +들여쓰기 = 공용 표 level { key: 명칭 칸 · of = 줄 level }(201-1 · 옛 is-lvN · is-indent · is-flat 갈음). """ import json @@ -20,7 +20,7 @@ SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply BODY = f""" const user = {{ estimate: {{}} }}; const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }}; -const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.cellClass(i, "name"), t.cellClass(i, t.columns[0].key), t.cellClass(i, "amount")]); +const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]); console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }})); """ @@ -42,39 +42,35 @@ def _run() -> dict: def test_총괄_레벨(): rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]} top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"] - assert all(rows[k] == (1, "is-lv1 is-title") for k in top) + assert all(rows[k] == (1, "is-title") for k in top) for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat", "supply"]: # fmt: skip - assert rows[k] == (2, "is-lv2"), k + assert rows[k] == (2, None), k assert {lv for lv, _ in rows.values()} == {1, 2} def test_원가계산서_레벨(): rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]} for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]: - assert rows[k] == (1, "is-lv1 is-title"), k + assert rows[k] == (1, "is-title"), k for k in ["material", "labor", "expense_sum"]: - assert rows[k] == (2, "is-lv2"), k + assert rows[k] == (2, None), k for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]: - assert rows[k] == (3, "is-lv3"), k + assert rows[k] == (3, None), k -def test_칸_class_명칭만_들여씀(): +def test_명칭_칸만_들여씀_공용_level(): out = _run() for tab in ("total", "cost"): - for _k, _lv, _cls, name, first, amount in out[tab]: - assert (name, first, amount) == ("is-indent", "is-flat", None) + for _k, lv, _cls, key, of, cell in out[tab]: + assert (key, of, cell) == ("name", lv, None) # 칸 class(is-indent · is-flat) 없음 -def test_CSS_레벨_규칙(): +def test_CSS_옛_레벨_규칙_없음_공용_표로(): css = (B09 / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8") + assert "is-indent" not in css and "is-flat" not in css and "is-lv" not in css + common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text(encoding="utf-8") assert re.search( - r'tr\[class\*="is-lv"\] td\.is-flat:first-child \{\s*padding-left: var\(--spacing-8\);', css + r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);", + common, ) - for lv, em in ((2, 1), (3, 2)): - assert re.search( - rf"tr\.is-lv{lv} td\.is-indent \{{\s*padding-left: calc\(var\(--spacing-8\) \+ {em}em\);", - css, - ) - # 글자 크기 = 182 규칙 재사용(줄 높이 28px → 16px 까지) - assert re.search(r"tr\.is-lv1 td \{\s*font-size: var\(--text-body\);", css) diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index b2e0936b5..142109187 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -174,34 +174,15 @@ def test_키_셋_편집_중엔_안_먹음(): def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌(): css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") bill = '.b09-estimation [data-b09-table="bill"]' - # 레벨마다 한 칸(1em) — 변수로 공종 · 명칭 칸(글 칸이면 글 상자) 왼쪽 padding - for lv in range(2, 7): - assert f"{bill} tbody tr.is-lv{lv} {{\n --b09-lv: {lv - 1};" in css.replace("\r", "") - pad = "padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);" - assert re.search( - re.escape(f'{bill} tbody tr[class*="is-lv"] td:first-child {{') + r"\s*" + re.escape(pad), - css, - ) - assert re.search( - re.escape(f"{bill} tbody td:first-child .ui-data-table__input {{") - + r"\s*" - + re.escape(pad), - css, - ) - assert re.search( - re.escape(f'{bill} tbody tr[class*="is-lv"] td.is-edit:first-child {{') - + r"\s*padding-left: 0;", - css, - ) - # 글 칸 왼쪽 맞춤(수량만 오른쪽) · 글자 크기 = 표 한 벌(182 16/14/13px 덮음 · 굵기만) + # 201-1 레벨 들여쓰기 = 공용 표 level 옵션(test_201_1) · 레벨별 글자 크기 규칙 없음(한 벌) + assert "is-lv" not in css and "font-size: 13px" not in css + src = (B09 / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8") + assert 'level: { key: "name", of: (i) => rows[i]?.level' in src + # 글 칸 왼쪽 맞춤(수량만 오른쪽) assert re.search( re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;", css, ) - assert re.search( - re.escape(f'{bill} tbody tr[class*="is-lv"] td {{') + r"\s*font-size: inherit;", css - ) - assert css.index("200-2 설계내역서 레벨") > css.index("182 설계내역서 레벨") # 뒤에 와서 덮음 assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css) diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py new file mode 100644 index 000000000..dd958cfc4 --- /dev/null +++ b/resources/tester/test_201_1_table_level_merge.py @@ -0,0 +1,136 @@ +"""201-1 공용 표 level(보기 전용) · mergeSame(같은 값 세로 병합) 옵션 + B08 · B09 적용 표. + +- level { key, of } = 그 열 칸 class ui-data-table__lv · 줄 style --dt-lv(레벨 − 1) · 글 상자 칸도 · 다루기 단추 없음 +- mergeSame [열] = 위아래 같은 글이 이어지면 rowspan · 앞 병합 열이 갈리면 갈림 · 빈 값 · 빈칸 글 · 고칠 칸 안 합침 · + 합계 줄 안 합침 · 그린 구간(win) 안에서만 · 병합 표 칸 = data-c(열 번호) +- 옵션 없으면 무변(rowspan · data-c · --dt-lv 없음) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +JS = r""" +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript")); +const file = path.join(process.argv[1], "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p))); +const m = box.exports; +const body = (html) => html.split("")[1].split("")[0].split("").filter(Boolean); +const columns = [ + { key: "g", label: "구분" }, { key: "s", label: "규격" }, { key: "n", label: "명칭" }, + { key: "v", label: "값", kind: "number" }, +]; +const rows = [ + { g: "흙깍기", s: "가", n: "a", v: 1 }, + { g: "흙깍기", s: "가", n: "b", v: 1 }, + { g: "흙깍기", s: "나", n: "c", v: 1 }, + { g: "운반", s: "나", n: "d", v: 2 }, + { g: "운반", s: " ", n: "e", v: 2 }, + { g: "", s: " ", n: "f", v: 2 }, + { g: "", s: "다", n: "g", v: 3 }, +]; +const total = { g: "계", v: 12 }; +const out = {}; +out.plain = m.renderDataTableHtml({ columns, rows, total }); +const merged = { columns, rows, total, mergeSame: ["g", "s"] }; +out.merged = body(m.renderDataTableHtml(merged)); +out.mergedFoot = m.renderDataTableHtml(merged).split("")[1]; +out.spans = m.mergeSpans(merged, 0, rows.length); +out.win = m.mergeSpans(merged, 1, 4); +out.edit = m.mergeSpans({ ...merged, editable: (i, k) => i === 1 && k === "g" }, 0, 3); +out.level = body(m.renderDataTableHtml({ + columns, rows: rows.slice(0, 3), level: { key: "n", of: (i) => [1, 2, null][i] }, + editable: (i, k) => i === 1 && k === "n", +})); +out.cell = [m.cellColumn({ dataset: { c: "3" }, cellIndex: 1 }), m.cellColumn({ dataset: {}, cellIndex: 1 })]; +console.log(JSON.stringify(out)); +""" + + +def _run() -> dict: + result = subprocess.run( + ["node", "-e", JS, str(ROOT)], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_옵션_없으면_무변(): + html = _run()["plain"] + assert "rowspan" not in html.split("")[1] + assert "data-c=" not in html and "--dt-lv" not in html and "ui-data-table__lv" not in html + + +def test_mergeSame_같은_값만_rowspan(): + out = _run() + # 구분: 흙깍기 3 · 운반 2 · 빈 값 2(안 합침) / 규격: 가 2(흙깍기 안) · 나 = 구분이 갈려 따로 · 빈칸 글 안 합침 + assert [s[0] for s in out["spans"]] == [3, 0, 0, 2, 0, 1, 1] + assert [s[1] for s in out["spans"]] == [2, 0, 1, 1, 1, 1, 1] + assert all(s[2] == 1 and s[3] == 1 for s in out["spans"]) # 지정 안 한 열 = 그대로 + rows = out["merged"] + assert '흙깍기' in rows[0].replace(' class="is-merged"', "") + assert 'class="is-merged"' in rows[0] + assert ( + rows[1].count("b' in rows[1] + ) # 합쳐진 칸 빠짐 · 열 번호 + assert '2' in rows[4] # 숫자 값 그대로 + assert "rowspan" not in out["mergedFoot"] # 합계 줄 안 합침 + # 그린 구간(1~3줄) 안에서만 · 고칠 칸 안 합침 + assert out["win"] == [[2, 1, 1, 1], [0, 1, 1, 1], [1, 1, 1, 1]] + assert [s[0] for s in out["edit"]] == [1, 1, 1] + assert out["cell"] == [3, 1] + + +def test_level_보기_전용_들여쓰기(): + rows = _run()["level"] + assert [re.search(r'style="--dt-lv:(\d)"', r).group(1) for r in rows] == ["0", "1", "0"] + assert 'a' in rows[0] + assert rows[0].count("ui-data-table__lv") == 1 # 지정 열만 + assert '' in rows[1] # 글 상자 칸도 + assert "button" not in "".join(rows) # 다루기 단추 없음(설계내역서만) + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);" + assert re.search(r"td\.ui-data-table__lv \{\s*" + pad + r"\s*text-align: left;", css) + assert re.search(r"td\.ui-data-table__lv \.ui-data-table__input \{\s*" + pad, css) + assert re.search(r"td\.is-merged \{\s*vertical-align: top;", css) + + +#: 적용 표 — [파일, 들어 있어야 할 글] +APPLIED = [ + ("B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts", 'mergeSame: ["group", "spec"]'), + ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["name"]'), + ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["group"]'), + ("B08_Quantity/B08_Quantity_UI_Material.ts", "mergeSame: built.mergeSame"), + ("B09_Estimation/B09_Estimation_UI_Tabs.ts", 'mergeSame: ["tab"]'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'mergeSame: ["part"]'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'level: blockLevel("name"'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts", 'level: blockLevel("name"'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'level: blockLevel("text"'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'mergeSame: ["kind"]'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["group", "part"]'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["part"]'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'level: { key: "name"'), + ("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"), +] + + +def test_B08_B09_적용(): + for path, text in APPLIED: + assert text in (ROOT / path).read_text("utf-8"), (path, text) + # 옛 글 빈칸 지우기 · 앞 빈칸 들여쓰기는 없앰(병합 · level 로 갈음) + mat = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text("utf-8") + assert '=== row.name ? ""' not in mat and '=== row.group ? ""' not in mat + labor = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8") + assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor + # 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림) + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert ".cellIndex" not in shell and shell.count("cellColumn(") == 2 diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index af0a5e26d..787d9e90f 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -172,3 +172,24 @@ outline: 2px solid var(--color-focus-ring); outline-offset: -2px; } + +/* 레벨(PLAN 201-1 · level 옵션 · 보기 전용) — 레벨 열 왼쪽 맞춤 · 레벨마다 1em 들여쓰기(글 상자도) */ +.ui-data-table__table td.ui-data-table__lv { + padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em); + text-align: left; +} + +.ui-data-table__table td.ui-data-table__lv.is-edit { + padding-left: 0; +} + +.ui-data-table__table td.ui-data-table__lv .ui-data-table__input { + padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em); + text-align: left; +} + +/* 같은 값 세로 병합(PLAN 201-1 · mergeSame) — 한 칸 글은 위에 */ +.ui-data-table__table td.is-merged { + vertical-align: top; + padding-top: calc((var(--dt-row-h) - 1.3em) / 2); +} diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 33656ca10..7f0b93d82 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -11,6 +11,9 @@ * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) + * 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도) + * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 + * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ import "./ui_template_data_table.css"; @@ -65,6 +68,10 @@ export interface DataTableOptions { cellClass?: (index: number, key: string) => string | null | undefined; /** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */ fillViewport?: boolean; + /** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) */ + level?: { key: string; of: (index: number) => number | null | undefined }; + /** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */ + mergeSame?: string[]; } export interface DataTable { @@ -145,15 +152,21 @@ function rowHtml( focus = false, extra: string | null | undefined = null, cellClass?: (key: string) => string | null | undefined, + more: RowMore = {}, ): string { let tr = ""; + const { lv, spans } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; + const span = spans?.[j] ?? 1; + if (!span) continue; // 위 칸에 합쳐짐 + const lvc = lv?.key === key ? " ui-data-table__lv" : ""; + const c = spans ? ` data-c="${j}"` : ""; if (edit !== null && editable?.(edit, key)) { const v = r[key]; const raw = v === null || v === undefined ? "" : String(v); const tip = esc(cols[j].title ?? cols[j].label); - tr += ``; + tr += ``; continue; } const [txt, empty] = formatCell(r[key], fmts[j], blank); @@ -163,24 +176,68 @@ function rowHtml( frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), + lvc.trim(), + span > 1 && "is-merged", ] .filter(Boolean) .join(" "); const attrs = (cls ? ` class="${cls}"` : "") + (frz ? ` style="left:${left[j]}px"` : "") + - (fmts[j] ? ' data-num=""' : ""); + (fmts[j] ? ' data-num=""' : "") + + (span > 1 ? ` rowspan="${span}"` : "") + + c; tr += `${esc(txt)}`; } const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra] .filter(Boolean) .join(" "); - const data = index !== null ? ` data-i="${index}"` : ""; + const data = + (index !== null ? ` data-i="${index}"` : "") + (lv ? ` style="--dt-lv:${lv.n}"` : ""); return cls || data ? `${tr}` : `${tr}`; } +/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */ +interface RowMore { + lv?: { key: string; n: number }; + spans?: number[]; +} + +/** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ +export function cellColumn(cell: HTMLTableCellElement): number { + return cell.dataset.c !== undefined ? Number(cell.dataset.c) : cell.cellIndex; +} + +/** 병합 구간(from ~ to 줄) — [줄][열] rowSpan · 0 = 위 칸에 합쳐짐 · 병합 안 하는 열 = 1 */ +export function mergeSpans(o: DataTableOptions, from: number, to: number): number[][] { + const cols = o.columns; + const keys = new Set(o.mergeSame ?? []); + const out = Array.from({ length: to - from }, () => cols.map(() => 1)); + // 이음 = 이 열까지 앞 병합 열이 모두 위 줄과 같은 글(빈 값 · 고칠 칸 빼고) + const shown = (i: number, j: number): string | null => { + const key = cols[j].key; + if (o.editable?.(i, key)) return null; + const [txt] = formatCell(o.rows[i][key], null, ""); + return txt.trim() ? txt : null; // 빈 값 · 빈칸 글(" ")은 안 합침 + }; + for (let i = from + 1; i < to; i++) { + let joined = true; + for (let j = 0; j < cols.length; j++) { + if (!keys.has(cols[j].key)) continue; + const v = shown(i, j); + joined = joined && v !== null && v === shown(i - 1, j); + if (!joined) continue; + let top = i - 1; + while (!out[top - from][j]) top--; + out[top - from][j]++; + out[i - from][j] = 0; + } + } + return out; +} + /** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */ export interface RowWindow { from: number; @@ -216,7 +273,9 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin let body = win ? pad(from * win.rowH, cols.length) : ""; const blank = o.empty ?? EMPTY; const pick = o.onRowClick !== undefined || o.onCellClick !== undefined; - for (let i = from; i < to; i++) + const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null; + for (let i = from; i < to; i++) { + const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0; body += rowHtml( cols, fmts, @@ -230,7 +289,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin o.focus === i, o.rowClass?.(i), o.cellClass ? (key) => o.cellClass?.(i, key) : undefined, + { + ...(o.level ? { lv: { key: o.level.key, n: lvN } } : {}), + ...(spans ? { spans: spans[i - from] } : {}), + }, ); + } if (win) body += pad((n - to) * win.rowH, cols.length); const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `${esc(o.caption)}` : ""; @@ -342,7 +406,7 @@ export function createDataTable(opts: DataTableOptions): DataTable { const row = (event.target as Element).closest("tr[data-i]"); if (!row || !root.contains(row)) return; const cell = (event.target as Element).closest("td"); - const column = cell ? state.columns[cell.cellIndex] : undefined; + const column = cell ? state.columns[cellColumn(cell)] : undefined; if (column) state.onCellClick?.(Number(row.dataset.i), column.key); state.onRowClick?.(Number(row.dataset.i)); }); From 2be44a7d2995c19718f393700276412a4cabd11b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:35:12 +0900 Subject: [PATCH 08/38] =?UTF-8?q?feat(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EB=B0=A9=ED=96=A5=ED=82=A4=20=C2=B7=20?= =?UTF-8?q?=EC=A4=84=20=EB=81=8C=EA=B8=B0(=EC=9E=90=EB=A6=AC=20=C2=B7=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8)=20=C2=B7=20=EC=9E=90=EC=86=90=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=C2=B7=20=EB=AC=B6=EC=9D=8C/=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20(204-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고른 줄 방향키 → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로 · Alt+←→ 안 받음) - 줄 끌기 = 위아래 자리 · 좌우 레벨 · 미리 보기 선 · 못 놓는 자리 빨간 점선(공용 표 안 고침) - 자식이 생긴 항목 = 저절로 묶음(Calc_Bill 짝 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음) - 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) · 자재 · 조달수수료 줄 = 부모 못 됨 - 시험 test_204_1 새로 · 184_3 · 166_2 · 200_2 메뉴 · 키 기대값 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B09_Estimation/B09_Estimation_Calc_Bill.py | 15 +- B09_Estimation/B09_Estimation_Calc_Bill.ts | 15 +- B09_Estimation/B09_Estimation_UI_Page.css | 13 + B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 2 + .../B09_Estimation_UI_Tabs_Bill_Drag.ts | 127 +++++++++ .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 39 ++- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 17 +- resources/tester/test_166_2_b09_bill_hand.py | 3 +- resources/tester/test_184_3_b09_bill_menu.py | 7 +- .../tester/test_200_2_b09_bill_level_bar.py | 6 +- resources/tester/test_204_1_b09_bill_drag.py | 242 ++++++++++++++++++ 11 files changed, 468 insertions(+), 18 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts create mode 100644 resources/tester/test_204_1_b09_bill_drag.py diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index c9f4db36e..30b69f9c4 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -2,7 +2,7 @@ ⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · - `test_184_2_b09_bill_free.py`(자유 편집). + `test_184_2_b09_bill_free.py`(자유 편집) · `test_204_1_b09_bill_drag.py`(묶음 자동). 실무 엑셀 설계내역서 꼴 · 기본 공종 나무(초기값) = 엑셀 꼴(`SECTIONS` · 공구 안 나눔): 1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액) @@ -33,7 +33,9 @@ `rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) = 기본 부모 끝 + 비고 「새 자동 줄」(기본 부모가 없으면 표 끝 맨 위) · 원천이 안 낸 키 = 안 보임 · - 부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 · + 부모 못 찾음 · 자재 줄(`mat:`) · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 · + 항목 줄에 자식이 생김 = 저절로 묶음(204 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음 — + 자식이 다 빠지면 다시 항목) · 관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래. 옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` · `after` 앞 줄 뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2). @@ -331,7 +333,7 @@ def _order_v2(bill: dict, titles: list[dict], items: list[dict], hands: list[tup def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]: - """부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 · + """부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 · 관급 자재 줄 = `sec:G` 안) → 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section (깊이 5 넘음 = 레벨 4 조상 아래).""" by_key = {row["key"]: row for row in order} @@ -353,7 +355,7 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]: up = by_key.get(row.get("parent") or "") if row["key"] == "sec:G": row["parent"] = None - elif up is None or up["kind"] == "item" or up is row: + elif up is None or up["key"].startswith("mat:") or up is row: row["parent"] = row["_home"] if row["_home"] in by_key else None for row in order: if loops(row): @@ -383,6 +385,11 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]: row["section"] = chain[-1]["section"] if chain else None rows.append(row) stack.extend((kid, [*chain, row]) for kid in reversed(kids.get(row["key"], []))) + parents = {row["parent"] for row in rows} + for row in rows: # 자식이 생긴 항목 = 저절로 묶음(204) + if row["kind"] == "item" and row["key"] in parents: + row.update({"kind": "group", "qty": None, "ref": None, "_gaps": []}) + row.update({f"price_{p}": None for p in (*PARTS, "sum")}) return rows diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 249a23463..3a5702b8e 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -3,7 +3,8 @@ * B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임). * * ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · - * 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집). + * 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집) · + * `test_204_1_b09_bill_drag.py`(묶음 자동). * * 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔 * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 @@ -304,7 +305,8 @@ function orderV2(bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] { return out; } -/** 부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) → +/** 부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) → + * 자식이 생긴 항목 = 저절로 묶음(204 · 자식이 다 빠지면 다시 항목) → * 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section(깊이 5 넘음 = 레벨 4 조상 아래) */ function tree(order: Row[]): Row[] { const byKey = new Map(); @@ -325,7 +327,7 @@ function tree(order: Row[]): Row[] { for (const row of order) { const up = byKey.get(row.parent ?? ""); if (row.key === "sec:G") row.parent = null; - else if (!up || up.kind === "item" || up === row) row.parent = home(row); + else if (!up || up.key.startsWith("mat:") || up === row) row.parent = home(row); } for (const row of order) { if (!loops(row)) continue; @@ -354,6 +356,13 @@ function tree(order: Row[]): Row[] { rows.push(row); for (const kid of [...(kids.get(row.key) || [])].reverse()) stack.push([kid, [...chain, row]]); } + const parents = new Set(rows.map((row) => row.parent)); + for (const row of rows) { + // 자식이 생긴 항목 = 저절로 묶음(204) + if (row.kind !== "item" || !parents.has(row.key)) continue; + Object.assign(row, { kind: "group", qty: null, ref: null, _gaps: [] }); + for (const p of [...PARTS, "sum"]) row[`price_${p}`] = null; + } return rows; } diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index f5fbd8357..7c1d0903d 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -209,3 +209,16 @@ .b09-estimation .ui-data-table__table tbody td.is-edited { background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); } + +/* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */ +.b09-estimation__drop { + position: fixed; + z-index: 1000; + height: 0; + border-top: 2px solid var(--color-primary); + pointer-events: none; +} + +.b09-estimation__drop.is-bad { + border-top: 2px dashed var(--color-danger); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index c14b71c14..b372be207 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -16,6 +16,7 @@ import type { Row } from "./B09_Estimation_Calc_Base"; import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs"; import { billMenu, + dropMove, editCell, linkable, openLinkMenu, @@ -96,6 +97,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" "); }, menu: (i) => billMenu(ctx, raw, i), + drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before), onCell: (i, key) => { if (key === "connect") openLinkMenu(ctx, raw, i); }, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts new file mode 100644 index 000000000..786ccc285 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts @@ -0,0 +1,127 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Bill_Drag.ts + * B09 설계내역서 줄 끌기(PLAN 204-1) — 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(한 칸 = 1em) · 공용 표 위 덧그림. + * + * 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음 + * 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만 + * 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed) + * 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름) + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; + +export interface DropAt { + parent: string | null; + before: string | null; + level: number; +} + +const holds = (row: DataRow): boolean => row.key !== "fee" && !String(row.key).startsWith("mat:"); + +/** 틈 gap(0 ~ 줄 수 · 그 줄 앞) · 바라는 레벨 → 놓을 자리 · 같은 자리 · 못 놓음 = null */ +export function dropAt(rows: DataRow[], from: number, gap: number, want: number): DropAt | null { + const row = rows[from]; + if (!row) return null; + const lv = (r: DataRow | undefined): number => Number(r?.level ?? 1); + let end = from + 1; + while (end < rows.length && lv(rows[end]) > lv(row)) end += 1; + if (gap > from && gap < end) return null; // 자기 자손 안 + const rest = [...rows.slice(0, from), ...rows.slice(end)]; + const g = gap <= from ? gap : gap - (end - from); + const up = rest[g - 1]; + const down = rest[g]; + const max = up ? lv(up) + (holds(up) ? 1 : 0) : 1; + const min = Math.max(down ? lv(down) : 1, lv(row) === 1 ? 1 : 2); + if (min > max) return null; + const level = Math.min(max, Math.max(min, want)); + if (level === lv(row) && (gap === from || gap === end)) return null; // 제자리 + let parent: DataRow | undefined = up; + const byKey = new Map(rest.map((r) => [String(r.key), r])); + while (parent && lv(parent) >= level) parent = byKey.get(String(parent.parent ?? "")); + return { + parent: parent ? String(parent.key) : null, + before: down && lv(down) === level ? String(down.key) : null, + level, + }; +} + +/** 표 root 에 줄 끌기를 붙임 — 놓으면 done(끈 줄 키) 뒤 drop 실행 */ +export function attachDrag(root: HTMLElement, table: BillTable, done: (key: string) => void): void { + let endAt = 0; + // 끌고 난 누름 = 줄 고름 아님 + root.addEventListener( + "click", + (event) => { + if (Date.now() - endAt < 300) event.stopPropagation(); + }, + true, + ); + root.addEventListener("pointerdown", (down) => { + const target = down.target as Element; + if (down.button !== 0 || !table.drop || target.closest("input,textarea,select,button,a")) + return; + const tr = target.closest("tr[data-i]"); + if (!tr || !root.contains(tr)) return; + const from = Number(tr.dataset.i); + const step = parseFloat(getComputedStyle(tr).fontSize) || 14; + const line = document.createElement("div"); + line.className = "b09-estimation__drop"; + let on = false; + let run: (() => void) | null = null; + let seen = ""; + const move = (event: PointerEvent): void => { + const dx = event.clientX - down.clientX; + if (!on && Math.abs(dx) + Math.abs(event.clientY - down.clientY) < 6) return; + if (!on) { + on = true; + document.body.append(line); + document.body.style.userSelect = "none"; + window.getSelection()?.removeAllRanges(); + } + const trs = table.rows.map((_r, i) => root.querySelector(`tr[data-i="${i}"]`)); + let gap = trs.length; + let y = trs.at(-1)?.getBoundingClientRect().bottom ?? 0; + for (const [i, el] of trs.entries()) { + const box = el?.getBoundingClientRect(); + if (box && event.clientY < box.top + box.height / 2) { + [gap, y] = [i, box.top]; + break; + } + } + const want = Number(table.rows[from]?.level ?? 1) + Math.round(dx / step); + const at = dropAt(table.rows, from, gap, want); + const key = `${gap}|${at?.level}`; + if (key !== seen) { + seen = key; + run = at ? table.drop!(from, at.parent, at.before) : null; + } + const box = root.getBoundingClientRect(); + const left = box.left + ((at?.level ?? want) - 1) * step + 8; + Object.assign(line.style, { + top: `${y - 1}px`, + left: `${left}px`, + width: `${Math.max(box.right - left, 40)}px`, + }); + line.classList.toggle("is-bad", !run); + document.body.style.cursor = run ? "grabbing" : "not-allowed"; + }; + const stop = (event: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + line.remove(); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + if (!on) return; + endAt = Date.now(); + if (event.type === "pointerup" && run) { + done(String(table.rows[from].key)); + run(); + } + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + }); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 105b3ff80..d8ea35242 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -11,6 +11,8 @@ * 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비) * 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기 * 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) + * 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 · + * 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -24,7 +26,11 @@ import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs"; import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; /** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */ -export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] }; +export type BillTable = TabTable & { + menu?: (index: number) => MapContextMenuItem[]; + /** 끌어 놓기(204) — 줄 index 를 parent(null = 맨 위) 아래 before 앞(null = 끝)으로 · 못 하면 null */ + drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null; +}; const NEW_NAME = "새 줄"; const NEW_SECTION = "새 공종"; @@ -47,6 +53,8 @@ interface Work { /** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */ const fixed = (key: string): boolean => key === "fee" || key === "sec:G"; +/** 자식을 둘 수 있는 줄 — 자재 · 조달수수료 빼고(항목은 저절로 묶음) */ +const canHold = (key: string): boolean => key !== "fee" && !key.startsWith("mat:"); /** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */ export const linkable = (row: DataRow | undefined): boolean => @@ -148,6 +156,11 @@ function attempt( const before = w.bill.rows.map((e) => ({ ...e })); if (change(w) === false) return null; w.bill.rows = tidy(w.bill.rows); + // 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) — 자식이 생긴 항목 = 셈이 묶음으로 + const had = new Set(before.map((e) => e.parent)); + const has = new Set(w.bill.rows.map((e) => e.parent)); + for (const e of w.bill.rows) + if (e.kind === "group" && e.parent && had.has(e.key) && !has.has(e.key)) delete e.kind; return fits(w, before) ? () => put(ctx, w.bill) : null; } @@ -273,7 +286,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex add(2, "들여쓰기 →", (w, e) => { const sib = siblings(w, e.parent); const up = sib[sib.indexOf(e) - 1]; - if (!up || !isGroup(w, up.key)) return false; + if (!up || !canHold(up.key)) return false; e.parent = up.key; w.bill.rows.push(pull(w.bill.rows, key)); }); @@ -315,6 +328,28 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex return out; } +/** 끌어 놓기(204) — 자손째 parent 아래 before 앞으로 · 지킬 것(깊이 · 자재 돈 갈래)은 메뉴와 같음 */ +export function dropMove( + ctx: TabContext, + rows: DataRow[], + i: number, + parent: string | null, + before: string | null, +): (() => void) | null { + const key = String(rows[i]?.key); + if (!rows[i] || fixed(key) || (parent !== null && !canHold(parent))) return null; + return attempt(ctx, rows, (w) => { + const e = entryOf(w, key); + if (!e || (parent !== null && !entryOf(w, parent))) return false; + if (parent !== null && chain(w.bill.rows, parent).includes(e)) return false; // 자기 자손 아래 + if (parent === null) delete e.parent; + else e.parent = parent; + pull(w.bill.rows, key); + const at = before === null ? -1 : find(w.bill.rows, before); + w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, e); + }); +} + let shared: MapContextMenu | null = null; /** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */ let lastCell: Element | null = null; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index ca070d200..6d9cb5125 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -5,12 +5,14 @@ * 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음 * 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) * 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂) + * 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄) * 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; import { createButton } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { attachDrag } from "./B09_Estimation_UI_Tabs_Bill_Drag"; import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; /** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */ @@ -21,11 +23,14 @@ const ACTS: [string, string][] = [ ["↓ 아래로", "아래로 ↓"], ]; -const GUIDE = "레벨 — 줄을 누르고 단추 · 키 Tab 들여 · Shift+Tab 내어 · Alt+↑↓ 옮김"; +const GUIDE = + "레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기"; const GUIDE_MORE = "레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " + - "들여쓰기 = 바로 위 묶음 줄 아래로 · 내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " + + "들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " + + "내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " + "위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " + + "끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " + "키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " + "묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭"; @@ -56,8 +61,11 @@ export function keyAct( ): string | null { if (editing || e.ctrlKey) return null; if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1]; - if (!e.altKey || e.shiftKey) return null; - return e.key === "ArrowUp" ? ACTS[2][1] : e.key === "ArrowDown" ? ACTS[3][1] : null; + if (e.shiftKey) return null; + const arrow = ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].indexOf(e.key); + // 방향키(204) — 그냥 · Alt 같음(Alt+←→ = 브라우저 뒤로 · 앞으로라 안 받음) + if (arrow < 0 || (e.altKey && arrow < 2)) return null; + return ACTS[arrow][1]; } const editing = (at: Element | null): boolean => @@ -122,6 +130,7 @@ export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { picked = String(row.key); sync(); }); + attachDrag(root, table, (key) => (picked = key)); sync(); live = { root, bar, run, sync }; listenKeys(); diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 9b2c75113..45a41bbca 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -229,8 +229,9 @@ console.log(JSON.stringify(out)); assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"] assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip + # 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음) assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑", - "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip + "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) above = out["above"] assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"] diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index c9062f9a3..653d8a64d 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -125,10 +125,13 @@ def test_메뉴_모든_줄(): # 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음) assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip + # 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음) assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓", - "내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip + "들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기", + "줄 지우기"] # fmt: skip assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑", - "아래로 ↓", "내어쓰기 ←", "항목으로 바꾸기", "-", "줄 지우기"] # fmt: skip + "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-", + "줄 지우기"] # fmt: skip assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음 diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index b2e0936b5..92acb2d64 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -122,7 +122,8 @@ out.keyAct = [ console.log(JSON.stringify(out)); """ -SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Level"] +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", + "UI_Tabs_Bill_Level"] # fmt: skip IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓" @@ -168,7 +169,8 @@ def test_키_셋_편집_중엔_안_먹음(): # 초점이 표 · 도구 줄 밖 = 안 먹음 · Esc(편집 아님) = 고름 풂 → 흐림 · Tab 은 기본 동작 assert out["outside"] == [False, []] assert out["unpick"] == [[True] * 4, False, False] - assert out["keyAct"] == [IN, OUT, UP, DOWN, None, None, None] + # 그냥 ↑ = 옮기기(204 방향키 · test_204_1) + assert out["keyAct"] == [IN, OUT, UP, DOWN, None, UP, None] def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌(): diff --git a/resources/tester/test_204_1_b09_bill_drag.py b/resources/tester/test_204_1_b09_bill_drag.py new file mode 100644 index 000000000..20367728b --- /dev/null +++ b/resources/tester/test_204_1_b09_bill_drag.py @@ -0,0 +1,242 @@ +"""204-1 B09 설계내역서 — 방향키 · 끌기 레벨 · 이동 · 자손 함께 · 묶음 자동 · 합 전달. + +- 방향키 = 고른 줄(편집 중 아님) → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로) · Alt+←→ 안 받음 +- 끌기 자리 = `dropAt`(틈 · 바라는 레벨 → 부모 · 앞 줄) · + 놓기 = 표 drop(`dropMove` · 메뉴와 같은 지킬 것) +- 묶음 자동 = 셈(짝 `Calc_Bill` · 거울) — 자식이 생긴 항목 = 묶음(자손 합 · 위로 전달) · + 자식이 빠지면 다시 항목(제 수량 · 단가) · 자식이 다 빠진 새 묶음 = 항목(화면 · 맨 위 공종 빼고) +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_2_b09_bill_free import ( # noqa: E402 + BASE, + BOOKS, + DG, + INPUTS, + _calc, + _freeze, + _put, + _rows, +) +from test_184_2_b09_bill_free import _node as _node_calc # noqa: E402 +from test_184_3_b09_bill_menu import ROWS, _node, _row, _ts # noqa: E402 +from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", + "UI_Tabs_Bill_Level"] # fmt: skip + + +def _order(bill): + return [(e["key"], e.get("parent"), e.get("kind")) for e in (bill or {}).get("rows", [])] + + +# --- 방향키 ----------------------------------------------------------------------------- + +KEYS_JS = """ +const out = {}; +const press = (pickKey, k, more = {}) => { + user = {}; draw(); pick(pickKey); root.focus(); + return [key(k, more), order()]; +}; +out.right = press("earth:5", "ArrowRight"); +out.left = press("earth:4", "ArrowLeft"); +out.up = press("earth:5", "ArrowUp"); +out.down = press("earth:4", "ArrowDown"); +out.altLeft = press("earth:4", "ArrowLeft", { altKey: true }); +user = {}; draw(); pick("earth:5"); +doc.activeElement = tr("earth:5").kids[0].kids[0]; +out.editing = [key("ArrowRight"), key("ArrowUp"), order()]; +out.guide = bar.kids.at(-1).textContent; +console.log(JSON.stringify(out)); +""" + + +def test_방향키_넷_편집_중엔_안_먹음(): + head = BAR_JS[: BAR_JS.index("const out = {};")] + body = head.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + KEYS_JS + out = json.loads(_node(_ts(SRC) + body)) + stopped, order = out["right"] # → = 위 항목 아래로(earth:4 = 저절로 묶음) + assert stopped and ["earth:5", "earth:4"] in order + stopped, order = out["left"] # ← = 1.1 밖으로(1 아래) + assert stopped and ["earth:4", "sec:1"] in order + for name in ("up", "down"): # ↑↓ = 같은 부모 안 자리 바꿈 + stopped, order = out[name] + keys = [k for k, _ in order] + assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name + assert out["altLeft"] == [False, []] # Alt+← = 브라우저 뒤로 — 안 받음 + assert out["editing"] == [False, False, []] # 글 칸 편집 중 = 글 칸 커서 + assert "←→" in out["guide"] and "↑↓" in out["guide"] and "끌기" in out["guide"] + + +# --- 끌기 자리 · 놓기 ------------------------------------------------------------------------ + +DROP_JS = """ +const start = __USER__; +let user = start; +const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, + get user() { return user; }, setUser: (u) => (user = u) }; +const table = buildBill(ctx); +const at = (k) => table.rows.findIndex((r) => r.key === k); +const order = (b) => (b?.rows ?? []).map((e) => [e.key, e.parent ?? null, e.kind ?? null]); +const out = { at: {}, drop: {} }; +for (const [name, k, gap, want] of __ATS__) out.at[name] = dropAt(table.rows, at(k), gap, want); +for (const [name, k, parent, before] of __DROPS__) { + user = start; + const run = table.drop(at(k), parent, before); + if (run) run(); + out.drop[name] = run ? order(user.bill) : null; +} +console.log(JSON.stringify(out)); +""" + +#: ROWS + 맨 위 새 공종(hand:n · 자식 hand:k) — 4 자재대 뒤 +ROWS2 = [*ROWS[:9], _row("hand:n", None, 1, "group", no="5"), _row("hand:k", "hand:n", 2), + *ROWS[9:]] # fmt: skip + + +def _drag(ats=(), drops=(), rows=ROWS2, user=None): + body = ( + DROP_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)) + .replace("__USER__", json.dumps(user or {}, ensure_ascii=False)) + .replace("__ATS__", json.dumps([list(a) for a in ats], ensure_ascii=False)) + .replace("__DROPS__", json.dumps([list(d) for d in drops], ensure_ascii=False)) + ) + return json.loads(_node(_ts(SRC) + body)) + + +def test_끌기_자리_레벨(): + # 차례 0 sec:1 · 1 sec:1.2 · 2 earth:4 · 3 earth:5 · 4 hand:g · 5 hand:a · 6 hand:b · 7 sec:4 · + # 8 mat:M1 · 9 hand:n · 10 hand:k · 11 sec:G · 12 mat:M2 · 13 fee + out = _drag( + ats=[ + ("오른쪽", "earth:5", 3, 4), # 제자리 + 한 칸 오른쪽 = 위 항목 아래로 + ("제자리", "earth:5", 3, 3), + ("제자리끝", "earth:5", 4, 3), + ("자손안", "hand:g", 5, 3), + ("위로", "hand:g", 2, 3), + ("멀리오른쪽", "hand:g", 2, 9), # 레벨 = 위 줄 + 1 까지 + ("자재아래", "earth:5", 9, 3), # 자재 줄 = 자식 못 둠 → 형제로 + ("맨위안됨", "earth:5", 7, 1), # 항목 = 맨 위(1) 못 감 → 2 + ("맨위공종", "sec:4", 0, 1), + ] + )["at"] + assert out["오른쪽"] == {"parent": "earth:4", "before": None, "level": 4} + assert out["제자리"] is None and out["제자리끝"] is None and out["자손안"] is None + assert out["위로"] == {"parent": "sec:1.2", "before": "earth:4", "level": 3} + assert out["멀리오른쪽"] == out["위로"] + assert out["자재아래"] == {"parent": "sec:4", "before": None, "level": 2} + assert out["맨위안됨"] == {"parent": "sec:1", "before": None, "level": 2} + assert out["맨위공종"] == {"parent": None, "before": "sec:1", "level": 1} + + +def test_놓기_자손_함께_묶음_자동_막음(): + out = _drag( + drops=[ + ("자손", "hand:g", "sec:1.2", "earth:4"), + ("항목아래", "earth:5", "earth:4", None), + ("하나빠짐", "hand:a", "sec:1.2", "hand:b"), + ("맨위남음", "hand:k", "sec:4", None), + ("관급밖", "mat:M2", "sec:1", None), + ("수수료", "fee", "sec:1", None), + ("관급제목", "sec:G", None, "sec:1"), + ("자재아래", "earth:5", "mat:M1", None), + ("자기자손", "hand:g", "hand:a", None), + ] + )["drop"] + keys = [k for k, *_ in out["자손"]] + # 묶음 통째 — 자식 hand:a 는 부모 키 그대로 · 차례 = hand:g 바로 뒤 + assert keys[keys.index("hand:g") + 1] == "hand:a" and keys.index("hand:g") < keys.index( + "earth:4" + ) + assert ["hand:a", "hand:g", None] in out["자손"] + assert ["earth:5", "earth:4", None] in out["항목아래"] # 저장 = 부모만(묶음 = 셈이 정함) + # 자식이 다 빠진 새 묶음 = 항목(kind 지움) · 맨 위 공종 = 묶음 그대로 + assert ["hand:g", "sec:1.2", None] in out["하나빠짐"] + assert ["hand:n", None, "group"] in out["맨위남음"] + for name in ("관급밖", "수수료", "관급제목", "자재아래", "자기자손"): + assert out[name] is None, name + + +def test_깊이_5_넘는_놓기_막음(): + deep = [ + _row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"), + _row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"), + _row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5), + _row("hand:y", "sec:1.2", 3), + ] # fmt: skip + out = _drag(drops=[("깊음", "hand:g", "hand:y", None), ("됨", "hand:z", "hand:y", None)], + rows=deep)["drop"] # fmt: skip + assert out["깊음"] is None + assert ["hand:z", "hand:y", None] in out["됨"] + + +# --- 셈 — 묶음 ↔ 항목 자동 · 합 전달 · 거울 ----------------------------------------------------- + +HANDS = [ + {"key": "hand:a", "parent": "sec:3", "name": "a", "unit": "㎡", "qty": 2, "ref": DG}, + {"key": "hand:c", "parent": "hand:a", "name": "c", "unit": "㎡", "qty": 1, "ref": DG}, +] + + +def _bills(): + frozen = _freeze(_calc()) + under = _put(frozen, "earth:1", parent="earth:4") # 1.1 규준틀 → 절토(토사) 아래 + hands = {**frozen, "rows": frozen["rows"] + HANDS} + alone = {**frozen, "rows": frozen["rows"] + HANDS[:1]} + mat = _put(frozen, "earth:5", parent="mat:b08:sand") + return frozen, under, hands, alone, mat + + +def test_자식_생긴_항목_묶음_합_전달_빠지면_다시_항목(): + plain = _calc() + frozen, under, hands, alone, mat = _bills() + base = _rows(plain) + rows = _rows(_calc(under)) + e1, e4 = rows["earth:1"], rows["earth:4"] + assert (e4["kind"], e4["auto"], e4["qty"], e4["ref"], e4["price_sum"]) == ( + "group", True, None, None, None) # fmt: skip + assert (e1["parent"], e1["level"], e4["level"]) == ("earth:4", 4, 3) + # 묶음 금액 = 자손 합 → 1.2 · 1 로 전달 · 제 금액은 빠짐 + assert e4["sum"] == e1["sum"] == base["earth:1"]["sum"] + assert rows["sec:1.2"]["sum"] == e1["sum"] and rows["sec:1.1"]["sum"] == 0 + assert rows["sec:1"]["sum"] == base["sec:1"]["sum"] - base["earth:4"]["sum"] + gaps = _calc(under)["bill"]["gaps"] + assert not [g for g in gaps if "earth:4" in json.dumps(g, ensure_ascii=False)] + # 자식이 빠지면(원래 부모로) = 다시 항목 · 제 수량 · 단가 · 표 그대로 + assert _calc(_put(under, "earth:1", parent="sec:1.1"))["bill"] == plain["bill"] + # 새 항목(수량 2 · 대가3) — 자식이 생기면 묶음(자식 합) · 빠지면 제 값 다시 + rows = _rows(_calc(hands)) + assert (rows["hand:a"]["kind"], rows["hand:a"]["sum"], rows["hand:c"]["level"]) == ( + "group", 1230, 3) # fmt: skip + a = _rows(_calc(alone))["hand:a"] + assert (a["kind"], a["qty"], a["sum"]) == ("item", 2, 2460) + # 저장 다듬기 = 자식 있는 새 항목도 제 수량 · 연결 지킴(묶음은 셈이 정함) + kept = {r["key"]: r for r in store.clean_bill(hands)["rows"]}["hand:a"] + assert (kept.get("kind"), kept["qty"], kept["ref"]) == (None, 2, DG) + # 자재 줄 = 부모 못 됨 → 기본 부모 + rows = _rows(_calc(mat)) + assert rows["earth:5"]["parent"] == "sec:1.2" and rows["mat:b08:sand"]["kind"] == "item" + + +def test_거울_TS_가_같은_값(): + calc = _ts(["Calc_Base", "Calc_Bill"]) + users = [{**BASE, "bill": b} for b in _bills()] + body = ( + f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};" + f"\nconst us = {json.dumps(users, ensure_ascii=False)};" + f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, b))));" + ) + got = json.loads(_node_calc(calc + body)) + assert got == [compute_estimation(INPUTS, u, None, BOOKS) for u in users] From 9ba94e46c3a67d1566fd5908fc5b51edc8ec73cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:35:12 +0900 Subject: [PATCH 09/38] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=9A=94=EA=B5=AC=201010=5F2=2061=EB=B2=88?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8=20=EC=A4=84(=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=8C=90=20=EA=B7=B8=EB=8C=80=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index c7e8c1d58..6a79287dd 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -81,3 +81,7 @@ 58. 제비율표는 일력하는 공사원가계산서와 총괄설계내역서의 내용에서 요율을 원문 제비율표를 보고 누락이 된게 없는지 봐줘. (조사만) 59. 현재 B08/B09의 테이블 컨테이너의 양식을 보면 제일상단에 경고줄이 있어 이걸제일하단으로 옮겨줘. 이때 박스보기와 가로 카드수 정의는 보조탭 행에 우측맞춤으로 이동 60. 다음으로 B09의 좌측패널의 원가 단가 컨테이너의 이름을 데이터 점검으로 이름을 변경하고 내부의 쓸대없는 설명은 삭제, 단가 갱신버튼의 제목을 업데이트, 업데이트가 필요한경우 버튼에 오버레이 마크 추가, 상세 페이지의 연결점검 탭은 데이터 점검 컨테이너 내부로 이동 +61. B09의 설계내역서에서 행들을 레벨이나 이동을 드래그나 키보드의 방향키로 제어가 되어야함. 구현하고 결국 내어쓰기와 들여쓰기는 결국 레벨이 되어야함. 상위 레벨로 이동이 되면 자연스럽게 하위항목의 합계가 세부항목이 되면 상위로 데이터를 전달하는 역할을 그리고 만약 기존 상위에 있던것들이 하위로 이동할경우에는 같이 이동되는 기능도 있어야함. 다음으로 일위대가와 단가산출의 정보를 연결할때 중요한건 마스터/회사/개인의 정보중 구조물조합, 일위대가사전조합, 현재처럼 벌써 들어와 있는 일위대가와 호표를 지정할 수도 있어야함. +62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만) +63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현 +64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임) From 8eff5b61e951126bb9effbf616038300c235db2c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:35:29 +0900 Subject: [PATCH 10/38] =?UTF-8?q?style(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EB=A7=A8=20=EC=95=84=EB=9E=98=20[?= =?UTF-8?q?=EC=B6=94=EA=B0=80]=C2=B7[=EC=82=AD=EC=A0=9C]=C2=B7[=EB=A6=AC?= =?UTF-8?q?=EC=85=8B]=20=EC=A4=84=20=EB=86=92=EC=9D=B4=20=3D=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=EC=95=88=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EB=8A=98?= =?UTF-8?q?=20=EB=B3=B4=EC=9E=84(205-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단추 높이 33 → 26(--b05-control-h) · 스크롤 영역 아래 끝에 붙음 - test_205_1 신규 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- .../B05_Profile_UI_Style_Structures.css | 20 +++++++- .../test_205_1_structure_actions_height.py | 50 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_205_1_structure_actions_height.py diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index d8fcc2277..ed8512694 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -569,7 +569,7 @@ } /* 상자 안 작은 단추 템플릿(54-41) — 높이 = 숫자 칸 [-][+](`--b05-control-h`) · 글 · 안 여백도 그 높이에 · - * 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 제외 */ + * 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 아래 205-1 */ .b05-structure__group-body button:not(.b05-structure__step), .b05-structure__group > legend > button { box-sizing: border-box; @@ -580,6 +580,24 @@ line-height: 1; } +/* 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄(205-1) — 단추 높이 = 상자 안 작은 단추(`--b05-control-h`) · + * 폼이 길어도 스크롤 영역 아래 끝에 붙어 늘 보임(자리 고정 · 뒤 내용이 비치지 않게 불투명 배경) */ +.b05-structure-section .b05-route__irregular-actions { + position: sticky; + bottom: 0; + z-index: 1; + background: var(--color-surface-raised); +} + +.b05-structure-section .b05-route__irregular-actions > button { + box-sizing: border-box; + height: var(--b05-control-h); + min-height: 0; + padding-block: 0; + font-size: var(--text-caption); + line-height: 1; +} + /* 노면공 「확폭 구간」 상자(PLAN 87-3) — 구간마다 단추 한 줄 */ .b05-structure__widening[hidden] { display: none; diff --git a/resources/tester/test_205_1_structure_actions_height.py b/resources/tester/test_205_1_structure_actions_height.py new file mode 100644 index 000000000..9d3ae79b4 --- /dev/null +++ b/resources/tester/test_205_1_structure_actions_height.py @@ -0,0 +1,50 @@ +"""205-1 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄 — 단추 높이 = 상자 안 작은 단추 · 늘 보임. + +- 높이 = `--b05-control-h`(상자 안 단추 · 입력 칸과 같은 값) · 상자 이름표 [삭제]와 같은 높이 식 +- 줄 = 스크롤 영역 아래 끝에 붙음(sticky · bottom 0) · 단추 · 줄에 hidden · display none · visibility · + 호버 조건이 없음 +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CSS = (ROOT / "B05_Profile/B05_Profile_UI_Style_Structures.css").read_text(encoding="utf-8") +FORM = (ROOT / "B05_Profile/B05_Profile_UI_Structures_Form.ts").read_text(encoding="utf-8") +SECTION = ".b05-structure-section .b05-route__irregular-actions" + + +def _block(selector: str) -> str: + m = re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([^}]*)\}", CSS) + assert m, selector + return m.group(1) + + +def _decl(block: str, prop: str) -> str: + m = re.search(r"(?:^|;|\s)" + re.escape(prop) + r"\s*:\s*([^;]+);", block) + assert m, prop + return m.group(1).strip() + + +def test_buttons_same_height_as_inner_buttons(): + outer = _block(SECTION + " > button") + inner = re.search(r"\.b05-structure__group > legend > button\s*\{([^}]*)\}", CSS).group(1) + assert _decl(outer, "height") == "var(--b05-control-h)" + for prop in ("height", "box-sizing", "padding-block", "font-size", "line-height"): + assert _decl(outer, prop) == _decl(inner, prop), prop + assert re.search(r"--b05-control-h:\s*26px;", CSS) + + +def test_row_always_visible(): + row = _block(SECTION) + assert _decl(row, "position") == "sticky" + assert _decl(row, "bottom") == "0" + assert "background" in row + for text in (_block(SECTION), _block(SECTION + " > button")): + assert not re.search(r"display\s*:\s*none|visibility|opacity|overflow", text) + assert not re.search(re.escape(SECTION) + r"[^{]*(:hover|:focus-within|\[hidden\])", CSS) + + +def test_form_never_hides_buttons(): + assert "removeButton.hidden" not in FORM and "resetButton.hidden" not in FORM + assert "actions.hidden" not in FORM From 31bf1fcb1a0040361bb41bb154220597ca7f7538 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:36:43 +0900 Subject: [PATCH 11/38] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2061~64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index c7e8c1d58..6a79287dd 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -81,3 +81,7 @@ 58. 제비율표는 일력하는 공사원가계산서와 총괄설계내역서의 내용에서 요율을 원문 제비율표를 보고 누락이 된게 없는지 봐줘. (조사만) 59. 현재 B08/B09의 테이블 컨테이너의 양식을 보면 제일상단에 경고줄이 있어 이걸제일하단으로 옮겨줘. 이때 박스보기와 가로 카드수 정의는 보조탭 행에 우측맞춤으로 이동 60. 다음으로 B09의 좌측패널의 원가 단가 컨테이너의 이름을 데이터 점검으로 이름을 변경하고 내부의 쓸대없는 설명은 삭제, 단가 갱신버튼의 제목을 업데이트, 업데이트가 필요한경우 버튼에 오버레이 마크 추가, 상세 페이지의 연결점검 탭은 데이터 점검 컨테이너 내부로 이동 +61. B09의 설계내역서에서 행들을 레벨이나 이동을 드래그나 키보드의 방향키로 제어가 되어야함. 구현하고 결국 내어쓰기와 들여쓰기는 결국 레벨이 되어야함. 상위 레벨로 이동이 되면 자연스럽게 하위항목의 합계가 세부항목이 되면 상위로 데이터를 전달하는 역할을 그리고 만약 기존 상위에 있던것들이 하위로 이동할경우에는 같이 이동되는 기능도 있어야함. 다음으로 일위대가와 단가산출의 정보를 연결할때 중요한건 마스터/회사/개인의 정보중 구조물조합, 일위대가사전조합, 현재처럼 벌써 들어와 있는 일위대가와 호표를 지정할 수도 있어야함. +62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만) +63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현 +64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임) From 49b3e79c2a1018890c902a3cc582ba6aec9541d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:39:12 +0900 Subject: [PATCH 12/38] =?UTF-8?q?feat(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EC=97=B0=EA=B2=B0=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=84=9C=EA=B3=A0=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20=E2=80=94=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=82=AC=EB=B3=B8=20?= =?UTF-8?q?=EB=93=A4=EC=97=AC=EC=99=80=20=EC=83=88=20=ED=98=B8=ED=91=9C=20?= =?UTF-8?q?=C2=B7=20=EA=B0=99=EC=9D=80=20=EA=B2=83=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EB=95=8C=20=EC=95=88?= =?UTF-8?q?=20=EC=93=B0=EB=8A=94=20=EC=82=AC=EB=B3=B8=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20(204-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- B09_Estimation/B09_Estimation_Bill_Library.py | 134 ++++++++++ B09_Estimation/B09_Estimation_Labor_Source.py | 31 ++- B09_Estimation/B09_Estimation_Linked.py | 16 +- B09_Estimation/B09_Estimation_Router.py | 40 ++- B09_Estimation/B09_Estimation_Store.py | 6 +- .../B09_Estimation_UI_Tabs_Bill_Link.ts | 68 ++++- M02_MasterTemplete/M02_Item_Source.py | 16 +- .../M02_MasterTemplete_Labor_Pick.ts | 26 +- resources/tester/test_182_b09_bill_level.py | 8 +- .../tester/test_204_2_b09_bill_library.py | 244 ++++++++++++++++++ 10 files changed, 558 insertions(+), 31 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Bill_Library.py create mode 100644 resources/tester/test_204_2_b09_bill_library.py diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py new file mode 100644 index 000000000..b1c1a16c6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -0,0 +1,134 @@ +"""B09 설계내역서 연결 — 서고 구조물조합 · 일위대가 사전조합 → 프로젝트 사본(PLAN 204-2). + +자리 = 프로젝트 `B09_Estimation/v1/library/{폴더}/` · 폴더 = `{종류}@{층}@{주인}@{키}` +(주인 `/` → `-` · 마스터 `-`) — 같은 것을 다시 고르면 새로 안 들여오고 있는 사본(같은 호표): + combo 구조물조합 = B08 구조물 사본과 한 꼴(`M02_Item_Snapshot.build_snapshot` — 집계 · + 도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값) + precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} · + `단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌) +사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) · +대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) · +단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} · +[저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`). +""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Store import folder as store_folder +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of +from M02_MasterTemplete.M02_Item_Source import ( + LABOR_FILE, + PRICE_FILE, + item_name, + labor_doc_rows, + price_bundle, + read_json, +) + +LIBRARY = "library" +KINDS = ("combo", "precombo") +#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) +NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$") + + +def library_dir(project_root: str | Path) -> Path: + return store_folder(project_root) / LIBRARY + + +def folder_name(kind: str, layer: str, owner: str | None, key: str) -> str: + """고른 서고 것 → 사본 폴더 이름 — 규칙 밖이면 ValueError.""" + name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}" + if not NAME.match(name): + raise ValueError(f"서고 항목 모양이 틀림 「{name}」") + return name + + +def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: + ref = {"층": layer, "주인": owner, "키": key} + doc = refs.read_ref(ref) + if doc is None or doc.get("종류") != refs.TITLE["precombo"]: + raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음") + line = {"참조": ref, "수량": {"손": 1}} + labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]}) + return { + LABOR_FILE: labor, + PRICE_FILE: price_bundle(labor, refs.folder_of(layer, owner)), + "_원본": {"참조": ref, "name": doc.get("이름") or key, "단위": doc.get("단위") or ""}, + } + + +def import_copy( + project_root: str | Path, + kind: str, + layer: str, + owner: str | None, + key: str, + who: dict[str, Any] | None = None, +) -> dict[str, Any]: + """서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) · + 원본이 없으면 LookupError · 모양이 틀리면 ValueError.""" + if kind not in KINDS: + raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나") + name = folder_name(kind, layer, owner, key) + target = library_dir(project_root) / name + if isinstance(read_json(target / MANIFEST_NAME), dict): + return {"폴더": name, "만듦": False} + if kind == "combo": + item = {"layer": layer, "owner": owner, "key": key} + item["name"] = item_name(item) + files, origin = build_snapshot(item), origin_of(item) + else: + files = _precombo_files(layer, owner, key) + origin = files.pop("_원본") + if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리 + shutil.rmtree(target) + target.mkdir(parents=True) + for file, content in files.items(): + atomic_write_json(target / file, content) + stamp = now_text() + manifest = {"종류": kind, "원본": origin, "가져온날": stamp, "가져온이": who} + atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더 + return {"폴더": name, "만듦": True} + + +def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]: + """다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례.""" + base = library_dir(project_root) + out = [] + for child in sorted(base.iterdir()) if base.is_dir() else []: + manifest = read_json(child / MANIFEST_NAME) + if child.is_dir() and NAME.match(child.name) and isinstance(manifest, dict): + out.append((child, manifest)) + return out + + +def used_folders(user: dict[str, Any]) -> set[str]: + """설계내역서 덮개 줄이 잇는 서고 사본 폴더.""" + rows = ((user or {}).get("bill") or {}).get("rows") or [] + return { + str(ref["lib"]) + for row in rows + if isinstance(row, dict) + and isinstance(ref := row.get("ref"), dict) + and ref.get("tab") == "dg" + and ref.get("lib") + } + + +def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]: + """[저장] 뒤 — 줄이 안 잇는 사본 폴더를 지움(다시 고르면 새로 들여옴) · 지운 이름.""" + keep = used_folders(user) + base = library_dir(project_root) + gone = [] + for child in sorted(base.iterdir()) if base.is_dir() else []: + if child.is_dir() and NAME.match(child.name) and child.name not in keep: + shutil.rmtree(child) + gone.append(child.name) + return gone diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index cb49e0f09..578f7dc74 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -26,6 +26,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Sangeun as sg from B09_Estimation import B09_Estimation_Sangeun_Text as st from M02_MasterTemplete import M02_Item_Prices as item_prices @@ -282,6 +283,19 @@ class Builder: } ) + def library(self, folder: Path, manifest: dict[str, Any]) -> str | None: + """설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로.""" + if manifest.get("종류") != "precombo": + return self.combo(folder) + labor = read_json(folder / LABOR_FILE) + ref = (manifest.get("원본") or {}).get("참조") + copies = (labor or {}).get("참조사본") or {} + doc = copies.get(refs.ref_id(ref)) if isinstance(ref, dict) else None + if not isinstance(doc, dict): + return None + whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + return self.precombo(whole, ref, doc, copies, 1) + def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]: """자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가.""" @@ -327,9 +341,11 @@ def _plain(value: Any) -> Any: def daega_inputs(project_root: str | Path) -> dict[str, Any]: - """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것. + """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 · + 설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 id}. - 차례 = 조합(구조물 id 차례) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — 호표 번호 = 이 차례. + 차례 = 조합(구조물 id 차례 → 서고 사본) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — + 호표 번호 = 이 차례. """ base = structures_dir(project_root) build = Builder() @@ -343,6 +359,15 @@ def daega_inputs(project_root: str | Path) -> dict[str, Any]: bid = None # 한 사본이 막혀도 나머지 블록은 셈 — 그 구조물은 없음 if bid: owners[owner] = bid + libs: dict[str, str] = {} + for folder, manifest in library.copies(project_root): + try: + bid = build.library(folder, manifest) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + bid = None + if bid: + libs[folder.name] = bid order = {"조합": 0, "사전조합": 1, "품셈": 2} blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]]) - return _plain({"blocks": blocks, "owners": owners}) + out = {"blocks": blocks, "owners": owners} + return _plain({**out, "libs": libs} if libs else out) diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index ff2427dd3..868e8e7e3 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -67,13 +67,19 @@ def used_keys(folder: Path) -> list[str]: def linked_resource_keys(project_root: str | Path) -> list[str]: - """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 · 겹침 없음.""" + """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) · + 겹침 없음.""" + from B09_Estimation.B09_Estimation_Bill_Library import copies + base = structures_dir(project_root) + folders = [ + base / linked["폴더"] + for _owner, linked in sorted(linked_snapshots(project_root).items()) + if "일위대가" in linked.get("부분", []) + ] out: list[str] = [] - for _owner, linked in sorted(linked_snapshots(project_root).items()): - if "일위대가" not in linked.get("부분", []): - continue - for key in used_keys(base / linked["폴더"]): + for folder in [*folders, *(f for f, _ in copies(project_root))]: + for key in used_keys(folder): if key not in out: out.append(key) return out diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index b4a7d7185..305ccd82d 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -6,6 +6,10 @@ [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + POST /api/projects/{id}/estimation/library {kind, layer, owner, key} + 설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로 + (있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} · + 층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`) 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · @@ -20,11 +24,13 @@ from pathlib import Path from typing import Any from uuid import UUID -from fastapi import APIRouter, Body +from fastapi import APIRouter, Body, Depends, HTTPException +from pydantic import BaseModel from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets +from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation import B09_Estimation_Store as store @@ -38,6 +44,9 @@ from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete.M02_Item_Snapshot import importer router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -139,7 +148,8 @@ async def estimation(project_id: UUID) -> dict[str, Any]: @router.put("/{project_id}/estimation") async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) - await asyncio.to_thread(store.write_user, root, body.get("user")) + user = await asyncio.to_thread(store.write_user, root, body.get("user")) + await asyncio.to_thread(library.prune, root, user) # 204-2 줄이 안 잇는 서고 사본 keys = await _demand(root) book = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.extend_book, book, keys) @@ -156,5 +166,31 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]: return await payload(project_id, root) +class LibraryBody(BaseModel): + kind: str + layer: str + owner: str | None = None + key: str + + +@router.post("/{project_id}/estimation/library") +async def import_library( + project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + got = access.grant(session, body.layer, body.owner) # 못 읽는 층 = 403 · 회사 없음 = 409 + root = await _project_root(project_id) + try: + made = await asyncio.to_thread( + library.import_copy, root, body.kind, got.layer, got.owner, body.key, importer(session) + ) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + out = await payload(project_id, root) + key = ((out["inputs"].get("daega") or {}).get("libs") or {}).get(made["폴더"]) + return {**made, "key": key, "payload": out} + + # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index da7557fbd..0f1c5862a 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -95,12 +95,14 @@ def _text(value: Any) -> str: def _clean_ref(ref: Any) -> dict[str, Any] | None: - """손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름).""" + """손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) · + dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움).""" if not isinstance(ref, dict): return None tab = ref.get("tab") if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]: - return {"tab": tab, "key": ref["key"]} + lib = ref.get("lib") if tab == "dg" else None + return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})} if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]: given = ref.get("given") if isinstance(ref.get("given"), dict) else {} picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 4bd17684e..0e384d428 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -1,14 +1,19 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Bill_Link.ts * B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · - * M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · 연결 풀기. + * 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새 + * 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · + * 연결 풀기. * * 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름) - * 로직 고르기 · 입력값 = M02 창 재사용 + * 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 · + * 층 읽기 권한 = 서버(`M02_Layer_Access`) * ========================================================================== */ +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { createFindList } from "@ui/ui_template_find_list"; +import { openItemPicker } from "@ui/ui_template_item_picker"; import { openModal } from "@ui/ui_template_modal"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input"; @@ -86,6 +91,63 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void ); } +/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만 + * 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */ +async function importLib( + ctx: TabContext, + apply: ApplyRef, + pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string }, +): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) return; + showToast("서고에서 들여오는 중…", "info"); + try { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(pick), + }); + const got = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !got) { + const detail = got?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦"); + const { user: _user, ...rest } = got.payload as Row; + Object.assign(ctx.payload, rest); + const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); + apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); + showToast( + got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함", + "success", + ); + } catch (error) { + showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error"); + } +} + +/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */ +function pickCombo(ctx: TabContext, apply: ApplyRef): void { + void openItemPicker({ current: null, canCopy: false }).then((got) => { + if (got.action !== "apply") return; + const { layer, owner, key } = got.item; + void importLib(ctx, apply, { kind: "combo", layer, owner, key }); + }); +} + +/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */ +function pickPrecombo(ctx: TabContext, apply: ApplyRef): void { + openLogicPicker(() => undefined, { + layer: "personal", + owner: null, + libOnly: true, + title: "일위대가 사전조합 고르기", + pick: (ref) => + void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }), + }); +} + /** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] { const more: Leaf[] = []; @@ -95,6 +157,8 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L const picks: Leaf[] = [ ["일위대가 호표", () => pickHopyo(ctx, apply, "dg")], ["단가산출 호표", () => pickHopyo(ctx, apply, "sg")], + ["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)], + ["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)], [ "단가산출 로직(M01 · 새 산근)", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)), diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 717791ba8..597cb086c 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -132,8 +132,6 @@ def logic_rows(row: dict[str, Any]) -> dict[str, Any]: def build_source(item: dict[str, Any]) -> dict[str, Any]: """원본 층 · 마스터에서 항목 내용을 모음 — SS 줄이 없으면 LookupError.""" - from M02_MasterTemplete import M02_Item_Prices as prices - root = layer_root(item) row = row_of(read_json(root / layers.COMBO_FILE), item["key"]) if row is None: @@ -153,6 +151,14 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None if labor is not None: files[LABOR_FILE] = labor + files[PRICE_FILE] = price_bundle(labor, root) + return files + + +def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: + """사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 · + 개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2).""" + from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Labor_Ref as refs logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] @@ -164,9 +170,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: ] materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재 wanted = logic_keys + [code for code in materials if code] - files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} + bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} if mine := own.bundle( [c for c in materials if own.is_own(c)], root ): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로 - files[PRICE_FILE][own.BUNDLE] = mine - return files + bundle[own.BUNDLE] = mine + return bundle diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 7dafc26ec..a7cc104cf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -34,6 +34,9 @@ export interface LibraryPick { layer: Layer; owner: string | null; pick: (ref: LaborRef, name: string) => void; + /** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */ + libOnly?: boolean; + title?: string; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -213,7 +216,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra el("div", { className: "m01-logic__section-head", children: [ - el("h3", { text: tc("Pick_Title") }), + el("h3", { text: lib?.title ?? tc("Pick_Title") }), createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }), ], }), @@ -226,14 +229,15 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra document.body.append(backdrop); finder.search.focus(); - void Promise.all([fetchLogicSubs(), fetchLogics()]) - .then(([s, l]) => { - subs.push(...s); - all = l; - drawSubs(); - find(); - }) - .catch((error) => - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), - ); + if (!lib?.libOnly) + void Promise.all([fetchLogicSubs(), fetchLogics()]) + .then(([s, l]) => { + subs.push(...s); + all = l; + drawSubs(); + find(); + }) + .catch((error) => + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), + ); } diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index f9f68a211..727d54fd6 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -311,7 +311,13 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): assert out["cls"] == [ "is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip assert out["edit"] == [True, False] # 묶음 줄 = 이름만 - picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"] + picks = [ + "일위대가 호표", + "단가산출 호표", + "서고 구조물조합(마스터 · 회사 · 개인)", + "서고 일위대가 사전조합(마스터 · 회사 · 개인)", + "단가산출 로직(M01 · 새 산근)", + ] # 204-2 # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 unlink = [10, 40, [*picks, "-", "연결 풀기"]] assert out["menus"] == [unlink, unlink, [10, 40, picks]] diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py new file mode 100644 index 000000000..c89d23564 --- /dev/null +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -0,0 +1,244 @@ +"""204-2 B09 설계내역서 연결 고르기 — 프로젝트 호표 + 서고(마스터 · 회사 · 개인) +구조물조합 · 사전조합. + +- 서고 고름 = 프로젝트 사본 들여옴(`B09_Estimation/v1/library/{폴더}`) + 새 대가 호표(`libs`) + + 줄 연결 `{tab: dg, key: 블록 id, lib: 폴더}` · 다시 고르면 같은 사본(만듦 거짓 · 같은 호표) +- 사본 단가 묶음(로직 · 사전조합 자재 · 고른 자재) · 원본을 지워도 그대로 · 단가 수요 키에 듦 +- [저장] = 줄이 안 잇는 사본 지움 · 덮개 저장 꼴이 `lib` 를 지킴 +- 층 권한 = `M02_Layer_Access`(남의 회사 403 · 회사 없는 개인 409) +- 화면 — 연결 메뉴 출처 다섯 · 사전조합 창(서고 줄만) → 들여오기 → 원가 자료 바꿔 끼움 · 줄 연결 +임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. +""" + +import asyncio +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from config import config_system # noqa: E402 +from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 +from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402 + +STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡ +NET = "GC000337" # 낙석방지망 — 고름 4 = NET_ITEM +NET_ITEM = "MT006697" +MAT = "MT000008" +COMBO = "SS000011" # 견본 관공 관매설(옛 UA 조합 줄) +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + system = copy_master_sample(tmp_path / "master") + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + lines = [ + {"로직": STONE, "수량": {"손": 2}}, + {"로직": NET, "수량": {"손": 1}, "고름": {"4": NET_ITEM}}, + ] + with store.at(system): + key = refs.create( + "precombo", + {"이름": "서고 조합", "단위": "㎡", "담은로직": lines, + "자재": [{"코드": MAT, "수량": {"손": 3}}]}, + )["이름"] # fmt: skip + company = layers.company_dir(7) + company.mkdir(parents=True) + with store.at(company): + other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"] + project = tmp_path / "storage" / "7" / "1" / "proj" + project.mkdir(parents=True) + return {"system": system, "key": key, "company": other, "root": project} + + +def _block(root, folder): + got = daega_inputs(root) + bid = got["libs"][folder] + return got, next(b for b in got["blocks"] if b["id"] == bid) + + +def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사용(world): + root = world["root"] + made = library.import_copy(root, "precombo", "system", None, world["key"]) + folder = made["폴더"] + assert made == {"폴더": f"precombo@system@-@{world['key']}", "만듦": True} + copy = library.library_dir(root) / folder + assert {p.name for p in copy.iterdir()} == {"일위대가.json", "단가.json", "manifest.json"} + got, block = _block(root, folder) + assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡") + kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]] + assert kinds == [("sangeun", 2.0), ("sangeun", 1.0), ("material", 3.0)] + assert got["owners"] == {} # 구조물 사본 없어도 호표 + # 단가 묶음 = 담은 로직 · 사전조합 자재 · 고른 자재(사본 셈이 마스터를 안 읽음) + bundle = json.loads((copy / "단가.json").read_text("utf-8")) + text = json.dumps(bundle, ensure_ascii=False) + assert all(code in text for code in (STONE, NET, MAT, NET_ITEM)) + keys = linked_resource_keys(root) + assert MAT in keys and any(k.startswith("LB") for k in keys) # 단가 수요에 듦 + # 다시 고름 = 새로 안 들여옴 · 같은 호표 + again = library.import_copy(root, "precombo", "system", None, world["key"]) + assert again == {"폴더": folder, "만듦": False} + assert _block(root, folder)[1]["id"] == block["id"] + # 원본을 지워도 굳힌 사본 그대로 + (world["system"] / "precombo" / f"{world['key']}.json").unlink() + assert _block(root, folder)[1]["id"] == block["id"] + + +def test_구조물조합_고름_사본_조합_호표(world): + root = world["root"] + made = library.import_copy(root, "combo", "system", None, COMBO) + copy = library.library_dir(root) / made["폴더"] + names = {p.name for p in copy.iterdir()} + assert {"집계.json", "일위대가.json", "단가.json", "입력값.json", "manifest.json"} <= names + manifest = json.loads((copy / "manifest.json").read_text("utf-8")) + assert manifest["종류"] == "combo" and manifest["원본"]["key"] == COMBO + assert manifest["원본"]["경로"] # 가져온 때 경로 + _, block = _block(root, made["폴더"]) + assert block["kind"] == "조합" and block["lines"] + assert block["origin"] == {"layer": "system", "owner": None, "key": COMBO} + + +def test_없는_것_모양_틀림(world): + root = world["root"] + with pytest.raises(LookupError): + library.import_copy(root, "precombo", "system", None, "SC-999999") + with pytest.raises(LookupError): + library.import_copy(root, "combo", "system", None, "SS999999") + with pytest.raises(ValueError): + library.import_copy(root, "precombo", "system", None, "../x") + with pytest.raises(ValueError): + library.import_copy(root, "other", "system", None, world["key"]) + assert library.copies(root) == [] + + +def test_저장_때_안_잇는_사본_지움_lib_지킴(world): + root = world["root"] + a = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"] + b = library.import_copy(root, "combo", "system", None, COMBO)["폴더"] + ref = {"tab": "dg", "key": "DG:x", "lib": a} + user = store_b09.clean_user( + {"bill": {"v": 2, "rows": [{"key": "hand:1", "name": "줄", "qty": 1, "ref": ref}]}} + ) + assert user["bill"]["rows"][0]["ref"] == ref # 저장 꼴이 lib 를 지킴 + assert library.prune(root, user) == [b] + assert [f.name for f, _ in library.copies(root)] == [a] + assert library.prune(root, {}) == [a] and library.copies(root) == [] + + +def _call(monkeypatch, root, session, **body): + async def fake_root(_): + return root + + async def fake_payload(_, at): + return {"inputs": {"daega": daega_inputs(at)}, "sheets": {}} + + monkeypatch.setattr(router, "_project_root", fake_root) + monkeypatch.setattr(router, "payload", fake_payload) + req = router.LibraryBody(**body) + return asyncio.run(router.import_library("00000000-0000-0000-0000-000000000001", req, session)) + + +def test_층_권한_길(world, monkeypatch): + root = world["root"] + mine = {"user_id": 1, "company_id": 7, "role": "USER", "name": "갑"} + got = _call(monkeypatch, root, mine, kind="precombo", layer="company", key=world["company"]) + assert got["만듦"] and got["폴더"] == f"precombo@company@7@{world['company']}" + assert got["key"] == got["payload"]["inputs"]["daega"]["libs"][got["폴더"]] + assert json.loads((library.library_dir(root) / got["폴더"] / "manifest.json").read_bytes())[ + "가져온이" + ] == {"id": 1, "이름": "갑"} + other = {"user_id": 2, "company_id": 8, "role": "USER"} + with pytest.raises(HTTPException) as error: + _call(monkeypatch, root, other, kind="precombo", layer="company", owner="7", + key=world["company"]) # fmt: skip + assert error.value.status_code == 403 + lonely = {"user_id": 3, "company_id": None, "role": "USER"} + with pytest.raises(HTTPException) as error: + _call(monkeypatch, root, lonely, kind="precombo", layer="personal", key=world["key"]) + assert error.value.status_code == 409 + with pytest.raises(HTTPException) as error: + _call(monkeypatch, root, lonely, kind="precombo", layer="system", key="SC-999999") + assert error.value.status_code == 404 + got = _call(monkeypatch, root, lonely, kind="precombo", layer="system", key=world["key"]) + assert got["만듦"] and got["key"] # 마스터 = 누구나 읽음 + + +# ── 화면 ────────────────────────────────────────────────────────────── +LINK_JS = """ +const toasts = []; +const sent = []; +globalThis.showToast = (m, k) => toasts.push([k, m]); +globalThis.API_BASE_URL = "/api"; +globalThis.CURRENT_PROJECT_ID_KEY = "pid"; +globalThis.localStorage = { getItem: () => "P1" }; +globalThis.openLogicPicker = (done, lib) => { sent.push(["pre", lib.layer, lib.libOnly, lib.title]); + lib.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); }; +globalThis.openItemPicker = async () => ({ action: "apply", + item: { layer: "system", owner: null, key: "SS000011", name: "관매설" } }); +const answer = { 폴더: "precombo@company@7@SC-000002", 만듦: true, key: "DG:new", + payload: { user: { bill: "서버 것" }, inputs: { daega: { libs: {} } }, + sheets: { daega: [{ key: "DG:new", name: "회사 조합", spec: "", unit: "㎡" }] } } }; +globalThis.fetch = async (url, init) => { sent.push([url, JSON.parse(init.body)]); + return { ok: true, status: 200, json: async () => answer }; }; +const ctx = { sheets: {}, payload: { inputs: { old: 1 } }, user: { keep: 1 }, setUser() {} }; +const applied = []; +const apply = (ref, from) => applied.push([ref, from && from.name]); +const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply); +const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) }; +picks.find((x) => x[0].startsWith("서고 일위대가"))[1](); +await new Promise((r) => setTimeout(r, 0)); +picks.find((x) => x[0].startsWith("서고 구조물"))[1](); +await new Promise((r) => setTimeout(r, 0)); +await new Promise((r) => setTimeout(r, 0)); +Object.assign(out, { sent, applied, payload: ctx.payload, toasts }); +console.log(JSON.stringify(out)); +""" + + +def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): + src = IMPORTS.sub( + "", (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=src + LINK_JS, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + out = json.loads(result.stdout) + assert out["picks"] == [ + "일위대가 호표", + "단가산출 호표", + "서고 구조물조합(마스터 · 회사 · 개인)", + "서고 일위대가 사전조합(마스터 · 회사 · 개인)", + "단가산출 로직(M01 · 새 산근)", + ] + assert out["more"] == ["연결 풀기"] + pre, (url, body) = out["sent"][0], out["sent"][1] + assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋 + assert url == "/api/projects/P1/estimation/library" + assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"} + assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None, + "key": "SS000011"} # fmt: skip + ref = {"tab": "dg", "key": "DG:new", "lib": "precombo@company@7@SC-000002"} + assert out["applied"][0] == [ref, "회사 조합"] + # 원가 자료만 바꿔 끼움 — 사용자 값(서버 user)은 안 받음 + assert "user" not in out["payload"] and out["payload"]["inputs"] == {"daega": {"libs": {}}} + assert out["toasts"][-1][0] == "success" From 1114f408fdf1f79f6432e27a7e8a2ee84cbf1c8e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:40:46 +0900 Subject: [PATCH 13/38] =?UTF-8?q?feat(B06):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=B9=A9=20=EB=91=A5=EA=B7=BC=20=EC=82=AC=EA=B0=81=20=C2=B7=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=B9=B8=20=EC=B9=B4=EB=93=9C=20=EC=9C=84?= =?UTF-8?q?=20=EA=B2=B9=EC=B9=A8=20=C2=B7=20=EA=B3=A0=EB=A5=B4=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=91=ED=9E=98=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EA=B0=95=EC=A1=B0(207-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칩 반경 = 공용 단추 반경(--radius-buttons) · 알약 아님 - 측점 목록 칸 = 고정 줄 아래 absolute(그림자 · 배경) — 열고 닫아도 페이지 · 카드 안 밀림 - 목록 측점 누르면 그 카드로 가며 칸 접힘 - 고른 측점(selectedStationId)이 목록에 있으면 열 때 그 단추 강조(카드 고름 색) - 새 시험 test_207_1 · test_196_1 기대값 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B06_Section_UI_Cross_FillSlope_Notice.ts | 7 +- .../B06_Section_UI_Cross_Notice_Chips.ts | 14 ++- B06_Section/B06_Section_UI_Section_View.ts | 5 +- B06_Section/B06_Section_UI_Style_Cross.css | 26 +++- .../tester/test_196_1_b06_notice_chips.py | 5 +- .../tester/test_207_1_b06_notice_overlay.py | 115 ++++++++++++++++++ 6 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_207_1_b06_notice_overlay.py diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index 2ceacd67b..0ab8bcb08 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -41,10 +41,13 @@ function rows(warnings: FillSlopeWarning[], stationInterval: number): NoticeChip }); } -export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { +export function createFillSlopeNotice( + onPick: (stationId: string) => void, + selected: () => string | null, +): FillSlopeNotice { const root = document.createElement("div"); root.className = "b06-section__notice-bar"; - const chips = createNoticeChips(onPick); + const chips = createNoticeChips(onPick, selected); root.append(chips.root); return { diff --git a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts index 648236959..54760e806 100644 --- a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts +++ b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts @@ -3,6 +3,8 @@ * 횡단 알림 줄 칩 꼴(196-1) — 항목 = 짧은 이름 + 측점 수 칩 한 줄 · 긴 설명 = 공용 호버. * 줄 아래 내용 칸 하나를 같이 씀 — 누른 칩의 측점 목록만 · 같은 칩 다시 누르면 닫힘 · * 다른 칩 누르면 바뀜(한 번에 하나). 측점 단추 누름 = `onPick`(그 카드로). + * ⭐ 207-1 — 내용 칸은 줄 아래 카드 위에 겹쳐 뜸(흐름 밖 · CSS) · 측점 누르면 접힘 · + * 열 때 지금 고른 측점(`selected`) 단추 강조. * 무엇을 셀지는 부르는 쪽(`_FillSlope_Notice`) — 여기는 그리기만. * ========================================================================== */ @@ -29,7 +31,10 @@ export interface NoticeChips { update: (items: NoticeChipItem[]) => void; } -export function createNoticeChips(onPick: (stationId: string) => void): NoticeChips { +export function createNoticeChips( + onPick: (stationId: string) => void, + selected: () => string | null = () => null, +): NoticeChips { const root = document.createElement("div"); root.className = "b06-section__notice-blocks"; const chips = document.createElement("div"); @@ -57,12 +62,14 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh ); panel.hidden = !open; panel.classList.toggle("is-info", open?.tone === "info"); + const picked = open ? selected() : null; panel.replaceChildren( ...(open?.rows ?? []).map((row) => { const button = document.createElement("button"); button.type = "button"; button.textContent = row.text; button.dataset.stationId = row.stationId; + button.classList.toggle("is-selected", row.stationId === picked); return button; }), ); @@ -77,7 +84,10 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh }); panel.addEventListener("click", (event) => { const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; - if (id) onPick(id); + if (!id) return; + openKey = null; + render(); + onPick(id); }); attachHoverInfo( chips, diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index a67009b38..bc95835f8 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -124,8 +124,9 @@ export function createSectionView( } = { balloonScope: "-" }; // 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 카드 위 · 측점 누르면 그 카드로. - const fillSlopeNotice = createFillSlopeNotice((id) => - selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true), + const fillSlopeNotice = createFillSlopeNotice( + (id) => (selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true)), + () => selectedStationId, ); // 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림 const columnsField = createInputField({ diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 8a0d5fdd7..494c58df0 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -172,9 +172,10 @@ } .b06-section__notice-chip { - padding: 0 var(--spacing-8); + padding: 2px var(--spacing-8); border: 1px solid currentColor; - border-radius: var(--radius-sm); + /* 둥근 사각(207-1) — 공용 단추 반경 · 알약 아님 */ + border-radius: var(--radius-buttons); background: transparent; color: var(--color-warning); font-size: var(--text-caption); @@ -193,14 +194,24 @@ font-weight: 600; } -/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 줄 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게 */ +/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 칸 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게. + 207-1 — 줄 아래 카드 위에 겹쳐 뜸(흐름 밖) · 열고 닫아도 줄 높이 · 카드 자리 그대로 */ .b06-section__notice-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1; display: flex; flex-wrap: wrap; gap: var(--spacing-4); max-height: 30vh; overflow: auto; - padding-top: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); color: var(--color-warning); } @@ -213,6 +224,13 @@ white-space: nowrap; } +/* 지금 고른 측점(207-1) — 카드 고름 색과 같음 */ +.b06-section__notice-list > button.is-selected { + outline: 2px solid var(--color-danger); + outline-offset: -1px; + font-weight: 600; +} + .b06-cross-card { cursor: pointer; transition: diff --git a/resources/tester/test_196_1_b06_notice_chips.py b/resources/tester/test_196_1_b06_notice_chips.py index 7027d4783..82ea5dbb6 100644 --- a/resources/tester/test_196_1_b06_notice_chips.py +++ b/resources/tester/test_196_1_b06_notice_chips.py @@ -144,9 +144,10 @@ def test_측점_누르면_그_카드로_다시_그려도_연_칩_유지_0이면_ """ bar.update(items(["a", "b"], [], [])); chip("fill").click(); -panel.children[1].click(); +panel.children[1].click(); // 207-1 — 고르면 내용 칸 접힘 chipsRow.children[0].click(); chipsRow.children[0].click(); // 칩 누름은 측점 고름 아님 out.picks = picks.slice(); +chip("fill").click(); bar.update(items(["a", "b", "x"], [], [])); out.kept = view(); bar.update(items([], ["p"], [])); @@ -165,7 +166,7 @@ out.gone = view(); def test_알림_줄은_칩을_쓰고_긴_설명은_호버_짧은_이름은_사전(): src = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8") assert " str: + body = CSS[CSS.index(selector + " {") :] + return body[: body.index("}")] + + +def _node(body: str): + script = ( + PRELUDE + + """ +const sel = { id: null }; +const picked = []; +const bar2 = mod.exports.createNoticeChips((id) => { picked.push(id); sel.id = id; }, () => sel.id); +const [chips2, panel2] = bar2.root.children; +const chip2 = (key) => chips2.children.find((c) => c.dataset.noticeKey === key); +const marks = () => panel2.children.map((b) => [b.dataset.stationId, b.classList.contains("is-selected")]); +""" + + body + + "\nconsole.log(JSON.stringify(out));" + ) + got = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert got.returncode == 0, got.stderr + return json.loads(got.stdout) + + +def test_칩은_둥근_사각_공용_단추_반경_알약_아님(): + chip = _rule(".b06-section__notice-chip") + assert "border-radius: var(--radius-buttons)" in chip + assert "radius-pills" not in chip and "radius-sm" not in chip + + +def test_내용_칸은_흐름_밖_카드_위에_겹침(): + panel = _rule(".b06-section__notice-list") + assert "position: absolute" in panel and "top: 100%" in panel + assert "z-index" in panel and "box-shadow: var(--shadow-lg)" in panel + assert "background: var(--color-surface-raised)" in panel + # 기준 = 고정 줄(sticky 는 absolute 의 기준 상자) · 그 사이에 기준을 바꾸는 position 없음 + assert "position: sticky" in _rule(".b06-section__notice-bar") + assert "position" not in _rule(".b06-section__notice-blocks") + + +def test_열고_닫아도_카드_자리_그대로_scroll_margin은_칩_줄_높이만(): + view = (B06 / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8") + # 줄 높이 = 고정 줄 offsetHeight — 흐름 밖 내용 칸은 높이에 안 듦 + assert '"--b06-notice-h", `${fillSlopeNotice.root.offsetHeight}px`' in view + assert "scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8))" in CSS + # 내용 칸만 흐름 밖 — 칩 줄은 흐름 안 + assert "position" not in _rule(".b06-section__notice-chips") + + +def test_측점_고르면_그_카드로_가며_내용_칸_접힘(): + got = _node( + """ +bar2.update(items(["a", "b"], ["p"], [])); +chip2("fill").click(); +out.before = panel2.hidden; +panel2.children[1].click(); +out.picked = picked.slice(); +out.after = panel2.hidden; +out.rows = panel2.children.length; +out.expanded = chips2.children.map((c) => c.getAttribute("aria-expanded")); +""" + ) + assert got["before"] is False + assert got["picked"] == ["b"] + assert got["after"] is True and got["rows"] == 0 + assert got["expanded"] == ["false", "false"] + + +def test_고른_측점이_있으면_열_때_그_단추_강조_없으면_강조_없음(): + got = _node( + """ +bar2.update(items(["a", "b", "c"], ["b"], [])); +chip2("fill").click(); +out.none = marks(); +chip2("fill").click(); +sel.id = "b"; // 측점 횡단도를 고른 상태 +chip2("fill").click(); +out.fill = marks(); +chip2("pave").click(); +out.pave = marks(); +sel.id = "zz"; // 목록에 없는 측점 +chip2("fill").click(); +out.absent = marks(); +""" + ) + assert got["none"] == [["a", False], ["b", False], ["c", False]] + assert got["fill"] == [["a", False], ["b", True], ["c", False]] + assert got["pave"] == [["b", True]] + assert got["absent"] == [["a", False], ["b", False], ["c", False]] + + +def test_고른_측점_넘김_부르는_쪽_이음과_강조_색(): + notice = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8") + assert "createNoticeChips(onPick, selected)" in notice + view = (B06 / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8") + assert "() => selectedStationId," in view + mark = _rule(".b06-section__notice-list > button.is-selected") + assert "var(--color-danger)" in mark # 카드 고름 색과 같음 + assert "border-color: var(--color-danger)" in _rule(".b06-cross-card--selected") From dc9d2a6542ed02b4746b179726a5194347dc2c13 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:44:04 +0900 Subject: [PATCH 14/38] =?UTF-8?q?fix(B06):=20=EC=96=91=EC=B8=A1=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=20=EC=B8=A1=EC=A0=90=20=EC=B8=A1=EA=B5=AC=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=3D=20=ED=8C=90=EC=A0=95=20=EC=97=AD?= =?UTF-8?q?=ED=95=A0=20=EA=B8=B0=EC=A4=80=20=C2=B7=20=EC=98=9B=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B6=84=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=C2=B7=20?= =?UTF-8?q?=EA=B7=B8=EB=A0=A4=EC=A7=84=20=EC=B8=A1=EA=B5=AC=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EB=B3=B4=EC=9E=84(206-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원인: 측구 판정의 양측 성토 조건이 입력 단면유형(옛 저장 right_cut)을 봄 · 계획고 변경으로 지형 판정은 양측 성토인데 옛 결과 ditch_enabled 가 선택으로 둔갑해 측구가 섬 - 엔진(파이썬 · TS 짝): 판정 역할(양쪽 성토)로 측구 없음 · 남은 선택 비움 - 낡음 판정: 양측 성토 + 측구 결과 저장분 → B05 · B06 진입 때 다시 셈 - 179-2 숨김: 측구가 그려져 있으면 측구 · 형식 단추 보임 - 시험: test_206_1(검증_sub1 No.1+0 사본 재현 · 거울) · 거울 시험 ditch_choice 칸 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B06_Section_Engine_Design_Geometry.py | 12 +- B06_Section/B06_Section_UI_Cross_Design.ts | 6 +- B06_Section/B06_Section_UI_Section_Common.ts | 4 + .../common_util_cross_design_geometry.ts | 11 +- .../tester/test_206_1_both_fill_no_ditch.py | 159 ++++++++++++++++++ .../tester/test_b06_cross_design_mirror.py | 1 + 6 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_206_1_both_fill_no_ditch.py diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 1bc4121df..3249f58da 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -121,7 +121,11 @@ class _SectionGeometry: # ⚠ 옛 저장분(`ditch_choice` 가 없던 것)은 `legacy_ditch_enabled` 로 온다. 그 값은 # **자동값과 다를 때만 뜻이 있다** — 같으면 자동이 그렇게 냈던 것이고, 다르면 # 사용자가 일부러 바꾼 것이다. 그래서 **다를 때만** 선택으로 살린다(설계 의도 보존). - if section_mode == "both_fill": + # ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1) — 입력 `section_mode` 는 옛 저장값일 수 + # 있다(계획고를 올려 우 절토 → 양측 성토 · 입력은 right_cut 그대로). 입력으로 가르면 옛 + # 측구 결과가 선택으로 둔갑해 양측 성토 단면에 측구가 섰다. ⚠ TS 짝 `SectionGeometry`. + both_fill = self.left_role == "fill" and self.right_role == "fill" + if both_fill: auto_ditch = False elif ground_at is not None: ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent @@ -136,10 +140,10 @@ class _SectionGeometry: # 없는데, 그것을 선택으로 굳히면 **다시 같은 병**(지형이 바뀌어도 안 따라감)이 된다. if choice is not None and bool(choice) == auto_ditch: choice = None - # 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다. - self.has_ditch = auto_ditch if (choice is None or section_mode == "both_fill") else choice + # 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움. + self.has_ditch = auto_ditch if (choice is None or both_fill) else choice #: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. - self.ditch_choice = choice + self.ditch_choice = None if both_fill else choice # 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** # 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 7bc478776..0c7e07f68 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -388,8 +388,10 @@ export function buildDesignControls( groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). // 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에. - const rockCut = isRock(state.ground) && state.mode !== "both_fill"; - const hasDitch = state.mode !== "both_fill"; + // 숨김은 「막힘 + 그려진 측구 없음」일 때만(206-1) — 측구가 그려져 있으면 단추를 보여 끌 수 있게. + const ditchDrawn = design?.ditch_enabled === true; + const rockCut = isRock(state.ground) && (state.mode !== "both_fill" || ditchDrawn); + const hasDitch = state.mode !== "both_fill" || ditchDrawn; // 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다. // 화면 표시는 **결과**를 보인다 — 선택이 없으면 자동으로 선 결과가 답이다. const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true; diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index bfda291de..105bcddb9 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -106,6 +106,8 @@ interface StaleCandidate { geometry_preset?: string; two_stage_slope?: boolean; surface_drop_m?: number; + section_mode?: string; + ditch_enabled?: boolean; } | null; } @@ -120,6 +122,8 @@ function isStaleSection( // 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의 // 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다. if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; + // 옛 엔진이 양측 성토에 측구를 세워 둔 저장분(206-1) — 지금 엔진으로 다시 셈. + if (design.section_mode === "both_fill" && design.ditch_enabled === true) return true; if (!profiles?.length) return false; const planned = designElevationAt(profiles, section.chainage_m); if (planned === undefined) return false; diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 80630a777..b67b2d67e 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -188,8 +188,11 @@ export class SectionGeometry { // `hasDitch`** 로 가른다. // ⚠ 옛 저장분은 `ditchEnabled` 로 온다. 그 값은 **자동값과 다를 때만 뜻이 있다** — // 같으면 자동이 그렇게 냈던 것이고, 다르면 사용자가 일부러 바꾼 것이다. + // ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1 · 짝: 파이썬) — 입력 `sectionMode` 는 옛 + // 저장값일 수 있다. 입력으로 가르면 옛 측구 결과가 선택으로 둔갑해 양측 성토에 측구가 섰다. + const bothFill = this.leftRole === "fill" && this.rightRole === "fill"; let autoDitch: boolean; - if (params.sectionMode === "both_fill") { + if (bothFill) { autoDitch = false; } else if (groundAt !== null) { const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; @@ -206,10 +209,10 @@ export class SectionGeometry { // 「자동으로 되돌리기」 단추가 없고, 원래 값으로 다시 누른 것을 선택으로 굳히면 // **다시 같은 병**이 된다. if (choice !== null && Boolean(choice) === autoDitch) choice = null; - // 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다. - this.hasDitch = choice === null || params.sectionMode === "both_fill" ? autoDitch : choice; + // 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움. + this.hasDitch = choice === null || bothFill ? autoDitch : choice; /** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */ - this.ditchChoice = choice; + this.ditchChoice = bothFill ? null : choice; // 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** // 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`. diff --git a/resources/tester/test_206_1_both_fill_no_ditch.py b/resources/tester/test_206_1_both_fill_no_ditch.py new file mode 100644 index 000000000..9e5d603a4 --- /dev/null +++ b/resources/tester/test_206_1_both_fill_no_ditch.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +"""PLAN 206-1 — 양측 성토 측점에 측구가 남는 흠(검증_sub1 No.1+0 사본 재현). + +원인 — 측구 판정의 「양측 성토 = 측구 없음」이 **입력** `section_mode` 를 봤다. 입력은 옛 저장값 +(초기값 right_cut)이고, 계획고를 +1.98 m 올려 지형 판정(역할)은 양측 성토가 됐는데 입력이 그대로라 +옛 결과 `ditch_enabled=True` 가 사용자 선택으로 둔갑(ditch_choice=True)해 측구가 섰다. +좌 · 우를 누르면 카드가 판정값 both_fill 을 입력으로 다시 보내 그때서야 사라졌다. +고침 — 판정된 역할(양쪽 성토)로 가름 · 파이썬 · TS 짝 같은 규칙 · 옛 저장분은 낡음으로 다시 셈. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 + +# 검증_sub1(7e420ed6) 노선 230 · No.1+0(20 m) 지반선 사본 — -20 ~ +20 m · 0.5 m 간격 +_Z = [ + 885.0166, 884.8327, 884.6401, 884.4294, 884.2270, 883.7564, 883.2482, 882.8840, 882.5737, + 882.3786, 882.2795, 881.9692, 881.3788, 880.7761, 880.1219, 879.4944, 879.1738, 878.8550, + 878.6597, 878.4670, 878.1648, 877.8416, 877.5119, 877.1749, 876.8564, 876.5584, 876.2612, + 875.9646, 875.6791, 875.4169, 875.1405, 874.7286, 874.3079, 874.0026, 873.7065, 873.4074, + 873.1107, 872.7726, 872.4127, 872.1306, 871.9252, 871.7424, 871.6078, 871.3723, 870.7716, + 870.1964, 869.8586, 869.5135, 869.2563, 869.0105, 868.5769, 868.0912, 867.7274, 867.4369, + 867.1615, 866.9033, 866.6590, 866.4489, 866.2281, 865.9711, 865.7139, 865.4391, 865.1650, + 864.8953, 864.6289, 864.3219, 863.9996, 863.6703, 863.3343, 863.0443, 862.8261, 862.5776, + 862.2364, 861.9035, 861.6491, 861.3932, 861.1137, 860.8302, 860.6107, 860.4051, 860.2006, +] # fmt: skip +SAMPLES = [{"offset_m": -20.0 + 0.5 * i, "elevation_m": z} for i, z in enumerate(_Z)] +ELEV = 873.900731 # 지금 계획고(초기값 871.9252 에서 +1.98 m) +# 그 프로젝트 저장분과 같은 입력 — 옛 단면유형 · 옛 측구 결과 +STORED = { + "ground_type": "ripping_rock", + "ditch_side": "right", + "ditch_type": "standard", + "rock_boundary_offset_m": -0.5, + "two_stage_slope": True, +} + + +_GROUP = { + "road_width_m": 3, + "shoulder_left_m": 0.5, + "shoulder_right_m": 0.5, + "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "fill_slope_ratio": 1.2, +} +# 그 프로젝트 종단 options 의 표준 횡단면 +STANDARD = { + "soil": {**_GROUP, "cross_slope_pct": {"min": 3, "max": 5}, "cut_slope_ratio": 1}, + "rock": { + **_GROUP, + "ditch_l_type": {"width_m": 0.5, "depth_m": 0.1}, + "cross_slope_pct": {"min": 3, "max": 5}, + "cut_slope_ratio": 0.4, + }, + "paved": { + **_GROUP, + "cross_slope_pct": {"min": 1.5, "max": 2}, + "cut_slope_ratio": 1, + "pavement_thickness_m": 0.2, + }, +} + + +def _design(section_mode: str, **kwargs): + return compute_cross_design( + SAMPLES, ELEV, section_mode=section_mode, standard=STANDARD, **STORED, **kwargs + ) + + +@pytest.mark.parametrize( + "stale", + [ + {"ditch_enabled": True}, # 옛 결과만(초기값 → 계획고 변경 재계산) + {"ditch_enabled": True, "ditch_choice": True}, # 둔갑한 선택이 저장된 뒤(지금 DB) + ], +) +@pytest.mark.parametrize("mode", ["right_cut", "both_cut", "left_cut"]) +def test_사본_양측_성토면_옛_입력이어도_측구_없음(mode: str, stale: dict) -> None: + design = _design(mode, **stale) + assert design["section_mode"] == "both_fill" + assert design["ditch_enabled"] is False, "양측 성토인데 측구가 섰다" + assert design["ditch_area_m2"] == 0.0 + assert design["ditch"] == {"type": "none"} + assert design["ditch_choice"] is None, "옛 결과가 선택으로 남았다" + + +def test_사본_옛_입력과_바른_입력이_같은_형상() -> None: + """좌 · 우를 눌러야만 사라지던 것 — 이제 입력이 옛 값이어도 처음부터 같은 형상 · 면적.""" + stale = _design("right_cut", ditch_enabled=True, ditch_choice=True) + fresh = _design("both_fill") + for key in ("design_line", "fill_area_m2", "cut_area_m2", "ditch_area_m2"): + assert stale[key] == fresh[key], key + assert fresh["fill_area_m2"] == pytest.approx(34.0513, abs=1e-4) + # 우 노견 바깥에 측구 꼭짓점(-2.3 · -2.6 · -2.9 m = 상단 0.9 · 저폭 0.3 사다리꼴)이 없다 + offsets = {p["offset_m"] for p in fresh["design_line"]} + assert not offsets & {-2.3, -2.6, -2.9} + + +def test_한쪽이라도_절토면_측구_그대로() -> None: + """양측 성토 판정은 역할로만 — 우 절토 지형은 옛 입력 both_fill 이어도 측구 섬.""" + design = _design("both_fill", ditch_enabled=False) + assert design["section_mode"] == "both_fill" # 이 지형은 양측 성토 + cut = compute_cross_design(SAMPLES, ELEV - 2.5, section_mode="both_fill", **STORED) + assert cut["section_mode"] in ("right_cut", "both_cut") + assert cut["ditch_enabled"] is True and cut["ditch_side"] == "right" + + +def test_TS_짝도_같은_값(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """거울 — 같은 사본을 TS `computeCrossDesign` 에 넣어 파이썬과 같은지(측구 없음까지).""" + import test_b06_cross_design_mirror as mirror + + cases = [ + { + "name": f"206-1 사본 {mode}", + "samples": SAMPLES, + "design_elevation_m": ELEV, + "options": { + "section_mode": mode, + **STORED, + "ditch_enabled": True, + "ditch_choice": True, + }, + } + for mode in ("right_cut", "both_fill") + ] + monkeypatch.setattr(mirror, "CASES", cases) + py, ts = mirror._python_results(), mirror._ts_results(tmp_path) + problems = [] + for case, left, right in zip(cases, py, ts): + problems.extend(mirror._diff(left, right, case["name"])) + assert not problems, "\n".join(problems[:20]) + assert all(item["ditch_enabled"] is False for item in ts) + + +def _src(rel: str) -> str: + return (PROJECT_ROOT / rel).read_text(encoding="utf-8") + + +def test_옛_저장분은_낡음으로_다시_셈() -> None: + """DB 에 남은 양측 성토 + 측구 결과 → B05 · B06 진입 때 브라우저가 다시 셈(공용 낡음 판정).""" + common = _src("B06_Section/B06_Section_UI_Section_Common.ts") + assert 'design.section_mode === "both_fill" && design.ditch_enabled === true' in common + + +def test_측구가_그려져_있으면_단추를_숨기지_않음() -> None: + """179-2 숨김은 「막힘 + 그려진 측구 없음」일 때만 — 그려진 측구는 끌 수 있어야 함.""" + cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts") + assert "const ditchDrawn = design?.ditch_enabled === true;" in cd + assert 'const hasDitch = state.mode !== "both_fill" || ditchDrawn;' in cd + assert '(state.mode !== "both_fill" || ditchDrawn)' in cd # 측구형식도 같은 규칙 diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index 151858314..d5169229f 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -234,6 +234,7 @@ const camel = { two_stage_slope: "twoStageSlope", cut_slope_ratio: "cutSlopeRatio", ditch_enabled: "ditchEnabled", + ditch_choice: "ditchChoice", surface_drop_m: "surfaceDropM", plan_radius_m: "planRadiusM", curve_outer_side: "curveOuterSide", From 3a2bef58db96543f92e9a0866163b42ee98547e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:44:25 +0900 Subject: [PATCH 15/38] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=9A=94=EA=B5=AC=5F1010=5F2=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=EB=B3=B8=2065~66=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 9cd35f849..65e94afc6 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -73,3 +73,17 @@ 50. 대시보드의 구조물옵션 정의가 제대로 연결안됨. 51. B08의 구조물 집계표에서 미연결 상태표시는 행의 좌측에 하나의 램프로 표기, 미지정 열을 필요없어 보임. 52. B08의 페이지들을 보면 합계가 나오는 행들을 보면 초기에는 행들이 추가되다가 특정 컨테이너 높이가 되면 합계 행을 고정하고 내부를 스크롤 방식으로 변경하고 있음. 이방법 자체는 좋은데 그렇다면 컨테이너의 높이제약을 현재 브라우져가 스크롤이 안되는 지점을 기준으로 적용하는게 조작 편의성이 좋아보임. 그럼 행들이 추가됨에 따라 밀리더라도 스크롤은 페이지스크롤은 없을테니 +53. B09의 박스보기 버튼과 가로 숫자 버튼, 그리고 그위의 계산들에 대한 안내는 같은행으로 변경 +54. 박스보기의 경우 지정된 카드 행들은 높이를 같게 변경 +55. 설계내역서의 공종 및 명칭의 글자들은 좌측 맞춤이고 좌측에 들여쓰기로 레벨이 표현되는게 맞아보임. 그리고 레벨 제어를 어떻게 하는지 모르겠음. +56. 박스보기에서 제목과 합계 정보열의 색은 다른색으로 지정하는게 테마고려하여 선택 +57. B08/B09의 테이블 양식이 에서 레벨이 필요한 정보들은 설계내역서의 레벨지정 양식을 따라가되 제어가 안되게 반영하면 일원화되지 않을까? 그리고 셀에 같은 값으로 표현되는 행들은 결국 병합인것같은데 맞지 않아? +58. 제비율표는 일력하는 공사원가계산서와 총괄설계내역서의 내용에서 요율을 원문 제비율표를 보고 누락이 된게 없는지 봐줘. (조사만) +59. 현재 B08/B09의 테이블 컨테이너의 양식을 보면 제일상단에 경고줄이 있어 이걸제일하단으로 옮겨줘. 이때 박스보기와 가로 카드수 정의는 보조탭 행에 우측맞춤으로 이동 +60. 다음으로 B09의 좌측패널의 원가 단가 컨테이너의 이름을 데이터 점검으로 이름을 변경하고 내부의 쓸대없는 설명은 삭제, 단가 갱신버튼의 제목을 업데이트, 업데이트가 필요한경우 버튼에 오버레이 마크 추가, 상세 페이지의 연결점검 탭은 데이터 점검 컨테이너 내부로 이동 +61. B09의 설계내역서에서 행들을 레벨이나 이동을 드래그나 키보드의 방향키로 제어가 되어야함. 구현하고 결국 내어쓰기와 들여쓰기는 결국 레벨이 되어야함. 상위 레벨로 이동이 되면 자연스럽게 하위항목의 합계가 세부항목이 되면 상위로 데이터를 전달하는 역할을 그리고 만약 기존 상위에 있던것들이 하위로 이동할경우에는 같이 이동되는 기능도 있어야함. 다음으로 일위대가와 단가산출의 정보를 연결할때 중요한건 마스터/회사/개인의 정보중 구조물조합, 일위대가사전조합, 현재처럼 벌써 들어와 있는 일위대가와 호표를 지정할 수도 있어야함. +62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만) +63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현 +64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임) +65. B06의 상단의 성토 5m 이상, 포장 권장, 소단 검토에서 버튼의 형상을 사각 라운드로 변경하고 누를시 나오는 버튼들은 현재를 유지, 그리고 메인 페이지의 스크롤이 제일 상단에 있을경우 상단 버튼으로 내역을 열면 페이지가 아래로 밀림. 안밀리게 변경, 그리고 항목을 선택하면 이동하면서 해당 버튼셋은 접힘으로 그리고 만약 선택된 상태에서 다시 열면 하이라이트 기능, 다시 말해 사용자가 측점 횡단도를 누른상태에서 상단 3버튼을 누르면 하이라이트 되게 변경 +66. M01의 단가산출로직들의 항목에 신규열인 별칭 열이 필요함 설계자들이 찾을때 사용하는 명칭과 다른 경우가 있음. 이를 보완하기 위해 별칭열을 추가해줘. 내가 별칭을 있을수도 있고 없을 수도 있지만 값은 내가 넣을거야. 그리고 이때 프로그램 전역에서 단가산출 관련 검색은 별칭도 같이 검색이 되게 해야함. From 12feefadb55f3340c3038982680b4e89e6697455 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:08:18 +0900 Subject: [PATCH 16/38] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=9A=94=EA=B5=AC=201010=5F2=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=B6=94=EA=B0=80=EB=B6=84(53~74)=20=EB=B3=B4?= =?UTF-8?q?=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 9cd35f849..32f0ba0a3 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -73,3 +73,27 @@ 50. 대시보드의 구조물옵션 정의가 제대로 연결안됨. 51. B08의 구조물 집계표에서 미연결 상태표시는 행의 좌측에 하나의 램프로 표기, 미지정 열을 필요없어 보임. 52. B08의 페이지들을 보면 합계가 나오는 행들을 보면 초기에는 행들이 추가되다가 특정 컨테이너 높이가 되면 합계 행을 고정하고 내부를 스크롤 방식으로 변경하고 있음. 이방법 자체는 좋은데 그렇다면 컨테이너의 높이제약을 현재 브라우져가 스크롤이 안되는 지점을 기준으로 적용하는게 조작 편의성이 좋아보임. 그럼 행들이 추가됨에 따라 밀리더라도 스크롤은 페이지스크롤은 없을테니 +53. B09의 박스보기 버튼과 가로 숫자 버튼, 그리고 그위의 계산들에 대한 안내는 같은행으로 변경 +54. 박스보기의 경우 지정된 카드 행들은 높이를 같게 변경 +55. 설계내역서의 공종 및 명칭의 글자들은 좌측 맞춤이고 좌측에 들여쓰기로 레벨이 표현되는게 맞아보임. 그리고 레벨 제어를 어떻게 하는지 모르겠음. +56. 박스보기에서 제목과 합계 정보열의 색은 다른색으로 지정하는게 테마고려하여 선택 +57. B08/B09의 테이블 양식이 에서 레벨이 필요한 정보들은 설계내역서의 레벨지정 양식을 따라가되 제어가 안되게 반영하면 일원화되지 않을까? 그리고 셀에 같은 값으로 표현되는 행들은 결국 병합인것같은데 맞지 않아? +58. 제비율표는 일력하는 공사원가계산서와 총괄설계내역서의 내용에서 요율을 원문 제비율표를 보고 누락이 된게 없는지 봐줘. (조사만) +59. 현재 B08/B09의 테이블 컨테이너의 양식을 보면 제일상단에 경고줄이 있어 이걸제일하단으로 옮겨줘. 이때 박스보기와 가로 카드수 정의는 보조탭 행에 우측맞춤으로 이동 +60. 다음으로 B09의 좌측패널의 원가 단가 컨테이너의 이름을 데이터 점검으로 이름을 변경하고 내부의 쓸대없는 설명은 삭제, 단가 갱신버튼의 제목을 업데이트, 업데이트가 필요한경우 버튼에 오버레이 마크 추가, 상세 페이지의 연결점검 탭은 데이터 점검 컨테이너 내부로 이동 +61. B09의 설계내역서에서 행들을 레벨이나 이동을 드래그나 키보드의 방향키로 제어가 되어야함. 구현하고 결국 내어쓰기와 들여쓰기는 결국 레벨이 되어야함. 상위 레벨로 이동이 되면 자연스럽게 하위항목의 합계가 세부항목이 되면 상위로 데이터를 전달하는 역할을 그리고 만약 기존 상위에 있던것들이 하위로 이동할경우에는 같이 이동되는 기능도 있어야함. 다음으로 일위대가와 단가산출의 정보를 연결할때 중요한건 마스터/회사/개인의 정보중 구조물조합, 일위대가사전조합, 현재처럼 벌써 들어와 있는 일위대가와 호표를 지정할 수도 있어야함. +62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만) +63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현 +64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임) +65. B06의 상단의 성토 5m 이상, 포장 권장, 소단 검토에서 버튼의 형상을 사각 라운드로 변경하고 누를시 나오는 버튼들은 현재를 유지, 그리고 메인 페이지의 스크롤이 제일 상단에 있을경우 상단 버튼으로 내역을 열면 페이지가 아래로 밀림. 안밀리게 변경, 그리고 항목을 선택하면 이동하면서 해당 버튼셋은 접힘으로 그리고 만약 선택된 상태에서 다시 열면 하이라이트 기능, 다시 말해 사용자가 측점 횡단도를 누른상태에서 상단 3버튼을 누르면 하이라이트 되게 변경 +66. M01의 단가산출로직들의 항목에 신규열인 별칭 열이 필요함 설계자들이 찾을때 사용하는 명칭과 다른 경우가 있음. 이를 보완하기 위해 별칭열을 추가해줘. 내가 별칭을 있을수도 있고 없을 수도 있지만 값은 내가 넣을거야. 그리고 이때 프로그램 전역에서 단가산출 관련 검색은 별칭도 같이 검색이 되게 해야함. + +## 셋째 붙임 (10-10 오후) +67. B06 횡단도 상세 모달 — 셀에 지정된 면적과 길이값을 선택하게 하였는데 안잡히는 경우가 있어 개선이 필요함. 상세 모달창에서 선들이 교차되는 지점들을 기준으로 선들이 분할되어야함. 그리고 선택할때는 다중 선택을 하면 원지반도 가능해야함. 그럼 문제 없어보임. 그럼 면적에서도 폐쇄 영역을 지정하기 좋아보임. 추가로 선택영 지점의 면적과 선들의 길이의 값을 보는 기능이 있었으면 함. 이는 메인페이지의 기능이 있으니 확인, 잘 반영되었다면 메인페이지에도 구현한 생각임. +68. 간혹가다 B06의 상단에 누락된 구조물 측점이라고 측점추가 버튼이 나오는데 이유는? (조사) +69. 관보호공 유입 측 흙막이의 경우에 기본 높이를 배관 + 0.5m로 지정되어 있을것임. 이걸 0.7m로 변경 +70. 도수로/돌붙입의 구조물 형상을 잡고 이동하는 경우 토스트 메세지로 '기준측점을 옮겼습니다 ....' 나오는데 나는 도수로와 돌붙임의 이동에 대해 요청한건 없음. 왜그런지 분석하고 알려줘. (조사) +71. B07 (기반영요청사항) 횡단도의 격자에 배치를 옮기기 위해 선을 선택할때 원지반선이 안잡힘 +72. B07 (기반영요청사항) 추가로 레이어별로 측점의 원지반선과 설계선을 잡을 수 있는 레이어 자동 생성 +73. B07 횡단도의 붉은색 격사선을 하이드쇼할수 있는 버튼 기능은 좋은데 뒤에 검은 격자선이 하나더 있음. 확인 후 삭제 +74. B07 횡단도 표준 옵션의 버튼들이 양식화와 일원화 안되어 있음. 확인후 정리할 수 있는 양식으로 적용 From e632dc729ac5996c5830c54f2860e5f381ec9ab2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:09:57 +0900 Subject: [PATCH 17/38] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2065~74(=EC=85=8B=EC=A7=B8=20=EB=B6=99=EC=9E=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 6a79287dd..32f0ba0a3 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -85,3 +85,15 @@ 62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만) 63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현 64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임) +65. B06의 상단의 성토 5m 이상, 포장 권장, 소단 검토에서 버튼의 형상을 사각 라운드로 변경하고 누를시 나오는 버튼들은 현재를 유지, 그리고 메인 페이지의 스크롤이 제일 상단에 있을경우 상단 버튼으로 내역을 열면 페이지가 아래로 밀림. 안밀리게 변경, 그리고 항목을 선택하면 이동하면서 해당 버튼셋은 접힘으로 그리고 만약 선택된 상태에서 다시 열면 하이라이트 기능, 다시 말해 사용자가 측점 횡단도를 누른상태에서 상단 3버튼을 누르면 하이라이트 되게 변경 +66. M01의 단가산출로직들의 항목에 신규열인 별칭 열이 필요함 설계자들이 찾을때 사용하는 명칭과 다른 경우가 있음. 이를 보완하기 위해 별칭열을 추가해줘. 내가 별칭을 있을수도 있고 없을 수도 있지만 값은 내가 넣을거야. 그리고 이때 프로그램 전역에서 단가산출 관련 검색은 별칭도 같이 검색이 되게 해야함. + +## 셋째 붙임 (10-10 오후) +67. B06 횡단도 상세 모달 — 셀에 지정된 면적과 길이값을 선택하게 하였는데 안잡히는 경우가 있어 개선이 필요함. 상세 모달창에서 선들이 교차되는 지점들을 기준으로 선들이 분할되어야함. 그리고 선택할때는 다중 선택을 하면 원지반도 가능해야함. 그럼 문제 없어보임. 그럼 면적에서도 폐쇄 영역을 지정하기 좋아보임. 추가로 선택영 지점의 면적과 선들의 길이의 값을 보는 기능이 있었으면 함. 이는 메인페이지의 기능이 있으니 확인, 잘 반영되었다면 메인페이지에도 구현한 생각임. +68. 간혹가다 B06의 상단에 누락된 구조물 측점이라고 측점추가 버튼이 나오는데 이유는? (조사) +69. 관보호공 유입 측 흙막이의 경우에 기본 높이를 배관 + 0.5m로 지정되어 있을것임. 이걸 0.7m로 변경 +70. 도수로/돌붙입의 구조물 형상을 잡고 이동하는 경우 토스트 메세지로 '기준측점을 옮겼습니다 ....' 나오는데 나는 도수로와 돌붙임의 이동에 대해 요청한건 없음. 왜그런지 분석하고 알려줘. (조사) +71. B07 (기반영요청사항) 횡단도의 격자에 배치를 옮기기 위해 선을 선택할때 원지반선이 안잡힘 +72. B07 (기반영요청사항) 추가로 레이어별로 측점의 원지반선과 설계선을 잡을 수 있는 레이어 자동 생성 +73. B07 횡단도의 붉은색 격사선을 하이드쇼할수 있는 버튼 기능은 좋은데 뒤에 검은 격자선이 하나더 있음. 확인 후 삭제 +74. B07 횡단도 표준 옵션의 버튼들이 양식화와 일원화 안되어 있음. 확인후 정리할 수 있는 양식으로 적용 From f1b22087655b34f1989732dea94d6861a1a2849b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:11:01 +0900 Subject: [PATCH 18/38] =?UTF-8?q?feat(M01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EB=B3=84=EC=B9=AD=20=EC=B9=B8?= =?UTF-8?q?=20=C2=B7=20=EB=A1=9C=EC=A7=81=20=EC=B0=BE=EA=B8=B0=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EB=A7=9E=EC=B6=A4=EC=97=90=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8(208-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- M01_MasterData/M01_MasterData_Store.py | 15 +- M01_MasterData/M01_MasterData_Store_Find.py | 20 ++ M01_MasterData/M01_MasterData_UI_LogicLab.ts | 30 ++- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 6 + .../M01_MasterData_UI_Logic_List.ts | 72 +++++-- .../M01_MasterData_UI_Logic_Pick.ts | 14 +- .../M01_MasterData_UI_Logic_Style.css | 17 ++ .../M01_MasterData_UI_Logic_Text.ts | 3 +- .../DataFlow/M02_DF_InputPanel_Build.ts | 9 +- .../DataFlow/M02_DF_InputPanel_Logic.ts | 6 + .../M02_MasterTemplete_Labor_Pick.ts | 7 +- resources/tester/test_208_1_logic_alias.py | 185 ++++++++++++++++++ resources/tester/test_m02_df_input_logic.py | 17 +- ui_template/ui_template_logic_find.ts | 53 +++++ 14 files changed, 411 insertions(+), 43 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Find.py create mode 100644 resources/tester/test_208_1_logic_alias.py create mode 100644 ui_template/ui_template_logic_find.ts diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 0e95c8319..8cb590c58 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -26,6 +26,8 @@ import master_keys as mk # noqa: E402 import master_text as mtx # noqa: E402 from M01_MasterData import M01_MasterData_Store_Cache as cache # noqa: E402 +from M01_MasterData.M01_MasterData_Store_Find import hit as _hit # noqa: E402 +from M01_MasterData.M01_MasterData_Store_Find import squash as _squash # noqa: E402 from M01_MasterData.M01_MasterData_Store_Shape import dump, keep_shape # noqa: E402 _COPY = os.environ.get("AISLO_MASTER_DIR") # 화면 저장 시험 사본 폴더(정함 13) · 없으면 정본 @@ -88,11 +90,6 @@ def chapter_of(file: str, data: dict | None = None) -> str: return f"{division} {m.group(1)}장 {m.group(2).replace('_', ' ')}".strip() -def _hit(row: dict, q: str) -> bool: - q = _squash(q) # 띄어쓰기 무시 — 「골 함 석」 ↔ 「함석」 - return not q or any(q in _squash(row.get(k, "")) for k in ("키", "원문번호", "이름", "옛이름")) - - def _book(data: dict) -> str | None: """머리 원문 이름 — 한 테이블 파일(기계)은 머리 「원문」 이 갈래 묶음이라 이름이 묶음 안에.""" return data.get("원문") if isinstance(data.get("원문"), str) else None @@ -367,6 +364,7 @@ def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") "소유", ) }, + "별칭": row.get("별칭") or "", # 없으면 빈칸(208-1) "blocked": bool(reasons), "reasons": reasons, } @@ -449,17 +447,14 @@ def elements(group: str, q: str, limit: int) -> dict: continue for row in data.get(items_key(data)) or []: if _hit(row, q): - hits.append({**_brief(str(row.get("키")), row), "file": name}) + more = {"별칭": row.get("별칭") or ""} if group == "로직" else {} # 208-1 + hits.append({**_brief(str(row.get("키")), row), "file": name, **more}) return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]} _PICK = {"price": "재료_자재품목.json", "job": "인력.json"} -def _squash(text) -> str: - return "".join(str(text or "").lower().split()) - - def _lowest(row: dict) -> object: """자재품목 줄 — 값 열 다섯 가운데 낮은 값(시험 계산용) · 값 칸이 있는 줄은 그 값 그대로.""" if "값" in row: diff --git a/M01_MasterData/M01_MasterData_Store_Find.py b/M01_MasterData/M01_MasterData_Store_Find.py new file mode 100644 index 000000000..47b64ceba --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Find.py @@ -0,0 +1,20 @@ +"""찾기 맞춤 — 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 옛이름 · 별칭 가운데 하나에 들면 맞음. + +띄어쓰기 · 대소문자 무시 · 별칭 = 설계자가 로직을 찾는 다른 이름(쉼표로 여럿 · 208-1). +짝: `ui_template/ui_template_logic_find.ts` `matchLogic`(화면 쪽) · +거울 시험 `test_208_1_logic_alias.py`. +""" + +from __future__ import annotations + +FIELDS = ("키", "원문번호", "이름", "옛이름", "별칭") + + +def squash(text) -> str: + return "".join(str(text or "").lower().split()) + + +def hit(row: dict, q: str) -> bool: + words = [squash(w) for w in str(q or "").split()] + fields = [squash(row.get(k)) for k in FIELDS] + return all(any(w in f for f in fields) for w in words) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 2dd75b26c..123b3ec9d 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -135,7 +135,10 @@ export async function mountM01LogicLab( const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") }); let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음 const pending = el("span", { className: "m01-logic__muted" }); - const list = buildList((item) => void open(item.id, item.sub, item.key)); + const list = buildList( + (item) => void open(item.id, item.sub, item.key), + (item, alias) => void onAlias(item, alias), + ); const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) }); const persist = (): void => { @@ -146,8 +149,10 @@ export async function mountM01LogicLab( } const marks = new Map(); const extra: ListItem[] = []; + const aliases = new Map(); for (const [id, d] of Object.entries(drafts)) { marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited"); + if (d.row) aliases.set(id, d.row.별칭 ?? ""); if (d.origKey === null && d.row) { extra.push({ id, @@ -156,6 +161,7 @@ export async function mountM01LogicLab( key: d.row.키, number: d.row.원문번호, name: d.row.이름, + alias: d.row.별칭 ?? "", unit: d.row.결과단위, source: d.row.출처, blocked: false, @@ -163,7 +169,7 @@ export async function mountM01LogicLab( }); } } - list.setMarks(marks, extra); + list.setMarks(marks, extra, aliases); const n = Object.keys(drafts).length; pending.textContent = n ? tx("Bar_Pending", { n }) : ""; saveButton.disabled = discardButton.disabled = n === 0; @@ -423,6 +429,26 @@ export async function mountM01LogicLab( persist(); }; + /** 목록 「별칭」 칸 고침(208-1) — 그 줄의 별칭만 바꿔 캐시에 · [저장] 때 다른 고침과 함께 나감 */ + const onAlias = async (item: ListItem, alias: string): Promise => { + const draft = drafts[item.id]; + if (draft && !draft.row) return; // 지운 줄 + try { + const one = draft?.origKey === null ? null : await fetchLogic(item.key); + const row = clone((draft?.row ?? one?.logic) as LogicRow); + if (alias) row.별칭 = alias; + else delete row.별칭; + if (one && JSON.stringify(row) === JSON.stringify(one.logic)) delete drafts[item.id]; + else + drafts[item.id] = draft + ? { ...draft, row } + : { sub: item.sub, file: one!.file, version: one!.version, origKey: item.key, row }; + persist(); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + }; + const onDelete = async (): Promise => { if (!opened?.row) return; if ( diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 0dcc8e932..947a8a044 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -42,6 +42,8 @@ export interface LogicRow { 덧줄?: NamedFormula[]; 끝수?: string | null; 비고?: string; + /** 설계자가 찾는 다른 이름 — 쉼표로 여럿 · 없으면 칸 없음(208-1) */ + 별칭?: string; [extra: string]: unknown; } @@ -54,6 +56,8 @@ export interface LogicSummary { 이름: string; 결과단위: string; 출처: string; + /** 없으면 빈칸(208-1) */ + 별칭?: string; blocked: boolean; reasons: string[]; } @@ -66,6 +70,8 @@ export interface ElementBrief { 단위?: unknown; 값?: unknown; 값칸?: Record | null; + /** 로직 그룹만 — 별칭(208-1) */ + 별칭?: string; /** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */ 값들?: Record; } diff --git a/M01_MasterData/M01_MasterData_UI_Logic_List.ts b/M01_MasterData/M01_MasterData_UI_Logic_List.ts index d22b7ecde..cd9a4931d 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_List.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_List.ts @@ -2,9 +2,11 @@ * M01_MasterData_UI_Logic_List.ts * 로직 목록 — 가운데 표 · 왼쪽 「전체」 + 구분 → 상세구분 거름이 고른 범위 + 이름 찾기 * 파일을 가로질러 한 목록 · 쪽 나눔 · 막힘(⛔)·고침(●) 표시 · 거르기는 화면에서(목록은 한 번 받음) + * 「별칭」 열(208-1) — 이름 옆 · 고치면 `onAlias`(캐시에 쌓고 [저장] 때 나감) · 찾기는 공용 `matchLogic` * ========================================================================== */ import { createInputField, el } from "@ui/ui_template_elements"; +import { aliasText, matchLogic } from "@ui/ui_template_logic_find"; import { buildPager } from "./M01_MasterData_UI_Cells"; import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api"; import { tx } from "./M01_MasterData_UI_Logic_Text"; @@ -19,6 +21,8 @@ export interface ListItem { key: string; number: string; name: string; + /** 쉼표로 여럿 · 없으면 빈칸 */ + alias: string; unit: string; source: string; blocked: boolean; @@ -28,8 +32,8 @@ export interface ListItem { export interface ListHandle { root: HTMLElement; setItems: (items: LogicSummary[]) => void; - /** 저장 안 한 새 로직도 목록 맨 위에 */ - setMarks: (marks: Map, extra: ListItem[]) => void; + /** 저장 안 한 새 로직도 목록 맨 위에 · aliases = 캐시에 고친 별칭(id → 글) */ + setMarks: (marks: Map, extra: ListItem[], aliases: Map) => void; /** 왼쪽 거름이 고른 범위 — 비면 전체 */ setPick: (sub: string, detail: string) => void; total: () => number; @@ -38,12 +42,27 @@ export interface ListHandle { export const logicId = (sub: string, key: string): string => `${sub}\n${key}`; const SIZE = 30; -const COLS = ["키", "원문번호", "구분", "상세구분", "이름", "결과 단위", "출처", ""] as const; +const COLS = [ + "키", + "원문번호", + "구분", + "상세구분", + "이름", + "별칭", + "결과 단위", + "출처", + "", +] as const; -export function buildList(onOpen: (item: ListItem) => void): ListHandle { +export function buildList( + onOpen: (item: ListItem) => void, + onAlias: (item: ListItem, alias: string) => void, +): ListHandle { let items: ListItem[] = []; let extra: ListItem[] = []; let marks = new Map(); + let aliases = new Map(); + const aliasOf = (x: ListItem): string => aliases.get(x.id) ?? x.alias; let page = 1; const pick = { sub: "", detail: "" }; const searchField = createInputField({ type: "search", placeholder: tx("List_Search") }); @@ -51,12 +70,19 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { const grid = el("div", { className: "m01-master__grid-wrap" }); const draw = (): void => { - const q = search.value.trim().toLowerCase(); + const q = search.value; const shown = [...extra, ...items].filter( (x) => (!pick.sub || x.sub === pick.sub) && (!pick.detail || x.detail === pick.detail) && - (!q || [x.key, x.number, x.name].some((v) => v.toLowerCase().includes(q))), + matchLogic(q, { + 키: x.key, + 원문번호: x.number, + 이름: x.name, + 구분: x.sub, + 상세구분: x.detail, + 별칭: aliasOf(x), + }).ok, ); page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE))); const rows = shown.slice((page - 1) * SIZE, page * SIZE).map(line); @@ -87,24 +113,36 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { ] .filter(Boolean) .join(" "); + const cell = (v: string): HTMLElement => el("td", { text: v, attrs: { title: v } }); const tr = el("tr", { className: `m01-master__table-row${mark ? " is-changed" : ""}`, children: [ - item.key, - item.number, - item.sub, - item.detail, - item.name, - item.unit, - item.source, - state, - ].map((v) => el("td", { text: v, attrs: { title: v } })), + ...[item.key, item.number, item.sub, item.detail, item.name].map(cell), + aliasCell(item, mark === "deleted"), + ...[item.unit, item.source, state].map(cell), + ], }); if (item.blocked) tr.title = item.reasons.join("\n"); tr.addEventListener("click", () => onOpen(item)); return tr; }; + /** 별칭 칸 — 눌러도 로직이 안 열림 · 고친 글은 「a, b」 꼴로 */ + const aliasCell = (item: ListItem, locked: boolean): HTMLElement => { + const input = el("input", { + className: "m01-logic__alias", + attrs: { type: "text", value: aliasOf(item), placeholder: "—", title: tx("List_Alias") }, + }) as HTMLInputElement; + input.disabled = locked; + input.addEventListener("click", (e) => e.stopPropagation()); + input.addEventListener("change", () => { + const next = aliasText(input.value); + input.value = next; + if (next !== aliasOf(item)) onAlias(item, next); + }); + return el("td", { children: [input] }); + }; + search.addEventListener("input", () => go(1)); let pager = buildPager(0, SIZE, 1, go); @@ -128,6 +166,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { key: x.키, number: x.원문번호, name: x.이름, + alias: x.별칭 ?? "", unit: x.결과단위, source: x.출처, blocked: x.blocked, @@ -135,9 +174,10 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { })); draw(); }, - setMarks: (next, more) => { + setMarks: (next, more, edited) => { marks = next; extra = more; + aliases = edited; draw(); }, setPick: (sub, detail) => { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts index 9a7886955..cdeee432b 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts @@ -2,6 +2,7 @@ * M01_MasterData_UI_Logic_Pick.ts * 고르기 두 가지 * ① 요소 찾기 창 — 그룹 전체에서 이름·키·원문번호로 찾아 호표 줄에 넣음(`GET /elements`) + * 로직 그룹은 별칭으로도 찾음(서버) · 별칭으로 맞은 줄은 그 별칭을 작게(공용 `matchLogic` · 208-1) * 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음 * ② 재료·기계 바꿔 고르기 — 구분 → 상세구분 → 후보 순으로 좁힘 * 바꾼 것은 시험 계산용 복사본에만 · 정본 로직은 그대로 @@ -14,6 +15,7 @@ import { el, showToast, } from "@ui/ui_template_elements"; +import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { fetchMaterials, searchElements, @@ -77,7 +79,11 @@ export function openPicker(mode: PickMode, group: string, done: PickDone): void const found = await searchElements(select.value, search.value.trim()); if (mine !== seq) return; // 최신 응답만 count.textContent = tx("Pick_Total", { n: found.total }); - list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose))); + const note = (item: ElementBrief): string => + select.value === "로직" + ? aliasNote(matchLogic(search.value, { ...item, 키: item.ref, 이름: item.이름 }).alias) + : ""; + list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose, note(item)))); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } @@ -97,12 +103,16 @@ function itemRow( item: ElementBrief, mode: PickMode, choose: (item: ElementBrief, column?: string) => void, + note = "", ): HTMLElement { const text = el("div", { className: "m01-logic__stack", children: [ el("strong", { text: `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() }), - el("span", { className: "m01-logic__muted", text: item.ref }), + el("span", { + className: "m01-logic__muted", + text: [item.ref, note].filter(Boolean).join(" · "), + }), ], }); if (mode === "table") { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Style.css index 246bb49b9..db43968d9 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Logic_Style.css @@ -266,6 +266,23 @@ input.m01-logic__input { font-size: var(--text-caption); } +/* 목록 「별칭」 칸(208-1) — 칸 안 글 상자 · 테두리는 올렸을 때만 */ +.m01-logic__alias { + width: 160px; + padding: 2px var(--spacing-4); + border: 1px solid transparent; + border-radius: var(--radius-buttons); + background: transparent; + color: inherit; + font: inherit; +} + +.m01-logic__alias:hover, +.m01-logic__alias:focus { + border-color: var(--color-border); + background: var(--color-canvas); +} + .m01-logic__bad { color: var(--color-danger); font-size: var(--text-caption); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index c9090312b..6a7f7010a 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -7,7 +7,8 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { Title: ["단가산출 로직", "Unit-price logic"], - List_Search: ["이름 찾기", "Search name"], + List_Search: ["이름 · 별칭 찾기", "Search name or alias"], + List_Alias: ["별칭 — 쉼표로 여럿 · 고친 뒤 [저장]", "Aliases — comma separated · then [Save]"], List_AllBooks: ["원문 모두", "All sources"], List_AllChapters: ["장 모두", "All chapters"], List_BlockedOnly: ["막힘만", "Blocked only"], diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index a34d3c543..ebc94a459 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -17,6 +17,7 @@ import type { CfScope, CfVariable, } from "./M02_DF_Model"; +import { matchLogic } from "@ui/ui_template_logic_find"; /** 입력 상자가 고르는 그룹 — 화면 차례 */ export const GROUPS: CfGroup[] = [ @@ -285,21 +286,21 @@ export interface MasterLogicBrief { 원문번호: string; 이름: string; 결과단위: string; + /** 쉼표로 여럿(208-1) */ + 별칭?: string; blocked?: boolean; reasons?: string[]; } -/** 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 원문번호 · 키 · 구분 · 상세구분 · 띄어쓰기는 가리지 않음) · 원문(구분) · 장(상세구분) 거름 */ +/** 찾기 — 공용 `matchLogic`(띄어 나눈 말마다 이름 · 원문번호 · 키 · 구분 · 상세구분 · 별칭 · 208-1) · 원문(구분) · 장(상세구분) 거름 */ export function filterLogics( items: MasterLogicBrief[], q: { text: string; book?: string; chapter?: string }, ): MasterLogicBrief[] { - const words = q.text.split(/\s+/).map(squash).filter(Boolean); return items.filter((i) => { if (q.book && i.구분 !== q.book) return false; if (q.chapter && i.상세구분 !== q.chapter) return false; - const hay = squash(`${i.이름} ${i.원문번호} ${i.키} ${i.구분} ${i.상세구분}`); - return words.every((w) => hay.includes(w)); + return matchLogic(q.text, i).ok; }); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index 04e9a4c80..af44df841 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -14,6 +14,7 @@ import { el, showToast, } from "@ui/ui_template_elements"; +import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { fetchAuto, fetchLogic, @@ -193,6 +194,11 @@ export function renderLogicInput( children: [ el("strong", { text: i.이름 }), el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }), + // 별칭으로 맞았으면 그 별칭을 작게(208-1) + el("span", { + className: "m02-dfip__muted", + text: ` ${aliasNote(matchLogic(q.input.value, i).alias)}`, + }), el("span", { className: "m02-dfip__tag", text: i.blocked ? t("Blocked") : i.상세구분, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 7dafc26ec..9d036b8c7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -7,6 +7,7 @@ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { createFindList } from "@ui/ui_template_find_list"; +import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { t as L } from "@ui/ui_template_locale"; import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; @@ -83,7 +84,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra (x) => (!sub.select.value || x.구분 === sub.select.value) && (!detail.select.value || x.상세구분 === detail.select.value) && - (!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)), + matchLogic(finder.search.value, x).ok, // 이름 · 키 · 별칭 …(공용 · 208-1) ); const libFound = libRows.filter( (x) => @@ -103,7 +104,9 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra key: x.키, tag: tc("Kind_Logic"), name: x.이름, - sub: `${x.키} · ${x.구분} ${x.상세구분}`.trim(), + sub: [`${x.키} · ${x.구분} ${x.상세구분}`.trim(), aliasNote(matchLogic(q, x).alias)] + .filter(Boolean) + .join(" · "), unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`, onPick: () => { close(); diff --git a/resources/tester/test_208_1_logic_alias.py b/resources/tester/test_208_1_logic_alias.py new file mode 100644 index 000000000..bf5ac9e66 --- /dev/null +++ b/resources/tester/test_208_1_logic_alias.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""PLAN 208-1 — 단가산출 로직 「별칭」 칸 · 로직 찾기는 이름 + 별칭. + +① 서버 찾기 맞춤(`M01_MasterData_Store_Find.hit`) ↔ 화면 공용 맞춤(`ui_template_logic_find.ts`) 거울 +② 읽기(없으면 빈칸) · 저장 왕복 — 임시 사본 폴더만(정본 마스터 파일은 안 바뀜) +③ M01 목록 「별칭」 열(이름 옆) · 쓰는 곳마다 공용 맞춤을 부름 +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData.M01_MasterData_Store_Find import hit + +ROOT = Path(__file__).resolve().parents[2] +REAL = store.FOLDER +LOGIC_KEY = "GC000091" # 공통 3-3-1 암발파(미진동굴착 TYPE-Ⅰ) +LOGIC_FILE = "로직_건설품셈_03장_토공사.json" + +ROW = { + "키": "GF000001", + "원문번호": "9-1-1", + "이름": "흙깎기(보통토사)", + "별칭": "절토, 터파기 기계", +} +# (찾는 글, 맞음, 별칭으로 맞은 것) +CASES = [ + ("흙깎기", True, ""), + ("흙 깎기", True, ""), # 띄어쓰기 무시 · 낱말마다 + ("절토", True, "절토"), + ("터파기", True, "터파기 기계"), + ("터파기기계", True, "터파기 기계"), # 별칭 안 띄어쓰기 무시 + ("절토 기계", True, "절토, 터파기 기계"), # 낱말이 여러 별칭에 나뉘어 듦 + ("흙깎기 절토", True, "절토"), # 이름 + 별칭 + ("gf000001", True, ""), + ("9-1", True, ""), + ("성토", False, ""), + ("", True, ""), +] + + +def _node(src: str) -> str: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "--experimental-strip-types", "--input-type=module", "-e", src], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=ROOT, + ) + assert done.returncode == 0, done.stderr + return done.stdout + + +def test_서버_맞춤_이름_별칭_여러_별칭() -> None: + for q, ok, _ in CASES: + assert hit(ROW, q) is ok, q + assert hit({**ROW, "별칭": ""}, "절토") is False # 별칭 없음 = 빈칸 + assert hit({k: v for k, v in ROW.items() if k != "별칭"}, "흙깎기") is True + + +def test_화면_맞춤_거울_서버와_같음() -> None: + url = (ROOT / "ui_template" / "ui_template_logic_find.ts").as_uri() + src = f""" +import {{ matchLogic, aliasText, aliasesOf, aliasNote }} from "{url}"; +const row = {json.dumps(ROW, ensure_ascii=False)}; +const cases = {json.dumps([c[0] for c in CASES], ensure_ascii=False)}; +console.log(JSON.stringify({{ + hits: cases.map((q) => matchLogic(q, row)), + text: aliasText(" 절토 ,, 터파기 기계,"), + none: aliasesOf(undefined), + note: [aliasNote("절토"), aliasNote("")], + chapter: matchLogic("토공", {{ ...row, 상세구분: "09장 토공" }}).ok, +}})); +""" + got = json.loads(_node(src)) + for (q, ok, alias), one in zip(CASES, got["hits"]): + assert one == {"ok": ok, "alias": alias}, q + assert one["ok"] is hit(ROW, q), q # 서버와 같은 판정 + assert got["text"] == "절토, 터파기 기계" + assert got["none"] == [] + assert got["note"] == ["별칭 절토", ""] + assert got["chapter"] is True # 구분 · 상세구분은 화면만 더 봄 + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _logics(client: TestClient, **params) -> list[dict]: + res = client.get("/api/m01/logics", params=params) + assert res.status_code == 200, res.text + return res.json()["logics"] + + +def _save(client: TestClient, row: dict) -> None: + one = client.get("/api/m01/logic", params={"key": LOGIC_KEY}).json() + part = { + "file": one["file"], + "version": one["version"], + "changes": [{"op": "edit", "key": LOGIC_KEY, "row": row}], + } + res = client.post("/api/m01/save", json={"files": [part]}) + assert res.status_code == 200, res.text + + +def test_읽기_빈칸_저장_왕복_임시_사본만(client: TestClient) -> None: + real = (REAL / LOGIC_FILE).read_bytes() + listed = _logics(client) + assert listed and all(isinstance(x["별칭"], str) for x in listed) # 없으면 빈칸 + one = client.get("/api/m01/logic", params={"key": LOGIC_KEY}).json() + base = one["logic"] + assert "별칭" not in base # 마스터 파일에 일괄로 쓰지 않음 + + _save(client, {**base, "별칭": "미진동 발파, 암깨기"}) + got = [x for x in _logics(client, q="암깨기") if x["키"] == LOGIC_KEY] + assert got and got[0]["별칭"] == "미진동 발파, 암깨기" + assert LOGIC_KEY in [x["키"] for x in _logics(client, q="미진동 발파")] + found = client.get("/api/m01/elements", params={"group": "로직", "q": "암깨기"}).json() + assert [x["별칭"] for x in found["items"] if x["ref"] == LOGIC_KEY] == ["미진동 발파, 암깨기"] + saved = json.loads((store.FOLDER / LOGIC_FILE).read_text(encoding="utf-8")) + assert next(r for r in saved["줄"] if r["키"] == LOGIC_KEY)["별칭"] == "미진동 발파, 암깨기" + + _save(client, base) # 지우면 칸째 빠짐 · 찾기에도 안 걸림 + assert LOGIC_KEY not in [x["키"] for x in _logics(client, q="암깨기")] + after = json.loads((store.FOLDER / LOGIC_FILE).read_text(encoding="utf-8")) + assert after == json.loads(real.decode("utf-8")) # 왕복 뒤 내용 그대로 + assert (REAL / LOGIC_FILE).read_bytes() == real # 정본은 안 건드림 + + +def _src(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_M01_목록_별칭_열은_이름_옆_고치면_캐시() -> None: + ts = _src("M01_MasterData/M01_MasterData_UI_Logic_List.ts") + cols = ts[ts.index("const COLS") : ts.index("as const")] + names = [x.strip().strip('"') for x in cols[cols.index("[") + 1 :].split(",")] + assert names[names.index("이름") + 1] == "별칭" # 이름 바로 옆 + assert "aliasCell(item" in ts and "onAlias(item, next)" in ts + assert "e.stopPropagation()" in ts # 별칭 칸을 눌러도 로직이 안 열림 + lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts") + assert "const onAlias = async" in lab and "list.setMarks(marks, extra, aliases)" in lab + # 자동저장 없음 — 캐시(drafts)에만 · [저장] 이 보냄 + body = lab[lab.index("const onAlias = async") : lab.index("const onDelete = async")] + assert "saveFiles" not in body and "persist()" in body + + +@pytest.mark.parametrize( + "rel", + [ + "M01_MasterData/M01_MasterData_UI_Logic_List.ts", # M01 로직 목록 + "M01_MasterData/M01_MasterData_UI_Logic_Pick.ts", # M01 요소 찾기 창 · 로직 그룹 + "M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts", # M02 흐름도 다른 로직 고르기 + "M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts", # 〃 결과 별칭 표시 + "M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts", # M02 · B09 [로직 더하기] + ], +) +def test_쓰는_곳마다_공용_맞춤을_부름(rel: str) -> None: + ts = _src(rel) + assert 'from "@ui/ui_template_logic_find"' in ts + assert "matchLogic(" in ts + + +def test_서버_찾기도_한_벌() -> None: + py = _src("M01_MasterData/M01_MasterData_Store.py") + assert "import hit as _hit" in py and "def _hit" not in py diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py index bbe4d9bb9..e0c659a65 100644 --- a/resources/tester/test_m02_df_input_logic.py +++ b/resources/tester/test_m02_df_input_logic.py @@ -18,12 +18,17 @@ const fs = require("fs"); const path = require("path"); const [root, file] = process.argv.slice(1); const ts = require(path.join(root, "config", "node_modules", "typescript")); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, () => ({})); -const m = box.exports; +const load = (src, req) => { + const js = ts.transpileModule(fs.readFileSync(src, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +}; +// 로직 찾기는 공용 맞춤(208-1) — 그것만 실제로 싣고 나머지 import 는 빈 것 +const find = load(path.join(root, "ui_template", "ui_template_logic_find.ts"), () => ({})); +const m = load(file, (p) => (p.endsWith("ui_template_logic_find") ? find : {})); (async () => { const jobs = JSON.parse(fs.readFileSync(0, "utf8")); const out = []; diff --git a/ui_template/ui_template_logic_find.ts b/ui_template/ui_template_logic_find.ts new file mode 100644 index 000000000..f67516f63 --- /dev/null +++ b/ui_template/ui_template_logic_find.ts @@ -0,0 +1,53 @@ +/* ============================================================================= + * ui_template_logic_find.ts + * 단가산출 로직 찾기 맞춤(208-1) — 로직을 찾는 화면은 모두 이것 하나를 부름. + * 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 구분 · 상세구분 · 별칭(쉼표로 여럿) 가운데 하나에 들면 맞음 · + * 띄어쓰기 · 대소문자 무시. 별칭으로 맞았으면 그 별칭을 돌려줌(결과에 작게). + * 짝: `M01_MasterData_Store_Find.hit`(서버 찾기 — 키 · 원문번호 · 이름 · 별칭 · 낱말 규칙이 같음 · + * 구분 · 상세구분은 화면만) · 거울 시험 `test_208_1_logic_alias.py`. + * ========================================================================== */ + +export interface LogicFindRow { + 키?: string; + 원문번호?: string; + 이름?: string; + 구분?: string; + 상세구분?: string; + /** 쉼표로 여럿 · 없으면 빈칸 */ + 별칭?: string | null; +} + +export interface LogicFindHit { + ok: boolean; + /** 별칭으로 맞았으면 맞은 별칭(쉼표로 이음) · 아니면 빈 글 */ + alias: string; +} + +const squash = (v: unknown): string => + String(v ?? "") + .replace(/\s+/g, "") + .toLowerCase(); + +/** 별칭 칸 → 낱낱 별칭(쉼표로 나눔 · 빈 것 뺌) */ +export const aliasesOf = (v: unknown): string[] => + String(v ?? "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + +/** 고친 별칭 글을 저장 꼴로 — 「a ,b,, c」 → 「a, b, c」 */ +export const aliasText = (v: unknown): string => aliasesOf(v).join(", "); + +/** 결과에 작게 붙이는 글 — 별칭으로 안 맞았으면 빈 글 */ +export const aliasNote = (alias: string): string => (alias ? `별칭 ${alias}` : ""); + +export function matchLogic(q: string, row: LogicFindRow): LogicFindHit { + const words = q.split(/\s+/).map(squash).filter(Boolean); + const fields = [row.키, row.원문번호, row.이름, row.구분, row.상세구분].map(squash); + const missed = words.filter((w) => !fields.some((f) => f.includes(w))); + if (!missed.length) return { ok: true, alias: "" }; + const aliases = aliasesOf(row.별칭); + const has = (a: string, w: string): boolean => squash(a).includes(w); + if (!missed.every((w) => aliases.some((a) => has(a, w)))) return { ok: false, alias: "" }; + return { ok: true, alias: aliases.filter((a) => missed.some((w) => has(a, w))).join(", ") }; +} From 88dec6e2404373ab04f19cbe513461597cd6b205 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:15:25 +0900 Subject: [PATCH 19/38] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2076?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 32f0ba0a3..1b7343755 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -97,3 +97,4 @@ 72. B07 (기반영요청사항) 추가로 레이어별로 측점의 원지반선과 설계선을 잡을 수 있는 레이어 자동 생성 73. B07 횡단도의 붉은색 격사선을 하이드쇼할수 있는 버튼 기능은 좋은데 뒤에 검은 격자선이 하나더 있음. 확인 후 삭제 74. B07 횡단도 표준 옵션의 버튼들이 양식화와 일원화 안되어 있음. 확인후 정리할 수 있는 양식으로 적용 +76. 다음으로 중요한 문제인데 특정 측점에 2개의 측점이 숨어 있는 경우가 있음. 엄상준_검증 프로젝트에서 13+3 지점에 대해 이유를 확인해줘. 원인파악을 제대로 할것 계속 반복적으로 동일한 문제가 발생됨 From 0c729d656a73806ed025208cd4af4a396663768a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:17:40 +0900 Subject: [PATCH 20/38] =?UTF-8?q?feat(B05=C2=B7B06=C2=B7B08):=20=EA=B4=80?= =?UTF-8?q?=20=EB=B3=B4=ED=98=B8=EA=B3=B5=20=EC=9C=A0=EC=9E=85=20=ED=9D=99?= =?UTF-8?q?=EB=A7=89=EC=9D=B4=20=EB=B9=88=20=EC=B9=B8=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20=EB=86=92=EC=9D=B4=20=EA=B4=80=EA=B2=BD=20+=200.7=20m(?= =?UTF-8?q?=EC=9C=A0=EC=B6=9C=20=C2=B7=20=EC=A7=91=EC=88=98=EC=A0=95=200.5?= =?UTF-8?q?=20=EA=B7=B8=EB=8C=80=EB=A1=9C)(211-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../B04_PreProcess_Router_Basins.py | 7 +- B06_Section/B06_Section_Engine_Culvert.py | 15 +- .../B06_Section_UI_Cross_Culvert_Const.ts | 14 +- B08_Quantity/B08_Quantity_Router_Summary.py | 11 +- common_util/common_util_culvert_sets.ts | 36 ++- .../tester/test_139_1_pipe_height_follow.py | 9 +- .../tester/test_211_1_inlet_revet_height.py | 219 ++++++++++++++++++ .../tester/test_b05_new_structure_defaults.py | 7 +- .../test_b06_pipe_wall_height_mirror.py | 15 +- 9 files changed, 298 insertions(+), 35 deletions(-) create mode 100644 resources/tester/test_211_1_inlet_revet_height.py diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 20429f91a..bbbb4973c 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -91,7 +91,7 @@ def _build( return detail, carried -# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) +# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1) PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m") @@ -130,7 +130,10 @@ def _apply_new_defaults( if diameter_m > 0: for key in PIPE_HEIGHT_KEYS: if key in keys: - options.setdefault(key, pipe_wall_min_height_m(diameter_m)) + side = ( + "inlet" if key == "inlet_revet_height_m" else "" + ) # 유입 흙막이 + 0.7(211-1) + options.setdefault(key, pipe_wall_min_height_m(diameter_m, side)) if point.facility == "ford_bridge" and "ford_height_m" not in options: flow = (flows or {}).get(round(point.chainage_m, 2)) height = ford_height_m(flow, float(options.get("ford_width_m") or 0)) diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 24ce66c42..4f50a3c08 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -78,19 +78,23 @@ REVET_FACE_SLOPE = 0.3 # `test_b06_pipe_wall_height_mirror`. REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금 REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이 +# 관 보호공 **유입** 흙막이 빈 칸 기본 = 관경(올림) + 0.7 — 유출 · 집수정은 근입 0.5 그대로(211-1). +# ⚠ TS 짝 `common_util_culvert_sets.INLET_REVET_ADD_M` · `_Cross_Culvert_Const.INLET_REVET_ADD_M`. +INLET_REVET_ADD_M = 0.7 # 옛 정의 기본 높이(10-6 표 · 관경 무관) — 관경 > 1.0 관에 이 값이면 관 윗면보다 낮은 벽이라 # 읽을 때 관경 기준 값으로 봄(139-1). OLD_PIPE_WALL_DEFAULT_M = 1.5 -def pipe_wall_min_height_m(diameter_m: float) -> float: +def pipe_wall_min_height_m(diameter_m: float, side: str = "") -> float: """관 벽 **순수 높이**(근입 포함) 기본값 = 관경(0.1m 로 올림) + 근입 0.5 — 벽 윗면 = 관 윗면(139-1). - 관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값. + 관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값. `side="inlet"`(유입 흙막이) = + 0.7(211-1). """ raw = float(diameter_m) steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) - return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3) + add = INLET_REVET_ADD_M if side == "inlet" else REVET_EMBED_DEPTH_M + return round(steps * REVET_HEIGHT_STEP_M + add, 3) def pipe_wall_height_m(stored: float | None, diameter_m: float) -> float | None: @@ -211,10 +215,11 @@ def _side_spec( spec["basin_form"] = str(options.get("inlet_structure") or "") or None return spec - # 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 139-1) · 옛 1.5 도 관경 > 1.0 이면 그 값. + # 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 유입 + 0.7 · 139-1 · 211-1) · + # 옛 1.5 도 관경 > 1.0 이면 관경 + 0.5(저장 값 해석 · 안 바뀜). height = pipe_wall_height_m(_number(options.get(f"{side}_revet_height_m"), None), diameter_m) if height is None: - height = pipe_wall_min_height_m(diameter_m) + height = pipe_wall_min_height_m(diameter_m, side) length = _number( options.get(f"{side}_revet_length_m"), None, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 673bfeaaf..a6ac1bf2b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -71,6 +71,10 @@ export function pipeWallThicknessM(kind: string | null, diameterM: number): numb */ export const REVET_EMBED_DEPTH_M = 0.5; +/** 관 보호공 **유입** 흙막이 빈 칸 기본 순수 높이 = 관경(올림) + 0.7(211-1) — 유출은 근입 0.5 그대로. + * ⚠ 파이썬 짝 `INLET_REVET_ADD_M` · 세트 짝 `common_util_culvert_sets.INLET_REVET_ADD_M`(거울 시험). */ +export const INLET_REVET_ADD_M = 0.7; + /* 기슭막이 높이는 형태와 무관하게 **사용자 입력 그대로** — 형태별 높이 한계·원지반 맞춤 * 자동 자르기 없음. 형태가 바꾸는 것은 안 무늬뿐. 높이 기준은 **순수 높이**(벽 바닥~상단, * 근입 0.5m 포함). 단 하나 — 벽 윗면은 노면 위로 올라오지 않음(`buildRevetWallGeometry`). */ @@ -138,7 +142,7 @@ export function revetWallSpec( // 관경 기준 높이는 기본값만 — 하한은 그림이 서는 값뿐(54-51 · 사용자가 고름). const preferred = hiddenPipe ? (spec.revet_height_m ?? autoHeightM ?? REVET_EMBED_DEPTH_M) - : (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM)); // 칸 값 → 그림(139-1) + : (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM, spec.role)); // 칸 값 → 그림(139-1) const pureHeight = Math.max(adjust.h ?? preferred, WALL_MIN_PURE_HEIGHT_M); return { form, @@ -155,10 +159,12 @@ export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180); /** * 배관 기슭막이 관 하부 여유(m) — 관 invert 아래 벽 바닥까지의 깊이 = 근입 0.5. * **순수 높이 기본 = 관경 + 0.5**(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · - * 10-6 표 Ø1000 = 1.5 · 139-1). 칸을 비우면 이 값. + * 10-6 표 Ø1000 = 1.5 · 139-1 · 유입 흙막이 = 관경 + 0.7 · 211-1). 칸을 비우면 이 값. */ -export function pipeWallMinPureHeight(diameterM: number): number { - return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M; +export function pipeWallMinPureHeight(diameterM: number, side = ""): number { + return ( + revetTargetHeight(diameterM) + (side === "inlet" ? INLET_REVET_ADD_M : REVET_EMBED_DEPTH_M) + ); } /** 벽 순수 높이 하한(m) — 0 이하는 그림이 안 섬. 관경 · 근입 하한 없음(54-51). */ export const WALL_MIN_PURE_HEIGHT_M = 0.1; diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 3ac853296..104bcc4ac 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -187,11 +187,13 @@ def _revets( sides: list[tuple[str, str | None]], empty_height: float | None, names: dict[str, str], + inlet_empty_height: float | None = None, ) -> None: """흙막이 — 유입 · 유출(흙막이일 때 · `sides`) + 흙막이 n(container_rows 가 선 단까지만 냄). 저장 키 = 필수는 `{쪽}_revet_{칸}_m` · 선택 흙막이는 `soil_add_{n}_{칸}`(명세 인스턴스 키) · - 빈 높이 = `empty_height`(None 이면 「미입력」 열) · 머리 글은 관 정의 이름(공용 열). + 빈 높이 = `empty_height`(None 이면 「미입력」 열 · 관 유입 쪽만 `inlet_empty_height` · 211-1) · + 머리 글은 관 정의 이름(공용 열). """ sources = [(f"{side}_revet_", "_m", side, combo) for side, combo in sides] sources += [ @@ -203,7 +205,9 @@ def _revets( form = str(options.get(f"{head}form") or "") height = _num(options.get(f"{head}height{unit}")) if height is None: - height = empty_height + height = ( + inlet_empty_height if container == "inlet" and inlet_empty_height else empty_height + ) height_text = fill._label("height_m", height) sheet.add( at, @@ -588,7 +592,8 @@ def pipe_summary( ) wall_options = {**options, **moved} wall = pipe_wall_min_height_m(diameter / 1000) if diameter else None - _revets(sheet, at, wall_options, links, sources, wall, names) + wall_in = pipe_wall_min_height_m(diameter / 1000, "inlet") if diameter else None + _revets(sheet, at, wall_options, links, sources, wall, names, wall_in) _chutes(sheet, at, options, links, names) out = sheet.result() diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index be2df5118..e1a92706a 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -51,12 +51,15 @@ export const BOX_COVER_M = 0.5; export const REVET_HEIGHT_STEP_M = 0.1; export const REVET_EMBED_DEPTH_M = 0.5; export const OLD_PIPE_WALL_DEFAULT_M = 1.5; +/** 관 보호공 유입 흙막이 빈 칸 기본 = 관경(올림) + 0.7(211-1) — 유출 · 집수정은 근입 0.5 그대로. */ +export const INLET_REVET_ADD_M = 0.7; -/** 관 벽 순수 높이(근입 포함) 기본값 — 파이썬 `pipe_wall_min_height_m`. */ -export function pipeWallMinHeightM(diameterM: number): number { +/** 관 벽 순수 높이(근입 포함) 기본값 — 파이썬 `pipe_wall_min_height_m`. `side="inlet"` = + 0.7. */ +export function pipeWallMinHeightM(diameterM: number, side = ""): number { const raw = diameterM; const steps = Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9); - return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3); + const add = side === "inlet" ? INLET_REVET_ADD_M : REVET_EMBED_DEPTH_M; + return round(steps * REVET_HEIGHT_STEP_M + add, 3); } /** 관 벽 · 유입 집수정 높이 칸 읽기 — 옛 기본 1.5 이고 관경 > 1.0 이면 관경 기준 값(139-1) · @@ -71,7 +74,8 @@ export function pipeWallHeightM(stored: number | null, diameterM: number): numbe return stored; } -/** 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1 · 서버 짝 `PIPE_HEIGHT_KEYS`). */ +/** 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1) + * (서버 짝 `PIPE_HEIGHT_KEYS`). */ export const PIPE_HEIGHT_KEYS = [ "inlet_revet_height_m", "outlet_revet_height_m", @@ -86,12 +90,14 @@ export function fillPipeHeights( const diameterM = Number(values.pipe_diameter_mm) / 1000; if (!(diameterM > 0)) return; for (const key of PIPE_HEIGHT_KEYS) - if (keys.includes(key)) values[key] ??= pipeWallMinHeightM(diameterM); + if (keys.includes(key)) + values[key] ??= pipeWallMinHeightM(diameterM, key === "inlet_revet_height_m" ? "inlet" : ""); } -/** 배관 관 보호공 높이 칸(유입 · 유출 · 유입 집수정 = 항목 `height`) — 관경 + 0.5 규칙(139-1). +/** 배관 관 보호공 높이 칸(유입 · 유출 · 유입 집수정 = 항목 `height`) — 관경 + 0.5 규칙(139-1) · + * 유입 흙막이 + 0.7(211-1). * `previousMm` 없음 = 읽을 때(옛 1.5 · 관경 > 1.0 옮김) · 있음 = 관경을 바꿈(옛 관경 규칙 값 · 옛 1.5 - * 칸만 새 규칙 값으로 · 손 값 · 빈 칸은 그대로). */ + * 칸만 새 규칙 값으로 · 손 값 · 빈 칸은 그대로 · 유입은 옛 + 0.5 값도 새 + 0.7 로 따라감). */ export function followPipeHeights( state: Record> | undefined>, diameterMm: string, @@ -99,7 +105,13 @@ export function followPipeHeights( ): void { const d = Number(diameterMm) / 1000; if (!(d > 0)) return; - const prev = previousMm ? pipeWallMinHeightM(Number(previousMm) / 1000) : null; + const prevOf = (id: string): Array => { + if (!previousMm) return []; + const p = Number(previousMm) / 1000; + return id === "inlet" + ? [pipeWallMinHeightM(p, id), pipeWallMinHeightM(p)] + : [pipeWallMinHeightM(p)]; + }; for (const id of ["inlet", "outlet"]) { const values = state[id]?.[0]; const raw = values?.height ?? ""; @@ -108,8 +120,10 @@ export function followPipeHeights( const moved = previousMm === undefined ? pipeWallHeightM(now, d) - : [prev, OLD_PIPE_WALL_DEFAULT_M].some((v) => v !== null && Math.abs(now - v) < 1e-9) - ? pipeWallMinHeightM(d) + : [...prevOf(id), OLD_PIPE_WALL_DEFAULT_M].some( + (v) => v !== null && Math.abs(now - v) < 1e-9, + ) + ? pipeWallMinHeightM(d, id) : now; if (moved !== null && moved !== now) values.height = String(moved); } @@ -170,7 +184,7 @@ function sideSpec( const height = pipeWallHeightM(num(options[`${side}_revet_height_m`], null), diameterM) ?? - pipeWallMinHeightM(diameterM); + pipeWallMinHeightM(diameterM, side); const length = num(options[`${side}_revet_length_m`], null); const form = text(options[`${side}_revet_form`]); const before = num(options[`${side}_revet_before_m`], null); diff --git a/resources/tester/test_139_1_pipe_height_follow.py b/resources/tester/test_139_1_pipe_height_follow.py index 978ac0f69..ddc9e2c0f 100644 --- a/resources/tester/test_139_1_pipe_height_follow.py +++ b/resources/tester/test_139_1_pipe_height_follow.py @@ -59,13 +59,16 @@ def test_화면_높이_규칙(tmp_path: Path) -> None: assert run.returncode == 0, run.stderr[-2000:] got = json.loads(result.read_text(encoding="utf-8")) - for key in ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m"): + for key in ("outlet_revet_height_m", "inlet_basin_height_m"): assert got["fresh"][key] == pytest.approx(1.7), key # Φ1200 → 1.7 + assert got["fresh"]["inlet_revet_height_m"] == pytest.approx(1.9) # 유입 흙막이 + 0.7(211-1) assert "inlet_revet_height_m" not in got["noPipe"] # 관경 없음 = 안 채움 assert got["read"]["inlet"][0]["height"] == "1.7" # 옛 1.5 · Φ1200 → 1.7 assert got["read"]["outlet"][0]["height"] == "1.6" # 손 값 그대로 assert got["small"]["inlet"][0]["height"] == "1.5" # Φ800 의 1.5 는 그대로 - assert got["change"]["inlet"][0]["height"] == "2" # 옛 규칙 값(1.7) → 새 규칙(2.0) + assert ( + got["change"]["inlet"][0]["height"] == "2.2" + ) # 옛 + 0.5 값(1.7) → 유입 새 규칙(1.5 + 0.7) assert got["change"]["outlet"][0]["height"] == "2.2" # 손 값 그대로 - assert got["old"]["inlet"][0]["height"] == "1.3" # 옛 1.5 → Φ800 규칙 + assert got["old"]["inlet"][0]["height"] == "1.5" # 옛 1.5 → Φ800 유입 규칙(0.8 + 0.7) assert got["old"]["outlet"][0]["height"] == "" # 빈 칸 그대로 diff --git a/resources/tester/test_211_1_inlet_revet_height.py b/resources/tester/test_211_1_inlet_revet_height.py new file mode 100644 index 000000000..50fb28551 --- /dev/null +++ b/resources/tester/test_211_1_inlet_revet_height.py @@ -0,0 +1,219 @@ +"""211-1 관 보호공 **유입** 흙막이 빈 칸 기본 높이 = 관경(0.1 올림) + 0.7 — 유출 · 집수정은 + 0.5 그대로. + +못박는 것 + - 파이썬 `pipe_wall_min_height_m(d, "inlet")` · 새 관 기본값(`_apply_new_defaults`) · 관 세트 스펙 · B08 집계 빈 높이. + - TS 짝 `buildCulvertSets` ↔ 파이썬 `_culvert_set`(유입 · 유출 스펙 같은 값) · 횡단도 `revetWallSpec`. + - 저장된 높이는 그대로 · 유출 · 집수정 무변 · 흙막이 아닌 시설(숨은 관)은 본체 높이 그대로. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B06_Section import B06_Section_Engine_Culvert as culvert +from B08_Quantity import B08_Quantity_Router_Summary as summary +from common_util.common_util_drainage_pipes import PipePoint +from M02_MasterTemplete import M02_Item_Snapshot as snapshots +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + + +def _revet(side: str, height: float | None = None) -> dict: + found = { + f"{side}_type": "기슭막이", + f"{side}_revet_form": "돌쌓기(메)", + f"{side}_revet_before_m": 5, + f"{side}_revet_after_m": 5, + } + if height is not None: + found[f"{side}_revet_height_m"] = height + return found + + +def test_파이썬_기본_높이_유입만_0_7(): + h = culvert.pipe_wall_min_height_m + assert h(0.8, "inlet") == 1.5 and h(0.8, "outlet") == 1.3 + assert h(1.2, "inlet") == 1.9 and h(1.2) == 1.7 # 쪽 안 줌 = 옛 값(집수정) + assert h(1.0, "inlet") == 1.7 and h(1.0, "outlet") == 1.5 + assert h(0.45, "inlet") == 1.2 # 0.5 로 올림 + 0.7 + + +def test_새_관_기본값_유입_흙막이만_0_7(): + fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 1200}) + basins._apply_new_defaults([fresh]) + assert fresh.options["inlet_revet_height_m"] == pytest.approx(1.9) + assert fresh.options["outlet_revet_height_m"] == pytest.approx(1.7) + assert fresh.options["inlet_basin_height_m"] == pytest.approx(1.7) + # 이미 적은 높이(추천 · 손 값)는 그대로 · 나머지 칸만 채움 + kept = PipePoint( + chainage_m=20.0, + source="stream", + options={"pipe_diameter_mm": 1200, "inlet_revet_height_m": 1.2}, + ) + basins._apply_new_defaults([kept]) + assert kept.options["inlet_revet_height_m"] == 1.2 + assert kept.options["outlet_revet_height_m"] == pytest.approx(1.7) + + +def test_관_세트_빈_높이_유입_0_7_저장값_그대로(): + blank = culvert._culvert_set({"pipe_diameter_mm": 1200, **_revet("inlet"), **_revet("outlet")}) + assert blank["inlet"]["revet_height_m"] == pytest.approx(1.9) + assert blank["outlet"]["revet_height_m"] == pytest.approx(1.7) + stored = culvert._culvert_set( + {"pipe_diameter_mm": 1200, **_revet("inlet", 1.2), **_revet("outlet", 2.4)} + ) + assert stored["inlet"]["revet_height_m"] == 1.2 and stored["outlet"]["revet_height_m"] == 2.4 + # 옛 기본 1.5 · 관경 > 1.0 = 저장 값 해석(139-1) — 관경 + 0.5 그대로 + legacy = culvert._culvert_set({"pipe_diameter_mm": 1200, **_revet("inlet", 1.5)}) + assert legacy["inlet"]["revet_height_m"] == pytest.approx(1.7) + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { buildCulvertSets } from "./common_util_culvert_sets.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const sets = buildCulvertSets(input.points, input.registry); +writeFileSync(process.argv[3], JSON.stringify([...sets.entries()])); +""" + +_WALL_RUNNER = """ +import { writeFileSync } from "node:fs"; +import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const.js"; +const pick = (spec, hidden, auto) => revetWallSpec(spec, {}, hidden, 1.2, auto).pureHeight; +const base = { structure: "기슭막이" }; +writeFileSync(process.argv[2], JSON.stringify({ + inlet: pick({ ...base, role: "inlet" }, false), + outlet: pick({ ...base, role: "outlet" }, false), + stored: pick({ ...base, role: "inlet", revet_height_m: 1.2 }, false), + hidden: pick({ ...base, role: "inlet" }, true, 2.0), +})); +""" + + +def _tsc(source: Path, out: Path) -> None: + subprocess.run( # noqa: S603 — 고정 실행 파일 · 타입 오류가 있어도 JS 는 나옴 + [ + "node", + str(TSC), + str(source), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--skipLibCheck", + "--ignoreConfig", + ], + cwd=str(ROOT), + capture_output=True, + ) + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_TS_세트_거울(tmp_path: Path) -> None: + points = [ + { + "chainage_m": 20.0, + "facility": "pipe", + "options": {"pipe_diameter_mm": 1200, **_revet("inlet"), **_revet("outlet")}, + }, + { + "chainage_m": 40.0, + "facility": "pipe", + "options": {"pipe_diameter_mm": "800", **_revet("inlet", 1.2), **_revet("outlet")}, + }, + { + "chainage_m": 60.0, + "facility": "pipe", + "options": {"pipe_diameter_mm": 1200, **_revet("inlet", 1.5)}, # 옛 1.5 + }, + ] + registry = { + type_id: {o.key: o.default for o in entry.options} + for type_id, entry in structure_type_map().items() + if type_id in ("pipe", "ford_bridge", "ford_pavement", "box_culvert") + } + out = tmp_path / "js" + _tsc(ROOT / "common_util" / "common_util_culvert_sets.ts", out) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + payload, result = tmp_path / "in.json", tmp_path / "out.json" + payload.write_text(json.dumps({"points": points, "registry": registry}), encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(payload), str(result)], + capture_output=True, + text=True, + ) + assert run.returncode == 0, run.stderr[-2000:] + got = {at: spec for at, spec in json.loads(result.read_text(encoding="utf-8"))} + for point in points: + want = culvert._culvert_set(point["options"]) + spec = got[point["chainage_m"]] + for side in ("inlet", "outlet"): + assert spec[side].get("revet_height_m") == want[side].get("revet_height_m"), point + assert got[20.0]["inlet"]["revet_height_m"] == pytest.approx(1.9) + assert got[20.0]["outlet"]["revet_height_m"] == pytest.approx(1.7) + assert got[40.0]["inlet"]["revet_height_m"] == 1.2 # 저장 값 그대로 + assert got[40.0]["outlet"]["revet_height_m"] == pytest.approx(1.3) + assert got[60.0]["inlet"]["revet_height_m"] == pytest.approx(1.7) # 옛 1.5 → 관경 + 0.5 + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_횡단도_벽_기본_높이(tmp_path: Path) -> None: + out = tmp_path / "js" + _tsc(ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts", out) + built = next( + out.rglob("B06_Section_UI_Cross_Culvert_Const.js") + ) # 가져온 파일 따라 경로가 깊어짐 + runner = built.parent / "runner.mjs" + runner.write_text(_WALL_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + run = subprocess.run( # noqa: S603 + ["node", str(runner), str(result)], capture_output=True, text=True + ) + assert run.returncode == 0, run.stderr[-2000:] + got = json.loads(result.read_text(encoding="utf-8")) + assert got["inlet"] == pytest.approx(1.9) # Φ1200 + 0.7 + assert got["outlet"] == pytest.approx(1.7) # 무변 + assert got["stored"] == 1.2 # 칸 값 그대로 + assert got["hidden"] == 2.0 # 숨은 관(세월교 · 박스) = 본체 높이 그대로 + + +def test_B08_집계_빈_높이_유입만_0_7(tmp_path: Path) -> None: + combo = {"layer": "system", "owner": None, "key": "SS000011", "name": "횡단배수 배관"} + root = tmp_path / "proj" + edits = root / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + point = { + "chainage_m": 1433.0, + "source": "user", + "options": {"pipe_diameter_mm": 800, **_revet("inlet"), **_revet("outlet")}, + "summary_item": combo, + "group_items": {"gnbf9m9": combo, "g9lq9uk": combo}, + } + (edits / "pipe_points.json").write_text( + json.dumps({"points": [point]}, ensure_ascii=False), encoding="utf-8" + ) + designs = [ + { + "chainage_m": 1433.0, + "design": {"pipe_length_m": 18, "inlet_basin": None, "extra_walls": []}, + } + ] + names = definition.read()["종류"]["pipe"]["이름"] + linked = {owner: {} for owner in snapshots.assignments(root)} + out = summary.pipe_summary(root, designs, names, 6.0, linked=linked) + ids = [c["id"] for c in out["columns"] if c["id"].startswith("pipe:revet")] + assert ids == ["pipe:revet|돌쌓기(메)|1.3", "pipe:revet|돌쌓기(메)|1.5"], ( + ids + ) # 유출 1.3 · 유입 1.5 diff --git a/resources/tester/test_b05_new_structure_defaults.py b/resources/tester/test_b05_new_structure_defaults.py index b85ce87ae..d6a60d202 100644 --- a/resources/tester/test_b05_new_structure_defaults.py +++ b/resources/tester/test_b05_new_structure_defaults.py @@ -34,8 +34,8 @@ def test_새_관만_기본값_빈_키에만(): fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800}) basins._apply_new_defaults([fresh]) # 추천(먼저 들어간 값)이 이김 · 집수정 모양 L 도 실제 값(54-39) - # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) - heights = dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3) + # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 유입 흙막이만 + 0.7(211-1 → 1.5) + heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3), "inlet_revet_height_m": 1.5} expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L", **heights} assert fresh.options == expected assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "집수정" @@ -66,7 +66,8 @@ def test_원본에_있던_관은_빈칸_그대로(monkeypatch): by = {p.chainage_m: p for p in points} assert by[100.0].options is None # 옛 빈칸 그대로 # 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39) - heights = dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5) # 기본 관경 1000 → 1.5(139-1) + # 기본 관경 1000 → 1.5(139-1) · 유입 흙막이 1.7(211-1) + heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5), "inlet_revet_height_m": 1.7} assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L", **heights} diff --git a/resources/tester/test_b06_pipe_wall_height_mirror.py b/resources/tester/test_b06_pipe_wall_height_mirror.py index 75707760c..ad8b2b0fa 100644 --- a/resources/tester/test_b06_pipe_wall_height_mirror.py +++ b/resources/tester/test_b06_pipe_wall_height_mirror.py @@ -26,7 +26,7 @@ def _ts_const(source: str, name: str) -> float: def test_상수_셋이_TS_와_같다() -> None: - for name in ("REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M"): + for name in ("REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M", "INLET_REVET_ADD_M"): assert getattr(culvert, name) == _ts_const(TS, name) == _ts_const(SETS, name), name assert culvert.OLD_PIPE_WALL_DEFAULT_M == _ts_const(SETS, "OLD_PIPE_WALL_DEFAULT_M") @@ -35,9 +35,16 @@ def test_TS_식_모양이_그대로다() -> None: """식을 바꾸면 여기서 잡힘 — 파이썬 식도 함께 고칠 것.""" assert "const raw = diameterM;" in TS and "const raw = diameterM;" in SETS assert "Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M" in TS - assert "return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;" in TS - # 그림이 칸 값을 읽음(139-1) — 배관 기본 = 칸 → 관경 기준 - assert "(spec.revet_height_m ?? pipeWallMinPureHeight(diameterM))" in TS + assert ( + 'revetTargetHeight(diameterM) + (side === "inlet" ? INLET_REVET_ADD_M : REVET_EMBED_DEPTH_M)' + in ( + " ".join(TS.split()) # 줄바꿈 · 들여쓰기 무시(포맷터가 접음) + ) + .replace("( ", "(") + .replace(" )", ")") + ) + # 그림이 칸 값을 읽음(139-1) — 배관 기본 = 칸 → 관경 기준(유입 + 0.7 · 211-1) + assert "(spec.revet_height_m ?? pipeWallMinPureHeight(diameterM, spec.role))" in TS # 옛 1.5 옮김 — TS 세트 짝 식 assert "Math.abs(stored - OLD_PIPE_WALL_DEFAULT_M) < 1e-9 &&" in SETS assert "diameterM > 1.0 + 1e-9" in SETS From 10eb54f5e58f92996604e936b80bf39ed64b68b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:21:50 +0900 Subject: [PATCH 21/38] =?UTF-8?q?feat(B06):=20[=EC=83=81=EC=84=B8]=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EA=B5=90=EC=B0=A8=EC=A0=90=20=EB=B6=84=ED=95=A0=20?= =?UTF-8?q?=C2=B7=20=EC=97=AC=EB=9F=AC=20=EA=B3=A0=EB=A6=84(=EC=9B=90?= =?UTF-8?q?=EC=A7=80=EB=B0=98=20=ED=8F=AC=ED=95=A8)=20=C2=B7=20=EB=8B=AB?= =?UTF-8?q?=ED=9E=8C=20=EB=84=93=EC=9D=B4=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=EA=B2=83=20=EA=B0=92=20=C2=B7=20=EC=84=9C=EB=B2=84=20=EC=A1=B0?= =?UTF-8?q?=EA=B0=81=20=EA=B2=80=EC=82=B0=20(209-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선(설계 · 구조물 · 원지반 · 사토장)을 만나는 점에서 조각으로 나눔 · 조각마다 판정 선 - 누름 = 하나 · Shift/Ctrl+누름 = 더하기/빼기 · [+ − ⤢] 왼쪽 값 두 줄(길이 합 · 면적 · 닫히지 않음) - 칸 넣기 = 우클릭 고르기 길 그대로 · 길이 한 번 누름 = 바로 · 여럿 = Enter · [칸에 넣기] - 저장 = 조각 두 끝 목록(pieces) · 서버가 설계선 · 원지반선 · 사토장선 위 검산 · 구조물 선은 두 끝 직선 · 옛 두 끝 꼴 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B06_Section/B06_Section_Api_Types.ts | 8 + B06_Section/B06_Section_Router_CrossTable.py | 2 +- B06_Section/B06_Section_Schema.py | 12 +- B06_Section/B06_Section_UI_Cross_Detail.ts | 5 +- .../B06_Section_UI_Cross_Detail_Geom.ts | 227 ++++++++++++++++ .../B06_Section_UI_Cross_Detail_Select.ts | 245 ++++++++++++++++++ .../B06_Section_UI_Cross_Detail_Table.ts | 124 +++++---- .../B06_Section_UI_Style_Cross_Detail.css | 35 ++- ...07_DesignDetail_Engine_Cross_Table_Edit.py | 147 ++++++++++- .../B07_DesignDetail_Router_Support.py | 2 +- resources/tester/helper_164_detail_zoom.cjs | 2 + .../tester/helper_179_4_detail_table.cjs | 15 +- .../tester/helper_209_1_detail_select.cjs | 117 +++++++++ resources/tester/test_179_4_detail_table.py | 9 +- resources/tester/test_209_1_detail_select.py | 199 ++++++++++++++ 15 files changed, 1070 insertions(+), 79 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Detail_Geom.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Detail_Select.ts create mode 100644 resources/tester/helper_209_1_detail_select.cjs create mode 100644 resources/tester/test_209_1_detail_select.py diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 88f8e135f..7c09a92ed 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -633,8 +633,16 @@ export interface CrossSectionPatch { export type CrossCellSource = | { src: "area"; key: string } | { src: "length"; start: [number, number]; end: [number, number] } + | { src: "length" | "area"; pieces: CrossPickPiece[] } | { src: "text"; text: string }; +/** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */ +export interface CrossPickPiece { + line: "design" | "ground" | "spoil" | "structure"; + start: [number, number]; + end: [number, number]; +} + /** [상세] 표 손질 — 칸 출처(표 키 →) · 더한 행(`after` 아래 · 없으면 끝) · 더한 열(오른쪽 끝). */ export interface CrossDetailTable { cells: Record; diff --git a/B06_Section/B06_Section_Router_CrossTable.py b/B06_Section/B06_Section_Router_CrossTable.py index e13d35579..74d30efed 100644 --- a/B06_Section/B06_Section_Router_CrossTable.py +++ b/B06_Section/B06_Section_Router_CrossTable.py @@ -50,7 +50,7 @@ def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None) key: _format(value) if isinstance(value, (int, float)) else "-" for key, value in table.items() } - _values, texts, repick = edited_cells(design) + _values, texts, repick = edited_cells(design, source.get("samples")) text.update(texts) return { "quantity_table": table, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index d0ee0e92c..fae03161c 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -135,14 +135,24 @@ class BoxAdjustPatch(BaseModel): right: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch) +class CrossPickPiece(BaseModel): + """[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고).""" + + line: Literal["design", "ground", "spoil", "structure"] + start: list[float] = Field(..., min_length=2, max_length=2) + end: list[float] = Field(..., min_length=2, max_length=2) + + class CrossCellSource(BaseModel): - """[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text.""" + """[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text · + pieces(209-1) = 고른 선 조각 목록(length = 길이 합 · area = 닫힌 넓이).""" src: Literal["area", "length", "text"] key: str | None = Field(default=None, max_length=40) start: list[float] | None = Field(default=None, min_length=2, max_length=2) end: list[float] | None = Field(default=None, min_length=2, max_length=2) text: str | None = Field(default=None, max_length=200) + pieces: list[CrossPickPiece] | None = Field(default=None, min_length=1, max_length=200) class CrossDetailRow(BaseModel): diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 4f52eab17..260405b9b 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -332,6 +332,7 @@ export function openCrossDetail(section: CrossSection): void { axes = drawn.axes; const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); + tablePick.redraw(); // 선 조각 고르기(209-1) } showPicked(); }, @@ -367,11 +368,11 @@ export function openCrossDetail(section: CrossSection): void { }, stop: () => { resize.disconnect(); - detachTable(); + tablePick.detach(); }, }; // 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음 - const detachTable = attachDetailTable({ + const tablePick = attachDetailTable({ tableBox, cad, section: () => state.section, diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts new file mode 100644 index 000000000..f137535dc --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -0,0 +1,227 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Geom.ts + * [상세] 그래프 고르기 기하(209-1) — 선을 만나는 점에서 조각으로 나눔 · 조각 길 · 길이 · 닫힌 넓이. + * + * 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py` + * (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) · + * 거울 시험 `resources/tester/test_209_1_detail_select.py`. + * 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산. + * ========================================================================== */ + +import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; + +export type Pt = [number, number]; +export type LineKind = CrossPickPiece["line"]; +/** 고를 수 있는 조각 — 어느 선 · 그 선 위 꺾은선(m). */ +export interface SplitPiece { + line: LineKind; + points: Pt[]; +} + +/** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */ +export const LINE_TOLERANCE_M = 0.05; +/** 다른 선 끝이 이만큼 안이면 닿음으로 보고 자름(m) — 설계선 표고 0.1 mm 반올림. */ +const TOUCH_M = 0.01; +/** 이보다 짧은 조각은 버림(m) — 누를 수도 없음. */ +const MIN_PIECE_M = 0.02; + +const dist = (a: Pt, b: Pt): number => Math.hypot(a[0] - b[0], a[1] - b[1]); + +/* ── 서버 짝(검산 · 값) ─────────────────────────────────────────────────── */ + +/** 원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(서버 `ground_runs` · 카드 `appendGroundProfile`). */ +export function groundRuns(samples: ReadonlyArray | undefined): Pt[][] { + const runs: Pt[][] = [[]]; + for (const sample of samples ?? []) { + const x = sample.offset_m; + const y = sample.elevation_m ?? sample.z; + if (sample.valid === false || typeof x !== "number" || typeof y !== "number") runs.push([]); + else runs[runs.length - 1].push([x, y]); + } + return runs.filter((run) => run.length > 1); +} + +const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) => + (raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite)); + +/** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */ +export function pickLines( + design: CrossDesign | undefined, + samples: ReadonlyArray | undefined, +): Record { + return { + design: [polyline(design?.design_line)], + ground: groundRuns(samples), + spoil: [polyline(design?.spoil_fill_line)], + }; +} + +/** 선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 null. */ +function locate(point: Pt, run: Pt[]): number | null { + let best: [number, number] | null = null; + let along = 0; + for (let i = 0; i + 1 < run.length; i++) { + const [ax, ay] = run[i]; + const [bx, by] = run[i + 1]; + const dx = bx - ax; + const dy = by - ay; + const size = Math.hypot(dx, dy); + const raw = size === 0 ? 0 : ((point[0] - ax) * dx + (point[1] - ay) * dy) / size ** 2; + const t = Math.max(0, Math.min(1, raw)); + const gap = Math.hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy)); + if (gap <= LINE_TOLERANCE_M && (best === null || gap < best[0])) best = [gap, along + t * size]; + along += size; + } + return best ? best[1] : null; +} + +function at(run: Pt[], s: number): Pt { + for (let i = 0; i + 1 < run.length; i++) { + const [ax, ay] = run[i]; + const [bx, by] = run[i + 1]; + const size = Math.hypot(bx - ax, by - ay); + if (s <= size) { + const t = size === 0 ? 0 : s / size; + return [ax + t * (bx - ax), ay + t * (by - ay)]; + } + s -= size; + } + return run[run.length - 1]; +} + +/** 선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함(서버 `sub_path`). */ +export function subPath(run: Pt[], s0: number, s1: number): Pt[] { + const lo = Math.min(s0, s1); + const hi = Math.max(s0, s1); + const inner: Pt[] = []; + let along = 0; + for (let i = 0; i + 1 < run.length; i++) { + along += dist(run[i], run[i + 1]); + if (lo < along && along < hi) inner.push(run[i + 1]); + } + const path = [at(run, lo), ...inner, at(run, hi)]; + return s0 <= s1 ? path : path.reverse(); +} + +/** 고른 조각 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 null(구조물 선 = 두 끝 직선 · 검산 없음). */ +export function piecePath(piece: CrossPickPiece, lines: Record): Pt[] | null { + if (piece.line === "structure") return [piece.start, piece.end]; + for (const run of lines[piece.line] ?? []) { + const s0 = locate(piece.start, run); + const s1 = locate(piece.end, run); + if (s0 !== null && s1 !== null) return subPath(run, s0, s1); + } + return null; +} + +export const pathLength = (path: Pt[]): number => + path.slice(1).reduce((sum, p, i) => sum + dist(path[i], p), 0); + +const near = (a: Pt, b: Pt): boolean => dist(a, b) <= LINE_TOLERANCE_M; + +/** 조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = null(서버 `ring_area`). */ +export function ringArea(paths: Pt[][]): number | null { + if (!paths.length) return null; + const ring = [...paths[0]]; + const rest = paths.slice(1).map((path) => [...path]); + while (rest.length) { + const tail = ring[ring.length - 1]; + const index = rest.findIndex((p) => near(p[0], tail) || near(p[p.length - 1], tail)); + if (index < 0) return null; + const [path] = rest.splice(index, 1); + ring.push(...(near(path[0], tail) ? path : path.reverse()).slice(1)); + } + if (ring.length < 4 || !near(ring[0], ring[ring.length - 1])) return null; + ring.push(ring[0]); // 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음 + let twice = 0; + for (let i = 0; i + 1 < ring.length; i++) + twice += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]; + const area = Math.abs(twice) / 2; + return area > 1e-9 ? area : null; +} + +/** 고른 조각들 값(서버 `cell_value` 조각 갈래) — 길이 합 · 닫힌 넓이 · 못 셈 = null. */ +export function piecesValue( + kind: "length" | "area", + pieces: CrossPickPiece[], + lines: Record, +): number | null { + const paths = pieces.map((piece) => piecePath(piece, lines)); + if (!paths.length || paths.some((path) => path === null)) return null; + const valid = paths as Pt[][]; + return kind === "length" ? valid.reduce((sum, p) => sum + pathLength(p), 0) : ringArea(valid); +} + +/* ── 나눔(화면 몫) ───────────────────────────────────────────────────────── */ + +const cross = (a: Pt, b: Pt): number => a[0] * b[1] - a[1] * b[0]; +const sub = (a: Pt, b: Pt): Pt => [a[0] - b[0], a[1] - b[1]]; + +/** 점이 선분 a→b 위 어디(0~1)에 닿나 — `TOUCH_M` 밖이면 null. */ +function touchAt(point: Pt, a: Pt, b: Pt): number | null { + const d = sub(b, a); + const size2 = d[0] ** 2 + d[1] ** 2; + if (size2 === 0) return null; + const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * d[0] + (point[1] - a[1]) * d[1]) / size2)); + return dist(point, [a[0] + t * d[0], a[1] + t * d[1]]) <= TOUCH_M ? t : null; +} + +/** + * 선들을 서로 만나는 점(교차 · 다른 선 끝이 닿는 점)에서 조각으로 나눔. + * 자르는 자리 = 꺾은선 위치(선분 번호 + 0~1). 같은 선끼리는 안 자름. + * ponytail: 겹쳐 나란한 선분(같은 직선 위)은 교점 없음으로 봄 — 끝 닿음만 자름. + */ +export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] { + const out: SplitPiece[] = []; + lines.forEach((line, li) => { + const cuts: number[] = []; + const pts = line.points; + for (let i = 0; i + 1 < pts.length; i++) { + const p = pts[i]; + const r = sub(pts[i + 1], p); + lines.forEach((other, oi) => { + if (oi === li) return; + const q = other.points; + for (let j = 0; j + 1 < q.length; j++) { + const s = sub(q[j + 1], q[j]); + const denom = cross(r, s); + if (Math.abs(denom) > 1e-12) { + const t = cross(sub(q[j], p), s) / denom; + const u = cross(sub(q[j], p), r) / denom; + if (t >= 0 && t <= 1 && u >= 0 && u <= 1) cuts.push(i + t); + } + } + for (const end of [q[0], q[q.length - 1]]) { + const t = touchAt(end, p, pts[i + 1]); + if (t !== null) cuts.push(i + t); + } + }); + } + const at = (c: number): Pt => { + const i = Math.min(Math.floor(c), pts.length - 2); + const t = c - i; + return [ + pts[i][0] + t * (pts[i + 1][0] - pts[i][0]), + pts[i][1] + t * (pts[i + 1][1] - pts[i][1]), + ]; + }; + const marks = [0, ...cuts.sort((a, b) => a - b), pts.length - 1]; + for (let k = 0; k + 1 < marks.length; k++) { + const [c0, c1] = [marks[k], marks[k + 1]]; + const inner = pts.slice(Math.floor(c0) + 1, Math.ceil(c1)); + const points = [at(c0), ...inner, at(c1)]; + if (pathLength(points) >= MIN_PIECE_M) out.push({ line: line.line, points }); + } + }); + return out; +} + +/** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */ +export function pieceSource(piece: SplitPiece): CrossPickPiece { + const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4]; + return { + line: piece.line, + start: round(piece.points[0]), + end: round(piece.points[piece.points.length - 1]), + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts new file mode 100644 index 000000000..b3e8540ec --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -0,0 +1,245 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Select.ts + * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로 + * 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 · + * Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴): + * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」). + * 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀). + * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. + * ========================================================================== */ + +import type { CrossDesign, CrossPickPiece } from "./B06_Section_Api_Types"; +import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; +import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; +import { + DESIGN_LINE_ATTR, + mergeStraightPieces, + pieceLabel, +} from "./B06_Section_UI_Cross_Design_Pick"; +import { + pathLength, + pieceSource, + ringArea, + splitAtCrossings, + type Pt, + type SplitPiece, +} from "./B06_Section_UI_Cross_Detail_Geom"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +export const PIECE_HIT_CLASS = "b06-detail__piece"; + +/** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */ +export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt { + return ([px, py]) => { + const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter; + return [ + axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter, + axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration, + ]; + }; +} + +function mToPx(axes: CrossAxesOptions): (p: Pt) => string { + return ([offset, elevation]) => { + const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration; + const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter; + const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter; + return `${x},${y}`; + }; +} + +function pointsOf(el: Element): number[][] { + if (el.tagName.toLowerCase() === "line") { + const n = (name: string) => Number(el.getAttribute(name)); + return [ + [n("x1"), n("y1")], + [n("x2"), n("y2")], + ]; + } + return (el.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)) + .filter((p) => p.length === 2 && p.every(Number.isFinite)); +} + +/** 그림 도형 층 요소 → 고를 조각(m) — 설계 · 구조물 선은 같은 방향 토막을 직선 하나로 합친 뒤 나눔. */ +export function collectPieces(elements: Element[], axes: CrossAxesOptions): SplitPiece[] { + const toM = pxToM(axes); + const runs: Parameters[0] = []; + const others: SplitPiece[] = []; + for (const el of elements) { + const points = pointsOf(el).map(toM); + if (points.length < 2) continue; + if (el.classList.contains("b06-chart__design-cross")) + runs.push({ + points: points.map(([offset, elevation]) => ({ offset, elevation })), + structure: !el.hasAttribute(DESIGN_LINE_ATTR), + }); + else if (el.classList.contains("b06-chart__cross-profile")) + others.push({ line: "ground", points }); + else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points }); + } + const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({ + line: piece.structure ? "structure" : "design", + points: [ + [piece.from.offset, piece.from.elevation], + [piece.to.offset, piece.to.elevation], + ], + })); + return splitAtCrossings([...straight, ...others]); +} + +/** 고름 바꿈 — 더하기(Shift/Ctrl) = 넣고 빼기 · 아니면 그 하나(다시 누르면 비움). */ +export function toggleSelection(selected: number[], index: number, additive: boolean): number[] { + if (additive) + return selected.includes(index) ? selected.filter((i) => i !== index) : [...selected, index]; + return selected.length === 1 && selected[0] === index ? [] : [index]; +} + +const LINE_NAME: Record = { + design: "설계선", + structure: "구조물 선", + ground: "원지반선", + spoil: "사토장선", +}; + +/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */ +export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPiece[]): string { + if (!pieces.length) return ""; + if (pieces.length === 1) { + const [piece] = pieces; + const from = piece.points[0]; + const to = piece.points[piece.points.length - 1]; + if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil") + return pieceLabel(design, { + from: { offset: from[0], elevation: from[1] }, + to: { offset: to[0], elevation: to[1] }, + structure: piece.line === "structure", + }); + return ( + `${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` + + `수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m` + ); + } + const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0); + const area = ringArea(pieces.map((piece) => piece.points)); + return ( + `${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` + + (area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`) + ); +} + +/** 고른 조각 → 칸 출처(209-1 저장 꼴) — 면적은 닫혀야 · 길이는 하나 이상. */ +export function selectionSource( + kind: "area" | "length", + pieces: SplitPiece[], +): { src: "area" | "length"; pieces: CrossPickPiece[] } | null { + if (!pieces.length) return null; + if (kind === "area" && ringArea(pieces.map((piece) => piece.points)) === null) return null; + return { src: kind, pieces: pieces.map(pieceSource) }; +} + +/* ── 모달 배선 ─────────────────────────────────────────────────────────── */ + +export interface DetailSelect { + /** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */ + redraw: () => void; + /** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */ + setMode: (kind: "area" | "length" | null) => void; + source: (kind: "area" | "length") => ReturnType; + clear: () => void; + detach: () => void; +} + +export function attachDetailSelect(input: { + cad: HTMLElement; + design: () => CrossDesign | undefined; + axes: () => CrossAxesOptions | null; + /** 조각 누름 뒤(더하기 여부) — 표가 길이 고르기 한 번 누름이면 바로 칸에 넣음. */ + picked: (additive: boolean) => void; + /** [칸에 넣기] 단추. */ + commit: () => void; +}): DetailSelect { + const { cad } = input; + let pieces: SplitPiece[] = []; + let hits: SVGPolylineElement[] = []; + let selected: number[] = []; + let mode: "area" | "length" | null = null; + + const label = document.createElement("span"); + label.className = "b06-cross-card__line-dims b06-detail__measure"; + const put = document.createElement("button"); + put.type = "button"; + put.className = "b06-cross-card__zoom-btn b06-detail__commit"; + put.textContent = "칸에 넣기"; + put.title = "고른 선을 칸에 넣음(Enter)"; + put.addEventListener("click", (event) => { + event.stopPropagation(); + input.commit(); + }); + + const show = (): void => { + const chosen = selected.map((i) => pieces[i]); + hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i))); + label.textContent = selectionLabel(input.design(), chosen); + label.hidden = !chosen.length; + put.hidden = !mode || !selectionSource(mode, chosen); + // [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김 + const bar = cad.querySelector(".b06-cross-card__zoom"); + if (bar && label.parentElement !== bar) bar.prepend(label, put); + }; + + const onClick = (event: MouseEvent): void => { + const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`); + const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area"; + if (!hit) { + if (!additive && !mode && selected.length) { + selected = []; + show(); + } + return; + } + event.stopPropagation(); + selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive); + show(); + input.picked(additive); + }; + cad.addEventListener("click", onClick); + + return { + redraw() { + selected = []; + const axes = input.axes(); + const layer = cad.querySelector("g.b06-chart__plot-layer"); + pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; + const toPx = axes ? mToPx(axes) : null; + hits = toPx + ? pieces.map((piece) => { + const hit = document.createElementNS(SVG_NS, "polyline"); + hit.setAttribute("points", piece.points.map(toPx).join(" ")); + hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`); + layer?.append(hit); + return hit; + }) + : []; + show(); + }, + setMode(kind) { + mode = kind; + show(); + }, + source: (kind) => + selectionSource( + kind, + selected.map((i) => pieces[i]), + ), + clear() { + selected = []; + show(); + }, + detach() { + cad.removeEventListener("click", onClick); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts index 4413240e1..24bf4203d 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -6,7 +6,9 @@ * 값은 서버(`B07_DesignDetail_Engine_Cross_Table_Edit`)가 그릴 때마다 다시 셈 — 모달 · B07 도면 표 한 벌. * 조작 직후 그 칸 글만 `localCellValue` 로 즉시 — 짝(① 파이썬 · TS) · 거울 시험 * `resources/tester/test_179_4_detail_table.py`. - * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) · 길이 = 보라 판정 선 두 끝(화면 px → m · 축 옵션). + * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) — + * 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록. + * 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기]. * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; @@ -17,8 +19,10 @@ import type { CrossDetailTable, CrossSection, } from "./B06_Section_Api_Fetch"; +import type { SectionSample } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; -import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; +import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom"; +import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select"; /** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ export const MAX_ROWS = 12; @@ -162,7 +166,6 @@ const AREA_BANDS: ReadonlyArray<[string, string]> = [ ["b06-chart__spoil-fill", "spoil_fill"], ]; export const AREA_PICK_SELECTOR = ".b06-chart__area, .b06-chart__spoil-fill"; -export const LENGTH_PICK_SELECTOR = ".b06-chart__design-hit"; export function areaSourceOf(target: Element): CrossCellSource | null { const hit = target.closest(AREA_PICK_SELECTOR); @@ -170,27 +173,6 @@ export function areaSourceOf(target: Element): CrossCellSource | null { return band ? { src: "area", key: band[1] } : null; } -const round4 = (value: number): number => Math.round(value * 1e4) / 1e4; - -/** 판정 선 두 끝 → [offset, 표고](m) — 도형 층 좌표(카드 `x` · `toDisplayY` 의 역). */ -export function lengthSourceOf(target: Element, axes: CrossAxesOptions): CrossCellSource | null { - const hit = target.closest(LENGTH_PICK_SELECTOR); - const points = (hit?.getAttribute("points") ?? "") - .trim() - .split(/\s+/) - .map((pair) => pair.split(",").map(Number)); - if (points.length < 2 || points.some((p) => p.length !== 2 || !p.every(Number.isFinite))) - return null; - const toM = ([px, py]: number[]): [number, number] => { - const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter; - return [ - round4(axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter), - round4(axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration), - ]; - }; - return { src: "length", start: toM(points[0]), end: toM(points[points.length - 1]) }; -} - /* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */ const AREA_DESIGN: Readonly> = { @@ -219,9 +201,15 @@ function onLine([px, py]: [number, number], line: ReadonlyArray<[number, number] return false; } -/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null. */ -export function localCellValue(source: CrossCellSource, design: CrossDesign | undefined) { +/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null. + * `samples` = 측점 원지반(조각 검산 · 209-1). */ +export function localCellValue( + source: CrossCellSource, + design: CrossDesign | undefined, + samples?: ReadonlyArray, +) { const record = (design ?? {}) as unknown as Record; + if ("pieces" in source) return piecesValue(source.src, source.pieces, pickLines(design, samples)); if (source.src === "area") return finite(record[AREA_DESIGN[source.key] ?? ""]); if (source.src !== "length") return null; const line = (design?.design_line ?? []) @@ -292,12 +280,16 @@ export interface DetailTableHost { let menu: ReturnType | null = null; const PICK_HINT = { - area: "면적 띠를 누름 · Esc 취소", - length: "보라 선을 누름 · Esc 취소", + area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소", + length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소", } as const; -/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. 돌려준 함수 = 떼기(모달 닫힐 때). */ -export function attachDetailTable(host: DetailTableHost): () => void { +/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. detach = 떼기(모달 닫힐 때) · + * redraw = 그림을 새로 그린 뒤(선 조각 · 판정 선 다시 · 209-1). */ +export function attachDetailTable(host: DetailTableHost): { + detach: () => void; + redraw: () => void; +} { menu ??= createMapContextMenu("b06-detail"); const ctxMenu = menu; if (!ctxMenu.element.isConnected) { @@ -318,9 +310,30 @@ export function attachDetailTable(host: DetailTableHost): () => void { picking = null; delete cad.dataset.pick; delete cad.dataset.pickHint; + select.setMode(null); document.removeEventListener("keydown", onKey, true); }; + /** 고른 출처를 칸에 — 칸 글 즉시(짝 ①) · 세션 초안 → 서버 표 다시 받음. */ + const commit = (source: CrossCellSource | null): void => { + if (!picking || !source) return; + const { key } = picking; + stopPick(); + select.clear(); + const section = host.section(); + const shown = localCellValue(source, section.design, section.samples); + const td = tableBox.querySelector(`td[data-cell="${CSS.escape(key)}"]`); + if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2); + edit((table) => setCell(table, key, source)); + }; + const commitSelection = (): void => { + if (picking) commit(select.source(picking.kind)); + }; const onKey = (event: KeyboardEvent): void => { + if (event.key === "Enter") { + event.preventDefault(); + commitSelection(); + return; + } if (event.key !== "Escape") return; event.stopPropagation(); // 모달은 그대로 — 고르기만 취소 stopPick(); @@ -329,29 +342,30 @@ export function attachDetailTable(host: DetailTableHost): () => void { picking = { kind, key }; cad.dataset.pick = kind; cad.dataset.pickHint = PICK_HINT[kind]; + select.setMode(kind); document.addEventListener("keydown", onKey, true); }; + // 선 조각 고름(209-1) — 길이 한 번 누름(더하기 아님) = 바로 칸에 + const select = attachDetailSelect({ + cad, + design: () => host.section().design, + axes: host.axes, + picked: (additive) => { + if (picking?.kind === "length" && !additive) commitSelection(); + }, + commit: commitSelection, + }); + // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음 const onPick = (event: MouseEvent): void => { - if (!picking) return; - const selector = picking.kind === "area" ? AREA_PICK_SELECTOR : LENGTH_PICK_SELECTOR; - // 겹친 요소(띠 위 판정 선 등) 아래까지 — 그 자리 맨 위 대상 + if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`)) + return; const hit = document .elementsFromPoint(event.clientX, event.clientY) - .find((el) => cad.contains(el) && el.closest(selector)); - const axes = host.axes(); - const source = !hit - ? null - : picking.kind === "area" - ? areaSourceOf(hit) - : axes && lengthSourceOf(hit, axes); + .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); + const source = hit ? areaSourceOf(hit) : null; if (!source) return; event.stopPropagation(); - const { key } = picking; - stopPick(); - const shown = localCellValue(source, host.section().design); - const td = tableBox.querySelector(`td[data-cell="${CSS.escape(key)}"]`); - if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2); - edit((table) => setCell(table, key, source)); + commit(source); }; cad.addEventListener("click", onPick); @@ -401,12 +415,16 @@ export function attachDetailTable(host: DetailTableHost): () => void { tableBox.addEventListener("focusout", onBlur); tableBox.addEventListener("keydown", onEnter); - return () => { - stopPick(); - ctxMenu.close(); - cad.removeEventListener("click", onPick); - tableBox.removeEventListener("contextmenu", onMenu); - tableBox.removeEventListener("focusout", onBlur); - tableBox.removeEventListener("keydown", onEnter); + return { + detach: () => { + stopPick(); + ctxMenu.close(); + select.detach(); + cad.removeEventListener("click", onPick); + tableBox.removeEventListener("contextmenu", onMenu); + tableBox.removeEventListener("focusout", onBlur); + tableBox.removeEventListener("keydown", onEnter); + }, + redraw: () => select.redraw(), }; } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index ab0354901..2f14f0637 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -164,11 +164,42 @@ /* 그래프에서 고르기 — 고를 대상만 손가락 · 왼쪽 아래(눈금 자리) 안내 글 */ .b06-detail__cad[data-pick="area"] .b06-chart__area > polygon, -.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill, -.b06-detail__cad[data-pick="length"] .b06-chart__design-hit { +.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill { cursor: pointer; } +/* 선 조각 판정 선(209-1) — 투명 굵은 선 · 배율 무관 굵기 · 고른 조각 = 붉은 띠 */ +.b06-detail__piece { + fill: none; + stroke: transparent; + stroke-width: 10; + stroke-linecap: round; + vector-effect: non-scaling-stroke; + pointer-events: stroke; + cursor: pointer; +} + +.b06-detail__piece:hover { + stroke: color-mix(in srgb, var(--color-accent) 25%, transparent); +} + +.b06-detail__piece.is-selected { + stroke: color-mix(in srgb, var(--color-danger) 55%, transparent); + stroke-width: 6; +} + +/* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */ +.b06-detail__commit { + width: auto; + padding: 0 var(--spacing-4); + color: var(--color-accent); + white-space: nowrap; +} + +.b06-detail__commit[hidden] { + display: none; +} + .b06-detail__cad[data-pick]::after { content: attr(data-pick-hint); position: absolute; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py index bcdb964c2..d522ab3f9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -6,11 +6,17 @@ area = 설계 면적 칸 그대로(`AREA_KEYS` · 띠 키 = 표 키) length = 두 끝(m · offset · 표고)이 설계선 위면 hypot · 아니면 비움 → 「다시 고름」 text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만 + 고른 조각(209-1 · `pieces` = [{line, start, end}]) — 교차점에서 나눈 선 조각 목록: + length = 조각 길이 합 · area = 조각이 닫힌 고리를 이루면 그 넓이(안 닫힘 → 「다시 고름」) + 조각 길 = 그 선(설계선 · 원지반선 · 사토장선) 위 두 끝 사이 꺾은선 + (두 끝이 선 밖 → 「다시 고름」) + · 구조물 선(`structure` · 브라우저 기하)은 위치 검산 없이 두 끝 직선. 행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값). 열(`columns`) = 오른쪽 끝 · 모든 행(비고 등). 짝(① 파이썬 · TS) = `B06_Section/B06_Section_UI_Cross_Detail_Table.ts` `localCellValue` -(조작 중 즉시 표시) · 거울 시험 `resources/tester/test_179_4_detail_table.py`. +(조작 중 즉시 표시) · 조각 길 · 넓이 = `B06_Section/B06_Section_UI_Cross_Detail_Geom.ts` · +거울 시험 `resources/tester/test_179_4_detail_table.py` · `test_209_1_detail_select.py`. """ from __future__ import annotations @@ -46,9 +52,9 @@ def _point(value: Any) -> tuple[float, float] | None: return (x, y) if x is not None and y is not None else None -def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: +def _polyline(raw: Any) -> list[tuple[float, float]]: points = [] - for point in design.get("design_line") or []: + for point in raw if isinstance(raw, list) else []: if isinstance(point, dict): x, y = _num(point.get("offset_m")), _num(point.get("elevation_m")) if x is not None and y is not None: @@ -56,6 +62,24 @@ def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: return points +def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: + return _polyline(design.get("design_line")) + + +def ground_runs(samples: Any) -> list[list[tuple[float, float]]]: + """원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(카드 `appendGroundProfile` 과 같은 규칙).""" + runs: list[list[tuple[float, float]]] = [[]] + for sample in samples if isinstance(samples, list) else []: + sample = sample if isinstance(sample, dict) else {} + x = _num(sample.get("offset_m")) + y = _num(sample.get("elevation_m", sample.get("z"))) + if sample.get("valid") is False or x is None or y is None: + runs.append([]) + else: + runs[-1].append((x, y)) + return [run for run in runs if len(run) > 1] + + def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> bool: """점이 설계선(꺾은선) 조각 하나에서 `LINE_TOLERANCE_M` 안인가.""" px, py = point @@ -68,12 +92,119 @@ def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> boo return False +Point = tuple[float, float] + + +def _locate(point: Point, run: list[Point]) -> float | None: + """선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 None.""" + best: tuple[float, float] | None = None + along = 0.0 + for (ax, ay), (bx, by) in zip(run, run[1:]): + dx, dy = bx - ax, by - ay + size = hypot(dx, dy) + t = 0.0 if size == 0 else ((point[0] - ax) * dx + (point[1] - ay) * dy) / size**2 + t = max(0.0, min(1.0, t)) + gap = hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy)) + if gap <= LINE_TOLERANCE_M and (best is None or gap < best[0]): + best = (gap, along + t * size) + along += size + return best[1] if best else None + + +def _at(run: list[Point], s: float) -> Point: + """선 위 자리 s(m) 의 점.""" + for (ax, ay), (bx, by) in zip(run, run[1:]): + size = hypot(bx - ax, by - ay) + if s <= size: + t = 0.0 if size == 0 else s / size + return (ax + t * (bx - ax), ay + t * (by - ay)) + s -= size + return run[-1] + + +def sub_path(run: list[Point], s0: float, s1: float) -> list[Point]: + """선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함.""" + lo, hi = min(s0, s1), max(s0, s1) + inner: list[Point] = [] + along = 0.0 + for a, b in zip(run, run[1:]): + along += hypot(b[0] - a[0], b[1] - a[1]) + if lo < along < hi: + inner.append(b) + path = [_at(run, lo), *inner, _at(run, hi)] + return path if s0 <= s1 else path[::-1] + + +def piece_path(piece: Any, lines: dict[str, list[list[Point]]]) -> list[Point] | None: + """고른 조각 하나 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 None(구조물 선 = 두 끝 직선).""" + if not isinstance(piece, dict): + return None + start, end = _point(piece.get("start")), _point(piece.get("end")) + if not start or not end: + return None + if piece.get("line") == "structure": + return [start, end] + for run in lines.get(str(piece.get("line")), []): + s0, s1 = _locate(start, run), _locate(end, run) + if s0 is not None and s1 is not None: + return sub_path(run, s0, s1) + return None + + +def path_length(path: list[Point]) -> float: + return sum(hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(path, path[1:])) + + +def _near(a: Point, b: Point) -> bool: + return hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M + + +def ring_area(paths: list[list[Point]]) -> float | None: + """조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = None.""" + if not paths: + return None + ring = list(paths[0]) + rest = [list(path) for path in paths[1:]] + while rest: + index = next( + (i for i, p in enumerate(rest) if _near(p[0], ring[-1]) or _near(p[-1], ring[-1])), + None, + ) + if index is None: + return None + path = rest.pop(index) + ring += (path if _near(path[0], ring[-1]) else path[::-1])[1:] + if len(ring) < 4 or not _near(ring[0], ring[-1]): + return None + ring.append(ring[0]) # 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음 + area = abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(ring, ring[1:]))) / 2 + return area if area > 1e-9 else None + + +def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]: + """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선).""" + return { + "design": [_design_line(design)], + "ground": ground_runs(samples), + "spoil": [_polyline(design.get("spoil_fill_line"))], + } + + def cell_value( - source: dict[str, Any], design: dict[str, Any] | None + source: dict[str, Any], design: dict[str, Any] | None, samples: Any = None ) -> tuple[float | None, str | None, bool]: - """출처 하나 → (숫자, 글, 다시 고름).""" + """출처 하나 → (숫자, 글, 다시 고름). `samples` = 측점 원지반 샘플(조각 검산).""" design = design if isinstance(design, dict) else {} kind = source.get("src") + pieces = source.get("pieces") + if kind in ("length", "area") and isinstance(pieces, list): + lines = pick_lines(design, samples) + paths = [piece_path(piece, lines) for piece in pieces] + valid = [path for path in paths if path is not None] + if not valid or len(valid) != len(paths): + return None, None, True + number = sum(map(path_length, valid)) if kind == "length" else ring_area(valid) + return number, None, number is None if kind == "area": return _num(design.get(_AREA_DESIGN.get(str(source.get("key")), ""))), None, False if kind == "length": @@ -89,9 +220,9 @@ def cell_value( def edited_cells( - design: dict[str, Any] | None, + design: dict[str, Any] | None, samples: Any = None ) -> tuple[dict[str, float | None], dict[str, str], list[str]]: - """patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만.""" + """patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만 · `samples` = 원지반.""" values: dict[str, float | None] = {} texts: dict[str, str] = {} repick: list[str] = [] @@ -99,7 +230,7 @@ def edited_cells( for key, source in (cells if isinstance(cells, dict) else {}).items(): if not isinstance(source, dict): continue - number, text, again = cell_value(source, design) + number, text, again = cell_value(source, design, samples) if text is not None: if key[:2] in ("r:", "c:"): texts[key] = text diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b63a43981..8932f7cd2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -313,7 +313,7 @@ def _quantity_table( for key in QUANTITY_VALUE_KEYS: table.setdefault(key, num(quantities.get(key))) # [상세] 표 손질(179-4) — 고른 칸 · 더한 칸을 서버가 다시 셈(모달 · B07 도면 한 벌) - table.update(edited_cells(design)[0]) + table.update(edited_cells(design, source.get("samples"))[0]) return table diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index 9cb7ffa3f..a56ff0baa 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -35,6 +35,8 @@ const STUBS = { svgText, }, "./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} }, + // 선 조각 고르기(209-1)는 줌과 무관 — 따로 시험(`test_209_1_detail_select.py`) + "./B06_Section_UI_Cross_Detail_Select": { attachDetailSelect: () => ({}), PIECE_HIT_CLASS: "" }, }; const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); function loadTs(full) { diff --git a/resources/tester/helper_179_4_detail_table.cjs b/resources/tester/helper_179_4_detail_table.cjs index 80bf13535..743921ada 100644 --- a/resources/tester/helper_179_4_detail_table.cjs +++ b/resources/tester/helper_179_4_detail_table.cjs @@ -25,6 +25,8 @@ const store = () => { }; }; globalThis.window = { sessionStorage: store(), localStorage: store() }; +// 조정창 공용 부품(Select → Design_Pick → Cross_Panel_Base)이 불러올 때 문서에 귀를 닮 +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; const STUBS = { "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, @@ -38,6 +40,9 @@ function loadTs(full) { const box = { exports: {} }; const localRequire = (request) => { if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); return loadTs(resolve(path.join(path.dirname(full), request))); }; new Function("exports", "module", "require", js)(box.exports, box, localRequire); @@ -85,12 +90,10 @@ const px = ([offset, elevation]) => [ (AXES.displayMax - (AXES.elevationMid + (elevation - AXES.elevationMid) * AXES.exaggeration)) * AXES.pixelsPerMeter, ]; -const points = input.pick - .map(px) - .map((p) => p.join(",")) - .join(" "); -out.length = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points }), AXES); -out.lengthBad = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points: "1,2" }), AXES); +// 화면 px → m(209-1 `_Cross_Detail_Select.pxToM`) · 길이 = 옛 저장 꼴(두 끝) 그대로 읽음 +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +out.pxBack = input.pick.map(px).map(S.pxToM(AXES)); +out.length = { src: "length", start: input.pick[0], end: input.pick[1] }; out.lengthValue = T.localCellValue(out.length, input.design); // ③ 우클릭 메뉴 항목 diff --git a/resources/tester/helper_209_1_detail_select.cjs b/resources/tester/helper_209_1_detail_select.cjs new file mode 100644 index 000000000..81862f3cd --- /dev/null +++ b/resources/tester/helper_209_1_detail_select.cjs @@ -0,0 +1,117 @@ +/* PLAN 209-1 단위검증 헬퍼 — [상세] 선 조각 고르기(`_Cross_Detail_Geom` · `_Cross_Detail_Select`). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes} · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_209_1_detail_select.py`(파이썬 `cell_value` 거울). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts")); + +const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; + +// ① 그림 요소(화면 px) → 조각(m) — 설계선(직선 토막 셋) · 원지반선 · 구조물 선 아님 +const px = ([offset, elevation]) => + [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ].join(","); +const el = (classes, points, attrs = {}) => ({ + tagName: "polyline", + classList: { contains: (name) => classes.includes(name) }, + hasAttribute: (name) => name in attrs, + getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)), +}); +const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]); +const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]); +const pieces = S.collectPieces( + [ + el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }), + el(["b06-chart__cross-profile"], groundPts), + el( + ["b06-chart__grid"], + [ + [0, 0], + [1, 1], + ], + ), + ], + axes, +); +out.pieces = pieces; + +// ② 여러 고름 — 더하기 · 빼기 · 하나 · 다시 누르면 비움 +out.toggle = { + single: S.toggleSelection([1, 2], 3, false), + again: S.toggleSelection([3], 3, false), + add: S.toggleSelection([1], 3, true), + remove: S.toggleSelection([1, 3], 3, true), +}; + +// ③ 닫힌 영역 — 땅 아래 설계 골(경사 둘 · 바닥) + 원지반 가운데 조각 +const isGround = (p) => p.line === "ground"; +const mid = (p) => (p.points[0][0] + p.points[p.points.length - 1][0]) / 2; +const below = pieces.filter( + (p) => !isGround(p) && Math.max(...p.points.map((q) => q[1])) <= 100.0001, +); +const groundMid = pieces.filter((p) => isGround(p) && Math.abs(mid(p)) < 1); +const ring = [...below, ...groundMid]; +out.ringCount = ring.length; +out.label = { + ring: S.selectionLabel(design, ring), + open: S.selectionLabel(design, below), + single: S.selectionLabel(design, [below.find((p) => Math.abs(mid(p)) < 0.5)]), + ground: S.selectionLabel(design, groundMid), +}; +out.area = G.ringArea(ring.map((p) => p.points)); +out.open = G.ringArea(below.map((p) => p.points)); + +// ④ 칸 넣기 출처(저장 꼴) · 즉시 칸 값(짝 ①) +out.sourceArea = S.selectionSource("area", ring); +out.sourceOpen = S.selectionSource("area", below); +out.sourceLength = S.selectionSource("length", [...below, ...groundMid]); +const withStructure = { + src: "length", + pieces: [...out.sourceLength.pieces, { line: "structure", start: [20, 0], end: [23, 4] }], +}; +const offLine = { + src: "length", + pieces: [{ line: "ground", start: [0, 101], end: [1, 100] }], +}; +out.sources = [out.sourceArea, out.sourceLength, withStructure, offLine]; +out.local = out.sources.map((source) => T.localCellValue(source, design, samples)); +out.lengthSum = [...below, ...groundMid].reduce((sum, p) => sum + G.pathLength(p.points), 0); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_179_4_detail_table.py b/resources/tester/test_179_4_detail_table.py index a513fc37e..6df144346 100644 --- a/resources/tester/test_179_4_detail_table.py +++ b/resources/tester/test_179_4_detail_table.py @@ -113,10 +113,8 @@ def test_그래프_고르기_띠_키_판정_선_m(out): assert area["fill"] == {"src": "area", "key": "embankment"} assert area["spoil"] == {"src": "area", "key": "spoil_fill"} assert area["other"] is None - length = out["length"] - assert length["src"] == "length" - assert length["start"] == pytest.approx(PICK[0]) and length["end"] == pytest.approx(PICK[1]) - assert out["lengthBad"] is None + # 화면 px → m(209-1 `pxToM`) · 옛 저장 꼴(두 끝) 길이 = hypot 그대로 + assert out["pxBack"][0] == pytest.approx(PICK[0]) and out["pxBack"][1] == pytest.approx(PICK[1]) assert out["lengthValue"] == pytest.approx(hypot(4.0, 2.0)) @@ -261,4 +259,5 @@ def test_저장_patch_배선_초안_등록(): assert '"detail-table": { bucket: "draft", scope: "route" }' in state detail = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail.ts").read_text(encoding="utf-8") assert "designForTable(state.section, projectId, routeId)" in detail - assert "const detachTable = attachDetailTable({" in detail + assert "const tablePick = attachDetailTable({" in detail + assert "tablePick.redraw();" in detail diff --git a/resources/tester/test_209_1_detail_select.py b/resources/tester/test_209_1_detail_select.py new file mode 100644 index 000000000..b695354c9 --- /dev/null +++ b/resources/tester/test_209_1_detail_select.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +"""PLAN 209-1 — B06 [상세] 그래프 고르기(교차점 분할 · 여러 고름 · 원지반 포함 · 닫힌 넓이). + +정보창(고른 것 길이 · 면적) · 칸 넣기 · 서버 검산 · 옛 꼴 읽기. + +못박는 것 + - 선(설계선 · 원지반선)이 만나는 점에서 조각으로 나뉨 · 화면 px → m · 격자 등은 안 고름. + - 여러 고름(Shift/Ctrl 더하기 · 빼기) · 원지반 조각 포함 닫힌 영역 넓이 · 안 닫히면 null. + - 정보창 두 줄(하나 = 카드 꼴 길이 · 경사비율 / 여럿 = 길이 합 · 면적 · 「닫히지 않음」). + - 칸 넣기 출처(조각 두 끝 목록) → 스키마 → 서버 `cell_value` 거울(① 파이썬 · TS) · + 서버 검산(선 밖 · 설계선 바뀜 → 다시 고름 · 구조물 선 = 두 끝 직선) · 옛 두 끝 꼴 그대로. + +./venv/Scripts/python.exe -m pytest resources/tester/test_209_1_detail_select.py -q +""" + +import json +import os +import subprocess +from math import hypot +from pathlib import Path + +import pytest + +from B06_Section import B06_Section_Router_CrossTable as table_mod +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import ( + cell_value, + ground_runs, + ring_area, + sub_path, +) + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)] +DESIGN = { + "design_elevation_m": 98.0, + "road_edges": { + "left": {"offset_m": 2.0, "elevation_m": 98.0}, + "right": {"offset_m": -2.0, "elevation_m": 98.0}, + }, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] + [ + {"offset_m": 12.0, "elevation_m": None, "valid": False}, + {"offset_m": 14.0, "elevation_m": 101.0, "valid": True}, +] +AXES = { + "widthPx": 600, + "heightPx": 300, + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 110, + "elevationMid": 100, + "exaggeration": 2, +} +CROSS_X = 2 + 4 / 3 # 경사(1:0.667)가 원지반 100 m 와 만나는 offset +AREA = (2 * CROSS_X + 4) / 2 * 2 # 사다리꼴(윗변 2·CROSS_X · 아랫변 4 · 높이 2) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_209_1_detail_select.cjs")], + input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _ends(piece: dict) -> list[tuple[float, float]]: + pts = piece["points"] + return [tuple(round(v, 4) for v in pts[0]), tuple(round(v, 4) for v in pts[-1])] + + +# ── 분할 · 여러 고름 ───────────────────────────────────────────────────── + + +def test_교차점에서_조각으로_나뉨(out): + pieces = out["pieces"] + ground = [p for p in pieces if p["line"] == "ground"] + design = [p for p in pieces if p["line"] == "design"] + assert len(ground) == 3 and len(design) == 5 # 원지반 = 두 교점에서 셋 · 설계 경사 둘이 둘씩 + c = round(CROSS_X, 4) + assert sorted(_ends(p)[0][0] for p in ground) == pytest.approx([-10.0, -c, c]) + # 원지반 가운데 조각은 사이 샘플 꼭짓점(0 m)을 지남 — 꺾은선 그대로 + middle = next(p for p in ground if abs(p["points"][0][0]) < 4) + assert [0.0, 100.0] in [[round(v, 6) for v in q] for q in middle["points"]] + # 경사 조각 끝 = 교점(설계선 위 · 원지반 위) + assert any(_ends(p)[1] == (c, 100.0) or _ends(p)[0] == (c, 100.0) for p in design) + + +def test_여러_고름_더하기_빼기(out): + toggle = out["toggle"] + assert toggle == {"single": [3], "again": [], "add": [1, 3], "remove": [1]} + + +# ── 닫힌 넓이 · 정보창 ────────────────────────────────────────────────── + + +def test_원지반_포함_닫힌_영역_넓이(out): + assert out["ringCount"] == 4 # 경사 둘 · 바닥 · 원지반 가운데 + assert out["area"] == pytest.approx(AREA) + assert out["open"] is None # 원지반 빼면 안 닫힘 + + +def test_정보창_값(out): + label = out["label"] + length = 2 * hypot(4 / 3, 2) + 4 + 2 * CROSS_X + assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡" + assert label["open"].endswith("면적 닫히지 않음") + assert label["single"] == "길이 4.00m · 경사비율 0.0%\n수평 4.00m · 높이 0.00m" # 카드 꼴 + assert label["ground"] == ( + f"원지반선 길이 {2 * CROSS_X:.2f}m\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m" + ) + + +# ── 칸 넣기 · 짝 · 서버 검산 ───────────────────────────────────────────── + + +def test_칸_넣기_출처_저장_꼴(out): + area = out["sourceArea"] + assert area["src"] == "area" and len(area["pieces"]) == 4 + assert {p["line"] for p in area["pieces"]} == {"design", "ground"} + assert out["sourceOpen"] is None # 안 닫힌 면적은 칸에 못 넣음 + assert out["sourceLength"]["src"] == "length" + patch = CrossSectionPatch( + chainage_m=40.0, + detail_table={"cells": {"cut_soil": area, "fence": out["sourceLength"]}}, + ).model_dump() + assert patch["detail_table"]["cells"]["cut_soil"]["pieces"][0]["line"] in ("design", "ground") + + +def test_칸_값_파이썬_TS_거울(out): + for source, local in zip(out["sources"], out["local"], strict=True): + number, _text, again = cell_value(source, DESIGN, SAMPLES) + if number is None: + assert local is None and again is True, source + else: + assert local == pytest.approx(number, abs=1e-6), source + assert out["local"][0] == pytest.approx(AREA, abs=1e-3) + assert out["local"][1] == pytest.approx(out["lengthSum"], abs=1e-3) + assert out["local"][2] == pytest.approx(out["lengthSum"] + 5.0, abs=1e-3) # 구조물 = 두 끝 직선 + assert out["local"][3] is None # 원지반 밖 끝 + + +def test_서버_검산_선_밖이나_설계선_바뀌면_다시_고름(out): + cells = {"cut_soil": out["sourceArea"], "fence": out["sourceLength"]} + stored = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": cells}).model_dump() + design = {**DESIGN, "detail_table": stored["detail_table"]} + source = {"chainage_m": 40.0, "center_z": 100.0, "samples": SAMPLES} + sheet = table_mod.cross_quantity_sheet(source, design) + assert sheet["quantity_table"]["cut_soil"] == pytest.approx(AREA, abs=1e-3) + assert sheet["quantity_table"]["fence"] == pytest.approx(out["lengthSum"], abs=1e-3) + assert sheet["repick"] == [] + # 원지반 샘플이 없으면(검산 못 함) 다시 고름 — 브라우저 값 안 받음 + blind = table_mod.cross_quantity_sheet({"chainage_m": 40.0}, design) + assert sorted(blind["repick"]) == ["cut_soil", "fence"] + # 설계선이 내려가면 조각 끝이 선 밖 → 다시 고름 + moved = {**design, "design_line": [{"offset_m": x, "elevation_m": y - 1} for x, y in LINE]} + again = table_mod.cross_quantity_sheet(source, moved) + assert again["quantity_table"]["cut_soil"] is None + assert sorted(again["repick"]) == ["cut_soil", "fence"] + + +def test_옛_두_끝_꼴_그대로_읽음(): + old = {"src": "length", "start": [2.0, 98.0], "end": [6.0, 104.0]} + assert cell_value(old, DESIGN, SAMPLES)[0] == pytest.approx(hypot(4.0, 6.0)) + assert cell_value({"src": "area", "key": "cut_soil"}, {"cut_soil_area_m2": 1.5}) == ( + 1.5, + None, + False, + ) + + +def test_서버_조각_길_넓이_기본(): + assert ground_runs(SAMPLES) == [ + [(-10, 100.0), (-5, 100.0), (0, 100.0), (5, 100.0), (10, 100.0)] + ] + run = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0)] + assert sub_path(run, 3.0, 1.0) == [(2.0, 1.0), (2.0, 0.0), (1.0, 0.0)] + square = [ + [(0.0, 0.0), (1.0, 0.0)], + [(1.0, 1.0), (1.0, 0.0)], + [(1.0, 1.0), (0.0, 1.0), (0.0, 0.0)], + ] + assert ring_area(square) == pytest.approx(1.0) + assert ring_area(square[:2]) is None + assert ring_area([*square, [(5.0, 5.0), (6.0, 6.0)]]) is None # 남는 조각 From 002165f040d2d3c8cbfcf2b8c0b1e9e46210ee2c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:22:52 +0900 Subject: [PATCH 22/38] =?UTF-8?q?fix(B05=C2=B7B06):=20=ED=95=9C=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EC=B8=A1=EC=A0=90=20=EB=91=98=20=EC=88=A8?= =?UTF-8?q?=EC=9D=8C=20=E2=80=94=20=EC=B8=A1=EC=A0=90=200.006=20m=20?= =?UTF-8?q?=EC=95=88=20=EA=BC=AD=EC=A7=93=EC=A0=90=20=C2=B7=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=82=A4=EB=A5=BC=20=EA=B7=B8=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=AA=A8=EC=9D=8C(213-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭: 자동설계 관 측점 263.206 → 화면 저장 0.01 m 반올림 263.21 로 측점이 다시 섬 · 계획선 꼭짓점 · 편집 키는 옛 글자 그대로 → 새 측점 편집과 0.004 m 사이 두 꼭짓점(−15003% 계단 · 엄상준_검증 No.13+3) - 계획선 공용 함수 짝(파이썬 snap_to_stations ↔ TS snapToStations) — 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이(측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로 · 읽을 때 · [저장] 때 모두 정리 - 192 병합(_follow_moved_stations)도 옛 측점 0.006 m 안 글자를 같은 측점으로 봄 - 시험 test_213_1(합성 · 거울 · 엄상준 사본 재현) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B05_Profile_Engine_Grade_Alignment.py | 72 ++++++ B05_Profile/B05_Profile_Router_Confirm.py | 34 ++- .../B05_Profile_UI_Profile_Alignment.ts | 65 +++++- .../tester/test_213_1_hidden_station_pvi.py | 214 ++++++++++++++++++ 4 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_213_1_hidden_station_pvi.py diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 254b252d8..82e8e274f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -158,6 +158,62 @@ def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray) return np.interp(targets, pvi_s, pvi_z) +#: 반올림만 다른 같은 측점(m) — 관 측점 0.001m(자동설계) ↔ 0.01m(화면 저장) 차이. +#: 화면 `SAME_STATION_SNAP_M`(B05_Profile_UI_Profile_Alignment.ts) 와 짝. +SAME_STATION_SNAP_M = 0.006 + + +def _nearest_station(value: float, station_s: np.ndarray) -> float | None: + if not station_s.size: + return None + near = float(station_s[int(np.argmin(np.abs(station_s - value)))]) + return near if abs(near - value) < SAME_STATION_SNAP_M else None + + +def snap_to_stations( + base_s: np.ndarray, + base_z: np.ndarray, + edits: dict[str, dict[str, float]], + station_s: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, dict[str, dict[str, float]]]: + """측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1). + + 관 측점이 반올림으로 옮겨도(263.206 → 263.21) 꼭짓점 · 편집 키는 옛 글자로 남아, 새 측점 + 편집과 0.004m 사이 두 꼭짓점이 됨(−15000% 계단 · 숨은 측점). 겹치면 측점 위 것이 이김. + 옮긴 꼭짓점은 옛 선의 측점 높이를 받아 측점 계획고가 안 바뀜. 양 끝 · 측점에서 먼 키는 그대로. + """ + last = len(base_s) - 1 + nodes: dict[float, tuple[float, float, float]] = {} + for index, (s, z) in enumerate(zip(base_s.tolist(), base_z.tolist())): + near = None if index in (0, last) else _nearest_station(s, station_s) + at = s if near is None else near + gap = 0.0 if near is None else abs(near - s) + slot = round(at, _KEY_DECIMALS) + if slot not in nodes or gap < nodes[slot][0]: + # 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로 + nodes[slot] = (gap, at, z if near is None else float(np.interp(at, base_s, base_z))) + ordered = [nodes[slot] for slot in sorted(nodes)] + snapped: dict[str, dict[str, float]] = {} + for name, values in edits.items(): + best: dict[str, tuple[float, float]] = {} + for key, value in values.items(): + try: + chainage = float(key) + except (TypeError, ValueError): + chainage = float("nan") + near = _nearest_station(chainage, station_s) if np.isfinite(chainage) else None + target = key if near is None else chainage_key(near) + gap = 0.0 if near is None else abs(near - chainage) + if target not in best or gap < best[target][0]: + best[target] = (gap, value) + snapped[name] = {key: value for key, (_, value) in best.items()} + return ( + np.array([item[1] for item in ordered], dtype=np.float64), + np.array([item[2] for item in ordered], dtype=np.float64), + snapped, + ) + + def resolve_pvi( base_s: np.ndarray, base_z: np.ndarray, @@ -407,6 +463,22 @@ def build_alignment( if value is not None } + station_s = np.array( + sorted( + float(station["chainage_m"]) + for station in stations + if isinstance(station.get("chainage_m"), (int, float)) + ), + dtype=np.float64, + ) + base_s, base_z, snapped = snap_to_stations( + base_s, + base_z, + {"station_offsets": station_offsets, "curve_radii": curve_radii}, + station_s, + ) + station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"] + pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) curves, warnings = build_curves( pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index c21c05004..8e5117630 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -214,27 +214,41 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st None, ) moves[chainage_key(old)] = None if near is None else chainage_key(near) + + def moved(value: float) -> tuple[bool, str | None]: + """옛 측점 0.006m 안이면 (참, 옮길 키 · 지우면 None) — 옛 글자(263.206)도 같은 측점(213-1). + 남은 측점 위 값은 그대로.""" + if chainage_key(value) in after_keys: + return False, None + for old_key, new_key in moves.items(): + if abs(float(old_key) - value) < _SAME_STATION_M: + return True, new_key + return False, None + edits = stored.get("edits") or {} next_edits: dict[str, dict[str, Any]] = {} changed = False for name in ("station_offsets", "curve_radii"): values = dict(edits.get(name) or {}) - for old_key, new_key in moves.items(): - if old_key in values: - value = values.pop(old_key) - if new_key is not None: - values.setdefault(new_key, value) - changed = True + # 옛 측점 글자 그대로인 키가 먼저 — 반올림 글자보다 이김 + for key in sorted(values, key=lambda item: item not in moves): + hit, new_key = moved(float(key)) + if not hit: + continue + value = values.pop(key) + if new_key is not None: + values.setdefault(new_key, value) + changed = True next_edits[name] = values base: list[dict[str, Any]] = [] for node in stored["base_pvi"]: - key = chainage_key(node["chainage_m"]) - if key not in moves: + hit, new_key = moved(float(node["chainage_m"])) + if not hit: base.append(node) continue changed = True - if moves[key] is not None: - base.append({**node, "chainage_m": float(moves[key])}) + if new_key is not None: + base.append({**node, "chainage_m": float(new_key)}) if not changed: return None stored["base_pvi"] = base diff --git a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index 1a6c62b19..c30957a82 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -182,6 +182,65 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase { }; } +/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */ +const SAME_STATION_SNAP_M = 0.006; + +function nearestStation(value: number, stationS: number[]): number | null { + let near: number | null = null; + stationS.forEach((station) => { + if (near === null || Math.abs(station - value) < Math.abs(near - value)) near = station; + }); + return near !== null && Math.abs(near - value) < SAME_STATION_SNAP_M ? near : null; +} + +function snapKeys(values: Record, stationS: number[]): Record { + const best = new Map(); + Object.entries(values).forEach(([key, value]) => { + const chainage = Number(key); + const near = Number.isFinite(chainage) ? nearestStation(chainage, stationS) : null; + const target = near === null ? key : chainageKey(near); + const gap = near === null ? 0 : Math.abs(near - chainage); + const held = best.get(target); + if (!held || gap < held.gap) best.set(target, { gap, value }); + }); + return Object.fromEntries([...best].map(([key, entry]) => [key, entry.value])); +} + +/** + * 측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1) — 서버 + * `snap_to_stations` 와 같은 규칙. 관 측점이 반올림으로 옮겨도(263.206 → 263.21) 옛 글자로 남아 + * 0.004m 사이 두 꼭짓점이 되던 흠. 겹치면 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이 + * (측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로. + */ +function snapToStations( + base: AlignmentBase, + edits: AlignmentEdits, +): { basePvi: AlignmentNode[]; edits: AlignmentEdits } { + const stationS = base.stations.map((station) => station.chainage_m); + const baseS = base.basePvi.map((node) => node.chainage_m); + const baseZ = base.basePvi.map((node) => node.elevation_m); + const last = base.basePvi.length - 1; + const nodes = new Map(); + base.basePvi.forEach((node, index) => { + const near = index === 0 || index === last ? null : nearestStation(node.chainage_m, stationS); + const at = near ?? node.chainage_m; + const gap = near === null ? 0 : Math.abs(near - node.chainage_m); + const slot = Number(at.toFixed(3)); + const held = nodes.get(slot); + // 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로 + const elevation = near === null ? node.elevation_m : interpolate(baseS, baseZ, at); + if (!held || gap < held.gap) + nodes.set(slot, { gap, node: { chainage_m: at, elevation_m: elevation } }); + }); + return { + basePvi: [...nodes.keys()].sort((a, b) => a - b).map((slot) => nodes.get(slot)!.node), + edits: { + station_offsets: snapKeys(edits.station_offsets, stationS), + curve_radii: snapKeys(edits.curve_radii, stationS), + }, + }; +} + /** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */ function resolvePvi( base: AlignmentBase, @@ -316,8 +375,10 @@ function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits { }; } -export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): ProfileAlignment { - const edits = normalizeEdits(input); +export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): ProfileAlignment { + const snapped = snapToStations(raw, normalizeEdits(input)); + const base: AlignmentBase = { ...raw, basePvi: snapped.basePvi }; + const edits = snapped.edits; const warnings: string[] = []; const nodes = resolvePvi(base, edits.station_offsets); const pviS = nodes.map((node) => node.chainage_m); diff --git a/resources/tester/test_213_1_hidden_station_pvi.py b/resources/tester/test_213_1_hidden_station_pvi.py new file mode 100644 index 000000000..4e5480c8c --- /dev/null +++ b/resources/tester/test_213_1_hidden_station_pvi.py @@ -0,0 +1,214 @@ +"""213-1 — 한 자리에 측점 둘이 숨는 흠(엄상준_검증 d2245e3b · No.13+3). + +까닭: 자동설계 관 측점 263.206(0.001m) → 화면 저장이 관을 0.01m 로 반올림(263.21) → 측점은 +263.21 로 다시 섰는데 계획선 꼭짓점(base_pvi) · 편집 키는 옛 글자 「263.206」 그대로 → 새 측점 +편집 「263.210」과 0.004m 사이 두 꼭짓점(−15003% 계단). +고침: 계획선 공용 함수(파이썬 `snap_to_stations` ↔ TS `snapToStations`)가 측점 0.006m 안의 +꼭짓점 · 편집 키를 그 측점으로 모음(측점 위 것이 이김 · 측점 계획고 그대로) · 192 병합도 +옛 글자를 같은 측점으로 봄. 사용자 storage 는 읽기만 — 사본(tmp_path). +""" + +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + AlignmentPolicy, + build_alignment, +) +from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402 +from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402 + +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +SOURCE = ( + PROJECT_ROOT + / "storage/1/3/d2245e3b-0468-4a90-ac5c-50b2adcb56e7/B06_Section/longitudinal/longitudinal.json" +) +REL = "B06_Section/longitudinal/longitudinal.json" +needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="엄상준_검증 storage 없음") + +# 합성 — 격자 20m · 관 측점 263.206 → 263.21 반올림 · 끝 꼭짓점 1000.0(끝 측점 999.998) +STATIONS = [0.0, 20.0, 40.0, 263.21, 280.0, 300.0, 999.998] +BASE = [ + (0.0, 100.0), + (20.0, 99.0), + (40.0, 98.5), + (263.206, 90.0), + (280.0, 89.0), + (300.0, 88.0), + (1000.0, 60.0), +] +EDITS = { + "station_offsets": {"263.206": 1.5, "263.210": 0.9, "40.000": 0.2, "500.000": 0.3}, + "curve_radii": {"263.206": 300.0}, +} + + +def _synthetic() -> dict: + chainage = np.arange(0.0, 1000.0, 1.0) + return { + "base_s": np.array([s for s, _ in BASE]), + "base_z": np.array([z for _, z in BASE]), + "chainage": np.r_[chainage, 1000.0], + "ground": np.r_[100.0 - chainage * 0.04, 60.0], + "stations": [ + {"station_id": f"st{index}", "chainage_m": value} + for index, value in enumerate(STATIONS) + ], + } + + +def _python(edits: dict) -> dict: + data = _synthetic() + return build_alignment(**data, policy=AlignmentPolicy.from_dict({}), edits=copy.deepcopy(edits)) + + +def _close_pairs(alignment: dict, limit: float = 0.006) -> list[tuple[float, float]]: + pvi = [node["chainage_m"] for node in alignment["pvi"]] + return [(a, b) for a, b in zip(pvi, pvi[1:], strict=False) if b - a < limit] + + +def test_rounded_station_keys_collapse_onto_station(): + alignment = _python(EDITS) + pvi = {round(node["chainage_m"], 3): node for node in alignment["pvi"]} + assert _close_pairs(alignment) == [] + assert 263.206 not in pvi and 263.21 in pvi + # 측점 위 편집(263.210 = 0.9)이 이김 · 반경도 측점으로 옮김 · 측점에서 먼 키(500)는 그대로 + assert alignment["edits"]["station_offsets"] == {"263.210": 0.9, "40.000": 0.2, "500.000": 0.3} + assert alignment["edits"]["curve_radii"] == {"263.210": 300.0} + # 양 끝 꼭짓점은 그대로(끝 1000.0 을 측점 999.998 로 당기지 않음) + assert [node["chainage_m"] for node in alignment["base_pvi"]][-1] == 1000.0 + assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100 + + +def test_snapped_base_keeps_station_elevation(): + """편집 없는 측점 — 꼭짓점을 측점으로 옮겨도 그 측점 계획고(탄젠트)는 그대로.""" + alignment = _python({"station_offsets": {}, "curve_radii": {}}) + node = next(n for n in alignment["base_pvi"] if abs(n["chainage_m"] - 263.21) < 1e-9) + expected = float(np.interp(263.21, [s for s, _ in BASE], [z for _, z in BASE])) + assert node["elevation_m"] == pytest.approx(expected, abs=1e-6) + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { buildAlignment } from "./B05_Profile_UI_Profile_Alignment.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const result = buildAlignment(input.base, input.edits); +writeFileSync(process.argv[3], JSON.stringify(result)); +""" + + +@pytest.fixture(scope="module") +def ts_alignment(tmp_path_factory): + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + tmp = tmp_path_factory.mktemp("align213") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + data = _synthetic() + base = { + "policy": AlignmentPolicy.from_dict({}).as_dict(), + "onlyExplicitCurves": False, + "basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in BASE], + "chainage": data["chainage"].tolist(), + "ground": data["ground"].tolist(), + "stations": data["stations"], + } + src, dst = tmp / "in.json", tmp / "out.json" + src.write_text(json.dumps({"base": base, "edits": EDITS}), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True + ) + return json.loads(dst.read_text(encoding="utf-8")) + + +def test_mirror_python_ts(ts_alignment): + py = _python(EDITS) + ts = ts_alignment + assert ts["edits"] == py["edits"] + assert len(ts["base_pvi"]) == len(py["base_pvi"]) + for a, b in zip(ts["base_pvi"], py["base_pvi"], strict=True): + assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-6) + assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6) + assert [n["chainage_m"] for n in ts["pvi"]] == pytest.approx( + [n["chainage_m"] for n in py["pvi"]], abs=1e-6 + ) + assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx( + [n["elevation_m"] for n in py["pvi"]], abs=1e-6 + ) + for a, b in zip(ts["stations"], py["stations"], strict=True): + assert a["plan_elevation_m"] == pytest.approx(b["plan_elevation_m"], abs=1e-6) + + +def _copy(tmp_path: Path) -> Path: + target = tmp_path / REL + target.parent.mkdir(parents=True) + shutil.copyfile(SOURCE, target) + return target + + +@needs_copy +def test_d2245e3b_no13_3_spike_gone(tmp_path): + data = json.loads(_copy(tmp_path).read_text(encoding="utf-8")) + edits = data["profile_alignment"]["edits"] + assert {"263.206", "263.210"} <= set(edits["station_offsets"]) # 재현 조건 + alignment, _ = rebuild_alignment_profile(data, edits) + assert _close_pairs(alignment) == [] + assert "263.206" not in alignment["edits"]["station_offsets"] + assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100 + + +@needs_copy +def test_d2245e3b_station_regen_drops_rounded_key(tmp_path): + """[측점 만들기] 로 관 측점 263.21 → 263.31 — 옛 글자 263.206 꼭짓점 · 편집도 함께 지워짐.""" + path = _copy(tmp_path) + data = json.loads(path.read_text(encoding="utf-8")) + irregular = [] + for station in data["stations"]: + if station.get("kind") != "irregular": + continue + item = copy.deepcopy(station) + if abs(item["chainage_m"] - 263.21) < 1e-6: + item["chainage_m"] = 263.31 + irregular.append(item) + _merge_irregular_into_longitudinal(tmp_path, REL, irregular) + saved = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"] + leftovers = [ + value + for value in [n["chainage_m"] for n in saved["base_pvi"]] + + [float(key) for key in saved["edits"]["station_offsets"]] + if 263.2 < value < 263.22 + ] + assert leftovers == [] + assert _close_pairs(saved, 0.05) == [] From 7cb9860500719720b8e6362ef98a65d1775260a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:26:23 +0900 Subject: [PATCH 23/38] =?UTF-8?q?fix(CAD):=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=EC=9E=90=ED=8C=90=20=EB=8C=80=EC=9D=91=20?= =?UTF-8?q?=C2=B7=20M02=20=ED=99=94=EB=A9=B4=20=E2=86=B6=20=E2=86=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94(214-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ctrl+Z · Y · Shift+Z · S · A 를 자판 자리(code)로 판정 — 한글 자판 「ㅋ」·「ㅛ」 · Shift 대문자 대응 - 한글 조합 중에도 Ctrl 단축키는 먹음 - 제목줄 없는 M02 화면(양식 편집 · 구조물 카드)은 우하단 탐색 막대에 되돌리기 · 다시 하기 단추 - 포인터가 CAD 위에 오면 부모 입력 칸이 아닐 때 CAD 로 초점 - 새 시험 resources/tester/cad/ctrl-shortcut-hangul.test.ts Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../src/components/ViewControls.tsx | 21 +++- .../src/inputController/input-controller.ts | 16 +-- .../src/inputController/input-shortcut.ts | 24 ++++ .../tester/cad/ctrl-shortcut-hangul.test.ts | 113 ++++++++++++++++++ ui_template/cad_host/cad_host.ts | 6 + 5 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts create mode 100644 resources/tester/cad/ctrl-shortcut-hangul.test.ts diff --git a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx index 6b0b707ae..c5c16e0c2 100644 --- a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx +++ b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx @@ -1,13 +1,30 @@ -/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) */ +/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */ import type { FC } from 'react'; import { runCommandInput } from '../commands/run-command'; -import { getScreenCanvasDrawController } from '../state'; +import { + getScreenCanvasDrawController, + isDrawingReadOnly, + isHostSave, + isViewOnly, +} from '../state'; export const ViewControls: FC = () => { const zoom = getScreenCanvasDrawController()?.getScreenScale() ?? 1; + // 제목줄(빠른 막대)이 없는 M02 양식 · 구조물 카드 — 고칠 수 있을 때만 + const showUndo = isHostSave() && !isViewOnly() && !isDrawingReadOnly(); return (
+ {showUndo && ( + <> + + + + )}