From 1114f408fdf1f79f6432e27a7e8a2ee84cbf1c8e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:40:46 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B06):=20=EC=95=8C=EB=A6=BC=20=EC=B9=A9?= =?UTF-8?q?=20=EB=91=A5=EA=B7=BC=20=EC=82=AC=EA=B0=81=20=C2=B7=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=B9=B8=20=EC=B9=B4=EB=93=9C=20=EC=9C=84=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=A8=20=C2=B7=20=EA=B3=A0=EB=A5=B4=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=91=ED=9E=98=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EA=B0=95=EC=A1=B0(207-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칩 반경 = 공용 단추 반경(--radius-buttons) · 알약 아님 - 측점 목록 칸 = 고정 줄 아래 absolute(그림자 · 배경) — 열고 닫아도 페이지 · 카드 안 밀림 - 목록 측점 누르면 그 카드로 가며 칸 접힘 - 고른 측점(selectedStationId)이 목록에 있으면 열 때 그 단추 강조(카드 고름 색) - 새 시험 test_207_1 · test_196_1 기대값 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B06_Section_UI_Cross_FillSlope_Notice.ts | 7 +- .../B06_Section_UI_Cross_Notice_Chips.ts | 14 ++- B06_Section/B06_Section_UI_Section_View.ts | 5 +- B06_Section/B06_Section_UI_Style_Cross.css | 26 +++- .../tester/test_196_1_b06_notice_chips.py | 5 +- .../tester/test_207_1_b06_notice_overlay.py | 115 ++++++++++++++++++ 6 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_207_1_b06_notice_overlay.py diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index 2ceacd67b..0ab8bcb08 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -41,10 +41,13 @@ function rows(warnings: FillSlopeWarning[], stationInterval: number): NoticeChip }); } -export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { +export function createFillSlopeNotice( + onPick: (stationId: string) => void, + selected: () => string | null, +): FillSlopeNotice { const root = document.createElement("div"); root.className = "b06-section__notice-bar"; - const chips = createNoticeChips(onPick); + const chips = createNoticeChips(onPick, selected); root.append(chips.root); return { diff --git a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts index 648236959..54760e806 100644 --- a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts +++ b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts @@ -3,6 +3,8 @@ * 횡단 알림 줄 칩 꼴(196-1) — 항목 = 짧은 이름 + 측점 수 칩 한 줄 · 긴 설명 = 공용 호버. * 줄 아래 내용 칸 하나를 같이 씀 — 누른 칩의 측점 목록만 · 같은 칩 다시 누르면 닫힘 · * 다른 칩 누르면 바뀜(한 번에 하나). 측점 단추 누름 = `onPick`(그 카드로). + * ⭐ 207-1 — 내용 칸은 줄 아래 카드 위에 겹쳐 뜸(흐름 밖 · CSS) · 측점 누르면 접힘 · + * 열 때 지금 고른 측점(`selected`) 단추 강조. * 무엇을 셀지는 부르는 쪽(`_FillSlope_Notice`) — 여기는 그리기만. * ========================================================================== */ @@ -29,7 +31,10 @@ export interface NoticeChips { update: (items: NoticeChipItem[]) => void; } -export function createNoticeChips(onPick: (stationId: string) => void): NoticeChips { +export function createNoticeChips( + onPick: (stationId: string) => void, + selected: () => string | null = () => null, +): NoticeChips { const root = document.createElement("div"); root.className = "b06-section__notice-blocks"; const chips = document.createElement("div"); @@ -57,12 +62,14 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh ); panel.hidden = !open; panel.classList.toggle("is-info", open?.tone === "info"); + const picked = open ? selected() : null; panel.replaceChildren( ...(open?.rows ?? []).map((row) => { const button = document.createElement("button"); button.type = "button"; button.textContent = row.text; button.dataset.stationId = row.stationId; + button.classList.toggle("is-selected", row.stationId === picked); return button; }), ); @@ -77,7 +84,10 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh }); panel.addEventListener("click", (event) => { const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; - if (id) onPick(id); + if (!id) return; + openKey = null; + render(); + onPick(id); }); attachHoverInfo( chips, diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index a67009b38..bc95835f8 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -124,8 +124,9 @@ export function createSectionView( } = { balloonScope: "-" }; // 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 카드 위 · 측점 누르면 그 카드로. - const fillSlopeNotice = createFillSlopeNotice((id) => - selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true), + const fillSlopeNotice = createFillSlopeNotice( + (id) => (selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true)), + () => selectedStationId, ); // 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림 const columnsField = createInputField({ diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 8a0d5fdd7..494c58df0 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -172,9 +172,10 @@ } .b06-section__notice-chip { - padding: 0 var(--spacing-8); + padding: 2px var(--spacing-8); border: 1px solid currentColor; - border-radius: var(--radius-sm); + /* 둥근 사각(207-1) — 공용 단추 반경 · 알약 아님 */ + border-radius: var(--radius-buttons); background: transparent; color: var(--color-warning); font-size: var(--text-caption); @@ -193,14 +194,24 @@ font-weight: 600; } -/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 줄 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게 */ +/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 칸 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게. + 207-1 — 줄 아래 카드 위에 겹쳐 뜸(흐름 밖) · 열고 닫아도 줄 높이 · 카드 자리 그대로 */ .b06-section__notice-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1; display: flex; flex-wrap: wrap; gap: var(--spacing-4); max-height: 30vh; overflow: auto; - padding-top: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); color: var(--color-warning); } @@ -213,6 +224,13 @@ white-space: nowrap; } +/* 지금 고른 측점(207-1) — 카드 고름 색과 같음 */ +.b06-section__notice-list > button.is-selected { + outline: 2px solid var(--color-danger); + outline-offset: -1px; + font-weight: 600; +} + .b06-cross-card { cursor: pointer; transition: diff --git a/resources/tester/test_196_1_b06_notice_chips.py b/resources/tester/test_196_1_b06_notice_chips.py index 7027d4783..82ea5dbb6 100644 --- a/resources/tester/test_196_1_b06_notice_chips.py +++ b/resources/tester/test_196_1_b06_notice_chips.py @@ -144,9 +144,10 @@ def test_측점_누르면_그_카드로_다시_그려도_연_칩_유지_0이면_ """ bar.update(items(["a", "b"], [], [])); chip("fill").click(); -panel.children[1].click(); +panel.children[1].click(); // 207-1 — 고르면 내용 칸 접힘 chipsRow.children[0].click(); chipsRow.children[0].click(); // 칩 누름은 측점 고름 아님 out.picks = picks.slice(); +chip("fill").click(); bar.update(items(["a", "b", "x"], [], [])); out.kept = view(); bar.update(items([], ["p"], [])); @@ -165,7 +166,7 @@ out.gone = view(); def test_알림_줄은_칩을_쓰고_긴_설명은_호버_짧은_이름은_사전(): src = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8") assert " str: + body = CSS[CSS.index(selector + " {") :] + return body[: body.index("}")] + + +def _node(body: str): + script = ( + PRELUDE + + """ +const sel = { id: null }; +const picked = []; +const bar2 = mod.exports.createNoticeChips((id) => { picked.push(id); sel.id = id; }, () => sel.id); +const [chips2, panel2] = bar2.root.children; +const chip2 = (key) => chips2.children.find((c) => c.dataset.noticeKey === key); +const marks = () => panel2.children.map((b) => [b.dataset.stationId, b.classList.contains("is-selected")]); +""" + + body + + "\nconsole.log(JSON.stringify(out));" + ) + got = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert got.returncode == 0, got.stderr + return json.loads(got.stdout) + + +def test_칩은_둥근_사각_공용_단추_반경_알약_아님(): + chip = _rule(".b06-section__notice-chip") + assert "border-radius: var(--radius-buttons)" in chip + assert "radius-pills" not in chip and "radius-sm" not in chip + + +def test_내용_칸은_흐름_밖_카드_위에_겹침(): + panel = _rule(".b06-section__notice-list") + assert "position: absolute" in panel and "top: 100%" in panel + assert "z-index" in panel and "box-shadow: var(--shadow-lg)" in panel + assert "background: var(--color-surface-raised)" in panel + # 기준 = 고정 줄(sticky 는 absolute 의 기준 상자) · 그 사이에 기준을 바꾸는 position 없음 + assert "position: sticky" in _rule(".b06-section__notice-bar") + assert "position" not in _rule(".b06-section__notice-blocks") + + +def test_열고_닫아도_카드_자리_그대로_scroll_margin은_칩_줄_높이만(): + view = (B06 / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8") + # 줄 높이 = 고정 줄 offsetHeight — 흐름 밖 내용 칸은 높이에 안 듦 + assert '"--b06-notice-h", `${fillSlopeNotice.root.offsetHeight}px`' in view + assert "scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8))" in CSS + # 내용 칸만 흐름 밖 — 칩 줄은 흐름 안 + assert "position" not in _rule(".b06-section__notice-chips") + + +def test_측점_고르면_그_카드로_가며_내용_칸_접힘(): + got = _node( + """ +bar2.update(items(["a", "b"], ["p"], [])); +chip2("fill").click(); +out.before = panel2.hidden; +panel2.children[1].click(); +out.picked = picked.slice(); +out.after = panel2.hidden; +out.rows = panel2.children.length; +out.expanded = chips2.children.map((c) => c.getAttribute("aria-expanded")); +""" + ) + assert got["before"] is False + assert got["picked"] == ["b"] + assert got["after"] is True and got["rows"] == 0 + assert got["expanded"] == ["false", "false"] + + +def test_고른_측점이_있으면_열_때_그_단추_강조_없으면_강조_없음(): + got = _node( + """ +bar2.update(items(["a", "b", "c"], ["b"], [])); +chip2("fill").click(); +out.none = marks(); +chip2("fill").click(); +sel.id = "b"; // 측점 횡단도를 고른 상태 +chip2("fill").click(); +out.fill = marks(); +chip2("pave").click(); +out.pave = marks(); +sel.id = "zz"; // 목록에 없는 측점 +chip2("fill").click(); +out.absent = marks(); +""" + ) + assert got["none"] == [["a", False], ["b", False], ["c", False]] + assert got["fill"] == [["a", False], ["b", True], ["c", False]] + assert got["pave"] == [["b", True]] + assert got["absent"] == [["a", False], ["b", False], ["c", False]] + + +def test_고른_측점_넘김_부르는_쪽_이음과_강조_색(): + notice = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8") + assert "createNoticeChips(onPick, selected)" in notice + view = (B06 / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8") + assert "() => selectedStationId," in view + mark = _rule(".b06-section__notice-list > button.is-selected") + assert "var(--color-danger)" in mark # 카드 고름 색과 같음 + assert "border-color: var(--color-danger)" in _rule(".b06-cross-card--selected") From dc9d2a6542ed02b4746b179726a5194347dc2c13 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:44:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(B06):=20=EC=96=91=EC=B8=A1=20=EC=84=B1?= =?UTF-8?q?=ED=86=A0=20=EC=B8=A1=EC=A0=90=20=EC=B8=A1=EA=B5=AC=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=3D=20=ED=8C=90=EC=A0=95=20=EC=97=AD=ED=95=A0=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=C2=B7=20=EC=98=9B=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=B6=84=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=C2=B7=20=EA=B7=B8?= =?UTF-8?q?=EB=A0=A4=EC=A7=84=20=EC=B8=A1=EA=B5=AC=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=84(206-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원인: 측구 판정의 양측 성토 조건이 입력 단면유형(옛 저장 right_cut)을 봄 · 계획고 변경으로 지형 판정은 양측 성토인데 옛 결과 ditch_enabled 가 선택으로 둔갑해 측구가 섬 - 엔진(파이썬 · TS 짝): 판정 역할(양쪽 성토)로 측구 없음 · 남은 선택 비움 - 낡음 판정: 양측 성토 + 측구 결과 저장분 → B05 · B06 진입 때 다시 셈 - 179-2 숨김: 측구가 그려져 있으면 측구 · 형식 단추 보임 - 시험: test_206_1(검증_sub1 No.1+0 사본 재현 · 거울) · 거울 시험 ditch_choice 칸 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B06_Section_Engine_Design_Geometry.py | 12 +- B06_Section/B06_Section_UI_Cross_Design.ts | 6 +- B06_Section/B06_Section_UI_Section_Common.ts | 4 + .../common_util_cross_design_geometry.ts | 11 +- .../tester/test_206_1_both_fill_no_ditch.py | 159 ++++++++++++++++++ .../tester/test_b06_cross_design_mirror.py | 1 + 6 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_206_1_both_fill_no_ditch.py diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 1bc4121df..3249f58da 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -121,7 +121,11 @@ class _SectionGeometry: # ⚠ 옛 저장분(`ditch_choice` 가 없던 것)은 `legacy_ditch_enabled` 로 온다. 그 값은 # **자동값과 다를 때만 뜻이 있다** — 같으면 자동이 그렇게 냈던 것이고, 다르면 # 사용자가 일부러 바꾼 것이다. 그래서 **다를 때만** 선택으로 살린다(설계 의도 보존). - if section_mode == "both_fill": + # ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1) — 입력 `section_mode` 는 옛 저장값일 수 + # 있다(계획고를 올려 우 절토 → 양측 성토 · 입력은 right_cut 그대로). 입력으로 가르면 옛 + # 측구 결과가 선택으로 둔갑해 양측 성토 단면에 측구가 섰다. ⚠ TS 짝 `SectionGeometry`. + both_fill = self.left_role == "fill" and self.right_role == "fill" + if both_fill: auto_ditch = False elif ground_at is not None: ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent @@ -136,10 +140,10 @@ class _SectionGeometry: # 없는데, 그것을 선택으로 굳히면 **다시 같은 병**(지형이 바뀌어도 안 따라감)이 된다. if choice is not None and bool(choice) == auto_ditch: choice = None - # 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다. - self.has_ditch = auto_ditch if (choice is None or section_mode == "both_fill") else choice + # 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움. + self.has_ditch = auto_ditch if (choice is None or both_fill) else choice #: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. - self.ditch_choice = choice + self.ditch_choice = None if both_fill else choice # 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** # 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 7bc478776..0c7e07f68 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -388,8 +388,10 @@ export function buildDesignControls( groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). // 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에. - const rockCut = isRock(state.ground) && state.mode !== "both_fill"; - const hasDitch = state.mode !== "both_fill"; + // 숨김은 「막힘 + 그려진 측구 없음」일 때만(206-1) — 측구가 그려져 있으면 단추를 보여 끌 수 있게. + const ditchDrawn = design?.ditch_enabled === true; + const rockCut = isRock(state.ground) && (state.mode !== "both_fill" || ditchDrawn); + const hasDitch = state.mode !== "both_fill" || ditchDrawn; // 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다. // 화면 표시는 **결과**를 보인다 — 선택이 없으면 자동으로 선 결과가 답이다. const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true; diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index bfda291de..105bcddb9 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -106,6 +106,8 @@ interface StaleCandidate { geometry_preset?: string; two_stage_slope?: boolean; surface_drop_m?: number; + section_mode?: string; + ditch_enabled?: boolean; } | null; } @@ -120,6 +122,8 @@ function isStaleSection( // 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의 // 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다. if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; + // 옛 엔진이 양측 성토에 측구를 세워 둔 저장분(206-1) — 지금 엔진으로 다시 셈. + if (design.section_mode === "both_fill" && design.ditch_enabled === true) return true; if (!profiles?.length) return false; const planned = designElevationAt(profiles, section.chainage_m); if (planned === undefined) return false; diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 80630a777..b67b2d67e 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -188,8 +188,11 @@ export class SectionGeometry { // `hasDitch`** 로 가른다. // ⚠ 옛 저장분은 `ditchEnabled` 로 온다. 그 값은 **자동값과 다를 때만 뜻이 있다** — // 같으면 자동이 그렇게 냈던 것이고, 다르면 사용자가 일부러 바꾼 것이다. + // ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1 · 짝: 파이썬) — 입력 `sectionMode` 는 옛 + // 저장값일 수 있다. 입력으로 가르면 옛 측구 결과가 선택으로 둔갑해 양측 성토에 측구가 섰다. + const bothFill = this.leftRole === "fill" && this.rightRole === "fill"; let autoDitch: boolean; - if (params.sectionMode === "both_fill") { + if (bothFill) { autoDitch = false; } else if (groundAt !== null) { const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; @@ -206,10 +209,10 @@ export class SectionGeometry { // 「자동으로 되돌리기」 단추가 없고, 원래 값으로 다시 누른 것을 선택으로 굳히면 // **다시 같은 병**이 된다. if (choice !== null && Boolean(choice) === autoDitch) choice = null; - // 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다. - this.hasDitch = choice === null || params.sectionMode === "both_fill" ? autoDitch : choice; + // 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움. + this.hasDitch = choice === null || bothFill ? autoDitch : choice; /** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */ - this.ditchChoice = choice; + this.ditchChoice = bothFill ? null : choice; // 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** // 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`. diff --git a/resources/tester/test_206_1_both_fill_no_ditch.py b/resources/tester/test_206_1_both_fill_no_ditch.py new file mode 100644 index 000000000..9e5d603a4 --- /dev/null +++ b/resources/tester/test_206_1_both_fill_no_ditch.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +"""PLAN 206-1 — 양측 성토 측점에 측구가 남는 흠(검증_sub1 No.1+0 사본 재현). + +원인 — 측구 판정의 「양측 성토 = 측구 없음」이 **입력** `section_mode` 를 봤다. 입력은 옛 저장값 +(초기값 right_cut)이고, 계획고를 +1.98 m 올려 지형 판정(역할)은 양측 성토가 됐는데 입력이 그대로라 +옛 결과 `ditch_enabled=True` 가 사용자 선택으로 둔갑(ditch_choice=True)해 측구가 섰다. +좌 · 우를 누르면 카드가 판정값 both_fill 을 입력으로 다시 보내 그때서야 사라졌다. +고침 — 판정된 역할(양쪽 성토)로 가름 · 파이썬 · TS 짝 같은 규칙 · 옛 저장분은 낡음으로 다시 셈. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 + +# 검증_sub1(7e420ed6) 노선 230 · No.1+0(20 m) 지반선 사본 — -20 ~ +20 m · 0.5 m 간격 +_Z = [ + 885.0166, 884.8327, 884.6401, 884.4294, 884.2270, 883.7564, 883.2482, 882.8840, 882.5737, + 882.3786, 882.2795, 881.9692, 881.3788, 880.7761, 880.1219, 879.4944, 879.1738, 878.8550, + 878.6597, 878.4670, 878.1648, 877.8416, 877.5119, 877.1749, 876.8564, 876.5584, 876.2612, + 875.9646, 875.6791, 875.4169, 875.1405, 874.7286, 874.3079, 874.0026, 873.7065, 873.4074, + 873.1107, 872.7726, 872.4127, 872.1306, 871.9252, 871.7424, 871.6078, 871.3723, 870.7716, + 870.1964, 869.8586, 869.5135, 869.2563, 869.0105, 868.5769, 868.0912, 867.7274, 867.4369, + 867.1615, 866.9033, 866.6590, 866.4489, 866.2281, 865.9711, 865.7139, 865.4391, 865.1650, + 864.8953, 864.6289, 864.3219, 863.9996, 863.6703, 863.3343, 863.0443, 862.8261, 862.5776, + 862.2364, 861.9035, 861.6491, 861.3932, 861.1137, 860.8302, 860.6107, 860.4051, 860.2006, +] # fmt: skip +SAMPLES = [{"offset_m": -20.0 + 0.5 * i, "elevation_m": z} for i, z in enumerate(_Z)] +ELEV = 873.900731 # 지금 계획고(초기값 871.9252 에서 +1.98 m) +# 그 프로젝트 저장분과 같은 입력 — 옛 단면유형 · 옛 측구 결과 +STORED = { + "ground_type": "ripping_rock", + "ditch_side": "right", + "ditch_type": "standard", + "rock_boundary_offset_m": -0.5, + "two_stage_slope": True, +} + + +_GROUP = { + "road_width_m": 3, + "shoulder_left_m": 0.5, + "shoulder_right_m": 0.5, + "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "fill_slope_ratio": 1.2, +} +# 그 프로젝트 종단 options 의 표준 횡단면 +STANDARD = { + "soil": {**_GROUP, "cross_slope_pct": {"min": 3, "max": 5}, "cut_slope_ratio": 1}, + "rock": { + **_GROUP, + "ditch_l_type": {"width_m": 0.5, "depth_m": 0.1}, + "cross_slope_pct": {"min": 3, "max": 5}, + "cut_slope_ratio": 0.4, + }, + "paved": { + **_GROUP, + "cross_slope_pct": {"min": 1.5, "max": 2}, + "cut_slope_ratio": 1, + "pavement_thickness_m": 0.2, + }, +} + + +def _design(section_mode: str, **kwargs): + return compute_cross_design( + SAMPLES, ELEV, section_mode=section_mode, standard=STANDARD, **STORED, **kwargs + ) + + +@pytest.mark.parametrize( + "stale", + [ + {"ditch_enabled": True}, # 옛 결과만(초기값 → 계획고 변경 재계산) + {"ditch_enabled": True, "ditch_choice": True}, # 둔갑한 선택이 저장된 뒤(지금 DB) + ], +) +@pytest.mark.parametrize("mode", ["right_cut", "both_cut", "left_cut"]) +def test_사본_양측_성토면_옛_입력이어도_측구_없음(mode: str, stale: dict) -> None: + design = _design(mode, **stale) + assert design["section_mode"] == "both_fill" + assert design["ditch_enabled"] is False, "양측 성토인데 측구가 섰다" + assert design["ditch_area_m2"] == 0.0 + assert design["ditch"] == {"type": "none"} + assert design["ditch_choice"] is None, "옛 결과가 선택으로 남았다" + + +def test_사본_옛_입력과_바른_입력이_같은_형상() -> None: + """좌 · 우를 눌러야만 사라지던 것 — 이제 입력이 옛 값이어도 처음부터 같은 형상 · 면적.""" + stale = _design("right_cut", ditch_enabled=True, ditch_choice=True) + fresh = _design("both_fill") + for key in ("design_line", "fill_area_m2", "cut_area_m2", "ditch_area_m2"): + assert stale[key] == fresh[key], key + assert fresh["fill_area_m2"] == pytest.approx(34.0513, abs=1e-4) + # 우 노견 바깥에 측구 꼭짓점(-2.3 · -2.6 · -2.9 m = 상단 0.9 · 저폭 0.3 사다리꼴)이 없다 + offsets = {p["offset_m"] for p in fresh["design_line"]} + assert not offsets & {-2.3, -2.6, -2.9} + + +def test_한쪽이라도_절토면_측구_그대로() -> None: + """양측 성토 판정은 역할로만 — 우 절토 지형은 옛 입력 both_fill 이어도 측구 섬.""" + design = _design("both_fill", ditch_enabled=False) + assert design["section_mode"] == "both_fill" # 이 지형은 양측 성토 + cut = compute_cross_design(SAMPLES, ELEV - 2.5, section_mode="both_fill", **STORED) + assert cut["section_mode"] in ("right_cut", "both_cut") + assert cut["ditch_enabled"] is True and cut["ditch_side"] == "right" + + +def test_TS_짝도_같은_값(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """거울 — 같은 사본을 TS `computeCrossDesign` 에 넣어 파이썬과 같은지(측구 없음까지).""" + import test_b06_cross_design_mirror as mirror + + cases = [ + { + "name": f"206-1 사본 {mode}", + "samples": SAMPLES, + "design_elevation_m": ELEV, + "options": { + "section_mode": mode, + **STORED, + "ditch_enabled": True, + "ditch_choice": True, + }, + } + for mode in ("right_cut", "both_fill") + ] + monkeypatch.setattr(mirror, "CASES", cases) + py, ts = mirror._python_results(), mirror._ts_results(tmp_path) + problems = [] + for case, left, right in zip(cases, py, ts): + problems.extend(mirror._diff(left, right, case["name"])) + assert not problems, "\n".join(problems[:20]) + assert all(item["ditch_enabled"] is False for item in ts) + + +def _src(rel: str) -> str: + return (PROJECT_ROOT / rel).read_text(encoding="utf-8") + + +def test_옛_저장분은_낡음으로_다시_셈() -> None: + """DB 에 남은 양측 성토 + 측구 결과 → B05 · B06 진입 때 브라우저가 다시 셈(공용 낡음 판정).""" + common = _src("B06_Section/B06_Section_UI_Section_Common.ts") + assert 'design.section_mode === "both_fill" && design.ditch_enabled === true' in common + + +def test_측구가_그려져_있으면_단추를_숨기지_않음() -> None: + """179-2 숨김은 「막힘 + 그려진 측구 없음」일 때만 — 그려진 측구는 끌 수 있어야 함.""" + cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts") + assert "const ditchDrawn = design?.ditch_enabled === true;" in cd + assert 'const hasDitch = state.mode !== "both_fill" || ditchDrawn;' in cd + assert '(state.mode !== "both_fill" || ditchDrawn)' in cd # 측구형식도 같은 규칙 diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index 151858314..d5169229f 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -234,6 +234,7 @@ const camel = { two_stage_slope: "twoStageSlope", cut_slope_ratio: "cutSlopeRatio", ditch_enabled: "ditchEnabled", + ditch_choice: "ditchChoice", surface_drop_m: "surfaceDropM", plan_radius_m: "planRadiusM", curve_outer_side: "curveOuterSide",