diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index c8f068986..453b721dc 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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" }, /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 04d89ca2d..486d57300 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -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 차례)는 안 씀 — 알림 밖(틱 뒤)에서 씀(쓰기 고리 막음과 안 부딪힘) diff --git a/A00_Common/design/design_pipeline_page_types.ts b/A00_Common/design/design_pipeline_page_types.ts index 18010750d..b3ee12e04 100644 --- a/A00_Common/design/design_pipeline_page_types.ts +++ b/A00_Common/design/design_pipeline_page_types.ts @@ -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) => void, ) => (stations: PipelineStation[]) => Promise; /** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */ onUnstable?: () => void; diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 66069900c..ae5253e73 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -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); diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index dd16e9ea0..b4d565544 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -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; } +type HeldDraft = Omit & { routeId: number }; -/** 노선 초안 시작 — 초안 전 상세(`detail` · 객체 그대로 — 갈아끼움은 설계를 사본으로 씀) · 지금 측점 열쇠 · 횡단 초안 칸을 떠 둠. */ +/** + * 노선 초안 시작 — 초안 전 상세(`detail` · 객체 그대로 — 갈아끼움은 설계를 사본으로 씀) · 지금 측점 열쇠 · 횡단 초안 칸 · + * 관 · 구조물을 떠 둠 · 세션에도 한 벌(440-4). 초안 둔 채 새로고침이면 그 한 벌 — 세션 칸 · 열쇠 · 관은 이미 초안 + * 자리로 옮겨 쓴 값이라 처음 값으로 안 뜸. + */ export function holdSectionDetail( projectId: string, routeId: number, detail: SectionDetailResponse, ): SectionHold { + const kept = readState("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("pipes", projectId), + structures: readState("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; // 그 사이 다른 갈아끼움 · 새로 받음 diff --git a/resources/tester/helper_451_refresh_hold.cjs b/resources/tester/helper_451_refresh_hold.cjs new file mode 100644 index 000000000..f090d9f7c --- /dev/null +++ b/resources/tester/helper_451_refresh_hold.cjs @@ -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); +}); diff --git a/resources/tester/test_451_refresh_hold.py b/resources/tester/test_451_refresh_hold.py new file mode 100644 index 000000000..77f789732 --- /dev/null +++ b/resources/tester/test_451_refresh_hold.py @@ -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")]