From 5f54a09267db3c257cca4487411186bf2ce9918e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 20:04:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(b06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=EC=97=90=EC=84=9C=20=EC=A2=85=EB=A5=98?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=94=EA=BE=B8=EB=A9=B4=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=B6=80=ED=84=B0=20=EC=83=88=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=80=EC=9E=A5=EB=B6=84=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EC=9D=80=20=EB=B9=BC=20=EB=91=90=EA=B3=A0=20=EC=83=88=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=EC=B2=98=EB=9F=BC=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EB=9C=AC=20=ED=9A=A1=EB=8B=A8=20=C2=B7=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=AC=EB=A9=B4=20=EC=A0=80=EC=9E=A5=EB=B6=84=20=EB=B3=B5?= =?UTF-8?q?=EA=B7=80=20(317-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20213)?= 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_01C6BNBFXFndbqYxu6sZuknK --- B06_Section/B06_Section_Section_Drafts.ts | 25 +++++- .../tester/helper_317_kind_change_station.cjs | 79 +++++++++++++++++++ .../tester/test_317_kind_change_station.py | 49 ++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 resources/tester/helper_317_kind_change_station.cjs create mode 100644 resources/tester/test_317_kind_change_station.py diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 5454d6385..1778426f0 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -160,10 +160,33 @@ export async function withDraftStations( if (inputs) { const [pipes, structures, types] = inputs; const planted = plantedChainages(pipes, structures, types); + // 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고 + // 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 + const [oldPipes, oldStructures] = await savedOf(projectId).catch( + (): [Pipes, StructureInstance[]] => [[], []], + ); + const byType = new Map(types.map((type) => [type.type_id, type])); + const kindsAt = (ps: Pipes, ss: StructureInstance[], at: number): string => + [ + ...ps + .filter((pipe) => pipeStationMarks(pipe).some((m) => near(m.chainage_m, at))) + .map((pipe) => `p:${pipe.facility ?? "pipe"}`), + ...ss + .filter((s) => + structureStationMarks(s, byType.get(s.type_id)).some((m) => near(m.chainage_m, at)), + ) + .map((s) => `s:${s.type_id}`), + ] + .sort() + .join(","); + const replaced = (section: CrossSection): boolean => { + const was = kindsAt(oldPipes, oldStructures, section.chainage_m); + return was !== "" && was !== kindsAt(pipes, structures, section.chainage_m); + }; const isStale = (section: CrossSection): boolean => section.kind === "irregular" && !section.grid && - planted.every((at) => !near(at, section.chainage_m)); + (planted.every((at) => !near(at, section.chainage_m)) || replaced(section)); stale = all.filter(isStale); live = all.filter((section) => !isStale(section)); const missing = planted.filter( diff --git a/resources/tester/helper_317_kind_change_station.cjs b/resources/tester/helper_317_kind_change_station.cjs new file mode 100644 index 000000000..cb92ed9e5 --- /dev/null +++ b/resources/tester/helper_317_kind_change_station.cjs @@ -0,0 +1,79 @@ +/* PLAN 317 단위검증 헬퍼 — 구조물 측점에서 종류를 바꾸면 그 측점 횡단을 새로 뜬 것으로(저장분은 빼 둠). + * `withDraftStations` 를 Node 로 돌림 · 서버 부르는 모듈은 흉내 · 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_317_kind_change_station.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 state = { draftPipes: null, draftStructures: null, savedPipes: [], savedStructures: [] }; +const STUBS = { + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }), + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: state.savedStructures }), + fetchStructureTypes: async () => [], + readPendingStructures: () => state.draftStructures, + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes }, + "./B06_Section_Api_Fetch": { + previewStructureSections: async (_p, chainages) => + chainages.map((at) => ({ chainage_m: at, kind: "irregular", design: { fresh: true } })), + }, +}; +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 (STUBS[m]) return STUBS[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 { withDraftStations, forgetDraftStations } = load( + path.join(ROOT, "B06_Section", "B06_Section_Section_Drafts.ts"), +); + +const detail = { + cross_sections: [ + { chainage_m: 0, kind: "regular", design: { old: true } }, + { chainage_m: 50, kind: "irregular", design: { old: true } }, + ], +}; +const at50 = () => detail.cross_sections.find((s) => Math.abs(s.chainage_m - 50) < 0.01); +const snap = () => ({ + count: detail.cross_sections.length, + fresh: Boolean(at50()?.design?.fresh), + old: Boolean(at50()?.design?.old), + preview: Boolean(at50()?.preview), +}); + +(async () => { + state.savedPipes = [{ chainage_m: 50, facility: "ford_pavement" }]; + const out = {}; + state.draftPipes = [{ chainage_m: 50, facility: "ford_pavement", options: { ford_width_m: 3 } }]; + await withDraftStations(detail, "p1", 1); + out.same_kind = snap(); + state.draftPipes = [{ chainage_m: 50, facility: "box_culvert" }]; + await withDraftStations(detail, "p1", 1); + out.changed_kind = snap(); + state.draftPipes = [{ chainage_m: 50, facility: "ford_pavement" }]; + await withDraftStations(detail, "p1", 1); + out.back_to_saved = snap(); + forgetDraftStations("p1"); + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/test_317_kind_change_station.py b/resources/tester/test_317_kind_change_station.py new file mode 100644 index 000000000..c143904b7 --- /dev/null +++ b/resources/tester/test_317_kind_change_station.py @@ -0,0 +1,49 @@ +"""PLAN 317 — 구조물 측점에서 종류를 바꾸면 횡단을 「구조물 없는 상태」부터 새로(요구 213). + +- 저장분과 같은 종류 = 저장분 횡단 그대로 +- 다른 종류로 바꿈 = 저장분 횡단은 빼 두고 새 자리처럼 미리 뜬 횡단(옛 구조물 흔적 없음) +- 저장분 종류로 되돌림 = 빼 둔 저장분 횡단이 돌아옴 + +실제 TS(`withDraftStations`)를 `helper_317_kind_change_station.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="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def result() -> dict: + out = subprocess.run( + ["node", os.path.join(HERE, "helper_317_kind_change_station.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def test_same_kind_keeps_saved_section(result: dict) -> None: + assert result["same_kind"] == {"count": 2, "fresh": False, "old": True, "preview": False} + + +def test_changed_kind_starts_from_fresh_section(result: dict) -> None: + assert result["changed_kind"] == {"count": 2, "fresh": True, "old": False, "preview": True} + + +def test_back_to_saved_kind_restores_saved_section(result: dict) -> None: + assert result["back_to_saved"] == {"count": 2, "fresh": False, "old": True, "preview": False} From 972baf989d993de1080b310b40b643651f5506f3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 20:17:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(b05):=20[3D=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8]=20=3D=20=EB=82=A1=EC=9D=80=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EC=84=A4=EA=B3=84=EB=B6=80=ED=84=B0=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=85=88=ED=95=9C=20=EB=92=A4=20=EC=98=88=EC=83=81?= =?UTF-8?q?=ED=98=95=EC=83=81=20=C2=B7=20=EA=B7=B8=EB=A6=B0=20=EB=92=A4=20?= =?UTF-8?q?=EC=83=9D=EA=B8=B4=20=EB=82=A1=EC=9D=8C=EB=8F=84=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=85=88(=EA=B0=99=EC=9D=80=20=EB=82=A1=EC=9D=8C?= =?UTF-8?q?=EC=9D=80=20=ED=95=9C=20=EB=B2=88)=20(317-1=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 낡음 지문 staleKey(Section_Common) · 그래프가 그릴 때 잰 지문을 패널에 넘김(Render onStaleDesigns) - 패널 = 새 지문일 때만 다시 셈 예약(Preview scheduleIfStale · 서버 폴백 반복 막음) - 단추 refreshCorridor = 짓기 전 refreshStaleDesigns(세션 계획선 초안 → 저장분 · B06 과 같은 규칙) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- A00_Common/design/design_profile.ts | 2 +- B05_Profile/B05_Profile_UI_Corridor.ts | 7 +- B05_Profile/B05_Profile_UI_Profile_Preview.ts | 10 ++- B05_Profile/B05_Profile_UI_Profile_Render.ts | 15 ++-- B06_Section/B06_Section_Cross_Refresh.ts | 28 +++++++ B06_Section/B06_Section_UI_Section_Common.ts | 30 +++++++ resources/tester/helper_317_stale_preview.cjs | 78 +++++++++++++++++++ resources/tester/test_317_3d_update.py | 64 +++++++++++++++ 8 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 resources/tester/helper_317_stale_preview.cjs create mode 100644 resources/tester/test_317_3d_update.py diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 4a5146951..432d57736 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -427,6 +427,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { function draw(): void { renderProfile({ + onStaleDesigns: crossPreview.scheduleIfStale, // 그린 뒤 생긴 낡음도 다시 셈(317-1) body, callbacks, massHaul, @@ -535,7 +536,6 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). - // // 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이 // 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기 // 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다 diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index e9c39a6b3..c13401a46 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -20,6 +20,7 @@ import { writeCachedBytes, } from "../A00_Common/b_asset_cache"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { @@ -247,7 +248,7 @@ function withSections( /** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). * 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */ -export function refreshCorridor( +export async function refreshCorridor( viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, projectId: string, routeId: number | undefined, @@ -256,7 +257,9 @@ export function refreshCorridor( designSamples?: ProfileSamples, rebuild = true, ): Promise { - if (!routeId) return Promise.resolve(); + if (!routeId) return; + // [3D 업데이트] — 낡은 횡단 설계(계획고 · 구조물 자리 값)를 먼저 다시 셈한 뒤 그 설계로 지음(317-1) + if (rebuild) await refreshStaleDesigns({ projectId, routeId, detail }).catch(() => []); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) .then((build) => viewer.setCorridor(withSections(build, detail))) .catch(() => viewer.setCorridor(null)); diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index ac59faf63..b1e9db86a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -27,6 +27,8 @@ export interface CrossPreviewContext { export interface CrossPreview { /** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */ schedule: () => void; + /** 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). */ + scheduleIfStale: (key: string) => void; /** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ dispose: () => void; } @@ -34,8 +36,13 @@ export interface CrossPreview { export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { let timer = 0; let seq = 0; + let lastStale = ""; - return { + const preview: CrossPreview = { + scheduleIfStale(key) { + if (key && key !== lastStale) preview.schedule(); + lastStale = key; + }, schedule() { if (!ctx.detail() || ctx.routeId() === null) return; window.clearTimeout(timer); @@ -68,4 +75,5 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { window.clearTimeout(timer); }, }; + return preview; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 67488241b..af784b046 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -14,8 +14,8 @@ import { longitudinalMinimumWidth, } from "../B06_Section/B06_Section_UI_Longitudinal"; import { - hasStaleDesigns, LONG_PAD, + staleKey, windowElevationRange, } from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; @@ -59,6 +59,8 @@ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel" /** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */ export interface ProfileRenderContext { body: HTMLElement; + /** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1). */ + onStaleDesigns?: (key: string) => void; callbacks: RouteProfilePanelCallbacks | undefined; massHaul: { draw: (params: RouteMassHaulDrawParams) => void }; tableOverlay: { @@ -223,6 +225,12 @@ export function renderProfile(ctx: ProfileRenderContext): void { const designProfiles = alignment ? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])] : (longitudinal.design_profiles ?? []); + // 횡단이 지금 계획선 · 얹힌 구조물과 어긋나면(낡음) 패널이 다시 셈을 예약 — 같은 낡음은 한 번만(317-1) + const stale = staleKey({ + longitudinal: { design_profiles: designProfiles }, + cross_sections: detail.cross_sections, + }); + ctx.onStaleDesigns?.(stale); // 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다. const graphData = normalizedLongitudinal(longitudinal); // 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m @@ -416,10 +424,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분 // (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로 // 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고). - pendingRecalc: hasStaleDesigns({ - longitudinal: { design_profiles: designProfiles }, - cross_sections: detail.cross_sections, - }), + pendingRecalc: stale !== "", axis: { maxChainageM: maxChainageOf(longitudinal), // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 5c3cd6782..dfe0e8ad5 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -48,6 +48,8 @@ import { } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; import { readState } from "../A00_Common/b_page_state"; +import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; /** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ @@ -151,6 +153,32 @@ function preserveUserFields( * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). */ +/** + * 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1). + * 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙). + */ +export async function refreshStaleDesigns( + input: Omit, +): Promise { + const draft = readAlignmentDraft(input.routeId); + if (!draft && !hasStaleDesigns(input.detail)) return []; + const saved = ( + input.detail.longitudinal.profile_alignment as + | { + edits?: { + station_offsets?: Record; + curve_radii?: Record; + }; + } + | undefined + )?.edits; + const edits = draft ?? { + station_offsets: saved?.station_offsets ?? {}, + curve_radii: saved?.curve_radii ?? {}, + }; + return refreshCrossDesigns({ ...input, edits }); +} + export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { const sections = input.detail.cross_sections; const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 0ffbf01ad..3250b8525 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -99,6 +99,36 @@ export function hasStaleDesigns( return detail.cross_sections.some((section) => isStaleSection(section, profiles, toleranceM)); } +/** + * 낡은 측점의 지문 — 같은 낡음에 다시 셈을 되풀이하지 않게 부르는 쪽이 견줌(317-1). + * 다시 셈이 못 고친 측점(서버 폴백 등)은 지문이 그대로라 한 번만 묻는다 · 모두 맞으면 "". + */ +export function staleKey( + detail: { + longitudinal: { design_profiles?: DesignProfile[] }; + cross_sections: StaleCandidate[]; + }, + toleranceM = 1e-3, +): string { + const profiles = detail.longitudinal.design_profiles; + return detail.cross_sections + .filter((section) => isStaleSection(section, profiles, toleranceM)) + .map((s) => + [ + s.chainage_m, + s.design?.design_elevation_m, + s.design?.tilted, + s.design?.paved, + s.design?.surface_drop_m, + Boolean(s.ford_pavement), + Boolean(s.underdrain), + s.pavement?.length ?? 0, + s.ford?.overflow_depth_m ?? 0, + ].join(":"), + ) + .join("|"); +} + interface StaleCandidate { chainage_m: number; // 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314) diff --git a/resources/tester/helper_317_stale_preview.cjs b/resources/tester/helper_317_stale_preview.cjs new file mode 100644 index 000000000..af6868dc5 --- /dev/null +++ b/resources/tester/helper_317_stale_preview.cjs @@ -0,0 +1,78 @@ +/* PLAN 317-1 단위검증 헬퍼 — 낡음 지문(`staleKey`) · 같은 지문이면 다시 셈 안 함(`scheduleIfStale`). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 다시 셈 창구는 흉내(부른 횟수만 셈). + * 짝 = `test_317_3d_update.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +let refreshCalls = 0; +const STUBS = { + "../B06_Section/B06_Section_Cross_Refresh": { + refreshCrossDesigns: async () => { + refreshCalls += 1; + return []; + }, + }, +}; +globalThis.window = { setTimeout: (fn) => (fn(), 1), clearTimeout: () => {} }; +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 (STUBS[m]) return STUBS[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 { staleKey } = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts")); +const { createCrossPreview } = load( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Preview.ts"), +); + +const detailOf = (sections) => ({ + longitudinal: { design_profiles: [] }, + cross_sections: sections, +}); +const fordStale = { + chainage_m: 50, + ford_pavement: {}, + design: { design_elevation_m: 1, paved: true }, +}; +const fordFresh = { ...fordStale, design: { ...fordStale.design, tilted: true } }; + +(async () => { + const out = { + key_stale: staleKey(detailOf([fordStale])), + key_fresh: staleKey(detailOf([fordFresh])), + }; + const preview = createCrossPreview({ + projectId: "p1", + detail: () => detailOf([fordStale]), + routeId: () => 1, + edits: () => ({ station_offsets: {}, curve_radii: {} }), + debounceMs: () => 0, + onApplied: () => {}, + }); + const calls = []; + for (const key of ["a", "a", "", "a", "b"]) { + preview.scheduleIfStale(key); + await Promise.resolve(); + calls.push(refreshCalls); + } + out.calls = calls; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/test_317_3d_update.py b/resources/tester/test_317_3d_update.py new file mode 100644 index 000000000..c7d59d199 --- /dev/null +++ b/resources/tester/test_317_3d_update.py @@ -0,0 +1,64 @@ +"""PLAN 317-1 — B05 [3D 업데이트]가 낡은 횡단을 먼저 다시 셈(요구 213). + +- 낡음 지문(`staleKey`) — 낡은 측점이 있으면 지문 · 모두 맞으면 "" +- 그릴 때 잰 지문이 새것일 때만 다시 셈 예약(같은 낡음 되풀이 · 서버 폴백 반복 막음) +- 그래프가 그릴 때 지문을 패널에 넘김 · 단추는 예상형상을 짓기 전에 낡은 설계부터 다시 셈 + +실제 TS 를 `helper_317_stale_preview.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_317_stale_preview.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def test_stale_key_marks_only_stale_sections(result: dict) -> None: + assert result["key_stale"].startswith("50:") + assert result["key_fresh"] == "" + + +def test_same_stale_key_schedules_once(result: dict) -> None: + # a → 부름 · a(같음) → 안 부름 · "" → 안 부름(되돌림) · a(다시 낡음) → 부름 · b → 부름 + assert result["calls"] == [1, 1, 1, 2, 3] + + +def test_draw_and_button_wiring() -> None: + panel = _read("A00_Common", "design", "design_profile.ts") + assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel + render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") + assert "ctx.onStaleDesigns?.(stale);" in render + assert 'pendingRecalc: stale !== ""' in render + corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts") + body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0] + assert "await refreshStaleDesigns({ projectId, routeId, detail })" in body + assert body.index("refreshStaleDesigns") < body.index("ensureCorridor")