fix(451 B06 새로고침 hold): 노선 초안 전 상태 세션 한 벌 — 초안 둔 채 새로고침해도 초안 전 열쇠 · 칸 · 관으로 되돌림 (440-4)

- 초안 시작 hold = 측점 열쇠 · 횡단 초안 칸 · 가게 관 · 구조물 · 세션 한 칸(route-draft-hold)에도
- 초안 둔 채 새로고침 = 그 한 벌(초안이 옮겨 쓴 세션 칸 · 초안 자리 관을 처음 값으로 안 뜸)
- 묶음 초안 전 가게(beforeDraft) = hold 관 · 구조물 — 버리면 초안 전 관 자리로 되씀
- 되돌림 = 초안 얹기 다시(되쓴 관) 뒤 초안 전 열쇠 · 세션 한 벌 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 21:43:17 +09:00
co-authored by Claude Opus 5.5
parent eedafe8db9
commit c4f5530a49
7 changed files with 319 additions and 8 deletions
+2
View File
@@ -212,6 +212,8 @@ export const STATE_REGISTRY = {
/** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`).
* 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */
"route-draft": { bucket: "draft", scope: "project" },
/** 노선 초안 전 상태(440-4) — 측점 열쇠 · 횡단 초안 칸 · 관 · 구조물 · 초안 둔 채 새로고침해도 버리면 이것으로. */
"route-draft-hold": { bucket: "draft", scope: "project" },
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
+3 -2
View File
@@ -437,11 +437,12 @@ function createPagePipeline(
Object.assign(record, { restored: why.startsWith("saved"), restoreWhy: why });
return why;
};
/** 노선 초안 시작 — 지금 저장 상세 · 되돌리는 함수를 맡김(횡단 초안 칸 · 측점 열쇠는 맡은 쪽이 떠 둠). */
/** 노선 초안 시작 — 지금 저장 상세 · 되돌리는 함수를 맡김(횡단 초안 칸 · 측점 열쇠 · 관 · 구조물은 맡은 쪽이 떠 둠). */
const holdNow = (): typeof held => {
const { detail = null, routeId } = savedSource();
if (typeof routeId !== "number") return null;
return { detail, restore: options.holdDraft?.(routeId, detail) ?? null };
const restore = options.holdDraft?.(routeId, detail, (f) => (beforeDraft ??= f)); // 440-4
return { detail, restore: restore ?? null };
};
// 사용자 노선 조작 뒤 follow 가 다시 매긴 관 · 구조물 = 가게(목록 · 알약 · 횡단 보기가 바로 새 측점) ·
// 초안을 버리면 초안 전 값 · 열 때(start 차례)는 안 씀 — 알림 밖(틱 뒤)에서 씀(쓰기 고리 막음과 안 부딪힘)
@@ -16,6 +16,7 @@ import type {
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import type { BermAt } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import type { DesignStoreValues } from "./design_store";
/** 종단도(`design_profile`)가 묶음에 내주는 것. */
export interface ProfileLink {
@@ -50,6 +51,8 @@ export interface PagePipelineOptions {
holdDraft?: (
routeId: number,
saved: SectionDetailResponse | null,
/** 초안 전 가게 관 · 구조물(초안 둔 채 새로고침도 · 440-4) — 묶음이 초안을 버릴 때 되씀. */
seed?: (facilities: Pick<DesignStoreValues, "pipes" | "structures">) => void,
) => (stations: PipelineStation[]) => Promise<string>;
/** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */
onUnstable?: () => void;
+2 -1
View File
@@ -253,8 +253,9 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
applyStructureAreaRows(rows, structureAreaRows(rows));
},
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
holdDraft: (routeId, saved) => {
holdDraft: (routeId, saved, seed) => {
const hold = saved && holdSectionDetail(projectId, routeId, saved);
if (hold) seed?.(hold.facilities); // 초안 전 관 · 구조물(440-4)
return async (stations) => {
if (!hold) {
await swapSectionStations(projectId, routeId, stations);
+27 -5
View File
@@ -40,6 +40,7 @@ import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
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 {
carryStationKeys,
KEYED_STATES,
@@ -288,26 +289,44 @@ function recrossSaved(
.catch(() => undefined);
}
/** 노선 초안 전 상태(349-0o) — 상세(초안 전 객체 그대로 · 되돌리면 캐시) · 측점 열쇠 · 측점 열쇠 칸 다섯(횡단 초안). */
/** 노선 초안 전 상태(349-0o) — 상세(초안 전 객체 그대로 · 되돌리면 캐시) · 측점 열쇠 · 측점 열쇠 칸 다섯(횡단 초안) ·
* 가게 관 · 구조물(440-4 · 묶음이 버릴 때 되씀). */
export interface SectionHold {
detail: SectionDetailResponse;
keys: Keyed[];
cells: Array<[KeyedState, unknown]>;
facilities: Pick<DesignStoreValues, "pipes" | "structures">;
}
type HeldDraft = Omit<SectionHold, "detail"> & { routeId: number };
/** 노선 초안 시작 — 초안 전 상세(`detail` · 객체 그대로 — 갈아끼움은 설계를 사본으로 씀) · 지금 측점 열쇠 · 횡단 초안 칸을 떠 둠. */
/**
* 노선 초안 시작 — 초안 전 상세(`detail` · 객체 그대로 — 갈아끼움은 설계를 사본으로 씀) · 지금 측점 열쇠 · 횡단 초안 칸 ·
* 관 · 구조물을 떠 둠 · 세션에도 한 벌(440-4). 초안 둔 채 새로고침이면 그 한 벌 — 세션 칸 · 열쇠 · 관은 이미 초안
* 자리로 옮겨 쓴 값이라 처음 값으로 안 뜸.
*/
export function holdSectionDetail(
projectId: string,
routeId: number,
detail: SectionDetailResponse,
): SectionHold {
const kept = readState<HeldDraft>("route-draft-hold", projectId);
if (kept?.routeId === routeId && readState("route-draft", projectId)) {
const { keys, cells, facilities } = kept;
return { detail, keys, cells, facilities };
}
const sections = detail.cross_sections;
const keys = registeredKeys(projectId, routeId, sections, stationIntervalOf(detail));
return {
detail,
const held: HeldDraft = {
routeId,
keys: sections.map((section, i) => ({ key: keys[i], chainageM: section.chainage_m })),
cells: KEYED_STATES.map((name) => [name, readState(name, projectId, routeId)]),
facilities: {
pipes: readState<DesignStoreValues["pipes"]>("pipes", projectId),
structures: readState<DesignStoreValues["structures"]>("structures", projectId),
},
};
writeState("route-draft-hold", held, projectId);
return { detail, keys: held.keys, cells: held.cells, facilities: held.facilities };
}
/**
@@ -344,8 +363,11 @@ export async function restoreSectionDetail(
moves.set(text(section.chainage_m), text(to.chainageM));
});
for (const [name, value] of hold.cells) writeState(name, value ?? null, projectId, routeId);
useStationKeyList(projectId, routeId, hold.keys);
clearState("route-draft-hold", projectId);
cache.set(key, saved); // 세션 상세는 정본 그대로(387-7)
// 초안 둔 채 새로고침 = 초안 전 상세에 초안 자리 관이 얹혀 있었음 — 되쓴 관 · 구조물로 다시 얹음(440-4)
await withDraftStations(saved, projectId, routeId);
useStationKeyList(projectId, routeId, hold.keys);
await withDraftShapes(saved, projectId);
withStructureAreas(saved);
if (cache.get(key) !== saved) return null; // 그 사이 다른 갈아끼움 · 새로 받음
@@ -0,0 +1,205 @@
/* 451 헬퍼(440-4) — 노선 초안 둔 채 새로고침: 진짜 상세 가게 · 진짜 측점 열쇠 · 세션(모듈을 다시 세워도 남음).
* 초안 시작 hold = 세션 한 벌(열쇠 · 횡단 초안 칸 · 관 · 구조물) · 새로고침 뒤 hold = 그 한 벌(초안으로 옮겨 쓴 세션 아님) ·
* 버림 = 초안 전 칸 · 초안 얹기 다시(되쓴 관으로) 뒤 초안 전 열쇠. 서버 · 초안 얹기는 가짜. 짝 = `test_451_refresh_hold.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs");
const ROOT = path.join(__dirname, "..", "..");
const P = "P451";
const ROUTE = 3;
function memoryStorage() {
const map = new Map();
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, String(v)),
removeItem: (k) => map.delete(k),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
}
globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() };
const anyModule = (extra = {}) =>
new Proxy(extra, { get: (t, key) => (key in t ? t[key] : () => undefined) });
/** 서버 — 부를 때마다 지금 `server` 사본 · `gate` 가 풀릴 때 답함. */
let server = null;
let gate = Promise.resolve();
let asked = 0;
const keyAt = (sections) => sections.map((s) => `at:${s.chainage_m.toFixed(2)}`);
const STUBS = {
"design_pipeline_page.ts": {
reconcilePagePipeline: async () => 0,
pipelineOwnsCross: () => false,
pagePipeline: () => null,
},
"design_pipeline_cross.ts": { forceCross: () => undefined },
"B06_Section_Section_Drafts.ts": {
forgetDraftStations: () => undefined,
withDraftShapes: async () => undefined,
withDraftStations: async (detail) => {
// 초안 얹기 흉내 — 가게 관 자리(세션 pipes)마다 그 측점 관 표시 · 부른 차례를 적음
const pipes = state.readState("pipes", P) ?? [];
calls.push(`stations:${pipes.map((p) => p.chainage_m).join(",")}`);
for (const s of detail.cross_sections)
s.pipeAt = pipes.some((p) => p.chainage_m === s.chainage_m);
},
supersedeDraftStations: () => undefined,
},
"B05_Profile_Api_PlusStations.ts": {
loadSavedPlusStations: async () => undefined,
plusStationsOf: () => [],
},
"B06_Section_Api_Fetch.ts": anyModule({
fetchSectionDetail: async () => {
asked += 1;
await gate;
return JSON.parse(JSON.stringify(server));
},
}),
"B06_Section_Api_Culvert_Options.ts": anyModule({ STRUCTURE_ADJUST_KEYS: [] }),
"B06_Section_Swap_Bus.ts": anyModule(),
"ui_template_elements.ts": anyModule(),
"B06_Section_UI_Page_Persist.ts": anyModule({ readCutSlopeSession: () => ({}) }),
"B06_Section_UI_Standard_Panel.ts": anyModule(),
"B06_Section_Cross_Design_Session.ts": anyModule({ crossDesignChoices: () => new Map() }),
"B06_Section_Section_Adjusts.ts": anyModule({ withPipeOptions: async () => undefined }),
"common_util_cross_design.ts": anyModule(),
"B06_Section_UI_Cross_Culvert_Wire.ts": anyModule(),
"B06_Section_UI_Cross_Ford_Geom.ts": anyModule(),
"B06_Section_UI_Cross_Box_Geom.ts": anyModule(),
"B06_Section_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }),
"B05_Profile_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }),
"config_frontend.ts": anyModule({ PIPE_TYPES: [] }),
};
let cache = new Map();
const calls = [];
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
function loadTs(full) {
const stub = STUBS[path.basename(full)];
if (stub) return stub;
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,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const near = (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG;
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
}
let store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts"));
const state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
let keys = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Station_Keys.ts"));
/** 새로고침 흉내 — 모듈 메모리를 비우고 다시 세움(세션 · 서버는 그대로). */
function refresh() {
const keep = cache.get(path.join(ROOT, "A00_Common", "b_page_state.ts"));
cache = new Map([[path.join(ROOT, "A00_Common", "b_page_state.ts"), keep]]);
store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts"));
keys = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Station_Keys.ts"));
}
const sec = (c, kind) => ({
chainage_m: c,
kind,
samples: [],
design: { design_elevation_m: 100 },
});
// 저장 상세: 340 · 관 351 · 360
const saved = {
longitudinal: { design_profiles: [], stations: [] },
cross_sections: [sec(340, "regular"), sec(351, "irregular"), sec(360, "regular")],
};
const at = (c) => ({ key: `at:${c.toFixed(2)}`, chainageM: c });
const savedPipes = [{ id: "p1", chainage_m: 351 }];
const draftPipes = [{ id: "p1", chainage_m: 361 }];
const cell = () => state.readState("crossdesign", P, ROUTE);
const keysNow = () =>
keys.registeredKeys(P, ROUTE, store.peekSectionDetail(P, ROUTE).cross_sections, 20);
(async () => {
const out = {};
server = saved;
state.writeState("section-detail", saved, P, ROUTE);
state.writeState("pipes", savedPipes, P);
state.writeState("crossdesign", { before: true }, P, ROUTE);
// 탭 1 — 열기 · 노선 초안 시작(hold) · 초안이 세션 칸 · 관을 초안 자리로 옮겨 씀
const opened = await store.loadSectionDetail(P, ROUTE);
const first = store.holdSectionDetail(P, ROUTE, opened);
const kept = state.readState("route-draft-hold", P);
out.start = {
kept: !!kept && kept.routeId === ROUTE,
cells: JSON.stringify(first.cells.find(([n]) => n === "crossdesign")[1]),
pipes: first.facilities.pipes.map((p) => p.chainage_m),
keys: first.keys.map((k) => k.chainageM),
};
state.writeState("route-draft", { nodes: [], rev: 1 }, P);
state.writeState("crossdesign", { draft: true }, P, ROUTE);
state.writeState("pipes", draftPipes, P);
// 새로고침(초안 둔 채) — 새 모듈 · 세션 그대로 · 상세 = 세션(초안 관 자리로 얹힘)
refresh();
calls.length = 0;
const reopened = await store.loadSectionDetail(P, ROUTE);
const hold = store.holdSectionDetail(P, ROUTE, reopened);
out.refresh = {
cells: JSON.stringify(hold.cells.find(([n]) => n === "crossdesign")[1]),
pipes: hold.facilities.pipes.map((p) => p.chainage_m),
keys: JSON.stringify(hold.keys) === JSON.stringify(first.keys),
pipeShownAt: reopened.cross_sections.filter((s) => s.pipeAt).map((s) => s.chainage_m),
};
// 묶음이 초안을 버림 — 가게 관 = hold 관(묶음 몫 흉내) → 되돌림
state.writeState("route-draft", null, P);
state.writeState("pipes", hold.facilities.pipes, P);
calls.length = 0;
const back = await store.restoreSectionDetail(
P,
ROUTE,
hold,
saved.cross_sections.map((s) => at(s.chainage_m)),
);
out.drop = {
same: back && back.same,
cells: JSON.stringify(cell()),
kept: state.readState("route-draft-hold", P),
calls: [...calls],
pipeShownAt: back.detail.cross_sections.filter((s) => s.pipeAt).map((s) => s.chainage_m),
keys: JSON.stringify(keysNow()) === JSON.stringify(first.keys.map((k) => k.key)),
};
// 새 초안(route-draft 없음) = 지금 값을 다시 뜸 · 다른 노선 hold = 안 씀
state.writeState("crossdesign", { later: true }, P, ROUTE);
const fresh = store.holdSectionDetail(P, ROUTE, back.detail);
state.writeState("route-draft", { nodes: [], rev: 2 }, P);
const other = store.holdSectionDetail(P, ROUTE + 1, back.detail);
out.next = {
cells: JSON.stringify(fresh.cells.find(([n]) => n === "crossdesign")[1]),
otherRoute: state.readState("route-draft-hold", P).routeId,
};
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});
+77
View File
@@ -0,0 +1,77 @@
"""451 노선 초안 둔 채 새로고침 hold(440-4).
① 초안 시작 hold = 세션 한 벌(측점 열쇠 · 횡단 초안 칸 · 가게 관 · 구조물)
② 새로고침(모듈 다시 세움 · 세션 그대로) 뒤 hold = 그 한 벌 — 초안이 옮겨 쓴 세션 칸 · 초안 자리 관이 아님
③ 초안 버림 = 초안 전 칸 · 초안 얹기 다시(되쓴 관 · 저장 자리 351) 뒤 초안 전 열쇠 · 세션 한 벌 지움
④ 새 초안(노선 초안 없음) = 지금 값을 다시 뜸 · 다른 노선 한 벌은 안 씀
⑤ 묶음 = hold 가 돌려준 관 · 구조물로 초안 전 가게(`beforeDraft`)를 채움(버릴 때 되씀)
TS 셈은 `helper_451_refresh_hold.cjs`.
./venv/Scripts/python.exe -m pytest resources/tester/test_451_refresh_hold.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_451_refresh_hold.cjs")
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_초안_시작_hold_는_세션에도(got) -> None:
start = got["start"]
assert start["kept"] is True
assert start["cells"] == '{"before":true}'
assert start["pipes"] == [351] and start["keys"] == [340, 351, 360]
def test_새로고침_hold_는_초안_전_한_벌(got) -> None:
refresh = got["refresh"]
assert refresh["cells"] == '{"before":true}' # 초안이 옮겨 쓴 {"draft":true} 아님
assert refresh["pipes"] == [351] # 초안 자리 361 아님
assert refresh["keys"] is True
def test_버리면_초안_전_칸_관_열쇠(got) -> None:
drop = got["drop"]
assert drop["same"] is True
assert drop["cells"] == '{"before":true}'
assert drop["kept"] is None
assert drop["calls"] == ["stations:351"] # 되쓴 관으로 초안 얹기 다시
assert drop["pipeShownAt"] == [351] # 관 제원 = 제 카드
assert drop["keys"] is True
def test_새_초안은_다시_뜸(got) -> None:
assert got["next"]["cells"] == '{"later":true}'
assert got["next"]["otherRoute"] == 4
def test_묶음이_초안_전_가게를_hold_로() -> None:
page = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8")
hold = page[page.index("const holdNow") :]
hold = hold[: hold.index("};")]
assert "options.holdDraft?.(routeId, detail, (f) => (beforeDraft ??= f))" in hold
shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8")
draft = shared[shared.index("holdDraft: (routeId, saved, seed) => {") :]
assert "if (hold) seed?.(hold.facilities);" in draft[: draft.index("return async")]