From f8a13e234d5076694cbbd96e830055a381a82032 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:09:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=EC=97=B0=EB=8F=99=20=ED=9D=99?= =?UTF-8?q?=EB=A7=89=EC=9D=B4=20=EB=86=92=EC=9D=B4=20=3D=20=EB=B3=B8=20?= =?UTF-8?q?=ED=9D=99=EB=A7=89=EC=9D=B4=20=EA=B7=B8=EB=A6=B0=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=C2=B7=20=ED=92=80=EB=A6=B0=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=E3=80=8C=EC=9D=B4=20=EC=B8=A1=EC=A0=90=20=EB=86=92=EC=9D=B4?= =?UTF-8?q?=E3=80=8D=20=EC=A4=84(152-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 소유 측점 적용값의 빈 높이 = 그린 순수 높이 — 옆 카드 · B07 · 서버 면적 · 연동 풂 굳힘이 같은 값(박스 옆 측점 0.5 → 2.0 m) - 연동 푼 카드의 유입 · 유출 흙막이 = 좌측 칸에 「이 측점 높이」 ±0.1 줄(날개벽 칸 포함) - 새 시험 test_152_2_link_height · 받는 줄 문자열 시험 셋 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B06_Section/B06_Section_UI_Adjust_Dock.ts | 8 +- .../B06_Section_UI_Cross_Culvert_Geom.ts | 1 + .../B06_Section_UI_Cross_Culvert_Types.ts | 2 + .../B06_Section_UI_Cross_Culvert_Wire.ts | 11 ++- .../B06_Section_UI_Cross_Structure_Panel.ts | 14 ++- resources/tester/helper_152_2_link_height.cjs | 98 +++++++++++++++++++ resources/tester/test_152_2_link_height.py | 55 +++++++++++ .../tester/test_54_27_soil_tier_field.py | 7 +- resources/tester/test_65_ford_defaults.py | 7 +- resources/tester/test_91_ground_free.py | 2 +- 10 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 resources/tester/helper_152_2_link_height.cjs create mode 100644 resources/tester/test_152_2_link_height.py diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 3815cd3c4..577561ef9 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -49,7 +49,10 @@ const EXTRA_ROWS = [ /** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) · * 단 수 · 옵션 · 집수정 내공(24-4a). */ +/** 연동을 푼 옆 카드의 「이 측점 높이」 줄 — 어느 칸이든 받음(152-2). */ +const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height"; const SIDE_ROWS = [ + OWN_HEIGHT_ROW, ".b06-structure-panel__struct--readout", ".b06-structure-panel__struct--tier", ".b06-structure-panel__struct--options", @@ -59,15 +62,16 @@ const SIDE_ROWS = [ /** 배관 칸 = 폼에 자리 없는 집수정 내공 · 유입 단 수만 — 목록에서 골라도 같은 글(54-29). * 유출 단 수는 폼 「추가 흙막이(단)」 칸(54-27) · 읽기 · 옵션 줄은 안 받음. */ const PIPE_INLET_ROWS = [ + OWN_HEIGHT_ROW, ".b06-structure-panel__struct--tier", ".b06-structure-panel__struct--basin-size", ].join(", "); -const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size"; +const PIPE_OUTLET_ROWS = `${OWN_HEIGHT_ROW}, .b06-structure-panel__struct--basin-size`; /** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은 * 받는 줄 없음(측벽 높이 줄 지움 · 107-3 · 읽기는 칸에 안 둠 · 65-1). */ function rowsFor(slot: HTMLElement): string | null { - if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null; + if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return OWN_HEIGHT_ROW; if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS; return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 6aceb2af5..5bbd5364a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -680,6 +680,7 @@ export function computeCulvertLayout( }, bodyX: inletBody || outletBody ? { inlet: inletBody?.x ?? 0, outlet: outletBody?.x ?? 0 } : null, + pureHeights: { inlet: inletWallSpec.pureHeight, outlet: outletWallSpec.pureHeight }, designTrim: body && ownTrim ? mergeGuardTrim(ownTrim, body.trim, walls) : ownTrim, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 98bd07199..56c2499c6 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -221,6 +221,8 @@ export interface CulvertLayout { inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean }; /** 세월교 · 박스 흙막이(73-4) — 본체 끝 자리의 배관 4축 x(적용 x 의 0점). 본체 없으면 없음. */ bodyX?: { inlet: number; outlet: number } | null; + /** 유입 · 유출 벽의 순수 높이(m) — 높이 빈 칸인 연동 옆 카드가 물려받음(152-2). */ + pureHeights?: { inlet: number; outlet: number }; } /** 유입측 구조물 사용자 선택 — auto = 규칙 기반 자동 선택. */ export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U"; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 3796e0126..ab8212da6 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -606,7 +606,16 @@ export function ownerAppliedAdjusts( owner.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(owner), inletStructure?.adjustFor(owner), ); - return ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput; + if (!ownerLayout) return ownerInput; + // 높이 빈 칸 = 소유 측점이 그린 순수 높이 — 옆 측점엔 본체가 없어 근입 0.5 만 서던 것(152-2). + const shift = bodyAbsolute(ownerLayout); + const heights = ownerLayout.pureHeights; + if (!heights) return shift; + return { + ...shift, + inlet: { ...shift.inlet, h: shift.inlet.h ?? heights.inlet }, + outlet: { ...shift.outlet, h: shift.outlet.h ?? heights.outlet }, + }; } function adjustsInput( diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 89460db24..5380cb425 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -248,6 +248,7 @@ export function buildStructurePanel( const heightRow = heightParts.row; // 다단 벽에서는 이 행이 [추가 기슭막이] 그룹으로 옮겨 간다(2026-08-30 지시 4). heightRow.classList.add("b06-structure-panel__struct--height"); + const heightLabel = heightRow.querySelector?.(".b06-structure-panel__label"); const heightValue = document.createElement("span"); heightValue.className = "b06-structure-panel__hval"; const heightMinus = makeButton( @@ -550,11 +551,14 @@ export function buildStructurePanel( // 형태 · 높이는 좌측 폼 칸이 정본이다(유입구 · 유출구 · 독립 좌우 · 구조물 옵션) — 폼에 // 자기 칸이 없는 **다단 벽**만 이 줄을 「추가 기슭막이」 칸에 세운다(24-4a). materialRow.classList.toggle("is-hidden", !isExtra); - heightRow.classList.toggle("is-hidden", !isExtra); - if (isExtra) { - heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`; - materialSelect.value = deps.formFor(key); - } + // 연동을 푼 옆 카드의 흙막이 = 「이 측점 높이」 줄(폼 칸은 본 흙막이 · 152-2) + const ownHeight = deps.linkState()?.linked === false && guardKey && !isBasin; + heightRow.classList.toggle("is-hidden", !isExtra && !ownHeight); + heightRow.classList.toggle("b06-structure-panel__struct--own-height", ownHeight); + if (heightLabel) + heightLabel.textContent = ownHeight ? "이 측점 높이" : L("B06_Cross_Height_Label"); + if (isExtra || ownHeight) heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`; + if (isExtra) materialSelect.value = deps.formFor(key); // 단 수 행 — 벽 종류를 가리지 않고 **높이 행 바로 아래** 한 자리에 온다. // 유출 벽은 자기 채널(extra), 유입 벽·집수정 계류측은 basin 채널(bextra)을 쓴다. // 유출 벽과 독립 기슭막이 유입 벽에서는 **항상** 보인다(2026-08-22 사용자 ⑤ — diff --git a/resources/tester/helper_152_2_link_height.cjs b/resources/tester/helper_152_2_link_height.cjs new file mode 100644 index 000000000..8fa879473 --- /dev/null +++ b/resources/tester/helper_152_2_link_height.cjs @@ -0,0 +1,98 @@ +/* 152-2 연동 흙막이 높이 헬퍼 — 높이 칸 빈 박스 흙막이(73-4 픽스처 352.14)의 연동 옆 측점(340 · 360)이 + * 소유 측점이 그린 순수 높이를 물려받는지 · 연동을 푼 카드만 자기 높이로 서는지. 결과 = 표준출력 JSON. + * 짝 = `test_152_2_link_height.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +// 화면 전용 모듈(토스트 · 글) — 기하 계산엔 안 쓰임 +const STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts")); + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +); +// 높이 칸 비움(= 자동 · 세월교 · 박스 흙막이 기본) — 원본은 3.5 를 넣은 상태 +const sections = fixture.cross_sections.map((s) => { + if (!s.culvert?.body) return s; + const copy = JSON.parse(JSON.stringify(s)); + copy.culvert.inlet.revet_height_m = null; + copy.culvert.outlet.revet_height_m = null; + return copy; +}); +const at = (list, c) => list.find((s) => Math.abs(s.chainage_m - c) < 0.6); +const round = (v) => Math.round(v * 1000) / 1000; +const heightsOf = (list, c) => { + const culvert = computeStoredLayouts(at(list, c), list)?.culvert; + return { + pure: culvert?.pureHeights + ? { inlet: round(culvert.pureHeights.inlet), outlet: round(culvert.pureHeights.outlet) } + : null, + walls: (culvert?.walls ?? []).map((w) => ({ role: w.role, height: round(w.height) })), + shift: culvert?.revetShift ?? null, + }; +}; + +const out = { owner: heightsOf(sections, 352.14), linked: {} }; +for (const c of [340, 360]) out.linked[c] = heightsOf(sections, c); + +// 340 연동 풂 + 유출 흙막이만 +0.1 m(좌측 「이 측점 높이」 줄이 그 측점 값을 고친 것) +const shift = out.linked[340].shift; +const detached = sections.map((s) => { + if (Math.abs(s.chainage_m - 340) >= 0.6) return s; + const copy = JSON.parse(JSON.stringify(s)); + copy.design.revet_link_detached = true; + copy.design.revet_adjust = { + ...(copy.design.revet_adjust ?? {}), + inlet: { ...shift.inlet }, + outlet: { ...shift.outlet, h: round(shift.outlet.h + 0.1) }, + }; + return copy; +}); +out.detached = { + 340: heightsOf(detached, 340), + 360: heightsOf(detached, 360), + owner: heightsOf(detached, 352.14), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_152_2_link_height.py b/resources/tester/test_152_2_link_height.py new file mode 100644 index 000000000..d0945ba5f --- /dev/null +++ b/resources/tester/test_152_2_link_height.py @@ -0,0 +1,55 @@ +"""152-2 연동 흙막이 높이 — 본 흙막이가 그린 순수 높이를 옆 카드가 물려받음 · 풀면 그 측점만 따로. + +화면 TS 를 그 자리에서 돌린 값(`helper_152_2_link_height.cjs`) · 픽스처 = 73-4(박스 352.14 · 높이 칸 비움). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_152_2_link_height.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def screen() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def _src(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_연동_옆_카드_높이_본_흙막이와_같음(screen: dict) -> None: + owner = screen["owner"]["pure"] + assert owner == {"inlet": 2.0, "outlet": 2.0} # 본체 끝 높이(근입 0.5 아님) + for chainage in ("340", "360"): + assert screen["linked"][chainage]["pure"] == owner + assert screen["linked"][chainage]["walls"] == screen["owner"]["walls"] + + +def test_연동_풀린_카드만_따로(screen: dict) -> None: + detached = screen["detached"] + assert detached["340"]["pure"] == {"inlet": 2.0, "outlet": 2.1} + assert detached["360"]["pure"] == {"inlet": 2.0, "outlet": 2.0} + assert detached["owner"]["pure"] == {"inlet": 2.0, "outlet": 2.0} + + +def test_풀린_카드_이_측점_높이_줄() -> None: + panel = _src("B06_Section/B06_Section_UI_Cross_Structure_Panel.ts") + assert "deps.linkState()?.linked === false && guardKey && !isBasin" in panel + assert '"이 측점 높이"' in panel + dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") + assert 'const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height";' in dock + assert "return OWN_HEIGHT_ROW;" in dock # 세월교 · 박스 날개벽 칸도 받음 + wire = _src("B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts") + assert "h: shift.inlet.h ?? heights.inlet" in wire diff --git a/resources/tester/test_54_27_soil_tier_field.py b/resources/tester/test_54_27_soil_tier_field.py index 5a2ed80d3..51b4cb53f 100644 --- a/resources/tester/test_54_27_soil_tier_field.py +++ b/resources/tester/test_54_27_soil_tier_field.py @@ -88,5 +88,8 @@ def test_자동_자리_읽기_글이_없음() -> None: def test_배관_유출_칸은_옛_단_수_줄을_안_받음() -> None: dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") - # 54-29 — 배관 유출 칸 = 집수정 내공 줄만(단 수 · 읽기 · 옵션 줄 안 받음) - assert 'const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";' in dock + # 54-29 — 배관 유출 칸 = 집수정 내공 줄만(단 수 · 읽기 · 옵션 줄 안 받음) · 152-2 풀린 카드 높이 줄 + assert ( + "const PIPE_OUTLET_ROWS = `${OWN_HEIGHT_ROW}, .b06-structure-panel__struct--basin-size`;" + in dock + ) diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 6a8f5eb85..5d327f526 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -168,8 +168,11 @@ def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼(): def test_B06_세월교_박스_칸은_받는_줄_없음(): - """107-3 — 측벽 높이 줄 지움.""" + """107-3 — 측벽 높이 줄 지움 · 152-2 연동 풀린 카드 「이 측점 높이」 줄만 받음.""" dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") - assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock + assert ( + "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return OWN_HEIGHT_ROW" + in dock + ) lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8") assert "return isNone(c, state[c.id][0])" in lists diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index 1720f43fd..3ae13bcf1 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -44,7 +44,7 @@ def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict) def test_세월교_박스_칸은_측벽_높이_줄_없음() -> None: """107-3 — 「측벽 높이(자동)」 줄 지움(유입 · 유출).""" dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") - assert "sideSlots?.wingOutlet) return null" in dock + assert "sideSlots?.wingOutlet) return OWN_HEIGHT_ROW" in dock # 152-2 풀린 카드 높이 줄만 assert "wall-height" not in dock