fix(445-2 B05 노선 저장): 편집기가 떠나면 노선 저장 등록 풂 · 고정만 바꾼 걸음은 노선 그대로
- 노선 저장 뒤 페이지가 다시 서며 평면 판이 접혀 편집기가 떠나도 등록부(bindRouteApply)에 옛 편집기가 남아 「고친 것 있음」을 냄 → [저장] · [확정]마다 같은 초안으로 노선 저장 체인(378 → 414 → 416 → 417 · [확정]이 횡단 확정까지 못 감) - bindRouteApply 가 푸는 함수를 돌려줌 · 편집기 dispose 에서 풂(늦게 풀려도 새 편집기 등록은 안 지움) - 노선 저장 판정 · 묶음 초안 = lineChanged(노드 · 곡선만) — 고정(잠금)만 바꾸면 노선 저장 안 돎 · [초기화] 판정 isDirty 는 그대로 - 안 쓰는 overlay · busy 인자 뺌(편집기 줄 수 695 그대로) - 시험 test_445_2_route_pending Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -69,6 +69,7 @@ import * as edits from "./logic/B05_Profile_UI_RouteEdit_Edits";
|
|||||||
import {
|
import {
|
||||||
bindHistoryControls,
|
bindHistoryControls,
|
||||||
createRouteEditHistory,
|
createRouteEditHistory,
|
||||||
|
lineChanged,
|
||||||
type RouteEditHistory,
|
type RouteEditHistory,
|
||||||
type RouteEditSnapshot,
|
type RouteEditSnapshot,
|
||||||
} from "./B05_Profile_UI_RouteEdit_History";
|
} from "./B05_Profile_UI_RouteEdit_History";
|
||||||
@@ -114,7 +115,7 @@ export async function mountRouteEditor(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||||
const chrome = createRouteEditChrome(parent, options.tools);
|
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 { minorContourButton, terrainSelect, stationSnapButton } = chrome;
|
||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
|
|
||||||
@@ -249,6 +250,7 @@ export async function mountRouteEditor(
|
|||||||
|
|
||||||
/** 떠날 때 — 페이지가 버린다(345-3 · 닫기 단추 없음). */
|
/** 떠날 때 — 페이지가 버린다(345-3 · 닫기 단추 없음). */
|
||||||
const dispose = (): void => {
|
const dispose = (): void => {
|
||||||
|
unbindApply(); // 노선 저장 등록부에서 풂(445-2)
|
||||||
viewMemory.flush();
|
viewMemory.flush();
|
||||||
closed = true;
|
closed = true;
|
||||||
sizeWatch.disconnect();
|
sizeWatch.disconnect();
|
||||||
@@ -448,7 +450,7 @@ export async function mountRouteEditor(
|
|||||||
/** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430). */
|
/** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430). */
|
||||||
function tellDraft(): void {
|
function tellDraft(): void {
|
||||||
if (!history) return;
|
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);
|
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,14 +583,12 @@ export async function mountRouteEditor(
|
|||||||
draw,
|
draw,
|
||||||
});
|
});
|
||||||
|
|
||||||
bindRouteApply({
|
const unbindApply = bindRouteApply({
|
||||||
overlay,
|
|
||||||
busy,
|
|
||||||
projectId,
|
projectId,
|
||||||
nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }),
|
nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }),
|
||||||
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
|
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
|
||||||
changed: () =>
|
changed: () =>
|
||||||
(history?.isDirty() ?? false) || pickedTerrain() !== designTerrain || snap.changed(),
|
(!!history && lineChanged(history)) || pickedTerrain() !== designTerrain || snap.changed(),
|
||||||
terrain: pickedTerrain,
|
terrain: pickedTerrain,
|
||||||
snap: snap.draft,
|
snap: snap.draft,
|
||||||
initial,
|
initial,
|
||||||
|
|||||||
@@ -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() });
|
const settings = () => ({ ...params.snap(), terrain: params.terrain() });
|
||||||
sources.set(params.projectId, {
|
const source: RouteSource = {
|
||||||
pending: async () => {
|
pending: async () => {
|
||||||
const nodes = params.nodes();
|
const nodes = params.nodes();
|
||||||
if (nodes.planned.length < 2 || !params.changed()) return null;
|
if (nodes.planned.length < 2 || !params.changed()) return null;
|
||||||
@@ -106,7 +107,9 @@ export function bindRouteApply(params: RouteApplyParams): void {
|
|||||||
settings,
|
settings,
|
||||||
saved: () => params.initial.keep(),
|
saved: () => params.initial.keep(),
|
||||||
rebuild: params.onApplied,
|
rebuild: params.onApplied,
|
||||||
});
|
};
|
||||||
|
sources.set(params.projectId, source);
|
||||||
|
return () => void (sources.get(params.projectId) === source && sources.delete(params.projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 지금 보낼 노선 초안 — 편집기가 있으면 그것(안 바뀌었으면 null) · 없으면 가게 `routeDraft`. */
|
/** 지금 보낼 노선 초안 — 편집기가 있으면 그것(안 바뀌었으면 null) · 없으면 가게 `routeDraft`. */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
|
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
|
||||||
import { pageHistory } from "./B05_Profile_UI_Profile_History";
|
import { pageHistory } from "./B05_Profile_UI_Profile_History";
|
||||||
import {
|
import {
|
||||||
|
lineChanged,
|
||||||
sameLine,
|
sameLine,
|
||||||
type RouteEditHistory,
|
type RouteEditHistory,
|
||||||
type RouteEditSnapshot,
|
type RouteEditSnapshot,
|
||||||
@@ -65,7 +66,7 @@ export function watch(
|
|||||||
): RouteEditHistory {
|
): RouteEditHistory {
|
||||||
if (!bridge) return history;
|
if (!bridge) return history;
|
||||||
const tell = (snapshot: RouteEditSnapshot | null): RouteEditSnapshot | null => {
|
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;
|
return snapshot;
|
||||||
};
|
};
|
||||||
/** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */
|
/** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ export function sameLine(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
|
|||||||
return true;
|
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 {
|
function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
|
||||||
return sameLine(a, b) && a.apexLock.every((lock, index) => lock === b.apexLock[index]);
|
return sameLine(a, b) && a.apexLock.every((lock, index) => lock === b.apexLock[index]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user