From 5e5db8d4fb2fb21eedaf438a4affb15eea753dbd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:44:48 +0900 Subject: [PATCH 01/14] =?UTF-8?q?style(b05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9=20=EB=AF=B8=EC=97=B0=EA=B2=B0=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=20=3D=20=EC=97=B0=ED=95=9C=20=EB=85=B8=EB=9E=91=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=EB=B0=B0=EA=B2=BD=20+=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20(177-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_018jonfdxn1Xit9bdkNJpE4G --- .../B05_Profile_UI_Style_Structures.css | 6 +++++ .../test_177_1_summary_unassigned_bg.py | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 resources/tester/test_177_1_summary_unassigned_bg.py diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 63c9af9cc..d8fcc2277 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -453,6 +453,12 @@ background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface)); } +/* 미연결 — 연한 노랑 배경 + 노랑 테두리(경고 · 밝음 · 어두움 같은 토큰). */ +.b05-structure__summary-name:not(.is-assigned) { + border-color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); +} + .b05-structure__summary .b05-route__irregular-btn { flex: none; } diff --git a/resources/tester/test_177_1_summary_unassigned_bg.py b/resources/tester/test_177_1_summary_unassigned_bg.py new file mode 100644 index 000000000..bafa3cf4f --- /dev/null +++ b/resources/tester/test_177_1_summary_unassigned_bg.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +"""PLAN 177-1 — 구조물 조합 미연결 라벨 = 연한 노랑(경고) 배경 + 테두리(소스 시험).""" + +import os +import re + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +CSS = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Style_Structures.css") + + +def _rule(sel: str) -> str: + src = open(CSS, encoding="utf-8").read() + m = re.search(re.escape(sel) + r"\s*\{([^}]*)\}", src) + assert m, sel + return m.group(1) + + +def test_미연결_배경_테두리_경고색_글자색_없음(): + body = _rule(".b05-structure__summary-name:not(.is-assigned)") + assert re.search(r"border-color:\s*var\(--color-warning\)", body) + assert re.search(r"background:\s*color-mix\([^;]*--color-warning[^;]*--color-surface", body) + assert not re.search(r"(^|[;\s])color:", body) + + +def test_테마_토큰_밝음_어두움_둘다(): + th = open(os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8").read() + assert len(re.findall(r"--color-warning:\s*#", th)) >= 2 From 1157af125b3d790b046d69e2c577e683ca7454c6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:45:42 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat(m02):=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=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=EB=AA=A9=EB=A1=9D=20=E2=80=94=20=EC=84=9C=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EB=A7=89=ED=9E=98=20=EC=84=B8=20=EA=B3=B3?= =?UTF-8?q?=20=ED=92=82=20=C2=B7=20=EC=A0=80=EC=9E=A5=EC=9D=B4=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EC=9E=90=EC=9E=AC=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=C2=B7=20=EC=9E=90=EC=9E=AC=20=EC=82=B4=EB=A6=BC=20(175-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면: 사전조합도 구조물 일위대가와 같은 자재목록 부품(자재 추가 · 세 칸 · 운반비) · getDoc 이 자재를 안 지움 - 서버: _combo_lines 가 고름 · 자재표시 남김 · clean_doc 에 자재(labor.clean 검사 · 로직 자재와 같은 품목 400 = no_twice 한 벌) - 시험: test_175_precombo_mats(저장 왕복 · 400) · test_m02_labor_mat_marks 단정 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- M02_MasterTemplete/M02_Labor_Doc.py | 7 ++- M02_MasterTemplete/M02_Labor_Ref.py | 20 ++++++- .../M02_MasterTemplete_Labor_Detail.ts | 11 ++-- resources/tester/test_175_precombo_mats.py | 60 +++++++++++++++++++ resources/tester/test_m02_labor_mat_marks.py | 2 +- 5 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 resources/tester/test_175_precombo_mats.py diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 0692a0245..890c7155a 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -607,8 +607,13 @@ def server(doc: Any) -> dict[str, Any]: 사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈).""" got = clean(doc) out = calc(got) + no_twice(got, out) + return {**got, "계산값": out} + + +def no_twice(got: dict, out: dict) -> None: + """사용자 자재가 로직 자재 품목과 같으면 400 — 일위대가 · 사전조합(175-1) 저장 한 벌.""" inside = {x["품목"]["키"] for one in out["줄"] for x in one.get("재료") or [] if x.get("품목")} twice = sorted({m["코드"] for m in got["자재"]} & inside) if twice: raise _bad(f"자재 「{', '.join(twice)}」 는 로직 자재에 있음 — 같은 품목 두 번 담음") - return {**got, "계산값": out} diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 126a968a0..3dbf78e2f 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -6,8 +6,9 @@ 층 문서를 읽을 때 옛 참조 줄 = 로직 줄 + `승격`(`unpromote`) · 프로젝트 사본은 그대로 셈 precombo `{층}/precombo/SC-######.json` 사전조합 `{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모} - | {로직, 입력값, 메모, 수량:{손}}]}` — 승격 · 사전조합 · 단가산출 로직(51-11) · - 수량은 손(기본 1) · 값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + | {로직, 입력값, 메모, 수량:{손}, 고름?, 자재표시?}], 자재:[일위대가 자재 꼴]}` — + 승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) · 자재 수량은 손 · 빈칸 = 안 셈(175) · + 값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + Σ 자재 기준단가 × 손 참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) · 위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) · 같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭. @@ -142,6 +143,7 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: if not name: raise _bad("사전조합 이름이 빔") out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key)) + out["자재"] = _combo_mats(doc, out["담은로직"]) out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None return out @@ -206,7 +208,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]} if "로직" in one: head["입력값"] = one["입력값"] - head.update({k: one[k] for k in ("켜쌓기율",) if k in one}) + head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} @@ -219,6 +221,18 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: return lines +def _combo_mats(doc: dict, lines: list[dict[str, Any]]) -> list[dict[str, Any]]: + """사전조합 자재(175-1) — 일위대가 자재와 같은 검사(`labor.clean` · 개인 자재 · 세 칸) · + 로직 자재와 같은 품목 400(`labor.no_twice`).""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + mats = labor.clean({"자재": doc.get("자재") or []})["자재"] + if mats: + got = {"담은로직": lines, "자재": mats} + labor.no_twice(got, labor.calc(got, {})) + return mats + + def _no_loop(ref: Ref, trail: list[Ref]) -> None: """저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」.""" if len(trail) > DEPTH: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index a37a05411..71a5e79ce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -10,7 +10,7 @@ * · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) + * 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 - * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · + * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) · * 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) * · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음) * ========================================================================== */ @@ -228,13 +228,13 @@ export function mountLaborDetail( cur.담은로직, calc ?? last, !calc && !fail, - // 세 칸(148-4) = 구조물 일위대가만(사전조합 줄은 `_combo_lines` 가 안 남김) + // 세 칸(148-4) — 사전조합도 같음(175-1 · `_combo_lines` 가 남김) edit ? { open: openRow, swap: swapMat, reset: resetMat, - mark: lib ? undefined : markLogicMat, + mark: markLogicMat, } : { open: openRow }, ), @@ -641,7 +641,7 @@ export function mountLaborDetail( ]), // 사전조합 = 같은 [로직 추가](51-11) section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost), - ...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]), + section(tc("Labor_Mats"), opt.card ? null : addMat, matHost), ...(opt.card || lib ? [] : [dgBox]), ], }), @@ -668,12 +668,11 @@ export function mountLaborDetail( }); return { - // 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음 + // 서고 모드 = 문서 종류 그대로 · 열 없음 getDoc: () => { if (!lib) return cur; const out: Record = { ...cur, 종류: "사전조합" }; delete out.열; - delete out.자재; return out as unknown as LaborDoc; }, saveArea: async () => { diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py new file mode 100644 index 000000000..6d9355acf --- /dev/null +++ b/resources/tester/test_175_precombo_mats.py @@ -0,0 +1,60 @@ +"""M02 일위대가 사전조합 자재목록(PLAN 175) — 구조물 일위대가와 같은 자재목록. + +175-1 화면 · 저장 — 서고 모드 자재목록 · 로직 자재 세 칸 막힘 풂 · 저장 `_combo_lines` 가 +고름 · 자재표시 남김 · 문서 `자재`(일위대가 자재 검사 · 로직 자재와 같은 품목 400). +""" + +import json +import os + +import pytest + +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete") +NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 · 결속선 · 에폭시) +NET_ITEM = "MT006697" +WIRE = "MT000194" +MAT = "MT000008" # 자재품목 · 물가정보 83000 + + +def _save(folder, doc): + with store.at(folder): + key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"] + return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes()) + + +def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # noqa: F811 + detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read() + assert 'section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),' in detail + assert 'lib ? [] : [section(tc("Labor_Mats")' not in detail + assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail + assert "delete out.자재" not in detail and "delete out.열" in detail + + +def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811 + line = {"로직": NET, "고름": {"4": NET_ITEM}, "자재표시": {"3": {"자재구분": "관급"}}} + mats = [ + {"코드": MAT, "수량": {"손": 2}, "운반비": "미포함", "이름": "틀린 이름"}, + {"코드": WIRE, "수량": None}, + ] + _, got = _save(world["system"], {"담은로직": [line], "자재": mats}) + one = got["담은로직"][0] + assert one["고름"] == {"4": NET_ITEM} and one["자재표시"] == {"3": {"자재구분": "관급"}} + assert [(m["코드"], m["수량"]) for m in got["자재"]] == [(MAT, {"손": 2}), (WIRE, None)] + assert got["자재"][0]["운반비"] == "미포함" and got["자재"][0]["이름"] != "틀린 이름" + # 자재 없음 = 빈 목록(옛 문서 꼴 그대로 읽힘) + _, empty = _save(world["system"], {"담은로직": [{"로직": NET}]}) + assert empty["자재"] == [] + + +def test_로직_자재와_같은_품목_400_없는_자재_400(world): # noqa: F811 + with pytest.raises(store.StoreError) as e: + _save(world["system"], {"담은로직": [{"로직": NET}], "자재": [{"코드": WIRE}]}) + assert e.value.status == 400 and WIRE in str(e.value.detail) + with pytest.raises(store.StoreError) as e: + _save(world["system"], {"담은로직": [], "자재": [{"코드": "MT999999"}]}) + assert e.value.status == 400 diff --git a/resources/tester/test_m02_labor_mat_marks.py b/resources/tester/test_m02_labor_mat_marks.py index e10707d17..e788663a8 100644 --- a/resources/tester/test_m02_labor_mat_marks.py +++ b/resources/tester/test_m02_labor_mat_marks.py @@ -125,7 +125,7 @@ def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸(): assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts") detail = _src("M02_MasterTemplete_Labor_Detail.ts") - assert "mark: lib ? undefined : markLogicMat" in detail + assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail # 사전조합도(175-1) assert "mark: markMat" in detail # 서버 허용 값 = 화면 허용 값 api = _src("M02_MasterTemplete_Labor_Api.ts") From 41f0c3d6e3b276c2bbbdb0a843859654f2a92f0c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:46:49 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(b07):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=9B=90=EC=A7=80=EB=B0=98=20=EC=B8=B5=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=ED=92=82=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=ED=95=9C=20=EB=B2=8C?= =?UTF-8?q?=20groupId(180-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장 · 측점 한 장 원지반 층 잠금 지움(종단 그대로) - 장의 측점마다 그림 · 표 · 글 groupId 하나 · cross_placements group_id - 화면 덧그림(설계선 · 구조물)도 같은 groupId - 새 시험 test_180_b07_cross_group Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_Engine_Cad.py | 4 +- .../B07_DesignDetail_Engine_Cad_Sheet.py | 10 ++- .../B07_DesignDetail_UI_Cad_Structures.ts | 3 + resources/tester/test_180_b07_cross_group.py | 85 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 resources/tester/test_180_b07_cross_group.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index f6813dc6f..41a46d64b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -19,7 +19,7 @@ from uuid import UUID, uuid5 from config.config_system import DRAWING_SCALE_CROSS -# 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(N-1-3). +# 레이어 정의 — 지표면선은 종단에서 잠금(N-1-3) · 횡단은 장에서 옮기므로 풂(180-1). GROUND_LAYER_ID = "b08-ground" GROUND_COLOR = "#f5f7fa" DESIGN_LAYER_ID = "b08-design" @@ -622,7 +622,7 @@ def build_cross_drawing( } ], "layers": [ - _layer(GROUND_LAYER_ID, "원지반", locked=True), + _layer(GROUND_LAYER_ID, "원지반"), _layer(DESIGN_LAYER_ID, "계획선"), _layer(STRUCTURE_LAYER_ID, "구조물"), _layer(ROCK_LAYER_ID, "암 경계선"), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 46818bbd6..f9e8297bd 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -9,6 +9,7 @@ import re from typing import Any +from uuid import uuid5 from B05_Profile.B05_Profile_Engine_Sections import cross_key from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( @@ -22,6 +23,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( ROCK_LAYER_ID, STRUCTURE_LAYER_ID, TABLE_LINE_COLOR, + _ENTITY_NS, _clip_polyline, _layer, _section_window, @@ -211,6 +213,12 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> left, _bottom, right, top = frames[0] if frames and frames[0] else cell if left < cell[0] - 1e-6 or right > cell[2] + 1e-6 or top > cell[3] + 1e-6: overflow.append(cell) + # 측점 한 벌 = groupId 하나 — 한 번 클릭에 그림 · 표 · 글 함께(180-1) + group_id = str(uuid5(_ENTITY_NS, f"{seed_id}:group")) + for entity in placed["entities"]: + entity["groupId"] = group_id + for placement in placed.get("cross_placements") or []: + placement["group_id"] = group_id entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) @@ -233,7 +241,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> # 칸보다 큰 단면의 칸 [x0, y0, x1, y1] — 라우터가 CAD 가이드 붉은 틀로 보냄(169-2) "overflow_cells": overflow, "layers": [ - _layer(GROUND_LAYER_ID, "원지반", locked=True), + _layer(GROUND_LAYER_ID, "원지반"), _layer(DESIGN_LAYER_ID, "계획선"), _layer(STRUCTURE_LAYER_ID, "구조물"), _layer(ROCK_LAYER_ID, "암 경계선"), diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index e5aeaa014..f0e55d6ba 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -32,6 +32,8 @@ export interface CrossPlacement { x1: number; /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ frame?: number[]; + /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ + group_id?: string; } /** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ @@ -78,6 +80,7 @@ export async function appendStructureEntities( continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. } if (!entities.length) continue; + if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. replaced.push(frame); fresh.push(...entities); diff --git a/resources/tester/test_180_b07_cross_group.py b/resources/tester/test_180_b07_cross_group.py new file mode 100644 index 000000000..4feffb1a1 --- /dev/null +++ b/resources/tester/test_180_b07_cross_group.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +"""180-1 B07 횡단 장 — 원지반선 고르기 · 측점 한 벌 groupId. + +못박는 것 + - 횡단 장 · 측점 한 장의 원지반 층은 안 잠김(종단은 그대로 잠김). + - 측점마다 groupId 하나(원지반 · 계획선 · 표 · 글 전부) · 측점끼리 다름. + - 칸 격자선 · 도각엔 groupId 없음. + - cross_placements 의 group_id = 그 측점 엔티티 값(화면 덧그림이 같은 묶음). +""" + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + FRAME_LAYER_ID, + GROUND_LAYER_ID, + build_cross_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + build_cross_sheet, + plan_cross_sheets, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templates + + +def _source(chainage: float) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.1 * x} + for x in range(-5, 6) + ], + } + + +def _design_line() -> list[dict]: + return [{"offset_m": x, "elevation_m": 100.0} for x in (-3.0, 0.0, 3.0)] + + +def _drawing() -> dict: + use_project_templates(None) + chainages = [0.0, 20.0, 40.0, 60.0] + sheet = plan_cross_sheets(chainages, {"default": [2, 2]})[0] + sections = [ + { + "chainage": c, + "source": _source(c), + "design_line": _design_line(), + "design": {"structure_note": "L형 측구"}, + "quantity_table": {"ground": 1.0, "planned": 0.5}, + "title": f"No.{int(c // 20)}", + } + for c in chainages + ] + return build_cross_sheet(sheet, sections) + + +def _ground_locked(drawing: dict) -> bool: + return next(layer for layer in drawing["layers"] if layer["id"] == GROUND_LAYER_ID)["isLocked"] + + +def test_원지반_층_안_잠김() -> None: + assert _ground_locked(_drawing()) is False + single = build_cross_drawing(_source(0.0), "cross_00000m", _design_line(), None, None, "") + assert _ground_locked(single) is False + + +def test_측점마다_groupId_하나() -> None: + drawing = _drawing() + placements = drawing["cross_placements"] + assert len(placements) == 4 + group_ids = [p["group_id"] for p in placements] + assert len(set(group_ids)) == 4 # 측점끼리 다름 + grouped = [e for e in drawing["entities"] if "groupId" in e] + assert {e["groupId"] for e in grouped} == set(group_ids) + for group_id in group_ids: + members = [e for e in grouped if e["groupId"] == group_id] + layers = {e["layerId"] for e in members} + types = {e["type"] for e in members} + assert GROUND_LAYER_ID in layers and "Table" in types and "Text" in types + # 격자선 · 도각은 묶지 않음 + assert all("groupId" not in e for e in drawing["entities"] if e["layerId"] == FRAME_LAYER_ID) + assert all(e["layerId"] != FRAME_LAYER_ID for e in grouped) + + +def test_groupId_결정적() -> None: + first = [p["group_id"] for p in _drawing()["cross_placements"]] + assert first == [p["group_id"] for p in _drawing()["cross_placements"]] From e74228248518b967f2dfe40e6549981db4a1c9e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:46:57 +0900 Subject: [PATCH 04/14] =?UTF-8?q?chore(b07):=20openwebcad=20package-lock?= =?UTF-8?q?=20=EB=A3=A8=ED=8A=B8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4?= 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 --- B07_DesignDetail/openwebcad/package-lock.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/B07_DesignDetail/openwebcad/package-lock.json b/B07_DesignDetail/openwebcad/package-lock.json index d6c4ec55c..b4a1a1f01 100644 --- a/B07_DesignDetail/openwebcad/package-lock.json +++ b/B07_DesignDetail/openwebcad/package-lock.json @@ -49,6 +49,8 @@ "name": "forest-road-webapp", "version": "0.1.0", "dependencies": { + "@kninnug/constrainautor": "^4.1.0", + "delaunator": "^5.1.0", "maplibre-gl": "^5.24.0", "three": "^0.185.0" }, From 2d4c65dc7828768661edf79b3d866a5036dd8265 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:47:34 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix(b06):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B3=A0=EB=A6=84=20=EB=92=A4=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=EC=B8=A1=EC=A0=90=20=EC=84=B8=EB=A1=9C=EC=84=A0=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20=C2=B7=20=EC=A2=85=EB=8B=A8=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=EB=B0=98=EB=8C=80=20=EA=B8=B8=EB=8F=84=20(177-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setSelectedStructure keepStation — B06 카드가 선 주인 · 선 없는 구조물 · 풂도 카드 선 지킴 - 새 시험 test_177_3_station_line_keep Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- A00_Common/design/design_profile.ts | 8 +-- .../B06_Section_UI_Section_View_Profile.ts | 4 +- .../tester/helper_177_3_station_line_keep.cjs | 64 +++++++++++++++++ .../tester/test_177_3_station_line_keep.py | 72 +++++++++++++++++++ 4 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 resources/tester/helper_177_3_station_line_keep.cjs create mode 100644 resources/tester/test_177_3_station_line_keep.py diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index fbc325620..cc0b3335e 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -622,12 +622,12 @@ export function createDesignProfile(ctx: DesignProfileContext) { syncDrainageSpans(); draw(); }, - /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */ - setSelectedStructure(structureId: string | null) { - // 지금 측점(B06 카드)이 그 구조물 자리면 측점선도 그대로(53-2) + /** 고른 구조물에 알약 · 측점선을 맞춤 — 그 자리면 선 그대로(53-2) · keepStation(B06 카드 = 선 주인) = 선 없는 구조물 · 풂도 카드 선 지킴(177-3). */ + setSelectedStructure(structureId: string | null, keepStation = false) { selectedStructureId = structureId; + const own = stationIdAtStructure(structureId) ?? (keepStation ? selectedStationId : null); const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId; - selectedStationId = keep ? selectedStationId : stationIdAtStructure(structureId); + selectedStationId = keep ? selectedStationId : own; draw(); }, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 9ea0b7731..0df0e0f12 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -265,7 +265,7 @@ export function createSectionProfile( }, selectMark(structureId) { markedId = structureId; - profile?.setSelectedStructure(structureId); + profile?.setSelectedStructure(structureId, true); // 카드가 측점선 주인 — 선 없는 구조물 · 풂은 카드 선 지킴(177-3) }, showCard(section) { if (!profile) return; @@ -280,7 +280,7 @@ export function createSectionProfile( // 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1) const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined; if (!pipe && marked && coversChainage(marked, section.chainage_m)) - profile.setSelectedStructure(markedId); + profile.setSelectedStructure(markedId, true); }, }; } diff --git a/resources/tester/helper_177_3_station_line_keep.cjs b/resources/tester/helper_177_3_station_line_keep.cjs new file mode 100644 index 000000000..70673c23d --- /dev/null +++ b/resources/tester/helper_177_3_station_line_keep.cjs @@ -0,0 +1,64 @@ +/* 177-3 횡단 카드 ↔ 종단 측점선 헬퍼 — 진짜 design_profile.ts 의 `setSelectedStation` · `setSelectedStructure` + * 본문을 떼어(TS 는 그 자리 트랜스파일) 측점 · 구조물 짝 흉내에 붙이고, B06 카드 고름 차례 + * (showCard → setSelectedStation · 리스너 → selectMark → setSelectedStructure(mark, true))와 B05 기본 길을 돌려 + * 남은 측점선 id 를 표준출력 JSON 한 벌로. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const src = fs.readFileSync(path.join(ROOT, "A00_Common", "design", "design_profile.ts"), "utf8"); +const method = (name) => { + const pattern = String.raw`\r?\n ${name}\([^\r\n]*\) \{\r?\n[\s\S]*?\r?\n \},`; + const match = src.match(new RegExp(pattern)); + if (!match) throw new Error(`본문 없음: ${name}`); + return match[0]; +}; +const body = ` +let selectedStationId = null; +let selectedStructureId = null; +// 관(pipe-80.17)만 측점선(irr-80)이 있음 — 벽 · 노면공은 선 없음(PST stationIdAtStructure) +const structureIdAtStation = (stationId) => (stationId === "irr-80" ? "pipe-80.17" : null); +const stationIdAtStructure = (structureId) => (structureId === "pipe-80.17" ? "irr-80" : null); +const draw = () => {}; +module.exports = { + line: () => selectedStationId, + ${method("setSelectedStation")} + ${method("setSelectedStructure")} +};`; +const js = ts.transpileModule(body, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const dp = box.exports; + +/** B06 카드 고름 — showCard(카드 · 관 측점이면 관 선) 뒤 리스너가 selectMark(mark) = keepStation true. */ +const b06Card = (lineId, mark) => { + dp.setSelectedStation(lineId); + dp.setSelectedStructure(mark, true); + return dp.line(); +}; +/** B05 목록 고름 — 기본값(keepStation 없음). */ +const b05Pick = (lineId, structureId) => { + dp.setSelectedStation(lineId); + dp.setSelectedStructure(structureId); + return dp.line(); +}; + +const out = { + plainCard: b06Card("s40", null), // 일반 카드 — 덮는 것 없음 + wallCard: b06Card("s100", "wall"), // 벽(선 없는 구조물) 카드 + paveCard: b06Card("s140", "pave"), // 노면공 범위 안 카드 + pipeCard: b06Card("irr-80", "pipe-80.17"), // 관 카드 = 관 선 + pipeFromPlain: b06Card("s60", "pipe-80.17"), // 관 알약 = 관 선으로 옮김 + // 반대 길 — 종단 측점선 누름 → 카드 고름 → 같은 리스너(selectMark null) + profileClick: b06Card("s200", null), + // 카드 풂 = showCard(null) → 선 끔 + release: (dp.setSelectedStation(null), dp.line()), + b05Wall: b05Pick("s100", "wall"), // B05 = 지금 그대로(선 없는 구조물 → 선 끔) + b05Pipe: b05Pick("s40", "pipe-80.17"), + b05Keep: b05Pick("irr-80", "pipe-80.17"), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_177_3_station_line_keep.py b/resources/tester/test_177_3_station_line_keep.py new file mode 100644 index 000000000..26b2e1f63 --- /dev/null +++ b/resources/tester/test_177_3_station_line_keep.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""177-3 B06 횡단 카드 고름 → 종단 측점 세로선 유지(반대 방향도). + +못박는 것 + - 카드 고름 뒤 리스너의 selectMark(mark) 가 측점선을 끄지 않음 — 일반 · 벽 · 노면공 카드 = 그 카드 선. + - 관 카드 · 관 알약 = 관 측점선 · 카드 풂 = 선 끔. + - 종단 측점선 누름(반대 길)도 같은 리스너 — 누른 선 그대로. + - B05(keepStation 기본값) = 지금 그대로(선 없는 구조물 → 선 끔). + +Node 헬퍼 `helper_177_3_station_line_keep.cjs` 가 진짜 design_profile.ts 본문을 돌림. 화면은 사용자 확인. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_177_3_station_line_keep.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_카드_고름은_그_카드_측점선_유지(out): + assert out["plainCard"] == "s40" + assert out["wallCard"] == "s100" + assert out["paveCard"] == "s140" + + +def test_관은_관_측점선(out): + assert out["pipeCard"] == "irr-80" + assert out["pipeFromPlain"] == "irr-80" + + +def test_반대_길_종단_측점선_누름도_유지(out): + assert out["profileClick"] == "s200" + assert out["release"] is None + + +def test_B05_기본_길은_그대로(out): + assert out["b05Wall"] is None + assert out["b05Pipe"] == "irr-80" + assert out["b05Keep"] == "irr-80" + + +def test_배선(): + svp = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert "profile?.setSelectedStructure(structureId, true);" in svp + assert "profile.setSelectedStructure(markedId, true);" in svp + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "sectionView.selectMark(mark, false);" in cross + b05 = _read("A00_Common", "design", "design_view_profile.ts") + assert ", true)" not in "".join( + line for line in b05.splitlines() if "setSelectedStructure(" in line + ) From 626c60b68fca56372a8b5dccad6ed964330ff3d8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:48:09 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat(b09):=20=EC=9B=90=EA=B0=80=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=ED=83=AD=20=EB=91=90=20=EC=B8=B5=20=E2=80=94=20?= =?UTF-8?q?=EC=9C=84=20=ED=83=AD=2010=20=C2=B7=20=EC=95=84=EB=9E=98=20?= =?UTF-8?q?=ED=83=AD=20=EB=AC=B6=EC=9D=8C(B08=20=EA=BC=B4)=20=C2=B7=20?= =?UTF-8?q?=EC=9C=84=20=ED=83=AD=20=EB=8B=A4=EC=8B=9C=20=EB=88=84=EB=A6=84?= =?UTF-8?q?=20=3D=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=B3=B8=20=ED=95=98?= =?UTF-8?q?=EC=9C=84(181-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_01STqEqztUthVFBmEP18wBSz --- B09_Estimation/B09_Estimation_UI_Page.css | 7 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 23 +--- B09_Estimation/B09_Estimation_UI_TabBar.ts | 71 ++++++++++++ B09_Estimation/B09_Estimation_UI_Tabs.ts | 18 +++ resources/tester/helper_b09_group_cards.py | 1 + resources/tester/test_181_1_b09_tab_groups.py | 104 ++++++++++++++++++ 6 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_TabBar.ts create mode 100644 resources/tester/test_181_1_b09_tab_groups.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index b08290070..6045a5ed5 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -20,11 +20,16 @@ .b09-estimation__tabs { display: flex; flex-wrap: wrap; - gap: var(--spacing-4); + gap: var(--spacing-8); /* B08 위 · 보조 탭 줄과 같은 값(181-1) */ padding-bottom: var(--spacing-8); border-bottom: 1px solid var(--color-border); } +/* 아래 탭 없는 위 탭 — 보이지 않는 단추 한 줄로 높이만 채움(탭 바꿀 때 몸통이 안 튐) */ +.b09-estimation__tabs--blank > .ui-btn { + visibility: hidden; +} + /* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */ [data-b09-table] thead th { white-space: normal; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index a9497f0e8..a36538a9f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -28,6 +28,7 @@ import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; 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 { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { buildCheck, @@ -150,15 +151,14 @@ export function renderB09Estimation(root: HTMLElement): void { let sheets: Row = {}; let current: TabId = "check"; - const tabBar = document.createElement("div"); - tabBar.className = "b09-estimation__tabs"; - tabBar.setAttribute("role", "tablist"); + // 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar` + const bars = createTabBars((tab) => show(tab)); const panel = document.createElement("div"); panel.className = "b09-estimation__panel"; panel.setAttribute("role", "tabpanel"); const body = document.createElement("div"); body.className = "b09-estimation"; - body.append(tabBar, panel); + body.append(bars.top, bars.sub, panel); const made = document.createElement("p"); made.className = "b09-estimation__hint"; @@ -181,20 +181,7 @@ export function renderB09Estimation(root: HTMLElement): void { /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ const show = (tab: TabId, focus?: string): void => { current = tab; - tabBar.replaceChildren( - ...TABS.map(([id, label]) => { - const button = createButton({ - label, - variant: id === tab ? "filled" : "ghost", - size: "sm", - onClick: () => id !== current && show(id), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(id === tab)); - button.dataset.b09Tab = id; - return button; - }), - ); + bars.draw(tab); if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); const build = BUILDERS[tab]; diff --git a/B09_Estimation/B09_Estimation_UI_TabBar.ts b/B09_Estimation/B09_Estimation_UI_TabBar.ts new file mode 100644 index 000000000..c80caaf7e --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_TabBar.ts @@ -0,0 +1,71 @@ +/* ============================================================================= + * B09_Estimation_UI_TabBar.ts + * B09 탭 줄 두 층(PLAN 181-1) — 위 탭(md) · 아래 탭(sm) · B08 수량산출 페이지 꼴. + * + * 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움) + * 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭 + * 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐 + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { groupOf, tabName, TAB_GROUPS, type TabId } from "./B09_Estimation_UI_Tabs"; + +export interface TabBars { + top: HTMLElement; + sub: HTMLElement; + /** 이 탭이 켜진 꼴로 두 줄을 다시 그림 */ + draw: (tab: TabId) => void; +} + +function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) { + const button = createButton({ + label, + variant: on ? "filled" : "ghost", + ...(size ? { size } : {}), + onClick: () => !on && pick(), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(on)); + return button; +} + +export function createTabBars(onPick: (tab: TabId) => void): TabBars { + const top = document.createElement("div"); + top.className = "b09-estimation__tabs"; + top.setAttribute("role", "tablist"); + const sub = document.createElement("div"); + sub.className = "b09-estimation__tabs"; + const lastSeen = new Map(); + + const draw = (tab: TabId): void => { + const [, picked] = groupOf(tab); + lastSeen.set(picked[0], tab); + top.replaceChildren( + ...TAB_GROUPS.map(([label, tabs]) => { + const button = tabButton(label, tabs === picked, undefined, () => + onPick(lastSeen.get(tabs[0]) ?? tabs[0]), + ); + button.dataset.b09Group = tabs[0]; + if (tabs.length === 1) button.dataset.b09Tab = tabs[0]; + return button; + }), + ); + 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); + } + sub.replaceChildren( + ...picked.map((id) => { + const button = tabButton(tabName(id), id === tab, "sm", () => onPick(id)); + button.dataset.b09Tab = id; + return button; + }), + ); + }; + return { top, sub, draw }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index e2c2ae4aa..1b07aa109 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -56,6 +56,24 @@ export const TABS: Array<[TabId, string]> = [ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id; +/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ +export const TAB_GROUPS: Array<[string, TabId[]]> = [ + ["연결 점검", ["check"]], + ["공사원가계산서", ["cost"]], + ["총괄설계내역서", ["total"]], + ["설계내역서", ["bill"]], + ["일위대가", ["dg_list", "dg"]], + ["단가산출", ["sg_list", "sg"]], + ["환율및기초자료", ["basis"]], + ["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]], + ["재료", ["material", "compare", "sum_material"]], + ["노무", ["labor", "sum_labor"]], +]; + +/** 탭이 든 위 탭 묶음 */ +export const groupOf = (tab: TabId): [string, TabId[]] => + TAB_GROUPS.find(([, tabs]) => tabs.includes(tab)) ?? TAB_GROUPS[0]; + /** 다른 탭 줄 — 키가 같은 줄로 이동 */ export interface Link { tab: TabId; diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 104b84165..45027fdd4 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -58,6 +58,7 @@ class E { get className() { return [...this.cls].join(" "); } set className(v) { this.cls = new Set(String(v).split(/\s+/).filter(Boolean)); } append(...xs) { for (const x of xs) { x.parent = this; this.children.push(x); } } + replaceChildren(...xs) { this.children = []; this.append(...xs); } prepend(...xs) { for (const x of xs.reverse()) { x.parent = this; this.children.unshift(x); } } addEventListener(t, f) { (this.on[t] ??= []).push(f); } setAttribute(k, v) { this.attrs[k] = String(v); } diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py new file mode 100644 index 000000000..c1e288330 --- /dev/null +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -0,0 +1,104 @@ +"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. + +- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 +- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴) +""" + +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 resources.tester.helper_b09_group_cards import TABS, run # noqa: E402 + +BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts" + +# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례 +EXPECTED = [ + ["연결 점검", ["check"]], + ["공사원가계산서", ["cost"]], + ["총괄설계내역서", ["total"]], + ["설계내역서", ["bill"]], + ["일위대가", ["dg_list", "dg"]], + ["단가산출", ["sg_list", "sg"]], + ["환율및기초자료", ["basis"]], + ["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]], + ["재료", ["material", "compare", "sum_material"]], + ["노무", ["labor", "sum_labor"]], +] + + +def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩(): + out = run( + [TABS], + "\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t)," + " of: TABS.map(([t]) => groupOf(t)[0]) }));", + ) + 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 + # 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리 + firsts = list(dict.fromkeys(out["of"])) + assert firsts == [g[0] for g in EXPECTED] + assert len(firsts) == 10 + + +def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭(): + out = run( + [TABS, BAR], + """ +const picks = []; +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]), + blank: bars.sub.classList.contains('b09-estimation__tabs--blank'), + role: bars.sub.attrs.role, +}); +bars.draw('dg'); +const a = view(); +bars.top.children[4].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.draw('cost'); +const c = view(); +bars.top.children[0].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] == [ + "연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가" + ] # fmt: skip + assert [t[2] for t in a["top"]] == ["false"] * 4 + ["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" + assert b["sub"] == [ + ["재료비목록표", "false", "material"], + ["자재단가대비표", "true", "compare"], + ["재료비수량금액집계표", "false", "sum_material"], + ] # fmt: skip + assert b["top"][8][2] == "true" and b["top"][4][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"] + + +def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "createTabBars((tab) => show(tab))" in shell and "bars.draw(tab);" in shell + assert "body.append(bars.top, bars.sub, panel);" in shell + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + assert ".b09-estimation__tabs--blank > .ui-btn" in css and "visibility: hidden" in css From 3ed10bf386643e544f7a51c591847eaa8e503dfc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:48:41 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat(b05):=20=EC=A7=91=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=8F=99=EB=AC=BC=EC=9D=B4=EB=8F=99=ED=98=95=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=B0=95=EC=8A=A4=20=E2=86=92=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=A0=20/=20=EB=AC=B4=20=C2=B7=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B0=92=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EB=B9=88=20=EA=B0=92=20=3D=20=EC=9C=A0=20=C2=B7=20bool=20?= =?UTF-8?q?=EA=BC=B4=20=EC=A7=80=EC=9B=80=20(177-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_018jonfdxn1Xit9bdkNJpE4G --- A00_Common/design/design_structure_form.ts | 8 ---- A00_Common/design/design_structure_pipe.ts | 8 +++- A00_Common/design/design_structure_spec.ts | 3 +- resources/master_template/구조물정의.json | 2 + .../tester/helper_173_7_inlet_animal.cjs | 38 ++++++++++++------- resources/tester/test_173_7_inlet_animal.py | 18 +++++---- .../tester/test_177_2_inlet_animal_select.py | 38 +++++++++++++++++++ 7 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 resources/tester/test_177_2_inlet_animal_select.py diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index de75abeb6..10d353761 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -41,7 +41,6 @@ import { import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row"; import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item"; import { followPipeHeights } from "../../common_util/common_util_culvert_sets"; -import { createCheckbox } from "../../ui_template/ui_template_checkbox"; /** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */ export interface SpecLinks { @@ -144,13 +143,6 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { select.value = tag ? "" : now; return select; } - if (item.kind === "bool") { - // 빈 값 = 기본값으로 체크(173-7) · 듣개가 `.value` 를 읽음 — 바뀔 때 "true" / "false" - const box = createCheckbox("", { checked: (values[item.id] || item.default) === "true" }); - box.input.value = String(box.input.checked); - box.input.addEventListener("change", () => (box.input.value = String(box.input.checked))); - return box.root; - } if (item.kind === "text") { const input = document.createElement("input"); input.type = "text"; diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index 3aa987a31..9b1e7b79e 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -70,11 +70,15 @@ export const PIPE_SPEC: StructureSpec = { [BASIN]: { form: "inlet_structure", height: "inlet_basin_height_m" }, [SOIL]: revetKeys("inlet"), }), - // 집수정만 · 빈 값 = 참(키 없는 옛 관도 동물이동형 · 173-7) · 저장만(B07 · B08 안 읽음) + // 집수정만 · 유 / 무 = 저장 "true" / "false" · 빈 값 = 유(필수 상자 기본 채움 · 177-2) · 저장만(B07 · B08 안 읽음) { id: "animal", label: "동물이동형", - kind: "bool", + kind: "select", + choices: [ + { value: "true", label: "유" }, + { value: "false", label: "무" }, + ], default: "true", by: { item: "structure", map: { [BASIN]: { key: "inlet_basin_animal" } } }, }, diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 5c80408e8..3ba11d1d1 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -32,8 +32,7 @@ export type SplitRole = "length" | "before" | "after"; export interface SpecItem { id: string; label: string; - /** bool = 체크박스 · 저장 글 "true" / "false" · 빈 값 = `default`(173-7). */ - kind: "select" | "number" | "text" | "auto" | "bool"; + kind: "select" | "number" | "text" | "auto"; /** 저장 키(필수 컨테이너 · `by` 없을 때). */ key?: string; choices?: SpecChoice[]; diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 6cc24ba67..732b9baa1 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3333,6 +3333,8 @@ "inlet.before": "기준측점 전 (m)", "inlet.after": "기준측점 후 (m)", "inlet.animal": "동물이동형", + "inlet.animal=true": "유", + "inlet.animal=false": "무", "outlet.structure": "구조", "outlet.structure=기슭막이": "흙막이", "outlet.form": "형태", diff --git a/resources/tester/helper_173_7_inlet_animal.cjs b/resources/tester/helper_173_7_inlet_animal.cjs index 609c9c8db..89f9a38be 100644 --- a/resources/tester/helper_173_7_inlet_animal.cjs +++ b/resources/tester/helper_173_7_inlet_animal.cjs @@ -1,5 +1,5 @@ -/* 집수정 「동물이동형」 체크 칸(PLAN 173-7) 단위검증 헬퍼 — 공용 명세 폼을 그 자리에서 트랜스파일해 Node 로 돌린다. - * 빈 값 = 체크 · 끄면 "false" · 다시 켜면 "true" · 흙막이 갈래엔 칸 없음. 결과는 표준출력에 JSON 한 벌. +/* 집수정 「동물이동형」 칸(PLAN 173-7 · 177-2 고르기 유 / 무) 단위검증 헬퍼 — 공용 명세 폼을 그 자리에서 트랜스파일해 Node 로 돌린다. + * 빈 값 = 유("true" 적힘) · 무 = "false" · 다시 유 = "true" · 흙막이 갈래엔 칸 없음. 결과는 표준출력에 JSON 한 벌. * 짝 = `test_173_7_inlet_animal.py` · 화면 그릇 흉내는 `helper_54_10_soil_tiers.cjs` 꼴(+ 듣개). */ const fs = require("fs"); @@ -121,32 +121,44 @@ const { createSpecForm } = loadTs( const { PIPE_SPEC } = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts")); const form = createSpecForm(null, {}); +// 177-2 — 체크박스 → 고르기(유 / 무) · 저장 글 "true" / "false" 그대로 const box = () => form.root .querySelectorAll("label") .find((l) => l.dataset.specItem === "inlet.animal") - ?.querySelector("input") ?? null; + ?.querySelector("select") ?? null; const animal = () => form.read().inlet_basin_animal ?? null; -const toggle = (on) => { - const input = box(); - input.checked = on; - input.fire("change"); +const pick = (v) => { + const select = box(); + select.value = v; + select.fire("change"); +}; +const shown = () => { + const select = box(); + return select + ? { + value: select.value, + options: select.children.map((o) => [o.value, o.textContent]), + saved: animal(), + } + : null; }; const out = {}; form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "집수정" }); -out.blank = { checked: box()?.checked ?? null, saved: animal() }; -toggle(false); -out.off = { checked: box().checked, saved: animal() }; -toggle(true); -out.on = { checked: box().checked, saved: animal() }; +out.blank = shown(); +pick("false"); +out.off = shown(); +pick("true"); +out.on = shown(); form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "집수정", inlet_basin_animal: "false", }); -out.storedOff = { checked: box().checked, saved: animal() }; +out.storedOff = shown(); form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "기슭막이" }); out.soil = { shown: box() !== null, saved: animal() }; +out.checkbox = form.root.querySelectorAll("input").some((i) => i.type === "checkbox"); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_173_7_inlet_animal.py b/resources/tester/test_173_7_inlet_animal.py index b8539c476..ccb323207 100644 --- a/resources/tester/test_173_7_inlet_animal.py +++ b/resources/tester/test_173_7_inlet_animal.py @@ -1,4 +1,4 @@ -"""173-7 집수정 「동물이동형」 체크 칸 — 공용 체크박스 · 저장 키 inlet_basin_animal · 빈 값 = 참. +"""173-7 · 177-2 집수정 「동물이동형」 칸 — 고르기 유 / 무 · 저장 키 inlet_basin_animal("true"/"false") · 빈 값 = 유. 공용 명세 폼(`design_structure_form.ts`)을 그 자리에서 돌린 값(`helper_173_7_inlet_animal.cjs`) + 배관 폼 키 목록(`B05_Profile_UI_Drainage_Facility_Merge.ts`). @@ -28,14 +28,18 @@ def out() -> dict: return json.loads(done.stdout) -def test_빈_값은_체크_저장_안_함(out: dict) -> None: - assert out["blank"] == {"checked": True, "saved": None} +YU_MU = [["true", "유"], ["false", "무"]] -def test_끄면_false_다시_켜면_true(out: dict) -> None: - assert out["off"] == {"checked": False, "saved": "false"} - assert out["on"] == {"checked": True, "saved": "true"} - assert out["storedOff"] == {"checked": False, "saved": "false"} +def test_빈_값은_유_실제_값으로_적힘(out: dict) -> None: + # 177-2 — 필수 상자 빈 고르기 칸 = 기본값을 실제 값으로(54-43) · 옛 「저장 안 함」 뒤집음 + assert out["blank"] == {"value": "true", "options": YU_MU, "saved": "true"} + + +def test_무면_false_다시_유면_true(out: dict) -> None: + assert out["off"] == {"value": "false", "options": YU_MU, "saved": "false"} + assert out["on"] == {"value": "true", "options": YU_MU, "saved": "true"} + assert out["storedOff"] == {"value": "false", "options": YU_MU, "saved": "false"} def test_흙막이엔_칸_없음(out: dict) -> None: diff --git a/resources/tester/test_177_2_inlet_animal_select.py b/resources/tester/test_177_2_inlet_animal_select.py new file mode 100644 index 000000000..c77e1f35c --- /dev/null +++ b/resources/tester/test_177_2_inlet_animal_select.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""PLAN 177-2 — 동물이동형 = 고르기(유 / 무) · 체크박스 꼴(bool) 지움 · 화면 값은 helper_173_7.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] +DESIGN = ROOT / "A00_Common" / "design" + + +@pytest.fixture(scope="module") +def out() -> dict: + if not (ROOT / "config" / "node_modules").is_dir(): + pytest.skip("의존성 미설치") + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_173_7_inlet_animal.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_체크박스_없음(out: dict) -> None: + assert out["checkbox"] is False + + +def test_bool_꼴_지움() -> None: + spec = (DESIGN / "design_structure_spec.ts").read_text("utf-8") + form = (DESIGN / "design_structure_form.ts").read_text("utf-8") + pipe = (DESIGN / "design_structure_pipe.ts").read_text("utf-8") + assert '"bool"' not in spec and '"bool"' not in form and '"bool"' not in pipe + assert "createCheckbox" not in form From bc0160da2d3b045400ef7fbaa9fde1b0daa06f5a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:49:20 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat(m02):=20=EC=82=AC=EC=A0=84=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=9E=90=EC=9E=AC=EB=A5=BC=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EA=B0=92=20=C2=B7=20B08=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EC=9E=AC=EB=A3=8C=20=C2=B7=20B09=20=EA=B2=B9=20?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=C2=B7=20=EC=82=AC=EB=B3=B8=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EB=AC=B6=EC=9D=8C=EC=97=90=20=EC=85=88=20(175-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사전조합 1 단위 값(Expand._combo) = 안 줄 + 자재 기준단가 × 손 · 빈칸 = 안 셈 · 단가 없음 = 까닭 · 안 줄에 자재 줄 - B08 _ref_mats 가 사전조합 자재(손 × 겹 손)를 품목 코드로 더함 · 사용자 자재는 연료 거름 안 함 - B09 겹 대가 블록에 자재 줄(조합 자재 줄과 한 꼴 _material) - 사본 단가 묶음: 굳힌 사전조합 자재 · 고름 코드(frozen_materials) · 개인 자재 묶음도 - 화면 하위 행: 안 줄 자재 코드 표시 - 시험: test_175_precombo_mats 네 개 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B08_Quantity/B08_Quantity_Router_Material.py | 7 +- B09_Estimation/B09_Estimation_Labor_Source.py | 22 +++-- M02_MasterTemplete/M02_Item_Source.py | 5 +- M02_MasterTemplete/M02_Labor_Ref.py | 36 +++++++- .../M02_MasterTemplete_Labor_Calc.ts | 3 +- .../M02_MasterTemplete_Labor_Rows.ts | 4 +- resources/tester/test_175_precombo_mats.py | 89 +++++++++++++++++++ 7 files changed, 152 insertions(+), 14 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 4c7334dbd..d8e6626cb 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -57,6 +57,7 @@ def _qty(value: Any) -> float | None: def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> list[tuple]: """참조 문서 1 단위 재료 `[(재료 줄, 수량)]` — 층 셈 펼침(`M02_Labor_Ref.Expand`)과 같은 길: 승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 손 수량(빈칸 1) · 로직 줄은 알약 배로 나눔 · + 사전조합 자재(175-2) = 손 수량(빈칸 = 안 셈 · 품목 키 = 코드 · `자재` 표시 = 연료 거름 안 함) · 순환 · 깊이 넘음 · 없는 문서 = 빈 것.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Doc as labor @@ -94,6 +95,10 @@ def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> continue hand = _qty((one.get("수량") or {}).get("손")) out += [(m, q * (1.0 if hand is None else hand)) for m, q in sub] + for mat in doc.get("자재") or []: + if hand := _qty((mat.get("수량") or {}).get("손")): + line = {k: mat.get(k) for k in ("이름", "단위")} + out.append(({**line, "품목": {"키": mat.get("코드")}, "자재": True}, hand)) return out @@ -129,7 +134,7 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[ def logic_mat(mat: dict, qty: float) -> None: name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "") - if _fuel(name, unit): + if _fuel(name, unit) and not mat.get("자재"): # 사전조합 사용자 자재는 거르지 않음 return goods = mat.get("품목") code = str(goods.get("키") or "") if isinstance(goods, dict) else "" diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index b6d73a401..cb49e0f09 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -4,7 +4,8 @@ (`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침: 조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄 - 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) + 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) · + 자재 줄(175-2 · 수량 = 손 · 빈칸 = 수량 없음) 품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄) — 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근 산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`) @@ -206,7 +207,7 @@ class Builder: "spec_raw": block.get("spec_raw"), "unit": block["unit"], "qty": each} # fmt: skip def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str: - """사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표.""" + """사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표 · 자재 줄(175-2).""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] @@ -226,6 +227,10 @@ class Builder: continue qty = 1 if hand is None else hand lines.append(self.line_of(whole, one, qty, times, name, copies, depth)) + lines += [ + _material(whole, mat, (mat.get("수량") or {}).get("손")) + for mat in doc.get("자재") or [] + ] return self.put( { "kind": "사전조합", @@ -262,11 +267,7 @@ class Builder: if line.get("qty") is None and line["kind"] != "blank": line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"} lines.append(line) - for mat in result.get("자재") or []: - row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {} - lines.append({"kind": "material", "key": mat.get("코드"), "name": row.get("이름") or "", - "spec": row.get("규격") or "", "unit": row.get("단위") or "", - "qty": _f(mat.get("수량"))}) # fmt: skip + lines += [_material(whole, mat, mat.get("수량")) for mat in result.get("자재") or []] manifest = read_json(folder / MANIFEST_NAME) or {} origin = manifest.get("원본") or {} return self.put( @@ -282,6 +283,13 @@ class Builder: ) +def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]: + """자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가.""" + row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {} + names = {k: row.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))} + return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty)} + + def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]: """엔진 호표 줄 → 덧줄 밑수 후보 — `part:비목` 합 · `lines:labor` 인력 줄 합 · `line:이름`.""" out: dict[str, float] = {f"part:{p}": 0.0 for p in PART.values()} diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 30d53ed22..717791ba8 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -162,10 +162,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀 str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values() ] + 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 {} if mine := own.bundle( - own.codes(doc), root - ): # 개인 자재(148-3) — 원본 목록이 바뀌어도 사본 그대로 + [c for c in materials if own.is_own(c)], root + ): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로 files[PRICE_FILE][own.BUNDLE] = mine return files diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 3dbf78e2f..bc06b9ac0 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -7,7 +7,8 @@ precombo `{층}/precombo/SC-######.json` 사전조합 `{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모} | {로직, 입력값, 메모, 수량:{손}, 고름?, 자재표시?}], 자재:[일위대가 자재 꼴]}` — - 승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) · 자재 수량은 손 · 빈칸 = 안 셈(175) · + 승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) · + 자재 수량은 손 · 빈칸 = 안 셈(175) · 값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + Σ 자재 기준단가 × 손 참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) · 위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) · @@ -460,6 +461,29 @@ class Expand: why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}" continue rows.append({k: v * qty for k, v in sub["기준"].items()}) + for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈 + qty = labor._number((mat.get("수량") or {}).get("손")) + if qty is None: + continue + got = labor.material_price(self.whole, mat["코드"], mat.get("단위")) + price = labor._number(got["기준단가"]) + zero = dict.fromkeys(labor.COSTS, Decimal(0)) + base = None if price is None else {**zero, "재료비": price, "계": price} + name = mat.get("이름") or mat["코드"] + inner.append( + { + "자재": mat["코드"], + "수량": qty, + "이름": name, + "단위": mat.get("단위"), + "기준": base, + "까닭": got["까닭"], + } + ) + if base is None: + why = why or f"「{name}」 {got['까닭']}" + continue + rows.append({k: v * qty for k, v in base.items()}) return { "이름": doc.get("이름"), "단위": doc.get("단위"), @@ -512,6 +536,16 @@ def with_pills(doc: dict[str, Any]) -> dict[str, Any]: return {**doc, "알약": combo_store.combo_pills({"담은로직": lines})["물량"]} +def frozen_materials(copies: dict[str, Any]) -> list[str]: + """굳힌 사전조합의 자재 · 고른 로직 자재 코드(175-2) — 프로젝트 단가 묶음에 넣음.""" + out = [] + for doc in copies.values(): + out += [str(m.get("코드")) for m in doc.get("자재") or [] if isinstance(m, dict)] + lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] + out += [str(key) for one in lines for key in (one.get("고름") or {}).values()] + return out + + def frozen_logics(copies: dict[str, Any]) -> list[str]: """굳힌 단가승격 · 사전조합이 담은 로직 키 — 프로젝트 단가 묶음에 넣음.""" return [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 2024f610a..529f6bba4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -40,12 +40,13 @@ export interface CalcLine { /** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */ 재료?: LogicMat[]; /** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ · - * 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */ + * 참조 줄 = `참조` · 로직 줄(51-11) = `로직` · 자재 줄(175-2) = `자재` 코드) */ 이름?: string | null; 원로직?: string | null; 안?: { 참조?: LaborRef; 로직?: string; + 자재?: string; 수량: number; 이름?: string | null; 단위?: string | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 0380f2469..4b67f4686 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -267,14 +267,14 @@ export interface LogicTableData { } /** 고른 줄 하위 행(51-2 · 같은 표 · 같은 칸) — 단가산출 · 단가승격 = 그 로직 재료 · 노무 · 경비 줄 · - * 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) · + * 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 자재 줄 175-2 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) · * 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) · * 하위 합 = 위 줄 금액 · 줄이 없으면 빈 한 줄(「…」 · 까닭 · 「—」) */ function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean): HTMLElement[] { const times = line?.수량 === null || line?.수량 === undefined ? null : line.수량 / (line.배 || 1); const items = line?.안 ? line.안.map((x) => { - const key = x.참조?.키 ?? x.로직 ?? ""; + const key = x.참조?.키 ?? x.로직 ?? x.자재 ?? ""; const label = x.이름 && x.이름 !== key ? `${key} ${x.이름}` : key; // 로직 줄 이름 없으면 키만 return { name: `${label}${x.까닭 ? ` · ${x.까닭}` : ""}`, diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 6d9355acf..9b98c4cb1 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -2,13 +2,18 @@ 175-1 화면 · 저장 — 서고 모드 자재목록 · 로직 자재 세 칸 막힘 풂 · 저장 `_combo_lines` 가 고름 · 자재표시 남김 · 문서 `자재`(일위대가 자재 검사 · 로직 자재와 같은 품목 400). +175-2 셈 — 담은 자재(손 · 빈칸 = 안 셈)가 사전조합 1 단위 값(`Expand._combo` · 안 줄) · B08 참조 줄 +재료(`_ref_mats`) · B09 겹 대가 자재 줄 · 프로젝트 사본 단가 묶음(`frozen_materials`)에 들어감. """ import json import os +from decimal import Decimal import pytest +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture @@ -19,6 +24,8 @@ NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 · 결속 NET_ITEM = "MT006697" WIRE = "MT000194" MAT = "MT000008" # 자재품목 · 물가정보 83000 +STONE = "GF000219" # 원/㎡ +SYS = {"층": "system", "주인": None} def _save(folder, doc): @@ -58,3 +65,85 @@ def test_로직_자재와_같은_품목_400_없는_자재_400(world): # noqa: F with pytest.raises(store.StoreError) as e: _save(world["system"], {"담은로직": [], "자재": [{"코드": "MT999999"}]}) assert e.value.status == 400 + + +# ── 175-2 셈 ────────────────────────────────────────────────────────── +def _price(code): + whole = m01.loaded()[1] + return Decimal(str(labor.material_price(whole, code, None)["기준단가"])) + + +def _world_combo(world): # noqa: F811 + """STONE 1 + MAT 손 2 + WIRE 빈칸(안 셈) · NET 고름 4 = NET_ITEM — (키, 손셈 1 단위 값).""" + lines = [ + {"로직": STONE, "수량": {"손": 1}}, + {"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}}, + ] + mats = [{"코드": MAT, "수량": {"손": 2}}, {"코드": WIRE, "수량": None}] + with store.at(world["system"]): + key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[ + "이름" + ] + stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"])) + return key, stone + _price(MAT) * 2 + + +def test_사전조합_값에_자재_금액_빈칸은_안_셈_안_줄에_자재(world): # noqa: F811 + key, want = _world_combo(world) + with store.at(world["system"]): + doc = labor.clean( + {"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]} + ) + one = labor.calc(doc, {})["줄"][0] + assert one["기준"]["계"] == pytest.approx(float(want)) + assert one["반영"]["계"] == pytest.approx(float(want * 3)) + mats = [x for x in one["안"] if x.get("자재")] + assert [(x["자재"], x["수량"]) for x in mats] == [(MAT, 2)] # WIRE 빈칸 = 안 셈 + assert mats[0]["기준"]["재료비"] == pytest.approx(float(_price(MAT))) + # 목록 기준금액(51-4)도 같은 펼침 + rows = [{"종류": "precombo", "이름": key}] + refs.summaries(world["system"], rows) + assert rows[0]["기준금액"] == pytest.approx(float(want)) + + +def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F811 + from B08_Quantity.B08_Quantity_Router_Material import _ref_mats + + key, _ = _world_combo(world) + with store.at(world["system"]): + outer = refs.create( + "precombo", + {"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}, + )["이름"] + got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer}) + mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")] + assert mats == [(MAT, pytest.approx(6.0))] # 2 × 겹 손 3 · WIRE 빈칸 없음 + + +def test_B09_겹_대가에_자재_줄(world): # noqa: F811 + from B09_Estimation.B09_Estimation_Labor_Source import Builder + + key, _ = _world_combo(world) + ref = {**SYS, "키": key} + build = Builder() + bid = build.precombo(m01.loaded()[1], ref, refs.read_ref(ref), {}) + mats = [x for x in build.blocks[bid]["lines"] if x["kind"] == "material"] + assert [(x["key"], x["qty"]) for x in mats] == [(MAT, 2.0), (WIRE, None)] + assert mats[0]["name"] and mats[0]["unit"] + + +def test_사본_단가_묶음에_사전조합_자재_고름_사본_값_같음(world): # noqa: F811 + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Item_Source as source + + key, want = _world_combo(world) + doc = {"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 1}}], "자재": []} + with store.at(world["system"]): + rows = source.labor_doc_rows(labor.clean(doc)) + mats = refs.frozen_materials(rows["참조사본"]) + assert {MAT, WIRE, NET_ITEM} <= set(mats) + assert "refs.frozen_materials(" in open(source.__file__, encoding="utf-8").read() + bundle = prices.to_json(prices.collect(refs.frozen_logics(rows["참조사본"]), mats)) + (world["system"] / "precombo" / f"{key}.json").unlink() # 원본이 사라져도 굳힌 사본 + got = prices.calc(rows, bundle, {}, {}, None)["줄"][0] + assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9) From 2d7fb61a9c550ac6d39e69763609b1bb21f6c828 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:50:20 +0900 Subject: [PATCH 09/14] =?UTF-8?q?style(b06):=20=EC=84=A0=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=95=EB=B3=B4=EC=B0=BD=20=3D=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AD=20=EB=AC=B4=EA=B4=80=20=EB=8A=98=20?= =?UTF-8?q?=EC=A4=8C=20=EB=8B=A8=EC=B6=94=20=EC=99=BC=EC=AA=BD=20=C2=B7=20?= =?UTF-8?q?=EC=A2=81=EC=9D=80=20=EC=B9=B4=EB=93=9C=20=EB=82=B4=EB=A6=BC=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=A7=80=EC=9B=80=20(179-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_018jonfdxn1Xit9bdkNJpE4G --- .../B06_Section_UI_Style_Cross_Controls.css | 26 +-------------- resources/tester/test_137_design_pick.py | 9 +++-- resources/tester/test_179_1_line_dims_left.py | 33 +++++++++++++++++++ 3 files changed, 38 insertions(+), 30 deletions(-) create mode 100644 resources/tester/test_179_1_line_dims_left.py diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index d12b689e4..f4fee5c15 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -223,7 +223,7 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } -/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */ +/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */ .b06-cross-card__line-dims { align-self: center; padding: 1px var(--spacing-4); @@ -234,30 +234,6 @@ white-space: pre-line; } -/* 좁은 카드(3열 등)는 막대 옆에 두면 왼쪽 면적표를 덮음 — 표 아래 높이로 내려 오른쪽에 붙임(151-1). */ -.b06-cross-card__chart-wrap { - container-type: inline-size; -} - -@container (max-width: 620px) { - .b06-cross-card__zoom { - overflow: visible; - } - - .b06-cross-card__line-dims { - position: absolute; - top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem)); - right: -1px; - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); - } - - .b06-cross-card__line-dims + .b06-cross-card__zoom-btn { - border-left: none; - } -} - .b06-cross-card__line-dims[hidden] { display: none; } diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py index 0e88ef6c9..0e17f6d7e 100644 --- a/resources/tester/test_137_design_pick.py +++ b/resources/tester/test_137_design_pick.py @@ -106,14 +106,13 @@ def test_카드_배선_하나만() -> None: assert ".b06-chart__design-hit.is-active {" in css -def test_치수_두_줄_좁은_카드는_표_아래로() -> None: - """151-1 — 줄바꿈 글자를 살리고 오른쪽 맞춤 · 좁은 카드(3열 등)는 표 높이 아래로 내려 표를 안 덮음.""" +def test_치수_두_줄_늘_단추_왼쪽() -> None: + """151-1 — 줄바꿈 글자를 살리고 오른쪽 맞춤 · 179-1 — 좁은 카드도 표 아래로 안 내림(늘 [+ − ⤢] 왼쪽).""" css = _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css") dims = css.split(".b06-cross-card__line-dims {", 1)[1].split("}", 1)[0] assert "white-space: pre-line;" in dims and "text-align: right;" in dims - assert "container-type: inline-size;" in css - narrow = css.split("@container (max-width: 620px) {", 1)[1] - assert "top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));" in narrow + assert "position:" not in dims + assert "@container" not in css def test_폴리곤_자르기는_공용_한_벌() -> None: diff --git a/resources/tester/test_179_1_line_dims_left.py b/resources/tester/test_179_1_line_dims_left.py new file mode 100644 index 000000000..7d9406081 --- /dev/null +++ b/resources/tester/test_179_1_line_dims_left.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험).""" + +import os +import re + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _src(rel: str) -> str: + return open(os.path.join(ROOT, rel), encoding="utf-8").read() + + +def test_좁은_카드_내림_규칙_없음(): + css = _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css") + assert "@container" not in css + # 치수 칸 규칙은 기본 꼴 · 숨김(+ 옆 단추) 뿐 — 절대 자리 · 왼선 지움 규칙 없음 + assert re.findall(r"\.b06-cross-card__line-dims[^{,;]*\{", css) == [ + ".b06-cross-card__line-dims {", + ".b06-cross-card__line-dims[hidden] {", + ".b06-cross-card__line-dims[hidden] + .b06-cross-card__zoom-btn {", + ] + + +def test_치수_칸은_줌_막대_맨_앞(): + view = _src("B06_Section/B06_Section_UI_Cross_View.ts") + assert "zoomBar.prepend(linePick.label);" in view + zoom = ( + _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css") + .split(".b06-cross-card__zoom {", 1)[1] + .split("}", 1)[0] + ) + assert "display: flex;" in zoom and "position: absolute;" in zoom From 1a96188ce09c3d376ade72f926da86dc4ae8ad63 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:51:15 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat(b06):=20=EB=85=B8=EB=A9=B4=EA=B3=B5?= =?UTF-8?q?=20=EA=B3=A0=EB=A6=84=20=3D=20=EB=B2=94=EC=9C=84=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=EB=B6=80=20=ED=85=8C=EB=91=90=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=B9=B4=EB=93=9C=20=ED=95=98=EB=82=98=20=EC=95=88=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=C2=B7=20=EC=B2=AB=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EB=A7=8C=20(177-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - applySpan 노면공도 --span · revealStation(리스너 없이 옛 카드 풂 · 폼 유지) - focusChainage revealOnly · 세션 고름은 패널이 직접 - test_165 · test_106 기대값 고침 · 새 시험 test_177_4_pavement_span_box Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- A00_Common/design/design_view_cross.ts | 9 ++- .../B06_Section_UI_Page_Structures_Panel.ts | 16 ++-- B06_Section/B06_Section_UI_Section_View.ts | 13 +++- .../B06_Section_UI_Section_View_Types.ts | 2 + resources/tester/helper_165_pavement_flow.cjs | 25 +++++-- .../tester/test_106_span_highlight_wording.py | 4 +- resources/tester/test_165_pavement_flow.py | 14 ++-- .../tester/test_177_4_pavement_span_box.py | 74 +++++++++++++++++++ 8 files changed, 133 insertions(+), 24 deletions(-) create mode 100644 resources/tester/test_177_4_pavement_span_box.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 5dbd9f0c3..e1ae1e94c 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -115,13 +115,14 @@ export async function mountCrossView( const crossHalfWidthField = crossView.field; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */ - const focusStationAt = (chainageM: number): void => { + const focusStationAt = (chainageM: number, revealOnly = false): void => { const sections = sectionDetail?.cross_sections; if (!sections?.length) return; const nearest = sections.reduce((best, entry) => Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best, ); - sectionView.focusStation(nearest.station_id); + if (revealOnly) sectionView.revealStation(nearest.station_id); + else sectionView.focusStation(nearest.station_id); }; /** 그 누가거리를 가진 측점 카드(관 찾는 거리 한 값). */ const ownerAt = (chainageM: number) => @@ -186,8 +187,8 @@ export async function mountCrossView( stationInterval: () => stationInterval ?? 20, // 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저 // 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다. - focusChainage: (chainageM) => { - if (!isWallSelecting()) focusStationAt(chainageM); + focusChainage: (chainageM, revealOnly) => { + if (!isWallSelecting()) focusStationAt(chainageM, revealOnly); }, releaseCard: () => releaseCross(), markStructure: (structureId) => sectionView.selectMark(structureId), diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 375e616db..25c0eaded 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -84,8 +84,8 @@ export interface B06StructuresPanelDeps { wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null; /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ stationInterval: () => number; - /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ - focusChainage: (chainageM: number) => void; + /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지 않고 스크롤만 · 177-4). */ + focusChainage: (chainageM: number, revealOnly?: boolean) => void; /** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */ releaseCard?: () => void; /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */ @@ -216,11 +216,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2) // 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다. - preferredId = structure.structure_id ?? null; - deps.focusChainage(focusOf(structure)); + // 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 그 카드로 스크롤만 · 폼 유지(177-4) + const pave = !coverable(structure); + const id = structure.structure_id ?? null; + preferredId = id; + deps.focusChainage(focusOf(structure), pave); preferredId = null; // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) - deps.markStructure?.(structure.structure_id ?? null); + deps.markStructure?.(id); + // 카드 고름 리스너를 안 거치니 세션 고름은 여기서(B05 로 가도 그 노면공) + const at = { chainageM: focusOf(structure), structureId: id }; + if (pave) selectDesign(deps.projectId, at, { from: "b06" }); }, getInterval: deps.stationInterval, // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index b4f916667..805ac4c5d 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -235,7 +235,7 @@ export function createSectionView( /** * 고른 구간형 구조물(옹벽 · 독립 흙막이 · 사토장)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1). - * 노면공(G군)은 테두리 대신 범위 카드 안 그 노면공의 포장층만 강조(152-5) — 겹친 노면공은 범위 표식으로 하나만. + * 노면공(G군)도 테두리 + 범위 카드 안 그 노면공의 포장층 강조(152-5 · 177-4) — 겹친 노면공은 범위 표식으로 하나만. * 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다. */ let spanMarks: ReadonlyArray = []; @@ -257,7 +257,7 @@ export function createSectionView( : undefined; const span = inSpan(section, mark); const pave = span && isPavement(spanMarkId); - card.classList.toggle("b06-cross-card--span", span && !pave); + card.classList.toggle("b06-cross-card--span", span); highlightPavement(card, pave ? mark : undefined); }; const syncSpanHighlights = (): void => { @@ -576,6 +576,15 @@ export function createSectionView( ); } }, + revealStation(stationId) { + // 노면공 고름 = 카드 하나 안 고름 · 옛 카드는 리스너 없이 풂(폼 유지) · 그 카드로 스크롤만(177-4) + const previous = selectedStationId; + selectedStationId = null; + activeAreaKey = null; + markCard(previous); + profile.showCard(null); + revealCard(stationId, "smooth"); + }, selectMark: (structureId, span = true) => { // span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2) spanMarkId = span || isPavement(structureId) ? structureId : null; diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 1db878a43..0e7439732 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -42,6 +42,8 @@ export interface SectionViewController { /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; + /** 카드를 고르지 않고 스크롤만 — 고른 카드는 알림 없이 풂(노면공 목록 고름 · 177-4). */ + revealStation: (stationId: string) => void; /** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). * `span` = 구간형 범위 안 카드 전부 테두리(기본) · 끄면 카드는 고른 측점 하나(137-4). */ selectMark: (structureId: string | null, span?: boolean) => void; diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 407a783ef..75a3f17e1 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -202,6 +202,13 @@ const view = { profile.showCard(CARDS.find((card) => card.station_id === this.selected) ?? null); if (this.selected !== previous) this.listener(this.selected); }, + revealed: null, + revealStation(id) { + // 노면공 목록 고름 = 카드 하나 안 고름 · 리스너 없이 풂 · 스크롤만(177-4 · Section_View `revealStation`) + this.selected = null; + profile.showCard(null); + this.revealed = id; + }, focusStation(id) { if (id === null) this.selected = null; else if (this.selected !== id) this.selectStation(id); @@ -214,11 +221,12 @@ const view = { }; let wallSelecting = false; -const focusStationAt = (at) => { +const focusStationAt = (at, revealOnly = false) => { const nearest = CARDS.reduce((best, card) => Math.abs(card.chainage_m - at) < Math.abs(best.chainage_m - at) ? card : best, ); - view.focusStation(nearest.station_id); + if (revealOnly) view.revealStation(nearest.station_id); + else view.focusStation(nearest.station_id); }; const current = { structures: [PAVE] }; @@ -248,6 +256,7 @@ async function build(list) { form.id = null; form.pipe = null; view.selected = null; + view.revealed = null; view.spanMarkId = null; profile.markedId = null; profile.pill = null; @@ -256,8 +265,8 @@ async function build(list) { shared: { view: () => "cross" }, detail: () => ({ cross_sections: CARDS }), stationInterval: () => 20, - focusChainage: (at) => { - if (!wallSelecting) focusStationAt(at); + focusChainage: (at, revealOnly) => { + if (!wallSelecting) focusStationAt(at, revealOnly); }, markStructure: (id) => view.selectMark(id), onMarks: (next, types) => { @@ -308,7 +317,11 @@ const wallPick = (cardAtM, ownerM) => { await build([PAVE, { structure_id: "sign", type_id: "hazard_sign", chainage_m: 127.86 }]); listPick("pave"); out.step1 = snap(); - cardPick(40); // 고름이 이미 연 범위 첫 카드 재누름(브레인 ORCA ②) + out.reveal1 = view.revealed; // 고르지 않고 스크롤만 한 카드(177-4) + out.pick1 = selection.readSelection(PID); + cardPick(40); // 스크롤만 한 범위 첫 카드 누름 + out.step2first = snap(); + cardPick(40); // 고른 범위 카드 재누름(167-3 · 브레인 ORCA ②) out.step2re = snap(); cardPick(140); out.step2 = snap(); @@ -318,6 +331,7 @@ const wallPick = (cardAtM, ownerM) => { out.step3 = snap(); pillPick("pave"); out.step4 = snap(); + out.reveal4 = view.revealed; cardPick(80); // 관 자리 카드 = 관(지금처럼) out.pipeCard = snap(); pillPick("pave"); @@ -339,6 +353,7 @@ const wallPick = (cardAtM, ownerM) => { await build([PAVE, PAVE2]); listPick("pave2"); out.step6 = snap(); + out.reveal6 = view.revealed; cardPick(160); out.step6b = snap(); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_106_span_highlight_wording.py b/resources/tester/test_106_span_highlight_wording.py index aeec79260..e0b5f9c97 100644 --- a/resources/tester/test_106_span_highlight_wording.py +++ b/resources/tester/test_106_span_highlight_wording.py @@ -32,8 +32,8 @@ def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길 r"spanMarks = structures;\s+spanTypes = types;\s+syncSpanHighlights\(\);", VIEW ) assert VIEW.count("applySpan(card, section);") == 2 - # 152-5 노면공(G군)은 테두리 대신 포장층 - assert 'card.classList.toggle("b06-cross-card--span", span && !pave);' in VIEW + # 152-5 노면공(G군) 포장층 · 177-4 범위 카드 테두리도 함께 + assert 'card.classList.toggle("b06-cross-card--span", span);' in VIEW def test_측점_고름은_카드_하나_목록_고름은_여럿(): diff --git a/resources/tester/test_165_pavement_flow.py b/resources/tester/test_165_pavement_flow.py index 7ab3b105e..e03dfe7c6 100644 --- a/resources/tester/test_165_pavement_flow.py +++ b/resources/tester/test_165_pavement_flow.py @@ -2,7 +2,7 @@ """165 B06 노면공 고름 일관성 — 조사 K 재현 차례 6단계. 못박는 것(단계마다 폼 · 알약 · 포장층 · 고른 카드 · 세션 sid 가 같은 것을 가리킴) - 1. 목록 노면공 = 범위 안 카드 · 포장층 · 알약 · 폼 · sid 모두 노면공(기준점이 범위 경계여도 범위 밖 카드로 안 감). + 1. 목록 노면공 = 포장층 · 알약 · 폼 · sid 모두 노면공 · 카드는 안 고름(스크롤만 · 177-4 · 기준점이 범위 경계여도 범위 밖 카드로 안 감). 2. 범위 안 빈 카드(관 · 덮는 구조물 없음) = 노면공 그대로 · 카드만 옮김(165-1). 3. 고른 카드의 벽 = 관 폼 + 관 알약(알약이 꺼진 채 폼만 남지 않음 · 165-2) · 포장층 풂. 4. 알약 다시 = 1 과 같음. 관 자리 카드 = 관 · 범위 밖 카드 = 모두 풂(지금처럼). @@ -55,10 +55,12 @@ PIPE = {"form": "pipe-80.17", "pill": "pipe-80.17", "paveLayer": None, "paveCard def test_1_목록_노면공(out): - assert out["step1"] == _pave(40) # 기준점 30 = 범위 경계 → 범위 안 첫 카드(20 아님) + assert out["step1"] == _pave(None) # 카드 하나 안 고름(177-4) + assert out["reveal1"] == "s40" # 기준점 30 = 범위 경계 → 범위 안 첫 카드(20 아님) def test_2_범위_안_빈_카드는_노면공_그대로(out): + assert out["step2first"] == _pave(40) assert out["step2re"] == _pave(40) # 이미 고른 카드 재누름도 그대로(167-3) assert out["step2"] == _pave(140) assert out["step2b"] == _pave(100) @@ -69,7 +71,7 @@ def test_3_벽은_관_폼과_관_알약_함께(out): def test_4_알약_다시_관_카드_범위_밖(out): - assert out["step4"] == _pave(40) + assert out["step4"] == _pave(None) assert out["pipeCard"] == {**PIPE, "card": 80, "sid": None} assert out["outside"] == { "form": None, @@ -83,17 +85,17 @@ def test_4_알약_다시_관_카드_범위_밖(out): def test_5_B05_복귀(out): - assert out["step5"] == _pave(40) + assert out["step5"] == _pave(None) def test_6_겹친_노면공(out): - assert out["step6"] == _pave(100, "pave2", 6) + assert out["step6"] == _pave(None, "pave2", 6) assert out["step6b"] == _pave(160, "pave2", 6) def test_배선(): panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") - assert "deps.focusChainage(focusOf(structure));" in panel + assert "deps.focusChainage(focusOf(structure), pave);" in panel assert "return { chainageM, structureId: openId };" in panel # 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔) at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);") diff --git a/resources/tester/test_177_4_pavement_span_box.py b/resources/tester/test_177_4_pavement_span_box.py new file mode 100644 index 000000000..b604678ee --- /dev/null +++ b/resources/tester/test_177_4_pavement_span_box.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""177-4 B06 노면공 목록 · 알약 고름 = 범위 카드 전부 테두리(박스) · 카드 하나 안 고름. + +못박는 것 + - 노면공 고름 = 범위 안 카드 전부 `b06-cross-card--span`(다른 구간형과 같음) + 포장층 강조 그대로. + - 카드 하나 고르기(`--selected`) 없음 · 범위 첫 카드로 스크롤만 · 옛 고른 카드는 리스너 없이 풂(폼 · 알약 유지). + - 세션 고름은 노면공 + 스크롤한 카드 측점(B05 로 가도 그 노면공). + +Node 헬퍼 `helper_165_pavement_flow.cjs`(진짜 SPN) 결과를 같이 씀. 화면은 사용자 확인. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_165_pavement_flow.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_노면공_고름은_카드_안_고르고_스크롤만(out): + for step, reveal, layer, cards in ( + ("step1", "reveal1", "pave", 12), + ("step4", "reveal4", "pave", 12), + ("step6", "reveal6", "pave2", 6), + ): + snap = out[step] + assert snap["card"] is None # --selected 0 + assert snap["form"] == layer and snap["pill"] == layer # 폼 · 알약 유지 + assert snap["paveCards"] == cards # 범위 카드 전부 + assert out[reveal] in ("s40", "s100") + assert out["pick1"] == {"chainageM": 40, "structureId": "pave"} + + +def test_범위_카드_테두리는_노면공도(): + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + assert 'card.classList.toggle("b06-cross-card--span", span);' in view + assert "highlightPavement(card, pave ? mark : undefined);" in view + + +def test_스크롤만_길은_리스너를_안_거침(): + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + body = re.search(r"revealStation\(stationId\) \{(.*?)\n \},", view, re.S) + assert body, "revealStation 없음" + text = body.group(1) + assert "selectedStationId = null;" in text + assert "profile.showCard(null);" in text + assert 'revealCard(stationId, "smooth");' in text + assert "stationSelectListener" not in text # 폼을 닫지 않음 + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "if (revealOnly) sectionView.revealStation(nearest.station_id);" in cross + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "const pave = !coverable(structure);" in panel + assert 'if (pave) selectDesign(deps.projectId, at, { from: "b06" });' in panel From be57e54fffd531398e7ed9f452d828231f0dcb17 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:51:20 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat(b09):=20=ED=98=B8=ED=91=9C=EB=B3=84?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=20=E2=86=92=20=EB=B0=95=EC=8A=A4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=A0=91=EA=B8=B0=20=EC=97=86=EC=95=B0=20?= =?UTF-8?q?=C2=B7=20=EB=AA=A9=EB=A1=9D=ED=91=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=ED=98=B8=ED=91=9C=20=EB=B6=89=EC=9D=80=20?= =?UTF-8?q?=EC=99=B8=EA=B3=BD=EC=84=A0(=EB=B0=95=EC=8A=A4=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=20=EC=A4=84=20=EA=B5=AC=EA=B0=84=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?)(181-2)?= 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_01STqEqztUthVFBmEP18wBSz --- B09_Estimation/B09_Estimation_UI_Page.css | 25 ++++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 7 +- .../B09_Estimation_UI_Tabs_Groups.ts | 27 +++++-- .../tester/test_157_2_b09_sangeun_views.py | 4 +- resources/tester/test_171_2_group_cards.py | 44 ++++++----- resources/tester/test_171_3_b09_group_tabs.py | 6 +- resources/tester/test_181_2_b09_box_pick.py | 76 +++++++++++++++++++ ui_template/ui_template_group_cards.css | 14 ++-- ui_template/ui_template_group_cards.ts | 29 ++----- 9 files changed, 171 insertions(+), 61 deletions(-) create mode 100644 resources/tester/test_181_2_b09_box_pick.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 6045a5ed5..ecd0e603e 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -48,3 +48,28 @@ .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); } + +/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */ +.b09-estimation .ui-data-table__table tbody tr.is-pick td { + --pick-t: inset 0 0 0 0 transparent; + --pick-b: inset 0 0 0 0 transparent; + --pick-l: inset 0 0 0 0 transparent; + --pick-r: inset 0 0 0 0 transparent; + box-shadow: var(--pick-t), var(--pick-b), var(--pick-l), var(--pick-r); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-top td { + --pick-t: inset 0 2px 0 var(--color-danger); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-end td { + --pick-b: inset 0 -2px 0 var(--color-danger); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick td:first-child { + --pick-l: inset 2px 0 0 var(--color-danger); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child { + --pick-r: inset -2px 0 0 var(--color-danger); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index a36538a9f..39109d305 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -192,7 +192,8 @@ export function renderB09Estimation(root: HTMLElement): void { const goto = (link: Link | null): void => { if (link) show(link.tab, link.key); }; - const makeTable = (): DataTable => { + // extra = 줄 class 곁값(181-2 표 보기 호표 구간) + const makeTable = (extra?: (index: number) => string | null): DataTable => { const made = createDataTable({ columns: spec.columns, rows: spec.rows, @@ -202,7 +203,9 @@ export function renderB09Estimation(root: HTMLElement): void { caption: TABS.find(([id]) => id === tab)?.[1], editable: spec.editable, onEdit: spec.onEdit, - rowClass: spec.rowClass, + rowClass: extra + ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null + : spec.rowClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 957d62c6d..8b523847e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -1,10 +1,11 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Groups.ts * B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 · - * [표로 보기] = 공용 표 그대로. + * [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]). * * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) + * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 * ========================================================================== */ @@ -29,8 +30,7 @@ export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"]; export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab); /** 단추 글 = 바꿀 보기 이름 */ -export const viewLabel = (view: GroupView): string => - view === "cards" ? "표로 보기" : "호표별 보기"; +export const viewLabel = (view: GroupView): string => (view === "cards" ? "표로 보기" : "박스보기"); export const flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards"); @@ -146,6 +146,22 @@ export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] { return cards; } +/** 고른 호표의 표 줄 구간 [첫 줄, 끝 줄](키 `|` 앞머리가 같은 줄) · 없으면 null */ +export function pickRange(rows: DataRow[], focus: string | undefined): [number, number] | null { + if (focus === undefined) return null; + const head = focus.split("|")[0]; + const at = rows.flatMap((r, i) => (str(r.key).split("|")[0] === head ? [i] : [])); + return at.length ? [at[0], at[at.length - 1]] : null; +} + +/** 표 줄 class — 구간 안 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` */ +export function pickClass(range: [number, number] | null, i: number): string | null { + if (!range || i < range[0] || i > range[1]) return null; + return ["is-pick", i === range[0] && "is-pick-top", i === range[1] && "is-pick-end"] + .filter(Boolean) + .join(" "); +} + /** 표 열과 같은 숫자 꼴(열 소수 자리) */ function numberFormat(spec: TabTable, key: string): (v: number | null | undefined) => string { const c = spec.columns.find((col) => col.key === key); @@ -168,7 +184,7 @@ export function renderGroupPanel(o: { hint: HTMLElement | null; focus?: string; goto: (link: Link | null) => void; - makeTable: () => DataTable; + makeTable: (rowClass?: (index: number) => string | null) => DataTable; redraw: () => void; }): HTMLElement[] { const view = chosen.get(o.tab) ?? readGroupView(o.tab); @@ -201,7 +217,8 @@ export function renderGroupPanel(o: { cards.dataset.b09Table = o.tab; return [bar, cards]; } - const table = o.makeTable(); + 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]; } diff --git a/resources/tester/test_157_2_b09_sangeun_views.py b/resources/tester/test_157_2_b09_sangeun_views.py index 81dd451f8..b8cbb03d4 100644 --- a/resources/tester/test_157_2_b09_sangeun_views.py +++ b/resources/tester/test_157_2_b09_sangeun_views.py @@ -1,7 +1,7 @@ """157-2 B09 단가산출근거 탭 보기 둘 · 157-3 산근 규격 칸 「이름 규격」. - 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 값 · 같은 차례(171-3 공용 묶음 카드로 갈음) -- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본) +- 단추 = 보기 바꿈(글 「박스보기」 ↔ 「표로 보기」 · 181-2) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본) - 157-3 규격 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 번호는 풍선(tip) """ @@ -66,7 +66,7 @@ def test_157_2_단추_바꿈(): "\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')," " flipView('table'), flipView(flipView('table'))]));" ) - assert out == ["호표별 보기", "표로 보기", "cards", "table"] + assert out == ["박스보기", "표로 보기", "cards", "table"] def test_157_2_기억_실패_때_카드(): diff --git a/resources/tester/test_171_2_group_cards.py b/resources/tester/test_171_2_group_cards.py index 679ea754d..3a5fe8211 100644 --- a/resources/tester/test_171_2_group_cards.py +++ b/resources/tester/test_171_2_group_cards.py @@ -2,7 +2,7 @@ - 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 이름 · 규격 · 수량 · 단위 · 합계 · 비고 / 합계 줄 = 노무 · 재료 · 경비 · 계 - 줄 금액 = 합계만 · 비목 셋 = 풍선 · 숫자 오른쪽 · 글자 14px 한 벌 · 줄 간격 좁게(공용 카드 16px 끼움 없음) -- 가로 열 수 1~4(기본 2 · 고르면 알림) · 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) +- 가로 열 수 1~4(기본 2 · 고르면 알림) · 접기 없음(181-2) · 이동해 온 박스 = `is-pick`(붉은 외곽선) · 머리 누름 = 이동 - 157-2 산근 카드 파일 = 이 양식으로 갈음(지움) """ @@ -43,11 +43,9 @@ def test_도우미_열수_요약_풍선(): out = _run( "\nconsole.log(JSON.stringify({" " cols: [0, 1, 2, 3, 4, 5, '3', null, undefined].map(clampColumns)," - " summary: [12, 13].map(isSummary)," " tip: [partsTip([1000, 0, null]), partsTip([null, null, null]), partsTip(null)] }));" ) assert out["cols"] == [2, 1, 2, 3, 4, 2, 2, 2, 2] - assert out["summary"] == [False, True] assert out["tip"] == ["노무 1,000 · 재료 0 · 경비 -", None, None] @@ -91,7 +89,7 @@ def test_카드_머리_몸_합계_줄(): assert out["foot"] == ["노무 100", "재료 20", "경비 3", "계 123", "빈 줄 1"] assert out["link"] == [True, False] assert out["cols"] == "2" # 기본 2 - assert out["summary"] == ["1", "2", "3", "4"] # 12 이하 = 요약 단추 없음 + assert out["summary"] == ["1", "2", "3", "4"] # 가로 열 수 단추뿐(요약 · 접기 단추 없음) def test_가로_열수_고름_알림_누름_이동(): @@ -111,29 +109,35 @@ def test_가로_열수_고름_알림_누름_이동(): assert out["picks"] == [21, 20] # 줄 · 머리 누름 = 표 줄 번호 그대로(갈 곳은 부르는 쪽 link) -def test_묶음_12_넘으면_요약_보기(): +def test_묶음_많아도_접힘_없음_고른_박스_붉은_외곽선(): out = _run( "\nconst root = make(13, { focus: 52 });" "\nconst cards = root.all('.ui-group-card');" - "\nconst closed = () => cards.map((c) => c.classList.contains('is-closed'));" - "\nconst first = closed();" "\nconst focused = root.querySelector('[data-at=\"52\"]');" + "\nconst first = cards.map((c) => c.classList.contains('is-pick'));" "\ncards[0].querySelector('.ui-group-card__head').click();" - "\nconst opened = closed()[0];" - "\nconst btn = (label) => root.all('.ui-btn').find((b) => b.textContent === label);" - "\nbtn('모두 펼침').click(); const all = closed();" - "\nbtn('모두 접기').click(); const none = closed();" - "\nconsole.log(JSON.stringify({ first, opened, all, none, picks," + "\nconsole.log(JSON.stringify({ first, picks," + " closed: cards.map((c) => c.classList.contains('is-closed'))," + " buttons: root.all('.ui-btn').map((b) => b.textContent)," " foot: cards.every((c) => c.querySelector('.ui-group-card__foot'))," - " focus: [focused.classList.contains('is-focus'), !!focused.scrolled] }));" + " focus: [focused.classList.contains('is-focus'), cards[4].scrolled === true] }));" ) - # 이동해 온 줄(5번째 묶음 줄) = 그 묶음만 펼침 · 강조 · 스크롤 - assert out["first"] == [True] * 4 + [False] + [True] * 8 + # 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 그 줄 강조 · 박스 스크롤 + assert out["first"] == [False] * 4 + [True] + [False] * 8 assert out["focus"] == [True, True] - assert out["opened"] is False # 머리 누름 = 펼침 - assert out["picks"] == [] # 요약 보기 머리 누름은 이동 아님 - assert out["all"] == [False] * 13 and out["none"] == [True] * 13 - assert out["foot"] is True # 접어도 합계 줄은 보임(몸만 숨김) + assert out["closed"] == [False] * 13 # 13 묶음도 접힘 없음 + assert out["picks"] == [10] # 머리 누름 = 이동(표 줄 번호 그대로) + assert out["buttons"] == ["1", "2", "3", "4"] # 모두 펼침 · 접기 단추 없음 + assert out["foot"] is True + + +def test_고른_박스_없으면_외곽선_없음(): + out = _run( + "\nconst root = make(3);" + "\nconsole.log(JSON.stringify(root.all('.ui-group-card').map(" + "(c) => c.classList.contains('is-pick'))));" + ) + assert out == [False, False, False] def test_양식_글자_간격_숫자(): @@ -146,6 +150,8 @@ def test_양식_글자_간격_숫자(): "font-size: inherit;", ] assert "padding: 1px var(--spacing-4);" in css # 줄 간격 좁게 + assert "is-closed" not in css and "is-closed" not in (ROOT / COMMON).read_text("utf-8") + assert "border-color: var(--color-danger);" in css # 고른 박스 = B06 고름 꼴(181-2) assert "spacing-16" not in css and "card-padding" not in css # 공용 카드 16px 끼움 없음 assert "grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr));" in css assert "text-align: right;" in css diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index 98d98d0d2..cd5dd8e83 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -151,9 +151,10 @@ def test_화면_보기_바꿈_누름_이동(): "\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');" "\nbar.children[0].click();" "\nconst [bar2, view2] = renderGroupPanel(o);" - "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, redraws," + "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," " label2: bar2.children[0].textContent, tag2: view2.tagName, focused," " kept: Object.fromEntries(kept) }));", RAW, @@ -161,8 +162,9 @@ def test_화면_보기_바꿈_누름_이동(): assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드 assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동 assert out["lit"] is True # 이동해 온 줄(키 DG1|1 = 표 3번째 줄) 강조 + assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2) assert out["redraws"] == 1 - assert out["label2"] == "호표별 보기" and out["tag2"] == "TABLE" and out["focused"] == [3] + assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3] assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py new file mode 100644 index 000000000..05d14ce34 --- /dev/null +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -0,0 +1,76 @@ +"""181-2 박스보기 — 이름 · 접기 없음 · 목록표에서 고른 호표 = 박스 붉은 외곽선 · 표 보기 = 줄 구간 전체. + +- 단추 글 「박스보기」 ↔ 「표로 보기」 · 요약(접기) 보기 지움 +- 표 보기 = 고른 호표 줄 구간(키 `|` 앞머리 같은 줄) 모두 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` +- 붉은 색 = `--color-danger`(B06 횡단 카드 고름 꼴) +""" + +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 resources.tester.helper_b09_group_cards import COMMON, GROUPS, TABS, run # noqa: E402 + +ROWS = [ # 호표 A(3줄) · 호표 B(4줄) · 호표 C(1줄) + {"key": "A"}, {"key": "A|1"}, {"key": "A|합계"}, + {"key": "B"}, {"key": "B|1"}, {"key": "B|2"}, {"key": "B|총계"}, + {"key": "C"}, +] # fmt: skip + + +def test_구간_첫_끝_줄(): + out = run( + [COMMON, TABS, GROUPS], + f"\nconst rows = {json.dumps(ROWS)};" + "\nconst show = (focus) => { const r = pickRange(rows, focus);" + " return [r, rows.map((_, i) => pickClass(r, i))]; };" + "\nconsole.log(JSON.stringify({ a: show('A|1'), b: show('B'), c: show('C')," + " none: show(undefined), unknown: show('Z|1') }));", + ) + assert out["a"][0] == [0, 2] + assert out["a"][1] == [ + "is-pick is-pick-top", "is-pick", "is-pick is-pick-end", None, None, None, None, None, + ] # fmt: skip + assert out["b"][0] == [3, 6] + assert out["b"][1][3:7] == [ + "is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end", + ] # fmt: skip + assert out["b"][1][:3] == [None] * 3 # 다른 호표 없음 + assert out["c"][0] == [7, 7] and out["c"][1][7] == "is-pick is-pick-top is-pick-end" + assert out["none"] == [None, [None] * 8] and out["unknown"] == [None, [None] * 8] + + +def test_표_보기_줄_class_넘김(): + out = run( + [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: () => {}," + " makeTable: (e) => { extra = e; return { root: document.createElement('table')," + " focusRow: () => {} }; } };" + "\nchosen.set('dg', 'table');" + "\nrenderGroupPanel(o);" + "\nconsole.log(JSON.stringify(spec.rows.map((_, i) => extra(i))));", + ) + assert out == ( + [None] * 3 + ["is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end"] + [None] + ) + + +def test_글_접기_없음_CSS_잇기(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert '"표로 보기" : "박스보기"' in groups and "호표별 보기" not in groups + common = (ROOT / COMMON).read_text("utf-8") + for gone in ("isSummary", "GROUP_SUMMARY_OVER", "is-closed", "모두 펼침", "모두 접기"): + assert gone not in common + assert 'box?.classList.add("is-pick")' in common + page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + for sel in ("tr.is-pick td", "tr.is-pick.is-pick-top td", "tr.is-pick.is-pick-end td"): + assert sel in page + assert page.count("var(--color-danger)") == 4 # 위 · 아래 · 왼 · 오른 2px + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "extra?: (index: number) => string | null" in shell diff --git a/ui_template/ui_template_group_cards.css b/ui_template/ui_template_group_cards.css index a2e6f033f..3fa11762a 100644 --- a/ui_template/ui_template_group_cards.css +++ b/ui_template/ui_template_group_cards.css @@ -47,6 +47,12 @@ border-radius: var(--radius-cards); } +/* 목록표에서 고른 호표 — B06 횡단 카드 고름과 같은 붉은 외곽선(181-2) */ +.ui-group-card.is-pick { + border-color: var(--color-danger); + box-shadow: 0 0 0 2px var(--color-danger); +} + .ui-group-card__head { display: flex; flex-wrap: wrap; @@ -76,14 +82,6 @@ overflow-x: auto; } -.ui-group-card.is-closed .ui-group-card__body { - display: none; -} - -.ui-group-card.is-closed .ui-group-card__head { - cursor: pointer; -} - .ui-group-card__lines { width: 100%; border-collapse: collapse; diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 086ce3552..65923f767 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -5,8 +5,9 @@ * 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) / * 합계 줄 = 노무 · 재료 · 경비 · 계 · 줄 금액 = 합계만(비목 셋 = 풍선) * 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽) - * 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) - * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 펼침 + 스크롤 + 강조 + * 접기 없음 — 묶음 모두 펼친 박스 + * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선 + * (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`) * ========================================================================== */ import "./ui_template_group_cards.css"; @@ -68,7 +69,6 @@ export interface GroupCardsOptions { actions?: HTMLElement[]; } -export const GROUP_SUMMARY_OVER = 12; export const GROUP_COLUMNS = [1, 2, 3, 4] as const; export const PART_LABELS = ["노무", "재료", "경비"] as const; @@ -76,9 +76,6 @@ export const PART_LABELS = ["노무", "재료", "경비"] as const; export const clampColumns = (value: unknown): number => GROUP_COLUMNS.includes(value as 1) ? (value as number) : 2; -/** 요약 보기 — 묶음 12 넘음 */ -export const isSummary = (count: number): boolean => count > GROUP_SUMMARY_OVER; - const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 }); const plain = (value: number | null | undefined): string => value === null || value === undefined || !Number.isFinite(value) ? "" : won.format(value); @@ -173,7 +170,7 @@ function card(g: GroupCard, o: Required return box; } -/** 묶음 카드 판 — 머리 띠(가로 열 수 · 요약 단추 · 부르는 쪽 단추) + 카드 격자 */ +/** 묶음 카드 판 — 머리 띠(가로 열 수 · 부르는 쪽 단추) + 카드 격자 */ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { const fmt = { money: opts.money ?? plain, qty: opts.qty ?? plain }; const root = document.createElement("div"); @@ -184,9 +181,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { grid.style.setProperty("--group-cols", String(columns)); const cards = opts.groups.map((g) => card(g, fmt)); grid.append(...cards); - const summary = isSummary(opts.groups.length); - const setOpen = (box: Element, open: boolean) => box.classList.toggle("is-closed", !open); - if (summary) cards.forEach((c) => setOpen(c, false)); const bar = document.createElement("div"); bar.className = "ui-group-cards__bar"; @@ -215,14 +209,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { }); pick.append(...colButtons); bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`)); - if (summary) { - const all = (open: boolean) => () => cards.forEach((c) => setOpen(c, open)); - bar.append( - span("ui-group-cards__count", "요약 보기 — 머리를 누르면 펼침"), - createButton({ label: "모두 펼침", variant: "ghost", size: "sm", onClick: all(true) }), - createButton({ label: "모두 접기", variant: "ghost", size: "sm", onClick: all(false) }), - ); - } const tail = document.createElement("div"); tail.className = "ui-group-cards__actions"; tail.append(...(opts.actions ?? [])); @@ -232,18 +218,15 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { grid.addEventListener("click", (event) => { const el = (event.target as Element).closest("[data-at]"); if (!el || !grid.contains(el)) return; - const box = el.closest(".ui-group-card"); - if (summary && box && el.classList.contains("ui-group-card__head")) - return setOpen(box, box.classList.contains("is-closed")); opts.onPick?.(Number(el.dataset.at)); }); if (opts.focus !== undefined && opts.focus >= 0) { const el = grid.querySelector(`[data-at="${opts.focus}"]`); const box = el?.closest(".ui-group-card"); - if (box) setOpen(box, true); + box?.classList.add("is-pick"); el?.classList.add("is-focus"); - requestAnimationFrame(() => el?.scrollIntoView({ block: "center" })); + requestAnimationFrame(() => (box ?? el)?.scrollIntoView({ block: "center" })); } return root; } From 1b92e711c522074aa71f5c2cda7cf749096eb736 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:51:50 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat(b07):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=9E=A5=20=ED=91=9C=20=C2=B7=20=ED=91=9C=20=EC=9C=84=20?= =?UTF-8?q?=EA=B8=80=20=EB=B0=B0=EC=9C=A8=20=EC=B9=B8(180-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cross_layout.json table_scale(모든 장 한 값 · 0.5~2.0 · 기본 1) · 저장 검사 · 「모든 장」 저장이 배율 안 지움 - 표 폭 · 행 높이 · 글 · 글 줄 높이에 배율 · 174 쌓기 · 넘침 판정이 같은 값으로 다시 셈 - 확정 장 그대로 · 다른 배율로 확정된 장 = stale_scale 안내 - 배치 칸 줄에 「표 배율 [1] [배율 적용]」 - 새 시험 test_180_2_b07_cross_scale · test_140 기본값 · test_153_1 배율 칸 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_Api_Fetch.ts | 5 +- .../B07_DesignDetail_Engine_Cad.py | 25 ++- .../B07_DesignDetail_Engine_Cad_Sheet.py | 11 +- .../B07_DesignDetail_Engine_Cad_Table.py | 31 ++-- B07_DesignDetail/B07_DesignDetail_Router.py | 3 +- .../B07_DesignDetail_Router_Support.py | 21 ++- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 13 +- .../B07_DesignDetail_UI_Panels.ts | 37 +++- .../tester/helper_153_b07_cross_layout.cjs | 26 +++ resources/tester/test_140_b07_cross_grid.py | 6 +- .../tester/test_153_1_b07_cross_layout.py | 9 + .../tester/test_180_2_b07_cross_scale.py | 168 ++++++++++++++++++ 12 files changed, 323 insertions(+), 32 deletions(-) create mode 100644 resources/tester/test_180_2_b07_cross_scale.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 4cec19747..696c3f773 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -39,6 +39,8 @@ export interface CadDrawing { }[]; /** 횡단 장 — 옛 배치(칸 통일)로 확정된 저장본. [수정]을 누르면 새 배치로 다시 만든다. */ stale_layout?: boolean; + /** 횡단 장 — 지금과 다른 표 · 글 배율로 확정된 저장본(180-2). */ + stale_scale?: boolean; layers: { id: string; name: string; @@ -178,10 +180,11 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P ); } -/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3). */ +/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2). */ export interface CrossLayout { default: [number, number]; sheets: Record; + table_scale?: number; } export function fetchCrossLayout(projectId: string): Promise { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 41a46d64b..2fd6e76a3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -498,6 +498,7 @@ def build_cross_drawing( overlay: tuple[float, float, float, float] | None = None, border: bool = True, table_bottom: float | None = None, + table_scale: float = 1.0, ) -> dict[str, Any]: """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. @@ -507,6 +508,7 @@ def build_cross_drawing( 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. table_bottom(장 칸 · 174-1)을 주면 표 아래선을 그 y 에 두고 글 · 그림을 위로 쌓는다 — 표 · 글 가운데 x = 도로 중심(origin x) · origin y 는 쌓은 결과로 정함. + table_scale = 표 · 표 위 글 배율(180-2) — 8 mm 간격은 그대로. """ ox, oy = origin raw_ground = points_from_samples(source.get("samples", []), "offset_m") @@ -546,7 +548,11 @@ def build_cross_drawing( # 아래에서 위로 — 표 → 글(150-3) → 간격 8 → 그림(174-1) stack = 0.0 if quantity_table is not None: - stack = cross_table_height() + cross_note_height(cross_note_lines(design)) + 8.0 + stack = ( + cross_table_height(table_scale) + + cross_note_height(cross_note_lines(design), table_scale) + + 8.0 + ) oy = table_bottom + stack + own_half_height * CROSS_MM def paper(point: tuple[float, float]) -> tuple[float, float]: @@ -576,18 +582,23 @@ def build_cross_drawing( table_top = oy - half_height - 8.0 # 표 위 글(150-3) — 줄 수만큼 표를 내림 note_lines = cross_note_lines(design) - entities.extend(_cross_note_entities(drawing_id, note_lines, table_top, center_x)) - table_top -= cross_note_height(note_lines) entities.extend( - _cross_table_entities(drawing_id, quantity_table, table_top, title_label, center_x) + _cross_note_entities(drawing_id, note_lines, table_top, center_x, table_scale) ) - table_bottom = table_top - cross_table_height() + table_top -= cross_note_height(note_lines, table_scale) + entities.extend( + _cross_table_entities( + drawing_id, quantity_table, table_top, title_label, center_x, table_scale + ) + ) + table_bottom = table_top - cross_table_height(table_scale) # 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다. pad = CROSS_BORDER_PAD_MM # 그림 범위와 표 중 바깥 — 장 칸에서는 표가 도로 중심이라 좌우가 다를 수 있음(174-1) - frame_left = min(ox + x0 * CROSS_MM, center_x - cross_table_width() / 2.0) - pad - frame_right = max(ox + x1 * CROSS_MM, center_x + cross_table_width() / 2.0) + pad + table_half = cross_table_width(table_scale) / 2.0 + frame_left = min(ox + x0 * CROSS_MM, center_x - table_half) - pad + frame_right = max(ox + x1 * CROSS_MM, center_x + table_half) + pad frame_top = oy + half_height + pad frame_bottom = table_bottom - pad corners = [ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index f9e8297bd..b5bcda6e2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -13,6 +13,7 @@ from uuid import uuid5 from B05_Profile.B05_Profile_Engine_Sections import cross_key from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + _ENTITY_NS, CROSS_BORDER_PAD_MM, CROSS_MM, CROSS_TABLE_LAYER_ID, @@ -23,7 +24,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( ROCK_LAYER_ID, STRUCTURE_LAYER_ID, TABLE_LINE_COLOR, - _ENTITY_NS, _clip_polyline, _layer, _section_window, @@ -72,6 +72,7 @@ def section_block_size( with_table: bool = True, design: dict[str, Any] | None = None, overlay: tuple[float, float, float, float] | None = None, + table_scale: float = 1.0, ) -> tuple[float, float]: """단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다. @@ -93,13 +94,14 @@ def section_block_size( width = 2.0 * max( (x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM, - cross_table_width() / 2.0 + _BORDER_PAD_MM, + cross_table_width(table_scale) / 2.0 + _BORDER_PAD_MM, ) # 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유 top = half_height + _BORDER_PAD_MM below = half_height + _BORDER_PAD_MM if with_table: - below += 8.0 + cross_note_height(cross_note_lines(design_spec)) + cross_table_height() + note_h = cross_note_height(cross_note_lines(design_spec), table_scale) + below += 8.0 + note_h + cross_table_height(table_scale) return (width, top + below) @@ -146,6 +148,8 @@ def plan_cross_sheets( "chainages": group, "grid": [cols, rows], "slots": grid_slots(len(group), cols, rows), + # 표 · 글 배율(180-2) — 모든 장 한 값 + "table_scale": float((layout or {}).get("table_scale") or 1.0), } ) start += len(group) @@ -207,6 +211,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> overlay=section.get("overlay"), border=False, table_bottom=cell[1] + CELL_TABLE_PAD_MM, + table_scale=float(sheet.get("table_scale") or 1.0), ) # 칸을 넘는 단면 — 축척 그대로 두고 그 칸을 화면 가이드로 짚음(169-2) frames = [p.get("frame") for p in placed.get("cross_placements") or []] diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index b92b8f5c3..8c5af69fb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -128,16 +128,17 @@ def _cross_table_entities( table_top: float, title_label: str, center_x: float = 0.0, + scale: float = 1.0, ) -> list[dict[str, Any]]: - """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다. + """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다 — scale = 표 · 글 배율(180-2). 머리행은 폭을 8등분하고 본문은 11열 가중치를 쓴다 — 격자가 서로 다르므로 두 경계를 합친 열로 표를 만들고 병합으로 원래 칸을 되살린다. 병합 자리에서 선을 끊는 일은 표가 스스로 하므로, 예전처럼 가로선을 손으로 끊지 않는다. """ - width = _CROSS_TABLE_WIDTH - row_h = _CROSS_TABLE_ROW_HEIGHT - font = _CROSS_TABLE_FONT + width = _CROSS_TABLE_WIDTH * scale + row_h = _CROSS_TABLE_ROW_HEIGHT * scale + font = _CROSS_TABLE_FONT * scale left = center_x - width / 2.0 right = center_x + width / 2.0 body_edges = _cross_column_edges(width, center_x) @@ -331,24 +332,24 @@ def cross_note_lines(design: dict[str, Any] | None) -> list[str]: return [line.rstrip() for line in note.strip().splitlines()] -def cross_note_height(lines: list[str]) -> float: - """표 위 글 높이(mm) — 줄 수 × 줄 높이.""" - return _CROSS_NOTE_LINE_HEIGHT * len(lines) +def cross_note_height(lines: list[str], scale: float = 1.0) -> float: + """표 위 글 높이(mm) — 줄 수 × 줄 높이 × 배율.""" + return _CROSS_NOTE_LINE_HEIGHT * scale * len(lines) def _cross_note_entities( - drawing_id: str, lines: list[str], top: float, center_x: float = 0.0 + drawing_id: str, lines: list[str], top: float, center_x: float = 0.0, scale: float = 1.0 ) -> list[dict[str, Any]]: """표 위 글 줄마다 Text — 표 왼쪽 끝 맞춤 · 위에서 아래로(id `{도면}:note:{n}`).""" - left = center_x - _CROSS_TABLE_WIDTH / 2.0 + left = center_x - _CROSS_TABLE_WIDTH * scale / 2.0 return [ _text_entity( f"{drawing_id}:note:{index}", line, left, - top - _CROSS_NOTE_LINE_HEIGHT * (index + 0.5), + top - _CROSS_NOTE_LINE_HEIGHT * scale * (index + 0.5), CROSS_TABLE_LAYER_ID, - _CROSS_TABLE_FONT, + _CROSS_TABLE_FONT * scale, TABLE_LABEL_COLOR, align="left", ) @@ -357,11 +358,11 @@ def _cross_note_entities( ] -def cross_table_width() -> float: +def cross_table_width(scale: float = 1.0) -> float: """수량 산출표 가로 폭(mm).""" - return _CROSS_TABLE_WIDTH + return _CROSS_TABLE_WIDTH * scale -def cross_table_height() -> float: +def cross_table_height(scale: float = 1.0) -> float: """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행.""" - return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2) + return _CROSS_TABLE_ROW_HEIGHT * scale * (len(_CROSS_LEFT_ROWS) + 2) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 36bc1b6f5..7ffc5ceb4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -551,10 +551,11 @@ async def invalidate_design_drawing( class CrossLayoutRequest(BaseModel): - """횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3).""" + """횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2).""" default: list[int] = Field(default_factory=lambda: [4, 4]) sheets: dict[str, list[int]] = Field(default_factory=dict) + table_scale: float = 1.0 @router.get("/{project_id}/cross-layout") diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b42a95f53..ac63b7fac 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -104,6 +104,8 @@ CROSS_SHEET_LAYOUT = 5 CROSS_LAYOUT_FILE = "cross_layout.json" CROSS_GRID_DEFAULT = [4, 4] _CROSS_GRID_MAX = 10 +# 표 · 표 위 글 배율(180-2) — 모든 장 한 값 · 기본 1 +CROSS_SCALE_RANGE = (0.5, 2.0) def _cross_grid(value: Any) -> list[int]: @@ -116,13 +118,22 @@ def _cross_grid(value: Any) -> list[int]: return [int(value[0]), int(value[1])] +def _cross_scale(value: Any) -> float: + low, high = CROSS_SCALE_RANGE + if value is None: + return 1.0 + if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high: + raise ValueError(f"표 배율은 {low}~{high} 사이로 적어 주세요.") + return round(float(value), 2) + + def read_cross_layout(project_root: Path) -> dict[str, Any]: - """횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 만.""" + """횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 · 배율 1 만.""" path = _design_root(project_root) / CROSS_LAYOUT_FILE try: return checked_cross_layout(_read_json(path)) except (OSError, ValueError, AttributeError): - return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}} + return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}, "table_scale": 1.0} def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]: @@ -132,6 +143,7 @@ def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]: return { "default": _cross_grid(layout.get("default") or CROSS_GRID_DEFAULT), "sheets": {str(int(key)): _cross_grid(grid) for key, grid in sheets.items()}, + "table_scale": _cross_scale(layout.get("table_scale")), } @@ -449,6 +461,10 @@ def _read_drawing( label = str(manifest_entry.get("label") or drawing_id) if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT: saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내 + elif kind == "cross" and drawing_id.startswith("cross_s"): + scale = read_cross_layout(project_root)["table_scale"] + if manifest_entry.get("table_scale", 1.0) != scale: + saved["stale_scale"] = True # 옛 배율로 확정한 장 — 그대로 두고 안내(180-2) stored_table = manifest_entry.get("quantity_table") table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None return kind, label, saved, True, table @@ -594,6 +610,7 @@ def _store_confirmed_drawing( } if item.kind == "cross": entry["layout"] = CROSS_SHEET_LAYOUT + entry["table_scale"] = read_cross_layout(project_root)["table_scale"] if item.kind == "cross" and isinstance(quantity_table, dict): entry["quantity_table"] = quantity_table if isinstance(quantity_tables, dict) and quantity_tables: diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index c5dd63a11..df2c278d7 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -191,7 +191,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { number ? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }) : null, - (grid) => void save({ default: grid, sheets: {} }), + (grid) => void save({ ...layout, default: grid, sheets: {} }), + { + scale: { + value: layout.table_scale ?? 1, + onApply: (scale) => void save({ ...layout, table_scale: scale }), + }, + }, ), ); }; @@ -343,6 +349,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { "옛 배치로 확정된 장입니다. [수정]을 누르면 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.", "info", ); + } else if (response.drawing.stale_scale) { + showToast( + "옛 표 배율로 확정된 장입니다. [수정]을 누르면 지금 배율로 다시 만듭니다.", + "info", + ); } } catch (error) { cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 83bfd7725..0f214f7c5 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -175,14 +175,24 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); } +/** 표 · 글 배율 범위(180-2) — 서버 `CROSS_SCALE_RANGE` 와 같음. */ +const CROSS_SCALE_MIN = 0.5; +const CROSS_SCALE_MAX = 2; + +/** 배치 칸 줄 뒤 덧칸 — 표 배율(180-2). */ +export interface CrossLayoutExtras { + scale?: { value: number; onApply: (scale: number) => void }; +} + /** - * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」. + * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」 + 「표 배율 [1] [적용]」. * `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. */ export function buildCrossLayoutControls( grid: [number, number], onApply: ((grid: [number, number]) => void) | null, onAllDefault: (grid: [number, number]) => void, + extras: CrossLayoutExtras = {}, ): HTMLElement { const box = document.createElement("div"); box.className = "b07-cross-layout"; @@ -222,6 +232,31 @@ export function buildCrossLayoutControls( action("이 장", "이 장만 이 배치로", onApply), action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), ); + if (extras.scale) { + const { onApply: applyScale } = extras.scale; + const scale = createInputField({ + label: "표 배율", + type: "number", + value: String(extras.scale.value), + min: CROSS_SCALE_MIN, + max: CROSS_SCALE_MAX, + compact: true, + }); + scale.input.setAttribute("step", "0.1"); + const button = createButton({ + label: "배율 적용", + variant: "ghost", + size: "sm", + onClick: () => { + const value = Number(scale.input.value); + if (value >= CROSS_SCALE_MIN && value <= CROSS_SCALE_MAX) applyScale(value); + else + showToast(`표 배율은 ${CROSS_SCALE_MIN}~${CROSS_SCALE_MAX} 사이로 적어 주세요.`, "error"); + }, + }); + button.title = "모든 장의 표 · 표 위 글 크기(기본 1)"; + box.append(scale.root, button); + } return box; } diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index dc221cc88..c80f3c94c 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -162,4 +162,30 @@ out.notSheet = { values: inputs().map((n) => n.value), }; +// 180-2 표 배율 칸 — 배치 칸 줄 뒤 · 0.5~2.0 · step 0.1 · [배율 적용] +const scales = []; +const toastStart = toasts.length; +slot.replaceChildren( + buildCrossLayoutControls([4, 4], null, () => {}, { + scale: { value: 1, onApply: (value) => scales.push(value) }, + }), +); +const scaleInput = inputs()[2]; +out.scale = { + labels: byClass(slot, "ui-field__label").map((n) => n.textContent), + input: { + value: scaleInput.value, + min: scaleInput.attrs.min, + max: scaleInput.attrs.max, + step: scaleInput.attrs.step, + }, + buttons: buttons().map((n) => n.textContent), +}; +scaleInput.value = "1.5"; +buttons()[2].click(); +scaleInput.value = "2.5"; +buttons()[2].click(); +out.scale.applied = scales; +out.scale.toasts = toasts.slice(toastStart).map((t) => t[0]); + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_140_b07_cross_grid.py b/resources/tester/test_140_b07_cross_grid.py index f8fb20882..c2905d7e8 100644 --- a/resources/tester/test_140_b07_cross_grid.py +++ b/resources/tester/test_140_b07_cross_grid.py @@ -119,7 +119,11 @@ def _project(tmp_path: Path, count: int) -> tuple[Path, Path]: def test_layout_store_and_release_changed_confirmed(tmp_path: Path) -> None: root, longitudinal_path = _project(tmp_path, 20) - assert read_cross_layout(root) == {"default": [4, 4], "sheets": {}} # 파일 없으면 기본 + assert read_cross_layout(root) == { + "default": [4, 4], + "sheets": {}, + "table_scale": 1.0, + } # 파일 없으면 기본 ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] assert ids == ["cross_s00000m", "cross_s00320m"] # 16 + 4 manifest = _read_manifest(root) diff --git a/resources/tester/test_153_1_b07_cross_layout.py b/resources/tester/test_153_1_b07_cross_layout.py index 58275b487..edc706067 100644 --- a/resources/tester/test_153_1_b07_cross_layout.py +++ b/resources/tester/test_153_1_b07_cross_layout.py @@ -61,3 +61,12 @@ def test_장_아니면_이_장_꺼짐(out): assert not_sheet["disabled"] == [True, False] assert not_sheet["defaults"] == [[4, 4]] assert not_sheet["values"] == ["4", "4"] + + +def test_표_배율_칸(out): + scale = out["scale"] + assert scale["labels"] == ["배치 열", "행", "표 배율"] + assert scale["input"] == {"value": "1", "min": "0.5", "max": "2", "step": "0.1"} + assert scale["buttons"] == ["이 장", "모든 장", "배율 적용"] + assert scale["applied"] == [1.5] + assert scale["toasts"] == ["error"] # 2.5 = 범위 밖 diff --git a/resources/tester/test_180_2_b07_cross_scale.py b/resources/tester/test_180_2_b07_cross_scale.py new file mode 100644 index 000000000..3fc8dec6e --- /dev/null +++ b/resources/tester/test_180_2_b07_cross_scale.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +"""180-2 B07 횡단 장 — 표 · 표 위 글 배율(모든 장 한 값 · 0.5~2.0 · 기본 1). + +못박는 것 + - 배율 1.5 = 표 폭 162 · 높이 60 · 글 줄 높이 × 1.5 · 표 아래선은 칸 아래 + 여백 그대로. + - 그림 아래 끝 = 글(없으면 표) 위 8 mm(간격은 배율 안 탐) · 넓은 표가 칸을 넘으면 overflow_cells. + - 배율 1 = 배율 칸 없는 배치와 같은 엔티티. + - cross_layout.json 이 배율을 지킴 · 범위 밖 = 거절 · 배율만 바꾸면 확정 장 안 풂. + - 지금과 다른 배율로 확정된 장 = 그대로 + stale_scale 안내. +""" + +import json +from pathlib import Path + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, GROUND_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + CELL_TABLE_PAD_MM, + build_cross_sheet, + plan_cross_sheets, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + entities_bbox, + usable_area, + use_project_templates, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + CROSS_SHEET_LAYOUT, + _cross_sheet_plan, + _read_drawing, + _read_manifest, + _write_manifest, + read_cross_layout, + write_cross_layout, +) + +NOTE = {"structure_note": "L형 측구\n집수정 보강"} + + +def _source(chainage: float, lo: int = -5, hi: int = 5) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.05 * x} + for x in range(lo, hi + 1) + ], + } + + +def _drawing(layout: dict, designs: list[dict | None]) -> tuple[dict, dict]: + use_project_templates(None) + sources = [_source(i * 20.0) for i in range(len(designs))] + sheet = plan_cross_sheets([s["chainage_m"] for s in sources], layout)[0] + sections = [ + { + "chainage": s["chainage_m"], + "source": s, + "design": design, + "quantity_table": {"ground": 1.0, "planned": 0.5}, + "title": "No.0", + } + for s, design in zip(sources, designs, strict=True) + ] + return sheet, build_cross_sheet(sheet, sections) + + +def _tables(drawing: dict) -> list[dict]: + return [e for e in drawing["entities"] if e["type"] == "Table"] + + +def test_배율_15_표_크기와_쌓기() -> None: + sheet, drawing = _drawing({"default": [2, 2], "table_scale": 1.5}, [NOTE, None]) + assert sheet["table_scale"] == 1.5 + _w, h = usable_area() + tables = _tables(drawing) + for table, slot in zip(tables, sheet["slots"], strict=True): + shape = table["shapeData"] + assert sum(shape["columnWidths"]) == pytest.approx(162.0) + assert sum(shape["rowHeights"]) == pytest.approx(60.0) + bottom = shape["origin"]["y"] - sum(shape["rowHeights"]) + assert bottom == pytest.approx(slot[1] - h / 4 + CELL_TABLE_PAD_MM) # 표 아래선 그대로 + grounds = [ + entities_bbox([e]) for e in drawing["entities"] if e.get("layerId") == GROUND_LAYER_ID + ] + # 글 2 줄 × 3.0 × 1.5 = 9 mm · 간격 8 mm 는 배율 안 탐 + tops = [t["shapeData"]["origin"]["y"] for t in tables] + assert grounds[0][1] == pytest.approx(tops[0] + 9.0 + 8.0) + assert grounds[1][1] == pytest.approx(tops[1] + 8.0) + + +def test_배율_1_은_지금과_같음() -> None: + _s, plain = _drawing({"default": [2, 2]}, [NOTE, None]) + _s, one = _drawing({"default": [2, 2], "table_scale": 1.0}, [NOTE, None]) + assert one["entities"] == plain["entities"] + assert sum(_tables(one)[0]["shapeData"]["columnWidths"]) == pytest.approx(108.0) + + +def test_넓은_표_넘침() -> None: + w, _h = usable_area() + # 4 × 4 칸 폭 192.5 — 표 108 × 2.0 = 216 이 칸을 넘음 + _s, drawing = _drawing({"default": [4, 4], "table_scale": 2.0}, [None]) + assert w / 4 < 216.0 + assert len(drawing["overflow_cells"]) == 1 + _s, drawing = _drawing({"default": [4, 4], "table_scale": 1.0}, [None]) + assert drawing["overflow_cells"] == [] + + +def _project(tmp_path: Path, count: int) -> tuple[Path, Path]: + root = tmp_path / "project" + cross_dir = root / "B06_Section" / "cross_sections" + cross_dir.mkdir(parents=True) + longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json" + longitudinal_path.parent.mkdir(parents=True) + stations = [] + for i in range(count): + chainage = i * 20 + stations.append({"chainage_m": float(chainage), "center_x": 0.0, "center_y": 0.0}) + (cross_dir / f"cross_{chainage:05d}m.json").write_text( + json.dumps(_source(float(chainage))), "utf-8" + ) + longitudinal_path.write_text(json.dumps({"stations": stations}), "utf-8") + return root, longitudinal_path + + +def test_배치_파일_배율_저장_범위(tmp_path: Path) -> None: + root, longitudinal_path = _project(tmp_path, 5) + ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] + manifest = _read_manifest(root) + manifest["drawings"] = {i: {"confirmed": True} for i in ids} + _write_manifest(root, manifest) + # 배율만 바꿈 — 담긴 측점 그대로라 확정 장 안 풂 + assert ( + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "table_scale": 1.3}) == [] + ) + assert read_cross_layout(root)["table_scale"] == 1.3 + assert _read_manifest(root)["drawings"][ids[0]]["confirmed"] is True + assert _cross_sheet_plan(root, longitudinal_path)[0]["table_scale"] == 1.3 + for bad in (0.4, 2.1, "1.2", True): + with pytest.raises(ValueError): + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "table_scale": bad}) + assert read_cross_layout(root)["table_scale"] == 1.3 + + +def test_옛_배율_확정_장_안내(tmp_path: Path) -> None: + stage = tmp_path / "B07_DesignDetail" + (stage / "drawings").mkdir(parents=True) + saved = {"format": DRAWING_FORMAT, "entities": [], "layers": []} + (stage / "drawings" / "cross_s00000m.json").write_text(json.dumps(saved), encoding="utf-8") + entry = {"kind": "cross", "label": "1장", "confirmed": True, "layout": CROSS_SHEET_LAYOUT} + manifest = {"drawings": {"cross_s00000m": entry}} + (stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + def read() -> dict: + _k, _l, drawing, confirmed, _t = _read_drawing( + tmp_path, tmp_path / "x.json", "cross_s00000m" + ) + assert confirmed + return drawing + + assert "stale_scale" not in read() # 배율 적힘 없음 = 1 · 지금도 1 + (stage / "cross_layout.json").write_text( + json.dumps({"default": [4, 4], "sheets": {}, "table_scale": 1.5}), encoding="utf-8" + ) + assert read().get("stale_scale") is True + entry["table_scale"] = 1.5 + (stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + assert "stale_scale" not in read() From f60657c8b9b6ea5a2401cee0ed5649b292060f60 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:52:28 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat(b05):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=ED=8F=BC=20=EC=B8=A1=EC=A0=90=20=EB=B2=94=EC=9C=84=20=EC=A4=84?= =?UTF-8?q?=20=EC=82=AC=EB=A9=B4=EC=95=88=EC=A0=95=EA=B5=B0(C=EA=B5=B0)?= =?UTF-8?q?=EB=A7=8C=20=EC=88=A8=EA=B9=80=20(177-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncRangeDisplay C군 거름 · hasComputedRange 그대로 · 699줄 그대로 - 새 시험 test_177_5_slope_range_hidden Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- .../B05_Profile_UI_Structures_Panel.ts | 4 +- .../tester/test_177_5_slope_range_hidden.py | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_177_5_slope_range_hidden.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index bd0f0800f..6ea28ae39 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -154,9 +154,9 @@ export function createStructuresSection( } /** 측점 범위 표시 갱신 — 시작 = 기준 − 전길이, 종료 = 시작 + 길이(2026-08-19 - * 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—". */ + * 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—" · 사면안정군(C)은 줄 숨김(177-5). */ function syncRangeDisplay(): void { - const computed = hasComputedRange(currentType()); + const computed = hasComputedRange(currentType()) && currentType()?.group !== "C"; rangeWrap.hidden = !computed; if (!computed) return; const step = interval(); diff --git a/resources/tester/test_177_5_slope_range_hidden.py b/resources/tester/test_177_5_slope_range_hidden.py new file mode 100644 index 000000000..f0ead6c2a --- /dev/null +++ b/resources/tester/test_177_5_slope_range_hidden.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""177-5 좌측 폼 「측점 범위」 줄 — 사면안정군(C군)만 숨김. + +못박는 것 + - PNL5 `syncRangeDisplay` 가 C군이면 `rangeWrap.hidden` · 다른 구간형(B 소단배수로 · E 대피소 등)은 그대로. + - `hasComputedRange` 자체는 그대로(시작 · 종료 칸 숨김에도 씀). + - C군 = 사면안정 6종(정의 기준) · 그 밖 범위 종류는 C 가 아님. + +화면은 사용자 확인. +""" + +import os + +from B05_Profile.B05_Profile_Structures_Schema import load_structure_types + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +def test_범위_줄은_C군만_숨김(): + panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") + at = panel.index("function syncRangeDisplay(): void {") + body = panel[at : panel.index("\n }", at)] + assert ( + 'const computed = hasComputedRange(currentType()) && currentType()?.group !== "C";' in body + ) + assert "rangeWrap.hidden = !computed;" in body + + +def test_hasComputedRange_는_그대로(): + types = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Types.ts") + assert "export function hasComputedRange(" in types + assert '"C"' not in types[types.index("export function hasComputedRange(") :][:600] + + +def test_C군은_사면안정_6종_다른_범위_종류는_C_아님(): + groups = {t.type_id: t.group for t in load_structure_types()} + slope = { + "retaining_wall", + "masonry_wet", + "masonry_dry", + "soil_guard", + "boulder_masonry", + "berm", + } + assert {k for k, g in groups.items() if g == "C"} == slope + for kept in ("ditch_berm", "refuge", "work_yard", "guardrail"): + assert groups.get(kept) not in (None, "C"), kept From 81345a14383fe001bb5ad841066eda40a382e65d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:52:56 +0900 Subject: [PATCH 14/14] =?UTF-8?q?feat(b06):=20=EC=B8=A1=EA=B5=AC=20?= =?UTF-8?q?=EC=A4=84=20=EC=BB=B4=ED=8C=A9=ED=8A=B8=20=E2=80=94=20=EB=A7=89?= =?UTF-8?q?=ED=9E=8C=20=EB=8B=A8=EC=B6=94=20=EC=88=A8=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EC=B8=A1=EA=B5=AC=ED=98=95=EC=8B=9D=20=EA=B8=80=20=E2=86=92=20?= =?UTF-8?q?=ED=88=B4=ED=8C=81=20=C2=B7=20=EB=8B=A8=EB=A9=B4=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=EC=95=8C=EC=95=BD=EC=97=90=20=ED=8E=B8=EC=B8=A1=20?= =?UTF-8?q?=EC=AA=BD=20(179-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_018jonfdxn1Xit9bdkNJpE4G --- B06_Section/B06_Section_UI_Cross_Design.ts | 10 +++--- B06_Section/B06_Section_UI_Style_Cross.css | 8 ++--- .../tester/test_179_2_ditch_row_compact.py | 36 +++++++++++++++++++ ui_template/ui_template_locale_b2.ts | 4 +-- 4 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_179_2_ditch_row_compact.py diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 723b97f95..5020be6bb 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -385,7 +385,7 @@ export function buildDesignControls( const groundSegment = groundToggle.wrap; groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). - // 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6). + // 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에. const rockCut = isRock(state.ground) && state.mode !== "both_fill"; const hasDitch = state.mode !== "both_fill"; // 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다. @@ -417,9 +417,9 @@ export function buildDesignControls( !hasDitch, L("B06_Design_Disabled_NoDitch"), ); - // 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성. + // 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성 · 이름 글 없이 툴팁(179-2). const ditchTypeSeg = segment( - L("B06_Design_DitchType_Legend"), + "", DITCH_TYPE_OPTIONS, state.ditchType, (value) => { @@ -429,6 +429,7 @@ export function buildDesignControls( !rockCut || !ditchOn, !ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"), ); + ditchTypeSeg.title = L("B06_Design_DitchType_Legend"); // 2단계 경사 토글: 암 지반 + 절토 단면에서만 활성. 활성="복합경사"/비활성="단경사"(E-7). const twoStage = toggle( "", @@ -486,7 +487,8 @@ export function buildDesignControls( let overflow = bar.scrollWidth - clientWidth; let moveCount = 0; while (overflow > 1 && moveCount < moveable.length) { - overflow -= widths[moveable.length - 1 - moveCount] + barGapPx; + const width = widths[moveable.length - 1 - moveCount]; // 숨긴 칸(179-2)은 폭 · 간격 0 + overflow -= width ? width + barGapPx : 0; moveCount += 1; } return moveCount; diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 2694593da..0effa7db6 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -532,13 +532,9 @@ white-space: nowrap; } -/* 선행 조건 미충족 옵션(E-6) — 상시 노출하되 흐리게·클릭 불가. */ +/* 선행 조건 미충족 옵션 — 숨김(179-2 · 옛 E-6 「흐리게 늘 보임」 폐기 · 쪽은 단면유형 알약에). */ .b06-design__seg--disabled { - opacity: 0.4; -} - -.b06-design__seg--disabled .b06-design__btn { - cursor: not-allowed; + display: none; } .b06-design__seg-buttons { diff --git a/resources/tester/test_179_2_ditch_row_compact.py b/resources/tester/test_179_2_ditch_row_compact.py new file mode 100644 index 000000000..c1c0866ba --- /dev/null +++ b/resources/tester/test_179_2_ditch_row_compact.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""PLAN 179-2 — 측구 줄 컴팩트(W16 (가)): 막힌 단추 숨김 · 「측구형식」 글 지움(툴팁) · 알약에 편측 쪽(소스 시험).""" + +import os +import re + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _src(rel: str) -> str: + return open(os.path.join(ROOT, rel), encoding="utf-8").read() + + +def test_막힌_묶음은_숨김(): + css = _src("B06_Section/B06_Section_UI_Style_Cross.css") + body = re.search(r"\.b06-design__seg--disabled\s*\{([^}]*)\}", css).group(1) + assert "display: none;" in body + assert "opacity" not in body + + +def test_측구형식_글_없이_툴팁(): + cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts") + seg = cd.split("const ditchTypeSeg = segment(", 1)[1].split(");", 1)[0] + assert seg.lstrip().startswith('"",') + assert 'ditchTypeSeg.title = L("B06_Design_DitchType_Legend");' in cd + + +def test_넘침_셈은_숨긴_칸_폭_간격_뺌(): + cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts") + assert "overflow -= width ? width + barGapPx : 0;" in cd + + +def test_알약에_편측_쪽(): + lb = _src("ui_template/ui_template_locale_b2.ts") + assert 'B06_Design_Mode_LeftCut: ["편측 성토(좌 절토)"' in lb + assert 'B06_Design_Mode_RightCut: ["편측 성토(우 절토)"' in lb diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index e84c27cf3..a97c57165 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -420,8 +420,8 @@ export const ui_locales_b2 = { B06_Design_Ground_Ripping: ["리핑암", "Ripping rock"], B06_Design_Ground_Blasting: ["발파암", "Blasting rock"], B06_Design_Mode_Legend: ["단면유형", "Section type"], - B06_Design_Mode_LeftCut: ["편측 성토", "One-side fill"], - B06_Design_Mode_RightCut: ["편측 성토", "One-side fill"], + B06_Design_Mode_LeftCut: ["편측 성토(좌 절토)", "One-side fill (left cut)"], + B06_Design_Mode_RightCut: ["편측 성토(우 절토)", "One-side fill (right cut)"], B06_Design_Mode_BothCut: ["양측 절토", "Both cut"], B06_Design_Mode_BothFill: ["양측 성토", "Both fill"], B06_Design_Ditch_Legend: ["측구위치", "Ditch side"],