From fbaa5b4838f66f8edd012ea21a3535b4f0ed3edb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 19:54:27 +0900 Subject: [PATCH] =?UTF-8?q?fix(b05=C2=B7b06):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=A0=84=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=92=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=E2=80=94=20?= =?UTF-8?q?=EB=AC=BC=EB=84=98=EC=9D=B4=ED=8F=AC=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=EB=A7=B9=EC=95=94=EA=B1=B0=20=EC=B8=A1=EA=B5=AC=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EC=A4=80=EC=B8=A1=EC=A0=90=20=EC=98=AE?= =?UTF-8?q?=EA=B9=80=20(314=20=C2=B7=20=EC=9A=94=EA=B5=AC=20210=20=C2=B7?= =?UTF-8?q?=20211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 낡음 판정(B05 · B06 공용 isStaleSection)에 서버 _enforce_stored_designs 짝 셋: 포장 · 기울기(물넘이 · 맹암거) · 세월교 노면 하강 - 구조물 바뀐 뒤 횡단 다시 받으면 낡은 측점 다시 셈(refreshDetailForStructures) - 다시 셈의 세월교 노면 하강 = 그 측점에 얹힌 세월교 제원(옛 저장값 안 이음) - 물넘이포장: 옮기거나 지운 옛 자리 걷고 새 자리 · 걸친 기존 측점에 얹음(빈 관 목록도) - 월류 높이 기다림: 빈 글자도 빈칸(채우는 쪽과 같은 규칙) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- A00_Common/design/design_view_cross.ts | 3 +- .../B05_Profile_UI_Drainage_Ford_Fill.ts | 4 +- B06_Section/B06_Section_Cross_Refresh.ts | 5 +- B06_Section/B06_Section_Section_Store.ts | 13 ++- B06_Section/B06_Section_UI_Section_Common.ts | 16 ++++ .../tester/helper_314_stale_structure.cjs | 63 +++++++++++++++ resources/tester/test_314_stale_structure.py | 81 +++++++++++++++++++ 7 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 resources/tester/helper_314_stale_structure.cjs create mode 100644 resources/tester/test_314_stale_structure.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index e05df96aa..f94e999ee 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -235,6 +235,7 @@ export async function mountCrossView( ); } renderSectionDetail(); + void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314) } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error"); @@ -562,7 +563,7 @@ export async function mountCrossView( // 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38) if (!readStructurePick(projectId)) releaseCross(); void structuresPanel.load().then(restoreStructurePick); - void refreshDetailForStructures().then(() => reconcileStaleDesigns()); + void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314) }); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts index 5a0b67e48..5beac148f 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts @@ -32,7 +32,9 @@ export function waitsFordHeight( facility: string | undefined, options?: Record, ): boolean { - return !!facility && facility in FORD_KINDS && options?.ford_height_m === undefined; + // 빈 글자도 빈칸 — 채우는 쪽(`fillFordHeights`)과 같은 규칙(314) + const height = options?.ford_height_m; + return !!facility && facility in FORD_KINDS && (height === undefined || height === ""); } /** 기다리는 측점의 빈 높이를 채움 — 채운(또는 사용자 값이라 그만 기다릴) 측점은 `pending` 에서 뺌. diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 95b11ad4d..83ef1a82f 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -377,8 +377,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ? design.ditch_choice : null, ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, - // 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다. - surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0, + // 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314) + surfaceDropM: + Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0, // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). planRadiusM: section.plan_radius_m ?? null, curveOuterSide: diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 4af1cb327..fdbb34314 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -132,10 +132,17 @@ const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => */ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise { const pipes = readPendingPipes(projectId); - if (!pipes?.length) return; + if (!pipes) return; const sections = detail.cross_sections as unknown as Array>; const near = (spec: Record, section: Record, at: number) => Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M; + // 물넘이포장(구간형 · 걸친 측점마다 얹힘) — 옮기거나 지운 옛 자리는 걷음 · 새 자리는 아래에서 얹음(314) + for (const section of sections) { + const spec = section.ford_pavement as Record | undefined; + const kept = (pipe: (typeof pipes)[number]) => + pipe.facility === "ford_pavement" && !!spec && near(spec, section, pipe.chainage_m); + if (spec && !pipes.some(kept)) delete section.ford_pavement; + } /** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */ const fits = (pipe: (typeof pipes)[number], key: string, spec: Record) => key === facilityKey(pipe.facility ?? "pipe") && @@ -147,7 +154,9 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : []; }), ); - return here.length > 0 && !here.some(([key, spec]) => fits(pipe, key, spec)); + // 얹힌 것이 없는 관(새 관 · 옮긴 관)은 미리 뜬 횡단이 맡음 — 물넘이포장만 걸친 기존 측점까지 여기서(314) + if (!here.length) return pipe.facility === "ford_pavement"; + return !here.some(([key, spec]) => fits(pipe, key, spec)); }); if (!stale.length) return; const types = await fetchStructureTypes().catch(() => []); diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 105bcddb9..0ffbf01ad 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -101,6 +101,11 @@ export function hasStaleDesigns( interface StaleCandidate { chainage_m: number; + // 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314) + pavement?: unknown[] | null; + ford_pavement?: unknown; + underdrain?: unknown; + ford?: { overflow_depth_m?: number } | null; design?: { design_elevation_m: number; geometry_preset?: string; @@ -108,6 +113,8 @@ interface StaleCandidate { surface_drop_m?: number; section_mode?: string; ditch_enabled?: boolean; + paved?: boolean; + tilted?: boolean; } | null; } @@ -124,6 +131,15 @@ function isStaleSection( 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; + // 얹힌 구조물 제원과 저장분 설계값이 다르면 낡음 — 서버 `_enforce_stored_designs` 짝(314). + // 저장 전에 놓거나 · 옮기거나 · 종류를 바꾼 물넘이포장 · 맹암거 · 세월교 · 노면공이 [저장] 전에도 + // 측구 없음 · 포장 · 노면 하강으로 다시 셈(포장 = `pavedBySpecs` · 기울기 = `Cross_Refresh` 와 같은 규칙). + const paved = Boolean(section.pavement?.length) || Boolean(section.ford_pavement); + if (Boolean(design.paved) !== paved) return true; + if (Boolean(design.tilted) !== Boolean(section.ford_pavement || section.underdrain)) return true; + const drop = + Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0; + if (Math.abs((design.surface_drop_m ?? 0) - drop) > 1e-6) return true; if (!profiles?.length) return false; const planned = designElevationAt(profiles, section.chainage_m); if (planned === undefined) return false; diff --git a/resources/tester/helper_314_stale_structure.cjs b/resources/tester/helper_314_stale_structure.cjs new file mode 100644 index 000000000..a660a2372 --- /dev/null +++ b/resources/tester/helper_314_stale_structure.cjs @@ -0,0 +1,63 @@ +/* PLAN 314 단위검증 헬퍼 — 저장분 횡단 값(포장 · 기울기 · 노면 하강) 낡음 판정 · 월류 높이 기다림. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_314_stale_structure.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 load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +const { hasStaleDesigns } = load( + path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"), +); +const { waitsFordHeight } = load( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Ford_Fill.ts"), +); + +const stale = (section) => + hasStaleDesigns({ longitudinal: { design_profiles: [] }, cross_sections: [section] }); +const design = (extra) => ({ design_elevation_m: 100, ...extra }); + +const out = { + ford_untilted: stale({ chainage_m: 10, ford_pavement: {}, design: design({ paved: true }) }), + ford_tilted: stale({ + chainage_m: 10, + ford_pavement: {}, + design: design({ paved: true, tilted: true }), + }), + left_tilted: stale({ chainage_m: 10, design: design({ tilted: true }) }), + underdrain_tilted: stale({ chainage_m: 10, underdrain: {}, design: design({ tilted: true }) }), + pavement_unpaved: stale({ chainage_m: 10, pavement: [{}], design: design({ paved: false }) }), + ford_drop_missing: stale({ chainage_m: 10, ford: { overflow_depth_m: 0.3 }, design: design({}) }), + ford_drop_kept: stale({ + chainage_m: 10, + ford: { overflow_depth_m: 0.3 }, + design: design({ surface_drop_m: 0.3 }), + }), + plain: stale({ chainage_m: 10, design: design({}) }), + waits: { + pave_empty: waitsFordHeight("ford_pavement", {}), + pave_blank: waitsFordHeight("ford_pavement", { ford_height_m: "" }), + pave_set: waitsFordHeight("ford_pavement", { ford_height_m: 0.5 }), + bridge_none: waitsFordHeight("ford_bridge", undefined), + pipe: waitsFordHeight("pipe", {}), + }, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_314_stale_structure.py b/resources/tester/test_314_stale_structure.py new file mode 100644 index 000000000..0b680b005 --- /dev/null +++ b/resources/tester/test_314_stale_structure.py @@ -0,0 +1,81 @@ +"""PLAN 314 — 물넘이포장 · 맹암거 후속(요구 210 · 211). + +- 314-1 · 314-2 · 314-4 저장 전에 놓거나 · 옮기거나 · 종류를 바꾼 구조물 자리 = 저장분 횡단 값(포장 · 기울기 · + 세월교 노면 하강)과 얹힌 제원이 다르면 낡음 → 다시 셈(서버 `_enforce_stored_designs` 짝) · + 물넘이포장 옛 자리 걷고 새 자리(걸친 기존 측점까지) 얹음 +- 314-3 월류 높이 기다림 = 빈 글자도 빈칸(채우는 쪽과 같은 규칙) + +실제 TS 를 `helper_314_stale_structure.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +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 result() -> dict: + out = subprocess.run( + ["node", os.path.join(HERE, "helper_314_stale_structure.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def test_tilt_mismatch_is_stale(result: dict) -> None: + # 물넘이포장 · 맹암거 자리인데 저장분이 안 기울었음(측구 있음) = 낡음 · 맞으면 그대로 + assert result["ford_untilted"] is True + assert result["ford_tilted"] is False + assert result["underdrain_tilted"] is False + # 구조물이 떠났는데 저장분이 아직 기울었음(옮김 · 종류 바꿈) = 낡음 + assert result["left_tilted"] is True + + +def test_paved_and_surface_drop_mismatch_is_stale(result: dict) -> None: + assert result["pavement_unpaved"] is True + assert result["ford_drop_missing"] is True + assert result["ford_drop_kept"] is False + assert result["plain"] is False + + +def test_ford_height_wait_treats_blank_as_empty(result: dict) -> None: + waits = result["waits"] + assert waits["pave_empty"] is True + assert waits["pave_blank"] is True + assert waits["pave_set"] is False + assert waits["bridge_none"] is True + assert waits["pipe"] is False + + +def test_structure_change_reconciles_and_ford_reattach() -> None: + view = _read("A00_Common", "design", "design_view_cross.ts") + body = view.split("async function refreshDetailForStructures", 1)[1].split("\n }\n", 1)[0] + assert "void reconcileStaleDesigns();" in body + refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") + assert "Number(section.ford?.overflow_depth_m) > 0" in refresh + store = _read("B06_Section", "B06_Section_Section_Store.ts") + kinds = store.split("async function withDraftPipeKinds", 1)[1].split("\n}\n", 1)[0] + assert "if (!pipes) return;" in kinds + assert "delete section.ford_pavement" in kinds + assert 'if (!here.length) return pipe.facility === "ford_pavement";' in kinds