From d28f1e8584cb1b86ba3f751d6447241a0996f995 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 07:09:44 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EF=BB=BFfix(467=20=EA=B0=92=20=EC=95=88=20?= =?UTF-8?q?=EB=B3=80=ED=95=A8=20=E2=91=A1):=20=EA=B0=88=EC=95=84=EB=81=BC?= =?UTF-8?q?=EC=9B=80=20=EC=A0=95=EB=B3=B8=20=ED=91=9C=EB=B3=B8=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20=C2=B7=20=EC=86=8C=EB=8B=A8=20=EC=9B=90=EC=B2=9C=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (B1) 노선 초안 없는 차례 = 같은 열쇠 · 누가거리 0.01 m 안 앞 횡단 기하 · 표본 그대로(축 허용 1e-3) · 노선 초안 차례는 옛 규칙 · 가게 routeDraft 로 가름(Section_Store 한 줄) - (B2) 소단 사본(세션 berm) = 페이지 시설 줄이 씀(같으면 안 씀 · 쓰면 cross 한 번 · 늦은 import) · 횡단 보기 syncBermSpans 쓰기 뺌 · 다시 셈 = 사본 없으면 저장분 design.berm(파이썬 berm_from 짝 storedBermSpec) - 새 시험 test_272_swap_keeps_canon · test_272_berm_source(TS ↔ 파이썬 거울) · helper_271 에 Persist 덧대기(import.meta 로더 밖) Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_facility_lane.ts | 39 +++- B06_Section/B06_Section_Cross_Refresh.ts | 37 ++- B06_Section/B06_Section_Section_Store.ts | 9 +- .../B06_Section_UI_Page_Design_Sync.ts | 23 +- B06_Section/logic/B06_Section_Station_Swap.ts | 51 ++-- resources/tester/helper_271_facility_lane.cjs | 2 + resources/tester/helper_272_berm_source.mjs | 135 +++++++++++ .../tester/helper_272_berm_source_entry.ts | 7 + resources/tester/helper_272_swap_canon.mjs | 217 ++++++++++++++++++ .../tester/helper_272_swap_canon_entry.ts | 9 + resources/tester/test_272_berm_source.py | 200 ++++++++++++++++ resources/tester/test_272_swap_keeps_canon.py | 111 +++++++++ 12 files changed, 799 insertions(+), 41 deletions(-) create mode 100644 resources/tester/helper_272_berm_source.mjs create mode 100644 resources/tester/helper_272_berm_source_entry.ts create mode 100644 resources/tester/helper_272_swap_canon.mjs create mode 100644 resources/tester/helper_272_swap_canon_entry.ts create mode 100644 resources/tester/test_272_berm_source.py create mode 100644 resources/tester/test_272_swap_keeps_canon.py diff --git a/A00_Common/design/design_facility_lane.ts b/A00_Common/design/design_facility_lane.ts index 3cc92d480..374ef631e 100644 --- a/A00_Common/design/design_facility_lane.ts +++ b/A00_Common/design/design_facility_lane.ts @@ -8,9 +8,17 @@ * 입력 = 관 알림(배수유역도 · B06 관 목록) · 구조물(보기 고침 · 저장분) · 종류 표 · 측점 간격 · 추가 측점(가게) * 관 id 장부 = 관 고정 id 가 쥔 세션 id 를 그 자리로(411-B) · 헛 id 정리(411-E) — 어느 보기에서 고쳐도 * 계획고 편집 옮김(사라진 측점 → 새 측점 · 213-2) = 사용자 고침만 · 노선 따라감(`follow`)은 안 옮김(계산 묶음 몫 · 349-0d) + * 소단 사본(세션 `berm`) = 구조물 소단 구간 — 쓰는 자리 여기 하나(B05 만 열어도 · 같으면 안 씀 · 쓰면 cross 한 번 · 467) * ========================================================================== */ import { useDesignStore } from "./design_store"; +import { pageRouteId } from "./design_page_route"; +import { readState } from "../b_page_state"; +import { + bermSpansFromStructures, + writeBermSpans, + type BermSpan, +} from "../../B06_Section/B06_Section_UI_Page_Persist"; import { pipeIdAt, placePipeIds, prunePipeIds, withPipeIds } from "./design_pipe_ids"; import { plannedIrregular } from "./design_structure_status"; import { planStations } from "../../common_util/common_util_structure_station"; @@ -85,8 +93,9 @@ export function createFacilityLane( ): FacilityLane { const store = useDesignStore(projectId, null); let types: StructureType[] = []; - let stored: StructureInstance[] = []; + let stored: StructureInstance[] | null = null; let structures: StructureInstance[] = []; + let structuresKnown = false; // 구조물을 받기 전 = 소단 사본 안 씀(빈 목록으로 저장분 소단을 지우지 않게) /** 마지막 관 알림 — 노선 따라감 · 관 초안이 바뀌면 이것을 초안 측점으로 다시 맞춤. */ let reported: LanePipe[] | null = null; let pipes: LanePipe[] = []; @@ -139,6 +148,23 @@ export function createFacilityLane( const marks = (): StructureInstance[] => [...structures, ...pipesToStructureMarks(pipes)]; + /** 소단 사본 = 지금 구조물의 소단 구간(467 · 사본 없는 동안 다시 셈 = 저장분 `design.berm`) · 같으면 안 씀 · + * 쓰면 저장 노선 cross 한 번 더(같은 값 측점은 다시 셈 기억이 건너뜀 · 묶음은 늦게 불러옴 · 순환 없음). */ + function syncBerm(): void { + const routeId = pageRouteId(projectId); + if (!structuresKnown || routeId === null) return; + const copy = readState("berm", projectId, routeId); + const next = bermSpansFromStructures(structures); + if (Array.isArray(copy) && JSON.stringify(copy) === JSON.stringify(next)) return; + writeBermSpans(projectId, routeId, next); + void Promise.all([import("./design_pipeline_cross"), import("./design_pipeline_page")]) + .then(([cross, page]) => { + cross.forceCross(); + page.pipelineCross(projectId); + }) + .catch(() => undefined); + } + function paint(force = false): void { const to = profile(); if (!to) return; @@ -179,13 +205,20 @@ export function createFacilityLane( stored = next; }, set(next, carryEdits = true) { - if (next.structures) structures = next.structures; + if (next.structures) { + structures = next.structures; + structuresKnown = true; + } if (next.pipes) reported = [...next.pipes]; refresh(carryEdits); + if (next.structures) run(syncBerm); }, follow() { - structures = store.get("structures") ?? stored; + const drafted = store.get("structures"); + structures = drafted ?? stored ?? []; + structuresKnown ||= drafted !== null || stored !== null; refresh(false); + run(syncBerm); }, setIntervalM(next) { if (next > 0 && next !== intervalM) { diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 058354b46..7fa5c4872 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -34,8 +34,15 @@ import { readCutSlopeSession, readRockBoundarySession, } from "./B06_Section_UI_Page_Persist"; -import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist"; -import type { BermSpec } from "@util/common_util_cross_berm"; +import type { BermSessionSpec, BermSpan } from "./B06_Section_UI_Page_Persist"; +import { + BERM_DEFAULT_DEPTH_M, + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, + type BermSpec, +} from "@util/common_util_cross_berm"; +import { readState } from "../A00_Common/b_page_state"; import { crossDesignChoices, readBermDepths, @@ -260,6 +267,24 @@ function bermPayload( type Side = "left" | "right"; const SIDES: Side[] = ["left", "right"]; +/** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */ +export function storedBermSpec(berm: unknown): BermSessionSpec | null { + if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null; + const spec = berm as Record; + const value = (key: string, fallback: number): number => { + const raw = spec[key]; + if (raw === undefined) return fallback; + return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw); + }; + const out = { + width_m: value("width_m", BERM_DEFAULT_WIDTH_M), + interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M), + slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG), + depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0), + }; + return Object.values(out).every(Number.isFinite) ? out : null; +} + /** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */ function bermDepthReader(projectId: string, routeId: number) { const session = readBermDepths(projectId, routeId); @@ -333,11 +358,13 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { // 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07). const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId); // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 - // 순간 계단이 사라진다(계획서 3-9). - const bermSpans = readBermSpans(projectId, input.routeId); + // 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm` + // (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467). + const bermCopy = readState("berm", projectId, input.routeId); + const bermSpans = Array.isArray(bermCopy) ? bermCopy : null; const bermDepthAt = bermDepthReader(projectId, input.routeId); const bermAt = (chainageM: number, design: Record): BermSpec | null => { - const spec = bermSpecAt(bermSpans, chainageM); + const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm); return spec ? { widthM: spec.width_m, diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index f3d835590..974d53519 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -41,6 +41,7 @@ import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Re import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options"; import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types"; import type { DesignStoreValues } from "../A00_Common/design/design_store"; +import { useDesignStore } from "../A00_Common/design/design_store"; import { carryStationKeys, KEYED_STATES, @@ -237,7 +238,13 @@ export async function swapSectionStations( const interval = stationIntervalOf(current); const oldKeys = registeredKeys(projectId, routeId, current.cross_sections, interval); if (sameStationList(current.cross_sections, oldKeys, stations)) return null; - const swap = swapCrossSections(current, stations, oldKeys, interval); + const swap = swapCrossSections( + current, + stations, + oldKeys, + interval, + useDesignStore(projectId, null).get("routeDraft") !== null, + ); // 초안 없으면 정본 표본(467) // 설계는 사본 — 면적 얹기 · 다시 셈이 앞 상세(초안 전 상세 등)의 설계를 제자리로 안 고침(349-0o) for (const section of swap.detail.cross_sections) if (section.design) section.design = { ...section.design }; diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 4598ef1da..d08410e79 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -5,7 +5,7 @@ * - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산 * - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산 * - `reconcileStaleDesigns`: 횡단 다시 셈을 계산 묶음에 부탁(cross 단계 한 곳 · 387-4) - * - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기 + * - `syncBermSpans` : 구조물 측점 열쇠(소단 사본은 페이지 시설 줄이 씀 · 467) * - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영] * 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2). * ========================================================================== */ @@ -15,11 +15,6 @@ import { writeCrossDesignChoice, writeShoulderExtend, } from "./B06_Section_Cross_Design_Session"; -import { - bermSpansFromStructures, - readBermSpans, - writeBermSpans, -} from "./B06_Section_UI_Page_Persist"; import { showToast } from "@ui/ui_template_elements"; import { L } from "./B06_Section_UI_Page_Common"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; @@ -137,20 +132,10 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { await reconcilePagePipeline(projectId); } - /** - * 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다. - * - * 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만 - * 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다. - */ + /** 구조물 측점 열쇠 = 구조물 id(344-3) — 소단 사본(세션 `berm`)은 페이지 시설 줄 한 곳이 씀(B05 만 열어도 · 467). */ function syncBermSpans(structures: ReadonlyArray): void { - const currentRouteId = ctx.routeId(); - if (!projectId || currentRouteId === null) return; - useStructureIds(projectId, structures); // 구조물 측점 열쇠 = 구조물 id(344-3) - const next = bermSpansFromStructures(structures); - if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; - writeBermSpans(projectId, currentRouteId, next); - void reconcileStaleDesigns({ force: true }); + if (!projectId || ctx.routeId() === null) return; + useStructureIds(projectId, structures); } /** 패널 [전체 반영](N-2-1 · 346-2): 표준값으로 전 측점을 **한 번** 다시 셈 — 측점마다 돌면 diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index 26bffa0c3..12e587877 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -6,6 +6,8 @@ * 받음 — 설계 · 구조물 제원 · 이름 그대로 · 자리(누가거리 · 좌표 · 방위 · 확폭)만 새것(433-4). * · 지반 표본 · 상단측 · 중심고 = 자리 · 축이 같은 앞 횡단이면 앞 값(서버 샘플링 정본 · 덧 측점만 넣어도 나머지 * 측점 면적이 브라우저 지형으로 안 바뀜 · 433-11) · 새 측점 · 옮긴 측점만 지반 표본(채워 왔으면). + * · 노선 초안 없는 차례(관 · 구조물 · 추가 측점만) = 같은 열쇠 · 누가거리 0.01 m 안 앞 횡단의 기하 · 표본 그대로 + * (축 허용 1e-3 · 브라우저 측점 틀 ↔ 서버 틀 mm 차가 정본 표본을 안 덮음 · 467 · 요구 272). * · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움). * · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428). * · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가 @@ -146,27 +148,31 @@ export interface StationSwap { moved: boolean; } -/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */ -function samePlace(prior: CrossSection, station: PipelineStation): boolean { +/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터 · 허용 `axisTol`)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */ +function samePlace(prior: CrossSection, station: PipelineStation, axisTol = 1e-4): boolean { const [lx, ly] = prior.frame?.left_xy ?? [Number.NaN, Number.NaN]; return ( Math.abs(prior.chainage_m - station.chainageM) < 0.01 && Math.hypot(prior.center_x - station.x, prior.center_y - station.y) <= 0.01 && - Math.abs(lx - station.left[0]) < 1e-4 && - Math.abs(ly - station.left[1]) < 1e-4 + Math.abs(lx - station.left[0]) < axisTol && + Math.abs(ly - station.left[1]) < axisTol ); } +/** 노선 초안 없는 차례의 축 허용(측점 축 0.06° · 20 m 끝 2 cm) — 브라우저 틀 ↔ 서버 틀 차(R 12 곡선 3.3e-4)를 넘김. */ +const CANON_AXIS_TOL = 1e-3; + /** * 새 측점 목록으로 상세를 새로 짬. `oldKeys` = 지금 상세 측점마다 열쇠(같은 차례 · `stationKeysOf`) · * 열쇠가 안 맞으면 옮긴 곡선 열쇠(`curveMoves` · 서버 `carry_cross_inputs` 같은 규칙) · 그다음 같은 자리(0.01 m) · - * 같은 종류의 앞 횡단을 씀. + * 같은 종류의 앞 횡단을 씀. `routeDraft` = 노선 초안 차례(가게 `routeDraft` · 아니면 정본 기하 · 표본 유지 · 467). */ export function swapCrossSections( detail: SectionDetailResponse, stations: ReadonlyArray, oldKeys: ReadonlyArray, intervalM: number | null, + routeDraft = false, ): StationSwap { const byKey = new Map(detail.cross_sections.map((section, i) => [oldKeys[i], section])); const used = new Set(); @@ -209,8 +215,15 @@ export function swapCrossSections( moved = true; if (prior && text(prior.chainage_m) !== text(station.chainageM)) moves.set(text(prior.chainage_m), text(station.chainageM)); + // 노선 초안 없는 차례 · 같은 열쇠 · 같은 자리 = 앞 횡단 기하 · 표본 그대로(467 · 되돌리면 정본과 같음) + const canon = + !routeDraft && + !!prior && + byKey.get(station.key) === prior && + samePlace(prior, station, CANON_AXIS_TOL); // 자리 같은 앞 횡단 = 서버 표본 그대로(433-11) · 아니면 새 지반 표본(채워 왔으면) - const fresh = !!station.ground && !(prior?.samples?.length && samePlace(prior, station)); + const fresh = + !canon && !!station.ground && !(prior?.samples?.length && samePlace(prior, station)); const kind = kindOf(station); const onGrid = kind === "irregular" && @@ -233,13 +246,25 @@ export function swapCrossSections( center_z: fresh ? (station.ground?.centerZ ?? prior?.center_z ?? null) : (prior?.center_z ?? null), - azimuth_deg: station.azimuthDeg, - plan_radius_m: station.planRadiusM, - curve_outer_side: station.curveOuterSide, - curve_widening_m: station.curveWideningM, - center_x: station.x, - center_y: station.y, - frame: { left_xy: [station.left[0], station.left[1]] as [number, number] }, + ...(canon && prior + ? { + azimuth_deg: prior.azimuth_deg, + plan_radius_m: prior.plan_radius_m, + curve_outer_side: prior.curve_outer_side, + curve_widening_m: prior.curve_widening_m, + center_x: prior.center_x, + center_y: prior.center_y, + frame: prior.frame, + } + : { + azimuth_deg: station.azimuthDeg, + plan_radius_m: station.planRadiusM, + curve_outer_side: station.curveOuterSide, + curve_widening_m: station.curveWideningM, + center_x: station.x, + center_y: station.y, + frame: { left_xy: [station.left[0], station.left[1]] as [number, number] }, + }), }; longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) }); const samples = fresh ? groundSamples(station) : (prior?.samples ?? []); diff --git a/resources/tester/helper_271_facility_lane.cjs b/resources/tester/helper_271_facility_lane.cjs index 007204966..d50a92687 100644 --- a/resources/tester/helper_271_facility_lane.cjs +++ b/resources/tester/helper_271_facility_lane.cjs @@ -35,6 +35,8 @@ const STUBS = { }), "ui_template_elements.ts": anyModule(), "B04_PreProcess_Api_Fetch.ts": anyModule(), + // 소단 사본(467) — 끌어오는 줄이 import.meta 를 써 이 로더로 못 읽음 · 소단 몫은 test_272_berm_source(vite 묶음) + "B06_Section_UI_Page_Persist.ts": anyModule({ bermSpansFromStructures: () => [] }), }; const cache = new Map(); diff --git a/resources/tester/helper_272_berm_source.mjs b/resources/tester/helper_272_berm_source.mjs new file mode 100644 index 000000000..cb34698d0 --- /dev/null +++ b/resources/tester/helper_272_berm_source.mjs @@ -0,0 +1,135 @@ +/* 467 헬퍼 — 소단 원천 한 벌: 구조물 → 소단 구간(TS) · 저장분 소단 제원(TS) · 시설 줄이 세션 사본을 씀 · + * 다시 셈 = 사본 없으면 저장분 `design.berm`. + * node helper_272_berm_source.mjs <묶음.mjs> + * job = { standard, rockDefault, structureCases, storedCases, ground } · 짝 = `test_272_berm_source.py`. + */ +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const writes = []; +const memory = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => { + writes.push(k); + m.set(k, String(v)); + }, + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = memory(); +globalThis.localStorage = memory(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); +globalThis.fetch = async () => new Response("{}", { status: 404 }); + +const m = await import(pathToFileURL(process.argv[2]).href); +const job = JSON.parse(readFileSync(process.argv[3], "utf8")); +const P = "p272berm"; +const R = 5; +const out = {}; + +/* 1. 거울 — 구조물 → 소단 구간 · 저장분 제원 */ +out.spans = job.structureCases.map((list) => m.bermSpansFromStructures(list)); +out.stored = job.storedCases.map((spec) => m.storedBermSpec(spec)); + +/* 2. 시설 줄이 사본을 씀 — 구조물 받기 전 안 씀 · 같으면 안 씀 · 바뀌면 한 번 */ +{ + m.setPageRouteId(P, R); + const key = m.stateKey("berm", P, R); + const count = () => writes.filter((k) => k === key).length; + const copy = () => m.readState("berm", P, R); + const lane = m.createFacilityLane(P, () => null); + const steps = {}; + lane.setTypes([]); + lane.follow(); // 저장분 · 가게 구조물 없음 = 아직 모름 + steps.beforeKnown = { copy: copy(), writes: count() }; + const saved = job.structureCases[0]; + lane.setStored(saved); + lane.set({ structures: saved }, false); + steps.loaded = { copy: copy(), writes: count() }; + lane.set({ structures: saved.map((item) => ({ ...item })) }); + lane.set({ pipes: [] }); + steps.same = { writes: count() }; + const moved = saved.map((item) => + item.type_id === "berm" ? { ...item, start_m: item.start_m + 5, end_m: item.end_m + 5 } : item, + ); + lane.set({ structures: moved }); + steps.moved = { copy: copy(), writes: count() }; + lane.set({ structures: [] }); + steps.removed = { copy: copy(), writes: count() }; + lane.dispose(); + out.lane = steps; +} + +/* 3. 다시 셈 소단 원천 — 사본 없음(저장분 design.berm) · 사본 = 같은 구간 · 사본 = 빈 목록 */ +{ + m.writeState("std-cross", job.standard, P); + m.rememberRockBoundaryDefault(P, job.rockDefault); + const detailOf = () => ({ + longitudinal: { length_m: 200, stations: [], profile_alignment: { policy: {} } }, + cross_sections: [ + { + station_id: "station_000000040000", + chainage_m: 40, + label: "", + kind: "regular", + center_z: 96, + azimuth_deg: 90, + center_x: 40, + center_y: 0, + frame: { left_xy: [0, 1] }, + uphill_side: "left", + samples: job.ground, + design: { + design_elevation_m: 100, + ground_type: "soil", + section_mode: "both_fill", + ditch_type: "standard", + berm: job.storedBerm, + }, + }, + ], + }); + const alignment = { + stations: [{ chainage_m: 40, plan_elevation_m: 100 }], + samples: [ + { chainage_m: 0, elevation_m: 100 }, + { chainage_m: 100000, elevation_m: 100 }, + ], + balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 }, + }; + const run = async (copy) => { + if (copy === undefined) sessionStorage.removeItem(m.stateKey("berm", P, R)); + else m.writeState("berm", copy, P, R); + const detail = detailOf(); + await m.refreshCrossDesigns({ + projectId: P, + routeId: R, + detail, + edits: { station_offsets: {}, curve_radii: {} }, + alignment, + }); + const design = detail.cross_sections[0].design; + return { fill: design.fill_area_m2 ?? null, cut: design.cut_area_m2 ?? null }; + }; + out.refresh = { + noCopy: await run(undefined), + copySame: await run(m.bermSpansFromStructures(job.structureCases[0])), + copyEmpty: await run([]), + }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_272_berm_source_entry.ts b/resources/tester/helper_272_berm_source_entry.ts new file mode 100644 index 000000000..9aa269cd0 --- /dev/null +++ b/resources/tester/helper_272_berm_source_entry.ts @@ -0,0 +1,7 @@ +/* 467 시험 묶음 진입점 — 소단 원천 한 벌(시설 줄 사본 · 다시 셈 저장분 폴백 · 진짜 모듈 · vite ssr). */ +export { createFacilityLane } from "../../A00_Common/design/design_facility_lane"; +export { setPageRouteId } from "../../A00_Common/design/design_page_route"; +export { refreshCrossDesigns, storedBermSpec } from "../../B06_Section/B06_Section_Cross_Refresh"; +export { bermSpansFromStructures } from "../../B06_Section/B06_Section_UI_Page_Persist"; +export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel"; +export { readState, stateKey, writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/helper_272_swap_canon.mjs b/resources/tester/helper_272_swap_canon.mjs new file mode 100644 index 000000000..33516b1ba --- /dev/null +++ b/resources/tester/helper_272_swap_canon.mjs @@ -0,0 +1,217 @@ +/* 467 헬퍼 — 노선 초안 없는 차례(관만 옮김)의 갈아끼움이 정본(서버) 횡단 기하 · 표본을 지키는지. + * node helper_272_swap_canon.mjs <묶음.mjs> + * 정본 = 서버 틀(표본 A) · 묶음 측점 = 브라우저 틀(중심 0.5 mm · R 12 곡선 축 3.3e-4 차 · 표본 B). + * 짝 = `test_272_swap_keeps_canon.py` · 표준출력 JSON 한 벌. + */ +import { pathToFileURL } from "node:url"; + +const memory = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = memory(); +globalThis.localStorage = memory(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); +globalThis.fetch = async () => new Response("{}", { status: 404 }); + +const m = await import(pathToFileURL(process.argv[2]).href); +const idOf = (c) => `station_${String(Math.round(c * 1000)).padStart(12, "0")}`; +const OFFSETS = [-10, -6, -3, 0, 3, 6, 10]; +const groundA = (c, o) => 100 + 0.03 * c + 0.4 * o; // 서버 표본(정본) +const groundB = (c, o) => groundA(c, o) + 0.0004 * Math.sin(c + o) + 0.0001; // 브라우저 지형(mm 차) + +/* 측점 — [열쇠 · 누가거리 · 묶음 종류 · 상세 종류 · 축 차(rad)] · 곡선 셋 = R 12 */ +const LIST = [ + ["end:BP", 0, "end", "bp", 1e-5], + ["grid:1", 20, "grid", "regular", 1e-5], + ["curve:27.50", 27.5, "curve", "curve", 3.3e-4], + ["curve:33.20", 33.2, "curve", "curve", -3.3e-4], + ["curve:38.90", 38.9, "curve", "curve", 2.1e-4], + ["grid:2", 40, "grid", "regular", 1e-5], + ["pipe:p1", 45, "pipe", "irregular", 1e-5], + ["grid:3", 60, "grid", "regular", 1e-5], + ["end:EP", 70, "end", "ep", 1e-5], +]; +const azOf = (c) => 0.6 + c / 40; // 진행 방향(rad · 곡선 둘레가 휨) +const leftOf = (a) => [-Math.sin(a), Math.cos(a)]; +const xyOf = (c) => [1000 + 12 * Math.sin(azOf(c)), 2000 - 12 * Math.cos(azOf(c))]; + +const canonSection = ([, c, , kind]) => { + const [x, y] = xyOf(c); + const samples = OFFSETS.map((o) => ({ offset_m: o, elevation_m: groundA(c, o), valid: true })); + return { + station_id: idOf(c), + chainage_m: c, + label: "", + kind, + center_z: groundA(c, 0), + azimuth_deg: (azOf(c) * 180) / Math.PI, + plan_radius_m: kind === "curve" ? 12 : null, + curve_outer_side: kind === "curve" ? "left" : null, + curve_widening_m: kind === "curve" ? 1.25 : null, + center_x: x, + center_y: y, + frame: { left_xy: leftOf(azOf(c)) }, + uphill_side: "left", + samples, + design: { design_elevation_m: groundA(c, 0) + 0.3, mark: `canon@${c}` }, + ...(kind === "irregular" ? { structure: "관", culvert: {} } : {}), + }; +}; +const canonDetail = () => ({ + longitudinal: { + length_m: 70, + stations: [], + profile_alignment: { policy: { station_interval_m: 20 } }, + }, + cross_sections: LIST.map(canonSection), +}); +/** 묶음 측점 — 브라우저 틀(중심 0.5 mm · 축 차) · 표본 B · `shift` = 노선 초안이 옮긴 자리(m · y). */ +const station = ([key, c0, kind, , tilt], pipeAt, shift = () => 0) => { + const c = key === "pipe:p1" ? pipeAt : c0; + const [x, y] = xyOf(c); + const a = azOf(c) + tilt; + const side = (sign) => + OFFSETS.filter((o) => o * sign > 0).map((o) => ({ offsetM: Math.abs(o), z: groundB(c, o) })); + return { + key, + chainageM: c, + x: x + 0.0004, + y: y - 0.0003 + shift(c), + azimuthDeg: (a * 180) / Math.PI, + left: leftOf(a), + kind, + id: key === "pipe:p1" ? "p1" : null, + ground: { centerZ: groundB(c, 0), left: side(1), right: side(-1) }, + planRadiusM: kind === "curve" ? 12.0004 : null, + curveOuterSide: kind === "curve" ? "left" : null, + curveWideningM: kind === "curve" ? 1.2501 : null, + }; +}; +const stationsAt = (pipeAt, shift) => + LIST.map((row) => station(row, pipeAt, shift)).sort((p, q) => p.chainageM - q.chainageM); +const KEYS = LIST.map(([key]) => key); +const keysOf = (list) => list.map((s) => s.key); +const json = (v) => JSON.stringify(v); +const canonOf = new Map(canonDetail().cross_sections.map((s) => [s.chainage_m, s])); +/** 상세 측점마다 [정본과 같은가(기하 · 표본 · 중심고) · 표본이 브라우저 B 인가 · 설계 표시]. */ +const GEOM = ["center_x", "center_y", "frame", "azimuth_deg", "plan_radius_m", "curve_widening_m"]; +const report = (detail) => + Object.fromEntries( + detail.cross_sections.map((s) => { + const c = canonOf.get(s.chainage_m); + const same = + !!c && + GEOM.every((k) => json(s[k]) === json(c[k])) && + json(s.samples) === json(c.samples) && + s.center_z === c.center_z; + const browser = s.samples.every( + (p) => Math.abs(p.elevation_m - groundB(s.chainage_m, p.offset_m)) < 1e-12, + ); + return [s.chainage_m.toFixed(2), { same, browser, mark: s.design?.mark ?? null }]; + }), + ); +/** `skip` 밖 횡단이 정본과 바이트 같은가(이름 글은 갈아끼움이 새로 씀 · 뺌). */ +const bytesSame = (detail, skip) => + detail.cross_sections + .filter((s) => !skip.includes(s.chainage_m)) + .every((s) => { + const c = canonOf.get(s.chainage_m); + if (!c) return false; + const { label: _a, ...got } = s; + const { label: _b, ...want } = c; + return ( + json( + Object.keys(got) + .sort() + .map((k) => [k, got[k]]), + ) === + json( + Object.keys(want) + .sort() + .map((k) => [k, want[k]]), + ) + ); + }); + +const out = {}; + +/* 1. 순수 — 관만 옮김(45 → 55) · 노선 초안 없음 대 있음 · 되돌림 */ +{ + const moved = stationsAt(55); + const keep = m.swapCrossSections(canonDetail(), moved, KEYS, 20, false); + const draft = m.swapCrossSections(canonDetail(), moved, KEYS, 20, true); + const back = m.swapCrossSections(keep.detail, stationsAt(45), keysOf(moved), 20, false); + out.pure = { + keep: report(keep.detail), + draft: report(draft.detail), + keepMoved: keep.moved, + back: report(back.detail), + backBytes: bytesSame(back.detail, [45]), + defaultIsKeep: + json(m.swapCrossSections(canonDetail(), moved, KEYS, 20).detail) === json(keep.detail), + }; +} + +/* 2. 가게 길(swapSectionStations) — 가게 routeDraft 로 가름: 없음 · 있음 · 초안 버린 직후 */ +const seed = (P, R) => { + m.writeState("section-detail", canonDetail(), P, R); + m.writeState("pipe-ids", { next: 2, ids: [{ id: "p1", at: 45 }] }, P); +}; +const pipeAt = (P, at) => m.writeState("pipe-ids", { next: 2, ids: [{ id: "p1", at }] }, P); +const keyList = () => KEYS.map((key, i) => ({ key, chainageM: LIST[i][1] })); +{ + const P = "p272a"; + const R = 3; + seed(P, R); + await m.loadSectionDetail(P, R); + m.useStationKeyList(P, R, keyList()); + pipeAt(P, 55); + const moved = await m.swapSectionStations(P, R, stationsAt(55)); + pipeAt(P, 45); + const back = await m.swapSectionStations(P, R, stationsAt(45)); + out.storeNoDraft = { + moved: moved && report(moved.detail), + back: back && report(back.detail), + backBytes: !!back && bytesSame(back.detail, [45]), + }; +} +{ + const P = "p272b"; + const R = 4; + seed(P, R); + await m.loadSectionDetail(P, R); + m.useStationKeyList(P, R, keyList()); + const shop = m.useDesignStore(P, null); + shop.set("routeDraft", { nodes: [], rev: 1 }); + pipeAt(P, 55); + // 초안 = 곡선 둘레만 3 cm 옮김(나머지 자리 그대로) + const bend = (c) => (c > 25 && c < 39 ? 0.03 : 0); + const draft = await m.swapSectionStations(P, R, stationsAt(55, bend)); + shop.set("routeDraft", null); // 초안 버림 → 저장 노선 측점(묶음 「no-saved」 길) + pipeAt(P, 45); + const dropped = await m.swapSectionStations(P, R, stationsAt(45)); + out.storeDraft = { + routeDraftSet: draft !== null, + draft: draft && report(draft.detail), + dropped: dropped && report(dropped.detail), + }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_272_swap_canon_entry.ts b/resources/tester/helper_272_swap_canon_entry.ts new file mode 100644 index 000000000..f5cc450e5 --- /dev/null +++ b/resources/tester/helper_272_swap_canon_entry.ts @@ -0,0 +1,9 @@ +/* 467 시험 묶음 진입점 — 갈아끼움 정본 표본 유지(진짜 모듈 · vite ssr). */ +export { swapCrossSections } from "../../B06_Section/logic/B06_Section_Station_Swap"; +export { + loadSectionDetail, + swapSectionStations, +} from "../../B06_Section/B06_Section_Section_Store"; +export { useStationKeyList } from "../../B06_Section/B06_Section_Station_Keys"; +export { useDesignStore } from "../../A00_Common/design/design_store"; +export { writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/test_272_berm_source.py b/resources/tester/test_272_berm_source.py new file mode 100644 index 000000000..aff987337 --- /dev/null +++ b/resources/tester/test_272_berm_source.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +"""467 (요구 272 · 분석 4장 (B2)) — 소단 원천 한 벌. + +못박는 것 + - 소단 사본(세션 `berm`) 쓰는 자리 = 페이지 시설 줄 하나 — 구조물 받기 전 안 씀 · + 같으면 안 씀 · 바뀌면 한 번 · 지우면 빈 목록(B05 만 열어도 씀 · 횡단 보기는 안 씀). + - 다시 셈 = 사본 없으면 저장분 `design.berm`(파이썬 `load_berm_spans` 규칙) — + B05 만 연 값 = B06 연 뒤 값 = [저장] 서버 값(`enforce_design_lines`) · 빈 사본 = 소단 없음. + - 거울 — TS `bermSpansFromStructures` ↔ 파이썬 `load_berm_spans` · + TS `storedBermSpec` ↔ 파이썬 `berm_from`. + +Node 헬퍼 `helper_272_berm_source.mjs` 가 vite ssr 로 묶은 진짜 모듈을 부름. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section.B06_Section_Engine_Structures_Wall import load_berm_spans # noqa: E402 +from B06_Section.B06_Section_Router_Design import berm_from, enforce_design_lines # noqa: E402 +from config.config_system import ( # noqa: E402 + STANDARD_CROSS_SECTION, + STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, +) + + +def _berm(start, end, **options): + return { + "type_id": "berm", + "placement": "interval", + "start_m": start, + "end_m": end, + "options": options, + } + + +SAVED = [_berm(0.0, 100.0, width_m=1.0, interval_m=3.0, slope_deg=0.0, depth_m=1.0)] +STRUCTURE_CASES = [ + SAVED, + [ + _berm(50.0, 80.0, width_m=0), # 폭 0 = 없음 + _berm(10.0, 30.0, depth_m=""), # 빈 깊이 = 기본 1.0 + _berm(30.0, 40.0, depth_m=-2, width_m="0.7"), # 음수 깊이 = 0 · 글 수 + _berm(60.0, 70.0, width_m="abc", interval_m=2.5, slope_deg=4), # 못 읽는 폭 = 기본 + ], + [{"type_id": "berm", "placement": "point", "chainage_m": 25.0, "options": {}}], + [], +] +STORED_CASES = [ + {"width_m": 0.5}, + {"width_m": 0.5, "depth_m": -1}, + {}, + {"width_m": "0.7", "interval_m": 2}, + None, + {"width_m": None}, + {"width_m": ""}, +] +GROUND = [ + {"offset_m": o / 2, "elevation_m": round(96.0 + 0.02 * o / 2, 4), "valid": True} + for o in range(-40, 41) +] +STORED_BERM = {"width_m": 1.0, "interval_m": 3.0, "slope_deg": 0.0, "depth_m": 1.0} +LONG = { + "design_profiles": [ + { + "samples": [ + {"chainage_m": 0, "elevation_m": 100}, + {"chainage_m": 200, "elevation_m": 100}, + ] + } + ] +} + + +def _project(root: Path, structures: list[dict]) -> Path: + route = root / "B05_Profile" / "route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), encoding="utf-8" + ) + return root + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b272berm") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_272_berm_source_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_272_berm_source_entry.js", bundle) + job = work / "job.json" + job.write_text( + json.dumps( + { + "standard": STANDARD_CROSS_SECTION, + "rockDefault": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + "structureCases": STRUCTURE_CASES, + "storedCases": STORED_CASES, + "ground": GROUND, + "storedBerm": STORED_BERM, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 + ["node", str(HERE / "helper_272_berm_source.mjs"), str(bundle), str(job)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_거울_구조물_소단_구간(out, tmp_path): + for index, (structures, ts) in enumerate(zip(STRUCTURE_CASES, out["spans"], strict=True)): + py = load_berm_spans(_project(tmp_path / f"c{index}", structures)) + assert py is not None, index + want = [{"start_m": s, "end_m": e, **spec} for s, e, spec in py] + assert len(ts) == len(want), (index, ts, want) + for got, exp in zip(ts, want, strict=True): + assert got == pytest.approx(exp), (index, got, exp) + + +def test_거울_저장분_소단_제원(out): + for spec, ts in zip(STORED_CASES, out["stored"], strict=True): + py = berm_from(spec, {}) + if py is None: + assert ts is None, spec + continue + want = {k: getattr(py, k) for k in ("width_m", "interval_m", "slope_deg", "depth_m")} + assert ts == pytest.approx(want), spec + + +def test_시설_줄이_사본을_씀(out): + lane = out["lane"] + assert lane["beforeKnown"] == {"copy": None, "writes": 0} # 구조물 받기 전 = 안 씀 + assert lane["loaded"]["writes"] == 1 + assert lane["loaded"]["copy"] == out["spans"][0] # 저장 구조물 소단 구간 + assert lane["same"]["writes"] == 1 # 같은 구간 · 관만 알림 = 안 씀 + assert lane["moved"]["writes"] == 2 + assert lane["moved"]["copy"][0]["start_m"] == 5.0 and lane["moved"]["copy"][0]["end_m"] == 105.0 + assert lane["removed"] == { + "copy": [], + "writes": 3, + } # 소단 지움 = 빈 사본(저장분 소단으로 안 물러섬) + + +def test_다시_셈_B05만_B06_저장_같은_값(out, tmp_path): + r = out["refresh"] + no_copy, same, empty = r["noCopy"], r["copySame"], r["copyEmpty"] + assert no_copy["fill"] == same["fill"] and no_copy["cut"] == same["cut"] # B05 만 = B06 연 뒤 + assert empty["fill"] != no_copy["fill"] # 빈 사본 = 소단 없음(저장분으로 안 물러섬) + # [저장] = 서버가 구조물 정본으로 다시 셈 + root = _project(tmp_path / "save", SAVED) + design = {"ground_type": "soil", "section_mode": "both_fill", "ditch_type": "standard"} + sections = [{"chainage_m": 40.0, "samples": GROUND, "design": design}] + assert enforce_design_lines(LONG, sections, root) == 1 + assert abs(sections[0]["design"]["fill_area_m2"] - same["fill"]) < 1e-3 + bare = [{"chainage_m": 40.0, "samples": GROUND, "design": dict(design)}] + enforce_design_lines(LONG, bare, _project(tmp_path / "bare", [])) + assert abs(bare[0]["design"]["fill_area_m2"] - empty["fill"]) < 1e-3 + + +def test_쓰는_자리_하나(): + sync = (ROOT / "B06_Section" / "B06_Section_UI_Page_Design_Sync.ts").read_text(encoding="utf-8") + assert "writeBermSpans" not in sync and "useStructureIds(projectId, structures)" in sync + lane = (ROOT / "A00_Common" / "design" / "design_facility_lane.ts").read_text(encoding="utf-8") + assert "writeBermSpans(projectId, routeId, next)" in lane diff --git a/resources/tester/test_272_swap_keeps_canon.py b/resources/tester/test_272_swap_keeps_canon.py new file mode 100644 index 000000000..c5c91f970 --- /dev/null +++ b/resources/tester/test_272_swap_keeps_canon.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +"""467 (요구 272 · 분석 4장 (B1)) — 노선 초안 없는 차례의 갈아끼움이 정본 횡단을 지킴. + +못박는 것 + - 관만 옮김(노선 초안 없음) — 같은 열쇠 · 누가거리 0.01 m 안 측점은 앞 횡단(서버 정본) + 기하 · 표본 · 중심고 그대로(R 12 곡선 축 차 3.3e-4 · 중심 0.5 mm 차여도) · + 옮긴 관 측점만 브라우저 표본. + - 되돌림(관 제자리) — 관 측점 밖 모든 횡단이 정본과 바이트 같음(관 측점 = 브라우저 표본). + - 노선 초안 차례 = 옛 규칙(축 허용 1e-4 · 넘으면 브라우저 표본). + - 가게 길(`swapSectionStations`) = 가게 `routeDraft` 로 가름 — 없음 · 있음 · + 초안 버린 직후(초안 표본이 안 남음). + +Node 헬퍼 `helper_272_swap_canon.mjs` 가 vite ssr 로 묶은 진짜 모듈을 부름. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +CURVES = ("27.50", "33.20", "38.90") +STRAIGHT = ("0.00", "20.00", "40.00", "60.00", "70.00") +STILL = (*STRAIGHT, *CURVES) + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b272swap") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_272_swap_canon_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_272_swap_canon_entry.js", bundle) + proc = subprocess.run( # noqa: S603 + ["node", str(HERE / "helper_272_swap_canon.mjs"), str(bundle)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_관만_옮김_정본_기하_표본_그대로(out): + keep = out["pure"]["keep"] + for at in STILL: + assert keep[at]["same"], at # 곡선 축 차 3.3e-4 여도 서버 표본 · 기하 + assert keep[at]["mark"] == f"canon@{float(at):g}" + assert keep["55.00"]["browser"] and not keep["55.00"]["same"] # 옮긴 관 = 새 자리 표본 + assert out["pure"]["keepMoved"] is False # 「노선 밖 고침」 셈 그대로(0.01 m) + assert out["pure"]["defaultIsKeep"] # 기본 = 노선 초안 없음 + + +def test_되돌림_정본과_바이트_같음(out): + back = out["pure"]["back"] + assert all(back[at]["same"] for at in STILL) + assert out["pure"]["backBytes"] # 관 측점 밖 횡단 = 정본 바이트 + assert back["45.00"]["browser"] # 관 측점 = 브라우저 표본(정본은 저장 뒤 서버 값) + + +def test_노선_초안_차례는_옛_규칙(out): + draft = out["pure"]["draft"] + for at in CURVES: # 축 차 > 1e-4 = 새 표본 + assert draft[at]["browser"] and not draft[at]["same"], at + for at in STRAIGHT: # 축 차 1e-5 · 자리 같음 = 앞 표본(기하는 새 틀) + assert not draft[at]["browser"] and not draft[at]["same"], at + + +def test_가게_길_초안_없음(out): + s = out["storeNoDraft"] + assert s["moved"] and s["back"] + assert all(s["moved"][at]["same"] for at in STILL) + assert all(s["back"][at]["same"] for at in STILL) and s["backBytes"] + + +def test_가게_길_초안_있음_버린_직후(out): + s = out["storeDraft"] + assert s["routeDraftSet"] + for at in CURVES: # 초안이 옮긴 곡선 = 새 표본 + assert s["draft"][at]["browser"], at + for at in STRAIGHT: # 초안 차례 = 옛 규칙(기하 새 틀 · 표본 앞 값) + assert not s["draft"][at]["same"] and not s["draft"][at]["browser"], at + dropped = s["dropped"] + for at in CURVES: # 버린 뒤 = 저장 노선 자리 표본(초안 3 cm 자리 표본이 안 남음) + assert dropped[at]["browser"], at + for at in STRAIGHT: # 초안이 안 옮긴 측점 = 앞 횡단 표본(서버) 그대로 + assert not dropped[at]["browser"], at From 1f00e13852595b7d886ec4892dc0797519825756 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 07:26:10 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(467=20B1=20=EB=8D=A7=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=9E=90=EB=A6=AC):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=EC=97=86=EB=8A=94=20=EC=B0=A8=EB=A1=80=20?= =?UTF-8?q?=3D=20=EA=B0=99=EC=9D=80=20=EC=9E=90=EB=A6=AC=20=EC=95=9E=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8(=EC=97=86=EC=9C=BC=EB=A9=B4=20=EA=B7=B8=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EC=98=9B=20=ED=9A=A1=EB=8B=A8)=20?= =?UTF-8?q?=EA=B8=B0=ED=95=98=20=C2=B7=20=ED=91=9C=EB=B3=B8=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 격자 측점에 구조물을 놓고 빼면 열쇠가 바뀌어 브라우저 표본으로 갈렸음(검증_sub5 440 m · 소단 놓고 뺌 = 누가토량 0.04 ㎥ 갈림) - 시험 test_272_swap_keeps_canon 에 격자 구조물 놓고 뺌 = 정본 표본 · 기하 Co-Authored-By: Claude Opus 5.5 --- B06_Section/logic/B06_Section_Station_Swap.ts | 52 +++++++++++-------- resources/tester/helper_272_swap_canon.mjs | 14 +++++ resources/tester/test_272_swap_keeps_canon.py | 7 +++ 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index 12e587877..ac3583929 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -6,8 +6,9 @@ * 받음 — 설계 · 구조물 제원 · 이름 그대로 · 자리(누가거리 · 좌표 · 방위 · 확폭)만 새것(433-4). * · 지반 표본 · 상단측 · 중심고 = 자리 · 축이 같은 앞 횡단이면 앞 값(서버 샘플링 정본 · 덧 측점만 넣어도 나머지 * 측점 면적이 브라우저 지형으로 안 바뀜 · 433-11) · 새 측점 · 옮긴 측점만 지반 표본(채워 왔으면). - * · 노선 초안 없는 차례(관 · 구조물 · 추가 측점만) = 같은 열쇠 · 누가거리 0.01 m 안 앞 횡단의 기하 · 표본 그대로 - * (축 허용 1e-3 · 브라우저 측점 틀 ↔ 서버 틀 mm 차가 정본 표본을 안 덮음 · 467 · 요구 272). + * · 노선 초안 없는 차례(관 · 구조물 · 추가 측점만) = 같은 자리(누가거리 · 좌표 0.01 m · 축 1e-3) 앞 횡단 — 없으면 + * 그 자리 옛 횡단(격자에 구조물을 놓고 뺌) — 의 기하 · 표본 그대로(브라우저 측점 틀 ↔ 서버 틀 mm 차가 정본 표본을 + * 안 덮음 · 467 · 요구 272) · 설계는 지금 규칙. * · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움). * · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428). * · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가 @@ -215,12 +216,15 @@ export function swapCrossSections( moved = true; if (prior && text(prior.chainage_m) !== text(station.chainageM)) moves.set(text(prior.chainage_m), text(station.chainageM)); - // 노선 초안 없는 차례 · 같은 열쇠 · 같은 자리 = 앞 횡단 기하 · 표본 그대로(467 · 되돌리면 정본과 같음) - const canon = - !routeDraft && - !!prior && - byKey.get(station.key) === prior && - samePlace(prior, station, CANON_AXIS_TOL); + // 노선 초안 없는 차례 · 같은 자리 = 앞 횡단(없으면 그 자리 옛 횡단 · 격자에 구조물을 놓고 뺌) 기하 · 표본 그대로 + // (467 · 되돌리면 정본과 같음) + const atPlace = (section: CrossSection | undefined): section is CrossSection => + !!section?.samples?.length && samePlace(section, station, CANON_AXIS_TOL); + const canon = routeDraft + ? undefined + : atPlace(prior) + ? prior + : detail.cross_sections.find(atPlace); // 자리 같은 앞 횡단 = 서버 표본 그대로(433-11) · 아니면 새 지반 표본(채워 왔으면) const fresh = !canon && !!station.ground && !(prior?.samples?.length && samePlace(prior, station)); @@ -243,18 +247,20 @@ export function swapCrossSections( label: formatStation(station.chainageM), kind, ...(onGrid ? { grid: true } : {}), - center_z: fresh - ? (station.ground?.centerZ ?? prior?.center_z ?? null) - : (prior?.center_z ?? null), - ...(canon && prior + center_z: canon + ? canon.center_z + : fresh + ? (station.ground?.centerZ ?? prior?.center_z ?? null) + : (prior?.center_z ?? null), + ...(canon ? { - azimuth_deg: prior.azimuth_deg, - plan_radius_m: prior.plan_radius_m, - curve_outer_side: prior.curve_outer_side, - curve_widening_m: prior.curve_widening_m, - center_x: prior.center_x, - center_y: prior.center_y, - frame: prior.frame, + azimuth_deg: canon.azimuth_deg, + plan_radius_m: canon.plan_radius_m, + curve_outer_side: canon.curve_outer_side, + curve_widening_m: canon.curve_widening_m, + center_x: canon.center_x, + center_y: canon.center_y, + frame: canon.frame, } : { azimuth_deg: station.azimuthDeg, @@ -267,8 +273,12 @@ export function swapCrossSections( }), }; longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) }); - const samples = fresh ? groundSamples(station) : (prior?.samples ?? []); - const uphill = fresh ? uphillSide(station) : (prior?.uphill_side ?? null); + const samples = canon ? canon.samples : fresh ? groundSamples(station) : (prior?.samples ?? []); + const uphill = canon + ? (canon.uphill_side ?? null) + : fresh + ? uphillSide(station) + : (prior?.uphill_side ?? null); if (!prior) return { ...place, uphill_side: uphill, samples, design: freshDesign(uphill) }; const reseed = (station.kind === "pipe" || station.kind === "structure") && diff --git a/resources/tester/helper_272_swap_canon.mjs b/resources/tester/helper_272_swap_canon.mjs index 33516b1ba..ca96b8122 100644 --- a/resources/tester/helper_272_swap_canon.mjs +++ b/resources/tester/helper_272_swap_canon.mjs @@ -169,6 +169,20 @@ const out = {}; }; } +/* 1-2. 격자 측점(40 m)에 구조물을 놓고 뺌 — 열쇠가 바뀌어도 같은 자리 = 정본 표본 · 기하 */ +{ + const onGrid = stationsAt(45).map((s) => + s.key === "grid:2" ? { ...s, key: "structure:s1", kind: "structure", id: "s1" } : s, + ); + const placed = m.swapCrossSections(canonDetail(), onGrid, KEYS, 20, false); + const removed = m.swapCrossSections(placed.detail, stationsAt(45), keysOf(onGrid), 20, false); + out.gridStructure = { + placed: report(placed.detail)["40.00"], + removed: report(removed.detail)["40.00"], + removedBytes: bytesSame(removed.detail, [45, 40]), + }; +} + /* 2. 가게 길(swapSectionStations) — 가게 routeDraft 로 가름: 없음 · 있음 · 초안 버린 직후 */ const seed = (P, R) => { m.writeState("section-detail", canonDetail(), P, R); diff --git a/resources/tester/test_272_swap_keeps_canon.py b/resources/tester/test_272_swap_keeps_canon.py index c5c91f970..32e6204e4 100644 --- a/resources/tester/test_272_swap_keeps_canon.py +++ b/resources/tester/test_272_swap_keeps_canon.py @@ -82,6 +82,13 @@ def test_되돌림_정본과_바이트_같음(out): assert back["45.00"]["browser"] # 관 측점 = 브라우저 표본(정본은 저장 뒤 서버 값) +def test_격자에_구조물_놓고_뺌_같은_자리_정본(out): + g = out["gridStructure"] + assert g["placed"]["same"] and not g["placed"]["browser"] # 열쇠 바뀜(구조물) · 같은 자리 = 정본 + assert g["removed"]["same"] and not g["removed"]["browser"] # 다시 격자 = 정본 표본 · 기하 + assert g["removedBytes"] + + def test_노선_초안_차례는_옛_규칙(out): draft = out["pure"]["draft"] for at in CURVES: # 축 차 > 1e-4 = 새 표본 From 57db7a0608de802836cdd8bdea2ff2bed6486011 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 07:32:17 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(467=20=EB=8D=A7=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=BC):=20=ED=9A=A1=EB=8B=A8=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=85=88=20=EC=9E=85=EB=A0=A5=20=EB=AC=BC=EB=9F=AC?= =?UTF-8?q?=EC=84=AC=20=3D=20=EC=A0=80=EC=9E=A5=20=EC=A0=95=EB=B3=B8=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=C2=B7=20=EA=B3=A0=EB=A6=84=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EB=A5=BC=20=EC=95=88=20=EB=8D=AE=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cross_Refresh refreshLocally — 세션 고름(단추 · 노견 확장 · 소단 깊이 · 암 경계 · 암 경사) ?? 저장 정본(savedDesigns) · 정본에 없는 측점만 메모리 설계 · 소단 깊이 · 소단 제원은 이번 원천 그대로(↶ 뒤 앞 값이 안 남음) - Design_Sync handleDesignChange — target.design 덮기 셋 뺌(세션만 · 카드는 다시 셈 알림이 그림) - 새 시험 test_272_choice_undo(옛 코드 = 넷 다 ↶ 뒤 앞 고름에 머묾) Co-Authored-By: Claude Opus 5.5 --- B06_Section/B06_Section_Cross_Refresh.ts | 20 ++- .../B06_Section_UI_Page_Design_Sync.ts | 30 +--- resources/tester/helper_272_choice_undo.mjs | 134 ++++++++++++++++++ .../tester/helper_272_choice_undo_entry.ts | 10 ++ resources/tester/test_272_choice_undo.py | 111 +++++++++++++++ 5 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 resources/tester/helper_272_choice_undo.mjs create mode 100644 resources/tester/helper_272_choice_undo_entry.ts create mode 100644 resources/tester/test_272_choice_undo.py diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 7fa5c4872..635bb9f80 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -43,6 +43,7 @@ import { type BermSpec, } from "@util/common_util_cross_berm"; import { readState } from "../A00_Common/b_page_state"; +import { savedDesigns } from "./B06_Section_Section_Canon"; import { crossDesignChoices, readBermDepths, @@ -398,11 +399,22 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { // 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음) const grid = designTerrainGridNow(projectId)?.grid ?? null; + // 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로 + // (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계 + const canon = new Map( + (savedDesigns(projectId, input.routeId) ?? []).map((row) => [ + rockKey(row.chainage_m), + row.design, + ]), + ); const updated: number[] = []; for (const section of detail.cross_sections) { const previous = section.design; if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다. - const design = previous as unknown as Record; + const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record< + string, + unknown + >; // 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버 // `recompute_designs_for_alignment` 의 우선순위와 같다. const sessionOffset = rockOffsets.get(rockKey(section.chainage_m)); @@ -499,11 +511,15 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ); section.design.shoulder_extend_left_m = extendLeft; section.design.shoulder_extend_right_m = extendRight; - // 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) + // 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이 + // 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음) for (const side of SIDES) { const own = next[`berm_depth_${side}_m`]; if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own; + else delete section.design[`berm_depth_${side}_m`]; } + if (next.berm) section.design.berm = next.berm; + else delete section.design.berm; memos.set(section, { samples: section.samples, fingerprint, design: section.design }); updated.push(section.chainage_m); } diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index d08410e79..fe9c1f655 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -53,9 +53,8 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { }); /** - * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), - * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. - * 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다. + * 측점 설계 버튼 변경 처리: 선택을 세션 초안에 쓰고 브라우저 다시 셈(바뀐 카드만 다시 그림 · 전체 재렌더 없음). + * 다시 셈 입력 우선순위 = 세션 고름 → 저장 정본 → config 기본값. */ async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise { const sectionDetail = ctx.detail(); @@ -66,39 +65,24 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { ); if (!target) return; - // 설계선 쉬프트(139-6) — 그 쪽 노견 확장만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구) + // 고름은 세션에만 — 메모리 상세 설계를 안 덮음(덮으면 ↶ 로 세션 칸을 지워도 다시 셈이 덮인 값으로 물러섬 · + // 467 덧) · 카드는 다시 셈 알림(`onCrossDesignsUpdated`)이 그림 + // 설계선 쉬프트(139-6) — 그 쪽 노견 확장만 세션에 · 브라우저 재계산(같은 창구) const shift = change.shoulder_extend; if (shift) { writeShoulderExtend(projectId, currentRouteId, chainageM, shift.side, shift.valueM); - if (target.design) target.design[`shoulder_extend_${shift.side}_m`] = shift.valueM; await reconcileStaleDesigns({ force: true }); return; } - // 소단 높이(194-1) — 그 쪽 깊이만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구) + // 소단 높이(194-1) — 그 쪽 깊이만 세션에 · 브라우저 재계산(같은 창구) const berm = change.berm_depth; if (berm) { writeBermDepth(projectId, currentRouteId, chainageM, berm.side, berm.valueM); - if (target.design) target.design[`berm_depth_${berm.side}_m`] = berm.valueM; await reconcileStaleDesigns({ force: true }); return; } - // (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체. - if (target.design) { - target.design = { - ...target.design, - ground_type: change.ground_type, - section_mode: change.section_mode, - ditch_side: change.ditch_side ?? target.design.ditch_side, - ditch_type: change.ditch_type, - paved: change.paved, - two_stage_slope: change.two_stage_slope, - ditch_choice: change.ditch_choice, - }; - ctx.view().refreshCard(chainageM); - } - - // (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고, + // 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고, // [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로 // 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다. writeCrossDesignChoice(projectId, currentRouteId, chainageM, { diff --git a/resources/tester/helper_272_choice_undo.mjs b/resources/tester/helper_272_choice_undo.mjs new file mode 100644 index 000000000..21fe04717 --- /dev/null +++ b/resources/tester/helper_272_choice_undo.mjs @@ -0,0 +1,134 @@ +/* 467 덧 헬퍼 — 횡단 고름(단추 · 노견 확장 · 소단 깊이 · 암 경계)을 세션에 쓰고 다시 셈 → 세션 칸을 지움(↶) → + * 다시 셈 = 고르기 전(정본) 설계와 같은가 · 다시 씀(↷) = 고른 값과 같은가. + * node helper_272_choice_undo.mjs <묶음.mjs> + * job = { standard, rockDefault, ground } · 짝 = `test_272_choice_undo.py` · 표준출력 JSON 한 벌. + */ +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const memory = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = memory(); +globalThis.localStorage = memory(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); +globalThis.fetch = async () => new Response("{}", { status: 404 }); + +const m = await import(pathToFileURL(process.argv[2]).href); +const job = JSON.parse(readFileSync(process.argv[3], "utf8")); +const P = "p272undo"; +const R = 6; +const AT = 40; +m.writeState("std-cross", job.standard, P); +m.rememberRockBoundaryDefault(P, job.rockDefault); +// 소단 사본 = 측점을 덮는 구간(소단 깊이가 먹게) +m.writeState( + "berm", + [{ start_m: 0, end_m: 100, width_m: 1, interval_m: 3, slope_deg: 0, depth_m: 1 }], + P, + R, +); +const canonDetail = () => ({ + longitudinal: { length_m: 100, stations: [], profile_alignment: { policy: {} } }, + cross_sections: [ + { + station_id: "station_000000040000", + chainage_m: AT, + label: "", + kind: "regular", + center_z: 100, + azimuth_deg: 90, + center_x: AT, + center_y: 0, + frame: { left_xy: [0, 1] }, + uphill_side: "left", + samples: job.ground, + design: { + design_elevation_m: 100, + ground_type: "soil", + section_mode: "left_cut", + ditch_type: "standard", + ditch_side: "left", + ditch_choice: null, + two_stage_slope: false, + }, + }, + ], +}); +m.writeState("section-detail", canonDetail(), P, R); // 저장 정본(세션 상세) +const alignment = { + stations: [{ chainage_m: AT, plan_elevation_m: 100.4 }], + samples: [ + { chainage_m: 0, elevation_m: 100.4 }, + { chainage_m: 100000, elevation_m: 100.4 }, + ], + balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 }, +}; +const detail = canonDetail(); // 메모리 상세(가게가 쥔 객체) +const refresh = async (target = detail) => { + await m.refreshCrossDesigns({ + projectId: P, + routeId: R, + detail: target, + edits: { station_offsets: {}, curve_radii: {} }, + alignment, + }); + return JSON.stringify(target.cross_sections[0].design); +}; +const clear = (name) => sessionStorage.removeItem(m.stateKey(name, P, R)); + +const pristine = await refresh(canonDetail()); // 고르기 전 = 정본으로 셈 +await refresh(); // 메모리 상세 한 번(측점 열쇠 세움) +const out = { pristineSame: (await refresh()) === pristine, cases: {} }; +const CASES = { + choice: [ + () => + m.writeCrossDesignChoice(P, R, AT, { + ground_type: "blasting_rock", + section_mode: "right_cut", + ditch_side: "right", + ditch_type: "standard", + paved: false, + two_stage_slope: true, + ditch_choice: false, + }), + "crossdesign", + ], + shoulder: [() => m.writeShoulderExtend(P, R, AT, "left", 1.2), "shoulderext"], + bermDepth: [() => m.writeBermDepth(P, R, AT, "left", 2.5), "bermdepth"], + rock: [() => m.writeKeyed("rockb", P, R, new Map([["40.00", 3.5]]), ["40.00"]), "rockb"], +}; +for (const [name, [write, state]] of Object.entries(CASES)) { + write(); + const picked = await refresh(); + clear(state); // ↶ = 세션 칸 지움 + const undone = await refresh(); + write(); // ↷ = 다시 고름 + const redone = await refresh(); + clear(state); + const again = await refresh(); + out.cases[name] = { + changed: picked !== pristine, + undoIsCanon: undone === pristine, + redoIsPicked: redone === picked, + undoAgain: again === pristine, + }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_272_choice_undo_entry.ts b/resources/tester/helper_272_choice_undo_entry.ts new file mode 100644 index 000000000..55584e1ae --- /dev/null +++ b/resources/tester/helper_272_choice_undo_entry.ts @@ -0,0 +1,10 @@ +/* 467 덧 시험 묶음 진입점 — 횡단 고름 ↶ = 정본(진짜 모듈 · vite ssr). */ +export { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh"; +export { + writeBermDepth, + writeCrossDesignChoice, + writeShoulderExtend, +} from "../../B06_Section/B06_Section_Cross_Design_Session"; +export { writeKeyed } from "../../B06_Section/B06_Section_Station_Keys"; +export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel"; +export { stateKey, writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/test_272_choice_undo.py b/resources/tester/test_272_choice_undo.py new file mode 100644 index 000000000..fda0524ed --- /dev/null +++ b/resources/tester/test_272_choice_undo.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +"""467 덧 (요구 272) — 횡단 고름을 되돌리면(↶) 다시 셈이 정본 값으로 돌아옴. + +못박는 것 + - 카드 단추(지반 · 단면 갈래 · 측구) · 노견 확장 · 소단 깊이 · 암 경계를 세션에 쓰고 다시 셈 → 세션 칸 지움(↶) → + 설계 = 고르기 전(정본으로 셈) 설계와 글자까지 같음 · 다시 씀(↷) = 고른 값과 같음. + - 다시 셈 입력 물러섬 = 저장 정본 설계(메모리 설계에 남은 앞 고름 · 결과 아님). + - 고름 단추가 메모리 상세 설계를 덮지 않음(`handleDesignChange`). + +Node 헬퍼 `helper_272_choice_undo.mjs` 가 vite ssr 로 묶은 진짜 모듈을 부름. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from config.config_system import ( # noqa: E402 + STANDARD_CROSS_SECTION, + STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, +) + +GROUND = [ + {"offset_m": o / 2, "elevation_m": round(100.0 + 0.35 * o / 2, 4), "valid": True} + for o in range(-24, 25) +] + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b272undo") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_272_choice_undo_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_272_choice_undo_entry.js", bundle) + job = work / "job.json" + job.write_text( + json.dumps( + { + "standard": STANDARD_CROSS_SECTION, + "rockDefault": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + "ground": GROUND, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 + ["node", str(HERE / "helper_272_choice_undo.mjs"), str(bundle), str(job)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_고르기_전_메모리_다시_셈_같음(out): + assert out["pristineSame"] + + +@pytest.mark.parametrize("name", ["choice", "shoulder", "bermDepth"]) +def test_고름_되돌림_정본_다시_고름_같음(out, name): + case = out["cases"][name] + assert case["changed"], name # 고름이 실제로 설계를 바꿈 + assert case["undoIsCanon"], name # ↶ = 정본 + assert case["redoIsPicked"], name # ↷ = 고른 값 + assert case["undoAgain"], name + + +def test_암_경계_되돌림_정본(out): + case = out["cases"]["rock"] + assert case["undoIsCanon"] and case["redoIsPicked"] and case["undoAgain"] + + +def test_단추가_메모리_설계를_안_덮음(): + sync = (ROOT / "B06_Section" / "B06_Section_UI_Page_Design_Sync.ts").read_text(encoding="utf-8") + body = sync[ + sync.index("async function handleDesignChange") : sync.index("function recomputeIfRock") + ] + assert "target.design" not in body + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + assert "savedDesigns(projectId, input.routeId)" in refresh