From 030409f160572bec36b5eaac543509517e5deca7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 19:52:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(445-2=20B05=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5):=20=ED=8E=B8=EC=A7=91=EA=B8=B0=EA=B0=80=20?= =?UTF-8?q?=EB=96=A0=EB=82=98=EB=A9=B4=20=EB=85=B8=EC=84=A0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=93=B1=EB=A1=9D=20=ED=92=82=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=EB=A7=8C=20=EB=B0=94=EA=BE=BC=20=EA=B1=B8=EC=9D=8C?= =?UTF-8?q?=EC=9D=80=20=EB=85=B8=EC=84=A0=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노선 저장 뒤 페이지가 다시 서며 평면 판이 접혀 편집기가 떠나도 등록부(bindRouteApply)에 옛 편집기가 남아 「고친 것 있음」을 냄 → [저장] · [확정]마다 같은 초안으로 노선 저장 체인(378 → 414 → 416 → 417 · [확정]이 횡단 확정까지 못 감) - bindRouteApply 가 푸는 함수를 돌려줌 · 편집기 dispose 에서 풂(늦게 풀려도 새 편집기 등록은 안 지움) - 노선 저장 판정 · 묶음 초안 = lineChanged(노드 · 곡선만) — 고정(잠금)만 바꾸면 노선 저장 안 돎 · [초기화] 판정 isDirty 는 그대로 - 안 쓰는 overlay · busy 인자 뺌(편집기 줄 수 695 그대로) - 시험 test_445_2_route_pending Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 12 +-- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 11 ++- B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts | 3 +- .../B05_Profile_UI_RouteEdit_History.ts | 4 + .../tester/helper_445_2_route_pending.cjs | 94 +++++++++++++++++++ resources/tester/test_445_2_route_pending.py | 68 ++++++++++++++ 6 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 resources/tester/helper_445_2_route_pending.cjs create mode 100644 resources/tester/test_445_2_route_pending.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 3fc47eccc..5e5d7d6d1 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -69,6 +69,7 @@ import * as edits from "./logic/B05_Profile_UI_RouteEdit_Edits"; import { bindHistoryControls, createRouteEditHistory, + lineChanged, type RouteEditHistory, type RouteEditSnapshot, } from "./B05_Profile_UI_RouteEdit_History"; @@ -114,7 +115,7 @@ export async function mountRouteEditor( ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(parent, options.tools); - const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; + const { overlay, canvas, status, measureBox, measureText, measureButton } = chrome; const { minorContourButton, terrainSelect, stationSnapButton } = chrome; const context = canvas.getContext("2d")!; @@ -249,6 +250,7 @@ export async function mountRouteEditor( /** 떠날 때 — 페이지가 버린다(345-3 · 닫기 단추 없음). */ const dispose = (): void => { + unbindApply(); // 노선 저장 등록부에서 풂(445-2) viewMemory.flush(); closed = true; sizeWatch.disconnect(); @@ -448,7 +450,7 @@ export async function mountRouteEditor( /** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430). */ function tellDraft(): void { if (!history) return; - const dirty = history.isDirty() || pickedTerrain() !== designTerrain || snap.changed(); + const dirty = lineChanged(history) || pickedTerrain() !== designTerrain || snap.changed(); options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null); } @@ -581,14 +583,12 @@ export async function mountRouteEditor( draw, }); - bindRouteApply({ - overlay, - busy, + const unbindApply = bindRouteApply({ projectId, nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }), // 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2). changed: () => - (history?.isDirty() ?? false) || pickedTerrain() !== designTerrain || snap.changed(), + (!!history && lineChanged(history)) || pickedTerrain() !== designTerrain || snap.changed(), terrain: pickedTerrain, snap: snap.draft, initial, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index e1dedb512..11ef38788 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -90,10 +90,11 @@ export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteD }; } -/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. */ -export function bindRouteApply(params: RouteApplyParams): void { +/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. 돌려준 함수 = 편집기가 떠날 때 풂 + * (안 풀면 노선 저장 뒤 다시 선 페이지에서 옛 편집기 「고친 것 있음」이 [저장] · [확정]마다 노선 저장을 다시 돌림 · 445-2). */ +export function bindRouteApply(params: RouteApplyParams): () => void { const settings = () => ({ ...params.snap(), terrain: params.terrain() }); - sources.set(params.projectId, { + const source: RouteSource = { pending: async () => { const nodes = params.nodes(); if (nodes.planned.length < 2 || !params.changed()) return null; @@ -106,7 +107,9 @@ export function bindRouteApply(params: RouteApplyParams): void { settings, saved: () => params.initial.keep(), rebuild: params.onApplied, - }); + }; + sources.set(params.projectId, source); + return () => void (sources.get(params.projectId) === source && sources.delete(params.projectId)); } /** 지금 보낼 노선 초안 — 편집기가 있으면 그것(안 바뀌었으면 null) · 없으면 가게 `routeDraft`. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts index 11e75ec11..6db526b86 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts @@ -16,6 +16,7 @@ import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; import { pageHistory } from "./B05_Profile_UI_Profile_History"; import { + lineChanged, sameLine, type RouteEditHistory, type RouteEditSnapshot, @@ -65,7 +66,7 @@ export function watch( ): RouteEditHistory { if (!bridge) return history; const tell = (snapshot: RouteEditSnapshot | null): RouteEditSnapshot | null => { - if (snapshot) bridge.edited(history.isDirty() ? draftOfSnapshot(snapshot, snap()) : null); + if (snapshot) bridge.edited(lineChanged(history) ? draftOfSnapshot(snapshot, snap()) : null); return snapshot; }; /** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index 4c2f2273b..83ea25628 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -85,6 +85,10 @@ export function sameLine(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { return true; } +/** 노선(노드 · 곡선)이 창을 연 때와 다른가 — 고정만 바꾼 것은 노선 저장 거리가 아님(445-2 · [초기화]는 `isDirty`). */ +export const lineChanged = (history: RouteEditHistory): boolean => + !!history.first && !!history.current && !sameLine(history.current(), history.first()); + /** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { return sameLine(a, b) && a.apexLock.every((lock, index) => lock === b.apexLock[index]); diff --git a/resources/tester/helper_445_2_route_pending.cjs b/resources/tester/helper_445_2_route_pending.cjs new file mode 100644 index 000000000..e2f901d14 --- /dev/null +++ b/resources/tester/helper_445_2_route_pending.cjs @@ -0,0 +1,94 @@ +/* PLAN 445-2 헬퍼 — 노선 저장 뒤 [저장] · [확정]마다 노선 저장이 다시 돌던 것. + * ① 편집기가 떠나면(페이지 다시 세움 · 평면 판 접힘) 등록부에서 풀림 → `pendingRouteSave` = 가게 초안(저장 뒤 null) + * ② 고정(잠금)만 바꾼 걸음은 노선이 안 바뀜(`lineChanged`) · [초기화] 판정(`isDirty`)은 그대로. + * TS 를 그 자리에서 트랜스파일 · 가게 · 서버 창구는 가짜. 짝 = `test_445_2_route_pending.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 B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); + +function load(file, stubs) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (spec) => + spec in stubs ? stubs[spec] : anything(), + ); + return box.exports; +} + +const store = new Map(); +const apply = load(B05("B05_Profile_UI_RouteEdit_Apply"), { + "../A00_Common/design/design_store": { + useDesignStore: () => ({ + get: (key) => store.get(key) ?? null, + set: (k, v) => store.set(k, v), + }), + }, +}); +const history = load(B05("B05_Profile_UI_RouteEdit_History"), { + "./B05_Profile_UI_Profile_History": { onPageHistory: () => () => undefined }, +}); + +(async () => { + const out = {}; + const P = "p445"; + let dirty = true; // 옛 편집기 = 교점을 끈 채 남음 + const params = (changed) => ({ + projectId: P, + nodes: () => ({ + planned: [ + [0, 0], + [10, 0], + [20, 5], + ], + curveOn: [false, true, false], + curveRadius: [null, 30, null], + }), + changed, + terrain: () => null, + snap: () => ({ stationSnap: false, stationSnapStep: null }), + initial: { current: () => null, keep: () => undefined }, + onApplied: () => undefined, + }); + const unbind = apply.bindRouteApply(params(() => dirty)); + out.boundPending = !!(await apply.pendingRouteSave(P)); + unbind(); // 편집기가 떠남(저장 뒤 페이지 다시 세움) + out.afterUnbind = !!(await apply.pendingRouteSave(P)); // 가게 초안 없음 = null + // 새 편집기가 선 뒤 옛 편집기가 늦게 풀려도 새 것은 남음 + const unbindOld = apply.bindRouteApply(params(() => true)); + apply.bindRouteApply(params(() => false)); + unbindOld(); + out.newKept = await apply.pendingRouteSave(P); // 새 편집기 = 안 바뀜 → null(옛 것이면 true) + // 고정만 바꾼 걸음 + const snap = (lock) => ({ + planned: [ + [0, 0], + [10, 0], + [20, 5], + ], + curveOn: [false, true, false], + curveRadius: [null, 30, null], + curveLock: [null, null, null], + curveArc: [null, null, null], + apexLock: [true, lock, true], + picked: 1, + }); + const h = history.createRouteEditHistory(snap(false)); + h.commit(snap(true)); + out.lockOnly = { isDirty: h.isDirty(), lineChanged: history.lineChanged(h) }; + const moved = snap(false); + moved.planned[1] = [10, 3]; + h.commit(moved); + out.moved = { lineChanged: history.lineChanged(h) }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_445_2_route_pending.py b/resources/tester/test_445_2_route_pending.py new file mode 100644 index 000000000..56028bc15 --- /dev/null +++ b/resources/tester/test_445_2_route_pending.py @@ -0,0 +1,68 @@ +"""PLAN 445-2 — 교점 끈 뒤 [저장] · [확정]마다 노선 저장이 다시 돌던 것. + +까닭(화면 실측 378 → 414 → 416 → 417) — 노선 저장 뒤 페이지가 다시 서며 평면 판이 접혀 편집기가 +떠나는데, 노선 저장 등록부(`bindRouteApply`)에 옛 편집기가 남아 「고친 것 있음」(창을 연 때 대비)을 +계속 냄 → [저장] · [확정]마다 같은 초안으로 노선 저장 체인. 덧 — 고정(잠금)만 바꿔도 노선 저장. + +못박는 것 + - 편집기가 떠나면 등록부에서 풀림 → 노선 초안 = 가게 칸(저장 뒤 비움). + - 늦게 풀리는 옛 편집기가 새 편집기 등록을 안 지움. + - 고정만 바꾼 걸음 = 노선 안 바뀜(`lineChanged`) · [초기화] 판정(`isDirty`)은 그대로. + - 편집기 배선(떠날 때 풂 · 판정 셋 자리). +짝: `helper_445_2_route_pending.cjs`. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_445_2_route_pending.cjs")], + 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_편집기가_떠나면_노선_저장_안_돎(r): + assert r["boundPending"] is True # 끈 채 등록된 편집기 + assert r["afterUnbind"] is False # 떠난 뒤 = 가게 칸(비었음) — 예전엔 옛 편집기가 계속 true + + +def test_늦게_풀리는_옛_편집기가_새_등록을_안_지움(r): + assert r["newKept"] is None # 새 편집기(안 바뀜)가 판정 + + +def test_고정만_바꾸면_노선_안_바뀜(r): + assert r["lockOnly"] == {"isDirty": True, "lineChanged": False} + assert r["moved"] == {"lineChanged": True} + + +def test_편집기_배선(): + edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + dispose = edit[edit.index("const dispose = (): void => {") :] + assert "unbindApply();" in dispose[:80] + assert "const unbindApply = bindRouteApply({" in edit + assert "history.isDirty() ||" not in edit and "history?.isDirty()" not in edit + assert edit.count("lineChanged(history)") == 2 and len(edit.splitlines()) <= 695 + draft = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Draft.ts").read_text(encoding="utf-8") + assert "bridge.edited(lineChanged(history)" in draft