diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index b90159f4..3903281f 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -104,10 +104,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */ function applyIrregularStations(stations: IrregularStation[]): void { // 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다. + // 서버가 관 측점을 0.01m 로 반올림해 돌려주므로 0.005m 안의 새 측점은 같은 측점 — 편집을 옮김. const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3))); + const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3))); irregularStations .filter((station) => !nextKeys.has(station.chainage_m.toFixed(3))) - .forEach((station) => deps.profilePanel().resetStationEdit(station.chainage_m)); + .forEach((station) => { + const same = stations.find( + (next) => + Math.abs(next.chainage_m - station.chainage_m) < 0.006 && + !oldKeys.has(next.chainage_m.toFixed(3)), + ); + deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); + }); irregularStations = stations; deps.renderStationLines(); deps.profilePanel().setIrregularStations(withoutFordPavement(stations)); diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index e2fba671..0eacc6fb 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -70,7 +70,8 @@ export interface ProfileEditStore { restoreSaved(): void; /** 마지막 저장 시점과 지금이 다른가([초기화] 활성 조건). */ canRestoreSaved(): boolean; - resetStation(chainageM: number): void; + /** `moveTo` 를 주면 지우지 않고 그 측점으로 옮김(서버 반올림으로 측점이 조금 바뀐 때). */ + resetStation(chainageM: number, moveTo?: number): void; resetAll(): void; /** 아직 서버에 반영되지 않은 변경이 있는가. */ dirty(): boolean; @@ -184,10 +185,18 @@ export function createProfileEditStore( onChange(); }, canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline), - resetStation(chainageM) { + resetStation(chainageM, moveTo) { const key = chainageKey(chainageM); const stationOffsets = { ...current.station_offsets }; const curveRadii = { ...current.curve_radii }; + if (moveTo !== undefined) { + const to = chainageKey(moveTo); + if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key]; + if (curveRadii[key] !== undefined) curveRadii[to] = curveRadii[key]; + delete stationOffsets[key]; + delete curveRadii[key]; + return commit({ station_offsets: stationOffsets, curve_radii: curveRadii }); + } delete stationOffsets[key]; // 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면 // 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23). diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 08236aa3..523394d9 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -780,11 +780,11 @@ export function createRouteProfilePanel( * 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다. * 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다. */ - resetStationEdit(chainageM: number) { + resetStationEdit(chainageM: number, moveTo?: number) { const key = chainageKey(chainageM); const edits = store.edits(); if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return; - store.resetStation(chainageM); + store.resetStation(chainageM, moveTo); }, isDirty: () => store.dirty(), /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index bcba79c8..904e13c5 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -178,11 +178,18 @@ export function stationFields( // 다른 change 리스너(옵션 잠금·임시 배치)보다 먼저 등록돼 정규화된 값이 읽힌다. [station, remainder].forEach((input) => input.addEventListener("change", normalize)); } + // 칸은 잔여를 소수 1자리로 보임 — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌 + // (표시값을 다시 읽으면 0.1m 로 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨) + let written: { chainageM: number; station: string; remainder: string } | null = null; return { wrap, station, remainder, read(intervalM, required) { + if (written && station.value === written.station && remainder.value === written.remainder) { + markInvalid(false); + return written.chainageM; + } const stationText = station.value.trim(); const remainderText = remainder.value.trim(); if (!stationText && !remainderText) { @@ -197,6 +204,7 @@ export function stationFields( return valid ? stationNo * intervalM + rest : null; }, write(chainageM, intervalM) { + written = null; if (chainageM === null) { station.value = ""; remainder.value = ""; @@ -205,6 +213,7 @@ export function stationFields( const parts = chainageToStation(chainageM, intervalM); station.value = String(parts.station); remainder.value = parts.remainder.toFixed(1); + written = { chainageM, station: station.value, remainder: remainder.value }; }, clearInvalid: () => markInvalid(false), }; diff --git a/resources/tester/helper_b05_station_roundtrip.cjs b/resources/tester/helper_b05_station_roundtrip.cjs new file mode 100644 index 00000000..381362f8 --- /dev/null +++ b/resources/tester/helper_b05_station_roundtrip.cjs @@ -0,0 +1,100 @@ +/* 구조물 측점 왕복 단위검증 헬퍼 — `B05_Profile_UI_Structures_Fields.ts`(측점 칸)와 + * `B05_Profile_UI_Profile_Edit.ts`(계획고 편집 저장소)를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리와 같다. + */ +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(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => + request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +// 측점 칸이 쓰는 만큼만 흉내 낸 DOM +function fakeElement() { + const listeners = {}; + const classes = new Set(); + return { + value: "", + children: [], + style: {}, + dataset: {}, + classList: { + add: (c) => classes.add(c), + remove: (c) => classes.delete(c), + toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)), + contains: (c) => classes.has(c), + }, + setAttribute() {}, + append(...kids) { + this.children.push(...kids); + }, + appendChild(kid) { + this.children.push(kid); + return kid; + }, + addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn), + fire: (type) => (listeners[type] || []).forEach((fn) => fn({ type })), + }; +} +global.document = { createElement: () => fakeElement(), createTextNode: () => fakeElement() }; + +const { stationFields } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Fields.ts"), +); +const { createProfileEditStore } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts"), +); + +const INTERVAL = 20; +const out = {}; +const fields = stationFields("기준", () => INTERVAL); + +// ① 실어 둔 값은 칸을 안 고치면 그대로(표시는 6 + 3.4) +fields.write(123.37, INTERVAL); +out.shown = [fields.station.value, fields.remainder.value]; +out.untouched = fields.read(INTERVAL, true); +fields.write(123.3721, INTERVAL); +out.untouchedRaw = fields.read(INTERVAL, true); +// 사용자가 잔여를 고치면 친 값으로 +fields.remainder.value = "5.2"; +fields.remainder.fire("change"); +out.edited = fields.read(INTERVAL, true); +// 같은 글로 되돌려 치면 — 여전히 실어 둔 값(칸 글이 쓴 그대로) +fields.write(123.37, INTERVAL); +fields.remainder.value = "3.4"; +out.retypedSame = fields.read(INTERVAL, true); +// 넘침 정규화(1 + 30 → 2 + 10)는 친 값으로 +fields.station.value = "1"; +fields.remainder.value = "30"; +fields.remainder.fire("change"); +out.normalized = [fields.station.value, fields.remainder.value, fields.read(INTERVAL, true)]; +// 비운 폼은 읽기 null +fields.write(null, INTERVAL); +out.cleared = fields.read(INTERVAL, false); + +// ② 계획고 편집 — 측점이 0.005m 안으로 옮겨 오면 편집을 옮기고, 아니면 지움 +const saved = { station_offsets: { 123.372: 0.5, "200.000": 0.3 }, curve_radii: { 123.372: 40 } }; +const store = createProfileEditStore(null, saved, () => {}); +store.resetStation(123.372, 123.37); +out.moved = store.edits(); +store.resetStation(200); +out.deleted = store.edits(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_b05_station_roundtrip.py b/resources/tester/test_b05_station_roundtrip.py new file mode 100644 index 00000000..0bdbd305 --- /dev/null +++ b/resources/tester/test_b05_station_roundtrip.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +"""구조물 측점 왕복 — 옵션 · 메모만 고쳐 저장해도 측점이 그대로(PLAN 19장 sub6). + +측점 칸은 잔여거리를 소수 1자리로 보인다. 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 +읽어야 한다(123.37 → 123.4 로 뭉개져 옮겨 저장되던 흠). 서버가 관 측점을 0.01m 로 +반올림해 돌려주면 계획고 편집(station_offsets)은 지우지 않고 새 측점으로 옮긴다. + +Node 헬퍼(`helper_b05_station_roundtrip.cjs`)가 TS 를 트랜스파일해 돌리고 여기서 판정한다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_station_roundtrip.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_칸을_안_고치면_실어_둔_측점_그대로(results): + assert results["shown"] == ["6", "3.4"] + assert results["untouched"] == 123.37 + assert results["untouchedRaw"] == 123.3721 + assert results["retypedSame"] == 123.37 + + +def test_칸을_고치면_친_값(results): + assert results["edited"] == pytest.approx(125.2) + assert results["normalized"] == ["2", "10.0", 50] + assert results["cleared"] is None + + +def test_계획고_편집은_가까운_새_측점으로_옮김(results): + assert results["moved"] == { + "station_offsets": {"200.000": 0.3, "123.370": 0.5}, + "curve_radii": {"123.370": 40}, + } + # 옮길 곳이 없으면 종전대로 지움 + assert "200.000" not in results["deleted"]["station_offsets"]