From cc3a8a71099ff1d7940d2cb558f5501a33bc1388 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 09:52:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EC=98=B5=EC=85=98=20=C2=B7=20?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=EB=A7=8C=20=EA=B3=A0=EC=B3=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=B4=EB=8F=84=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=20=EC=B8=A1=EC=A0=90=EC=9D=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=C2=B7=20=EA=B3=84=ED=9A=8D=EA=B3=A0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=95=88=20=EC=A7=80=EC=9B=8C=EC=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 19장 sub6 ①. 측점 칸(stationFields)이 잔여거리를 소수 1자리로 보이고 저장 때 그 표시값을 다시 읽어, 옵션 · 메모만 고쳐도 측점이 0.1m 단위로 옮겨 저장되던 흠(123.37 → 123.4 · 관 85.05 → 85.0 은 updatePipeFacility 0.005m 기준을 넘어 실제 관 이동). 칸에 실어 둔 누가거리를 기억해 사용자가 칸 글을 안 고쳤으면 그 값을 그대로 돌려줌(읽는 곳 모두 이 한 함수를 거침) · 고치면 친 값. 계획고 수정(station_offsets)은 비정규 측점이 바뀔 때 옛 키를 지우던 것을, 새 목록에 0.005m 안의 측점이 있으면(서버 관 측점 0.01m 반올림) 그 키로 옮김(resetStationEdit · resetStation 의 moveTo). 확인 — ORCA(IP 5181 · 메인 폴더 탭 · 랩탑_보조 프로젝트): 옹벽 기준 6+3.37 저장 → 123.37 · 메모만 고쳐 저장 → structures.json 123.37 그대로(표시 6+3.4) · 다시 열기 같음. 배수관 1(85.05 · 표시 4+5.0)에 계획고 ▲ 뒤 관경만 1000 → 800 저장 → pipe_points 85.05 그대로 · station_offsets 유지 · 다시 열기 같음. 시험 뒤 프로젝트 파일은 백업으로 되돌림(diff 0). 시험 test_b05_station_roundtrip(칸 왕복 · 편집 옮김) 통과 · 타입검사 통과. ② 정수 m 격자 붙이기에서 manual 빼기는 횡단 파일 이름 · 도면 ID(cross_{정수m}m)가 B06 · B07 · B08 열쇠라 브레인에게 물음 — 이 커밋에 없음. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg --- B05_Profile/B05_Profile_UI_Page_Structures.ts | 11 +- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 13 ++- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 4 +- .../B05_Profile_UI_Structures_Fields.ts | 9 ++ .../tester/helper_b05_station_roundtrip.cjs | 100 ++++++++++++++++++ .../tester/test_b05_station_roundtrip.py | 51 +++++++++ 6 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_b05_station_roundtrip.cjs create mode 100644 resources/tester/test_b05_station_roundtrip.py 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"]