/* 451 헬퍼(433-16) — 세션 상세 판 확인: 진짜 상세 가게(`B06_Section_Section_Store`)가 세션 한 벌로 먼저 서고 * 뒤에서 서버 판을 물음 · 다르면 서버 값 위에 이 탭에서 고친 사용자 칸만 · 같으면 그대로 · 노선 초안 hold 는 버릴 때 * 새 판. 서버 창구 · 초안 얹기 · 열쇠는 가짜. 짝 = `test_451_session_canon.py`. */ const fs = require("fs"); const path = require("path"); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); 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 () => undefined, 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(), "B06_Section_Station_Keys.ts": anyModule({ KEYED_STATES: [], registeredKeys: (_p, _r, sections) => keyAt(sections), stationIntervalOf: () => 20, }), "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_Cross_Culvert_Wire.ts": anyModule(), "B06_Section_Cross_Ford_Geom.ts": anyModule(), "B06_Section_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: [] }), }; const resolve = (base) => [base, `${base}.ts`, path.join(base, "index.ts")].find( (p) => fs.existsSync(p) && fs.statSync(p).isFile(), ) ?? `${base}.ts`; const { loadTs } = makeLoader({ root: ROOT, interop: true, fileStub: (full) => STUBS[path.basename(full)], requireFor: (full) => (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))); }, }); const store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts")); const state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); const tick = () => new Promise((r) => setTimeout(r, 10)); /** 정본 — 측점 0 · 20 · 40 · 60 · 측점 20 집수정 조절 · 40 메모 · 계획고. */ const canon = (adjust, note, elevation) => ({ longitudinal: { design_profiles: [], stations: [] }, cross_sections: [0, 20, 40, 60].map((c) => ({ chainage_m: c, kind: c === 0 ? "bp" : "regular", samples: [], design: { design_elevation_m: elevation, ...(c === 20 ? { basin_adjust: { x: adjust } } : {}), ...(c === 40 ? { structure_note: note } : {}), }, })), }); const designAt = (detail, c) => detail.cross_sections.find((s) => s.chainage_m === c).design; const patchAt = (patches, c) => patches.find((p) => p.chainage_m === c) ?? null; const sessionNow = () => state.readState("section-detail", P, ROUTE); const open = () => { let release; gate = new Promise((r) => (release = r)); return release; }; (async () => { const out = {}; // ① 낡은 세션(조절 1 · 메모 A · 계획고 100) + 다른 탭이 저장(조절 2 · 계획고 101) → 새 탭(새로고침) state.writeState("section-detail", canon(1, "A", 100), P, ROUTE); server = canon(2, "A", 101); let release = open(); let heard = 0; store.subscribeSectionDetail(P, ROUTE, () => (heard += 1)); const first = await store.loadSectionDetail(P, ROUTE); out.firstFromSession = designAt(first, 20).basin_adjust.x === 1 && asked === 1; // 서버가 답하기 전 이 탭에서 고친 칸 — 측점 40 메모 designAt(first, 40).structure_note = "mine"; release(); await tick(); const now = store.peekSectionDetail(P, ROUTE); const patches = store.cachedCrossPatches(P, ROUTE); out.stale = { swapped: now !== first, heard, adjust: designAt(now, 20).basin_adjust.x, elevation: designAt(now, 60).design_elevation_m, note: designAt(now, 40).structure_note, patchAdjust: patchAt(patches, 20)?.basin_adjust?.x ?? null, patchNote: patchAt(patches, 40)?.structure_note ?? null, sessionAdjust: designAt(sessionNow(), 20).basin_adjust.x, sessionNote: designAt(sessionNow(), 40).structure_note, }; // ② 같은 판 — 새로고침(메모리 비움 흉내) · 서버 = 세션 → 그대로(갈아끼움 · 알림 없음) store.invalidateSectionDetail(P, ROUTE); state.writeState("section-detail", server, P, ROUTE); release = open(); heard = 0; const again = await store.loadSectionDetail(P, ROUTE); release(); await tick(); out.same = { kept: store.peekSectionDetail(P, ROUTE) === again, heard }; // ③ 서버 실패 · 엉뚱한 답 — 세션 그대로 store.invalidateSectionDetail(P, ROUTE); state.writeState("section-detail", canon(1, "A", 100), P, ROUTE); const saved = server; server = null; release = open(); const broken = await store.loadSectionDetail(P, ROUTE); release(); await tick(); out.broken = store.peekSectionDetail(P, ROUTE) === broken; server = saved; // ④ 노선 초안 hold(옛 상세) · 갈아끼움 뒤 서버가 답함 → 초안을 버리면 새 판 store.invalidateSectionDetail(P, ROUTE); state.writeState("section-detail", canon(1, "A", 100), P, ROUTE); release = open(); const held = await store.loadSectionDetail(P, ROUTE); const hold = store.holdSectionDetail(P, ROUTE, held); const draftStations = [0, 25, 45, 60].map((c) => ({ key: `at:${c}`, chainageM: c, kind: "grid", x: c, y: 0, left: [0, 1], azimuthDeg: 0, ground: null, })); await store.swapSectionStations(P, ROUTE, draftStations); const swappedDraft = store.peekSectionDetail(P, ROUTE) !== held; release(); await tick(); const draftKept = store.peekSectionDetail(P, ROUTE).cross_sections.map((s) => s.chainage_m); const back = await store.restoreSectionDetail( P, ROUTE, hold, [0, 20, 40, 60].map((c) => ({ key: `at:${c}`, chainageM: c })), ); out.hold = { swappedDraft, draftKept, renewed: back && back.detail !== held, adjust: back && designAt(back.detail, 20).basin_adjust.x, same: back && back.same, }; process.stdout.write(JSON.stringify(out)); })().catch((error) => { process.stderr.write(String(error && error.stack ? error.stack : error)); process.exit(1); });