diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 60ee6b9dd..ce0338fc0 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -206,12 +206,8 @@ export const STATE_REGISTRY = { /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ "b09-estimation": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). - * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ - "alignment-draft": { - bucket: "draft", - scope: "route", - legacy: (_p, r) => `b05-profile-alignment-draft:${r}`, - }, + * 옛 키(프로젝트 없는 `b05-profile-alignment-draft:{노선}`)는 안 받음 — 다른 프로젝트 초안이 섞임(387-1). */ + "alignment-draft": { bucket: "draft", scope: "route" }, /** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`). * 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */ "route-draft": { bucket: "draft", scope: "project" }, @@ -529,11 +525,21 @@ export function routeStateKeys(name: StateName, projectId: string): string[] { if (entry?.scope !== "route") return []; const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`; + return sessionKeys((key) => key.startsWith(prefix)); +} + +/** 한 통 · 한 프로젝트의 세션 키 전부(노선 칸 포함) — 되돌리기 사진(지금 프로젝트만 · 387-1). */ +export function projectStateKeys(bucket: StateBucket, projectId: string): string[] { + const head = `aislo:${bucket}:`; + return sessionKeys((key) => key.startsWith(head) && key.split(":")[3] === projectId); +} + +function sessionKeys(keep: (key: string) => boolean): string[] { const keys: string[] = []; try { for (let i = 0; i < window.sessionStorage.length; i += 1) { const key = window.sessionStorage.key(i); - if (key && key.startsWith(prefix)) keys.push(key); + if (key && keep(key)) keys.push(key); } } catch { /* 무시 — 저장소가 막힌 브라우저 */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 108c60135..4bbb56270 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -17,11 +17,7 @@ import type { AlignmentEdits, ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment"; import { chainageKey, emptyEdits, hasEdits } from "./logic/B05_Profile_UI_Profile_Alignment"; -import { - ALIGNMENT_DRAFT_KEY_PREFIX, - draftProjectId, - useDesignStore, -} from "../A00_Common/design/design_store"; +import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; import { clearState } from "../A00_Common/b_page_state"; import { viewOf } from "../common_util/common_util_view_window"; @@ -111,22 +107,11 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul * 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에 * 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: * 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남). - * 노선 번호를 모르는 옛 초안까지 쓸어야 해 여기서만 키를 훑는다. */ export function clearAlignmentDrafts(): void { shared.clear(); // 등록부 칸(`alignment-draft`) — 노선을 안 주면 그 프로젝트의 모든 노선 칸을 쓸어 낸다. clearState("alignment-draft", draftProjectId()); - try { - const keys: string[] = []; - for (let index = 0; index < sessionStorage.length; index += 1) { - const key = sessionStorage.key(index); - if (key?.startsWith(`${ALIGNMENT_DRAFT_KEY_PREFIX}:`)) keys.push(key); - } - keys.forEach((key) => sessionStorage.removeItem(key)); - } catch { - // 세션 저장소를 못 쓰는 환경이면 남길 초안도 없다. - } } /** diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 39b770abd..a59c35753 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -11,7 +11,8 @@ * 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라 * 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`). * - * 대상 = 초안 통(`aislo:draft:` 전부 — 노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(346-4). + * 대상 = 지금 프로젝트(사진첩을 세울 때 연 것)의 초안 통(`aislo:draft:` — 노선 초안 · 계획고 · 관 · 구조물 · 횡단 · 346-4). + * 다른 프로젝트 칸은 찍지도 지우지도 않음 · 세션은 등록부(`b_page_state`) 창구로만(387-1). * 끌기 · 누르고 있기 중간은 안 찍음 — 누름이 남아 있는 동안 record() 는 미루고 놓을 때 한 번. * 사진끼리 안 바뀐 값은 앞 사진 글을 같이 씀 — 칸마다 수백 KB × 100단이어도 바뀐 칸만 늘어남. * @@ -26,46 +27,30 @@ * 씨앗 값(364-1) — 비었을 때만 채우는 값(표준 횡단 · 횡단 보기 첫 열기)은 `seedPageHistory` 로 모든 사진에 넣음. * ========================================================================== */ -/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ -const DRAFT_PREFIX = "aislo:draft:"; -const LEGACY_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"]; +import { projectStateKeys, readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { draftProjectId } from "../A00_Common/design/design_store"; + /** 초안 통이지만 조작이 아닌 칸 · 다른 페이지 칸 — 사진 밖. */ const NOT_EDIT = /^aislo:draft:(selection|pipe-ids|structure-type-cache|structure-pick|b08-[^:]*|b09-[^:]*):/; /** 놓은 뒤 · 칸 바꾼 뒤 기다렸다 찍는 시간(ms) — 서버 다시 셈 뒤 초안 쓰기까지 한 걸음에. */ const SETTLE_MS = 400; -/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */ -function isLayoutKey(key: string): boolean { - return ( - key.includes("collapsed") || - key.includes("width") || - key.includes("height") || - key.includes("open") || - key.includes("visible") - ); -} - -function isDataKey(key: string): boolean { - if (key.startsWith(DRAFT_PREFIX)) return !NOT_EDIT.test(key); - return LEGACY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key); -} +/** 한 프로젝트의 사진 칸 키 — B05·B06 은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ +const editKeys = (projectId: string): string[] => + projectStateKeys("draft", projectId).filter((key) => !NOT_EDIT.test(key)); +const isEditKey = (key: string, projectId: string): boolean => + key.startsWith("aislo:draft:") && key.split(":")[3] === projectId && !NOT_EDIT.test(key); /** 한 시점의 조작값 — 키 → 값(JSON 문자열). */ export type HistorySnapshot = Record; -/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). */ -function takeSnapshot(previous?: HistorySnapshot): HistorySnapshot { +/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). 저장소가 막히면 빈 사진 — 되돌리기만 꺼짐. */ +function takeSnapshot(projectId: string, previous?: HistorySnapshot): HistorySnapshot { const snapshot: HistorySnapshot = {}; - try { - for (let index = 0; index < sessionStorage.length; index += 1) { - const key = sessionStorage.key(index); - if (!key || !isDataKey(key)) continue; - const value = sessionStorage.getItem(key); - if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value; - } - } catch { - // 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다. + for (const key of editKeys(projectId)) { + const value = readByKey(key); + if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value; } return snapshot; } @@ -163,21 +148,12 @@ function watchPointer(): void { ); } -/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */ -function restoreSnapshot(snapshot: HistorySnapshot): void { - try { - const existing: string[] = []; - for (let index = 0; index < sessionStorage.length; index += 1) { - const key = sessionStorage.key(index); - if (key && isDataKey(key)) existing.push(key); - } - existing.forEach((key) => { - if (!(key in snapshot)) sessionStorage.removeItem(key); - }); - Object.entries(snapshot).forEach(([key, value]) => sessionStorage.setItem(key, value)); - } catch { - // 되쓰기 실패 시 화면 상태는 그대로 둔다 — 잘못 섞인 복원보다 낫다. - } +/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 그 프로젝트 조작 키는 지운다. */ +function restoreSnapshot(projectId: string, snapshot: HistorySnapshot): void { + editKeys(projectId).forEach((key) => { + if (!(key in snapshot)) writeByKey(key, null); + }); + Object.entries(snapshot).forEach(([key, value]) => writeByKey(key, value)); } export interface ProfileHistory { @@ -207,7 +183,9 @@ export function createProfileHistory( ): ProfileHistory { watchPointer(); sync(); - const stack: HistorySnapshot[] = [takeSnapshot()]; + /** 사진첩을 세울 때 연 프로젝트 — 그 칸만 찍고 되씀. */ + const projectId = draftProjectId(); + const stack: HistorySnapshot[] = [takeSnapshot(projectId)]; let cursor = 0; /** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */ let restoring = false; @@ -223,12 +201,12 @@ export function createProfileHistory( const kind: PageHistoryEvent = index < cursor ? "undo" : "redo"; cursor = index; restoring = replaying = true; - restoreSnapshot(stack[cursor]); + restoreSnapshot(projectId, stack[cursor]); try { onRestore(); hooks.forEach((entry) => entry.restore?.(kind)); // 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦. - stack[cursor] = takeSnapshot(stack[cursor]); + stack[cursor] = takeSnapshot(projectId, stack[cursor]); } finally { restoring = replaying = false; } @@ -246,7 +224,7 @@ export function createProfileHistory( } sync(); restAt = gestures; - const snapshot = takeSnapshot(stack[cursor]); + const snapshot = takeSnapshot(projectId, stack[cursor]); if (sameSnapshot(snapshot, stack[cursor])) return; if (gestures === openedAt) { stack[0] = snapshot; // 열 때 — 첫 사진만 다시 뜸 @@ -274,10 +252,10 @@ export function createProfileHistory( rebaseActive = () => { if (restoring || paused || pointerHeld || settling || gestures !== restAt) return; sync(); - stack[cursor] = takeSnapshot(stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습 + stack[cursor] = takeSnapshot(projectId, stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습 }; seedActive = (key) => { - const value = isDataKey(key) ? sessionStorage.getItem(key) : null; + const value = isEditKey(key, projectId) ? readByKey(key) : null; if (value !== null) stack.forEach((snapshot) => void (snapshot[key] ??= value)); }; active = history; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 6372ff738..132e87ed9 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -36,13 +36,11 @@ import { createFreeDraw } from "../A00_Common/design/logic/design_profile_free"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { stepStructureStation } from "../common_util/common_util_structure_station"; +import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; /** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; -/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ -const PIPE_KEY = "b05:pipes"; - export interface PanelToolsContext { /** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */ root: HTMLElement; @@ -87,31 +85,23 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { /** 이력이 처음 본 관 목록 — 최초 스냅샷에는 아직 키가 없어 여기로 되돌린다. */ let initialPipes: number[] | null = null; - /** 관 위치를 스냅샷이 볼 수 있는 세션 키로 옮겨 적는다(기록 직전에 부른다). */ + /** 관 자리는 가게 관 초안(`pipes` · 사진에 듦)이 정본 — 찍기 직전엔 처음 본 목록만 잡아 둔다. */ function syncPipes(): void { - const chainages = ctx.drainage.pipeChainages(); - if (initialPipes === null) initialPipes = chainages; - sessionStorage.setItem(PIPE_KEY, JSON.stringify(chainages)); + if (initialPipes === null) initialPipes = ctx.drainage.pipeChainages(); } - /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 값으로 맞춘다. 같은 목록이면 조용히 끝난다. */ + /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 초안으로 맞춘다(초안 없는 걸음 = 처음 목록). 같은 목록이면 조용히 끝난다. */ function restorePipes(): void { - let chainages = initialPipes; - const raw = sessionStorage.getItem(PIPE_KEY); - if (raw) { - try { - chainages = JSON.parse(raw) as number[]; - } catch { - // 손상된 값은 최초 목록으로 되돌린다. - } - } + const draft = useDesignStore(draftProjectId(), null).get("pipes"); + const chainages = draft + ? draft.map((pipe) => Math.round(pipe.chainage_m * 100) / 100) + : initialPipes; if (chainages) ctx.drainage.setPipeChainages(chainages); } // 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부). - /** 찍기 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다(세울 때 · 쉬는 동안 온 값 덮을 때도). - * 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전** - * 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */ + /** 찍기 직전(세울 때 · 쉬는 동안 온 값 덮을 때 · 연속 조작 멈출 때) 처음 본 관 목록을 잡는다 — + * 그래야 그 목록이 조작 **전** 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측). */ const inner = createProfileHistory( () => { ctx.restore(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 742d132f4..067e7a30c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -40,7 +40,7 @@ import { CROSS_PLOT_PAD, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; -import { readByKey, stateKey } from "../A00_Common/b_page_state"; +import { readByKey, routeStateKeys, stateKey } from "../A00_Common/b_page_state"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint"; @@ -105,15 +105,11 @@ interface CrossPane { function appliedHalfWidth(projectId: string): number | undefined { const prefix = stateKey("cross-display", projectId, ""); if (!prefix) return undefined; - try { - const latest = Object.keys(window.sessionStorage) - .filter((key) => key.startsWith(prefix)) - .sort((a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)))[0]; - const value = Number(latest ? readByKey(latest) : NaN); - return Number.isFinite(value) && value > 0 ? value : undefined; - } catch { - return undefined; - } + const latest = routeStateKeys("cross-display", projectId).sort( + (a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)), + )[0]; + const value = Number(latest ? readByKey(latest) : NaN); + return Number.isFinite(value) && value > 0 ? value : undefined; } function createPane(title: string, empty: string): CrossPane { diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 27aa43bde..8b5c997b4 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -21,6 +21,7 @@ import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box"; import type { BoxAdjust, BoxPick, BoxSideAdjust } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; /** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */ type WingPatch = Partial<{ @@ -136,10 +137,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { const key = fordSessionKey(); if (!key) return; try { - const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record< - string, - FordAdjust - >; + const parsed = JSON.parse(readByKey(key) ?? "{}") as Record; Object.entries(parsed).forEach(([chainage, value]) => fordAdjustments.set(chainage, fordAdjustOf(value)), ); @@ -150,8 +148,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { function persistFordAdjustments(): void { const key = fordSessionKey(); - if (key) - window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(fordAdjustments))); + if (key) writeByKey(key, JSON.stringify(Object.fromEntries(fordAdjustments))); } // 옛 저장(측벽마다 높이 · 대각)은 읽을 때 바닥판 내림 하나로 옮김(139-3 · `fordDropOf`) @@ -337,10 +334,7 @@ export function createBoxControls(deps: FordControlDeps): { const key = deps.sessionKey(); if (!key) return; try { - const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record< - string, - BoxAdjust - >; + const parsed = JSON.parse(readByKey(key) ?? "{}") as Record; Object.entries(parsed).forEach(([chainage, value]) => adjustments.set(chainage, { left: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.left }, @@ -354,7 +348,7 @@ export function createBoxControls(deps: FordControlDeps): { function persist(): void { const key = deps.sessionKey(); - if (key) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(adjustments))); + if (key) writeByKey(key, JSON.stringify(Object.fromEntries(adjustments))); } const adjustAt = (chainageM: number): BoxAdjust => { diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index 18fe380ad..0ddc38301 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -28,6 +28,7 @@ import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc"; import { renameChainageKeys } from "./logic/B06_Section_Station_Swap"; import { onAnySwap } from "./B06_Section_Swap_Bus"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; /** 세션에 담기는 측점별 조작값 한 겹 맵. */ export interface SessionMap { @@ -81,7 +82,7 @@ export function createSessionMap( const key = sessionKey(); if (!key) return; try { - const raw = window.sessionStorage.getItem(key); + const raw = readByKey(key); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([entry, value]) => { @@ -96,7 +97,7 @@ export function createSessionMap( const key = sessionKey(); if (!key) return; try { - window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(values))); + writeByKey(key, JSON.stringify(Object.fromEntries(values))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -125,7 +126,7 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag const key = sessionKey(); if (!key) return; try { - const raw = window.sessionStorage.getItem(key); + const raw = readByKey(key); if (!raw) return; const parsed = JSON.parse(raw) as { detached?: Record; @@ -140,7 +141,7 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag persist(): void { const key = sessionKey(); if (!key) return; - window.sessionStorage.setItem( + writeByKey( key, JSON.stringify({ detached: Object.fromEntries(detached), diff --git a/resources/tester/helper_346_4_one_undo.cjs b/resources/tester/helper_346_4_one_undo.cjs index 963db4efd..198db7c34 100644 --- a/resources/tester/helper_346_4_one_undo.cjs +++ b/resources/tester/helper_346_4_one_undo.cjs @@ -20,6 +20,8 @@ globalThis.sessionStorage = { const win = new EventTarget(); win.setTimeout = setTimeout; win.clearTimeout = clearTimeout; +win.sessionStorage = globalThis.sessionStorage; +win.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) }; // 연 프로젝트(387-1) globalThis.window = win; const cache = new Map(); diff --git a/resources/tester/helper_356_center.cjs b/resources/tester/helper_356_center.cjs index 2e20a658d..4158debd7 100644 --- a/resources/tester/helper_356_center.cjs +++ b/resources/tester/helper_356_center.cjs @@ -20,6 +20,11 @@ globalThis.sessionStorage = { setItem: (k, v) => session.set(k, String(v)), removeItem: (k) => session.delete(k), }; +// 사진첩은 등록부 창구 · 연 프로젝트(p) 칸만(387-1) +globalThis.window = { + sessionStorage: globalThis.sessionStorage, + localStorage: { getItem: (k) => (k === "frd_current_project_id" ? "p" : null) }, +}; const toasts = []; let pipe = null; @@ -168,13 +173,13 @@ pipe = { ); state.edits = align.emptyEdits(); // 바뀐 구간 안 편집은 버림 sessionStorage.setItem("aislo:draft:route-draft:p", JSON.stringify(planned)); - sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(state.edits)); + sessionStorage.setItem("aislo:draft:alignment-draft:p:0", JSON.stringify(state.edits)); generation += 1; emit(); }, grade(edits) { state.edits = edits; - sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(edits)); + sessionStorage.setItem("aislo:draft:alignment-draft:p:0", JSON.stringify(edits)); generation += 1; emit(); }, diff --git a/resources/tester/helper_design_profile_single.cjs b/resources/tester/helper_design_profile_single.cjs index 15eff0cb5..06f20767b 100644 --- a/resources/tester/helper_design_profile_single.cjs +++ b/resources/tester/helper_design_profile_single.cjs @@ -49,7 +49,7 @@ const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "desig const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_History.ts")); const out = {}; -// ① 옛 키에 남은 초안 — 처음 읽을 때 새 칸으로 옮겨 받고 옛 키는 지운다. +// ① 옛 키(프로젝트 없음)에 남은 초안 — 안 받음(387-1 · 다른 프로젝트 초안일 수 있음). local.setItem("frd_current_project_id", "p-1"); session.setItem( "b05-profile-alignment-draft:7", diff --git a/resources/tester/helper_ts_cjs_load.cjs b/resources/tester/helper_ts_cjs_load.cjs new file mode 100644 index 000000000..4f2228671 --- /dev/null +++ b/resources/tester/helper_ts_cjs_load.cjs @@ -0,0 +1,25 @@ +/* 진짜 TS 를 그 자리에서 CommonJS 로 트랜스파일해 부름 — 상대 경로는 진짜 파일(.ts)로 따라감. + * ESM 드라이버는 `createRequire(import.meta.url)(이 파일)` 로 받음(test_346_4_profile_history · test_364_1). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + 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 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const near = (request) => + request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +module.exports = { loadTs }; diff --git a/resources/tester/test_346_4_profile_history.py b/resources/tester/test_346_4_profile_history.py index 9dbc09a8f..3a6d870ee 100644 --- a/resources/tester/test_346_4_profile_history.py +++ b/resources/tester/test_346_4_profile_history.py @@ -1,4 +1,4 @@ -"""346-4 되돌리기 앞일 — 사진 대상 = aislo:draft: 전부 · 누름 중간은 안 찍고 놓을 때 한 번 · 크기. +"""346-4 되돌리기 앞일 — 사진 대상 = 연 프로젝트 aislo:draft: 전부(387-1 · 옛 b05 키 · 다른 프로젝트 밖) · 누름 중간은 안 찍고 놓을 때 한 번 · 크기. 349 — 열기만(손길 없음) = 걸음 0 · 열 때 쓰는 초안은 첫 사진 · 조작 한 번 = 걸음 하나. @@ -15,10 +15,11 @@ import pytest ROOT = Path(__file__).resolve().parents[2] TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts" -TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs" DRIVER = r""" import { writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; // 세션 흉내 — getItem 은 진짜처럼 매번 새 글을 돌려줌(사진끼리 글을 같이 쓰는지 재려고). const data = new Map(); globalThis.sessionStorage = { @@ -30,11 +31,14 @@ globalThis.sessionStorage = { }; globalThis.window = new EventTarget(); window.setTimeout = setTimeout; -const { createProfileHistory } = await import("./B05_Profile_UI_Profile_History.js"); +window.sessionStorage = sessionStorage; +window.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) }; +const { loadTs } = createRequire(import.meta.url)(process.argv[3]); +const { createProfileHistory } = loadTs(process.argv[4]); const tick = () => new Promise((r) => setTimeout(r, 5)); const out = {}; -// 1. 대상 — 초안 통 전부 + 옛 키 · 보기 설정 · pref · result 는 뺌 +// 1. 대상 — 연 프로젝트(p1) 초안 통 전부 · 옛 키 · 보기 설정 · pref · result · 다른 프로젝트는 뺌 const K = { route: "aislo:draft:route-draft:p1", cross: "aislo:draft:crossdesign:p1:7", @@ -43,6 +47,7 @@ const K = { layout: "b05-panel-collapsed", pref: "aislo:pref:table-open", result: "aislo:result:latest:p1", + other: "aislo:draft:crossdesign:p2:7", }; for (const k of Object.values(K)) sessionStorage.setItem(k, "0"); let restored = 0; @@ -124,15 +129,9 @@ writeFileSync(process.argv[2], JSON.stringify(out)); @pytest.fixture(scope="module") def result(tmp_path_factory: pytest.TempPathFactory) -> dict: out = tmp_path_factory.mktemp("ts_history") - subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", - "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", - "--noResolve"], - cwd=str(ROOT), check=True, capture_output=True, - ) # fmt: skip (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") - run = subprocess.run( # noqa: S603 - ["node", "--expose-gc", str(out / "driver.mjs"), str(out / "out.json")], + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "--expose-gc", str(out / "driver.mjs"), str(out / "out.json"), str(LOADER), str(TS_FILE)], capture_output=True, text=True, cwd=str(out), ) # fmt: skip assert run.returncode == 0, run.stderr @@ -142,9 +141,10 @@ def result(tmp_path_factory: pytest.TempPathFactory) -> dict: def test_초안_통_전부를_되돌림(result) -> None: after = result["after"] assert result["undo"] and result["restored"] == 1 - assert after["route"] == after["cross"] == after["pipes"] == after["legacy"] == "0" - # 보기 설정 · pref · result 는 사진 밖 — 되돌리기가 안 건드림 - assert after["layout"] == after["pref"] == after["result"] == "1" + assert after["route"] == after["cross"] == after["pipes"] == "0" + # 옛 키 · 보기 설정 · pref · result · 다른 프로젝트는 사진 밖 — 되돌리기가 안 건드림 + assert after["legacy"] == after["layout"] == after["pref"] == after["result"] == "1" + assert after["other"] == "1" def test_누름_중간은_안_찍고_놓을_때_한_번(result) -> None: diff --git a/resources/tester/test_364_1_view_no_step.py b/resources/tester/test_364_1_view_no_step.py index d364f3145..3991e4290 100644 --- a/resources/tester/test_364_1_view_no_step.py +++ b/resources/tester/test_364_1_view_no_step.py @@ -17,10 +17,11 @@ import pytest ROOT = Path(__file__).resolve().parents[2] TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts" -TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs" DRIVER = r""" import { writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; const data = new Map(); globalThis.sessionStorage = { get length() { return data.size; }, @@ -31,7 +32,10 @@ globalThis.sessionStorage = { }; globalThis.window = new EventTarget(); window.setTimeout = setTimeout; -const { createProfileHistory, seedPageHistory } = await import("./B05_Profile_UI_Profile_History.js"); +window.sessionStorage = sessionStorage; +window.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) }; +const { loadTs } = createRequire(import.meta.url)(process.argv[3]); +const { createProfileHistory, seedPageHistory } = loadTs(process.argv[4]); const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const fire = (type) => window.dispatchEvent(new Event(type)); /** 누르고 놓음 — 보기 손길(세션 안 씀) 또는 편집(누른 동안 write). */ @@ -47,11 +51,11 @@ const K = { std: "aislo:draft:std-cross:p1", structures: "aislo:draft:structures:p1", cross: "aislo:draft:crossdesign:p1:7", - pipes: "b05:pipes", + pipes: "aislo:draft:pipes:p1", }; const out = {}; -// 새 페이지 — 관 자리는 찍을 때만 옮겨 적음(Panel_Tools syncPipes 꼴) +// 새 페이지 — 관 초안이 손길 없이 뒤에서 옴(찍기 직전에 쓰는 흉내) let pipes = []; sessionStorage.setItem(K.route, "r0"); const h = createProfileHistory(() => {}, 100, () => sessionStorage.setItem(K.pipes, JSON.stringify(pipes))); @@ -129,15 +133,9 @@ writeFileSync(process.argv[2], JSON.stringify(out)); @pytest.fixture(scope="module") def result(tmp_path_factory: pytest.TempPathFactory) -> dict: out = tmp_path_factory.mktemp("ts_364_1") - subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", - "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", - "--noResolve"], - cwd=str(ROOT), check=True, capture_output=True, - ) # fmt: skip (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") - run = subprocess.run( # noqa: S603 - ["node", str(out / "driver.mjs"), str(out / "out.json")], + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(out / "driver.mjs"), str(out / "out.json"), str(LOADER), str(TS_FILE)], capture_output=True, text=True, cwd=str(out), ) # fmt: skip assert run.returncode == 0, run.stderr diff --git a/resources/tester/test_384_1_session_keys.py b/resources/tester/test_384_1_session_keys.py new file mode 100644 index 000000000..c1ea1c7dd --- /dev/null +++ b/resources/tester/test_384_1_session_keys.py @@ -0,0 +1,77 @@ +"""387-1(384 단계 1) 세션 열쇠 — 설계 화면은 세션을 등록부(`b_page_state`)로만 · 프로젝트 없는 열쇠 0. + +· design · B05/B06 UI_* 파일 코드(주석 뺌)에 직접 sessionStorage 0 +· 등록부 초안 · 결과 칸 옛 키는 모두 프로젝트를 품음 · `b05:pipes` 글 0 +· `projectStateKeys` = 그 통 · 그 프로젝트 키만(노선 칸 포함 · 다른 프로젝트 · 다른 통 · 옛 키 밖) + +./venv/Scripts/python.exe -m pytest resources/tester/test_384_1_session_keys.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs" + + +def _code(path: Path) -> str: + text = path.read_text(encoding="utf-8") + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"(?m)^\s*//.*$|\s//\s.*$", "", text) + + +def _screen_files() -> list[Path]: + files = list((ROOT / "A00_Common" / "design").rglob("*.ts")) + files += (ROOT / "B05_Profile").glob("B05_Profile_UI_*.ts") + files += (ROOT / "B06_Section").glob("B06_Section_UI_*.ts") + return sorted(files) + + +def test_화면_파일에_직접_세션_0() -> None: + files = _screen_files() + assert len(files) > 100 + hits = [str(p.relative_to(ROOT)) for p in files if "sessionStorage" in _code(p)] + assert hits == [] + + +def test_프로젝트_없는_열쇠_0() -> None: + hits = [str(p.relative_to(ROOT)) for p in _screen_files() if '"b05:pipes"' in _code(p)] + assert hits == [] + + +DRIVER = r""" +const data = new Map(); +const fake = { + get length() { return data.size; }, + key: (i) => [...data.keys()][i] ?? null, + getItem: (k) => (data.has(k) ? data.get(k) : null), + setItem: (k, v) => data.set(k, String(v)), + removeItem: (k) => data.delete(k), +}; +globalThis.window = { sessionStorage: fake, localStorage: fake }; +const { loadTs } = require(process.argv[1]); +const s = loadTs(process.argv[2]); +const legacy = Object.entries(s.STATE_REGISTRY) + .filter(([, e]) => e.bucket !== "pref" && e.legacy) + .map(([name, e]) => [name, e.legacy("PX", "RX")]); +for (const k of [ + "aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1", "aislo:draft:pipes:p10", + "aislo:draft:crossw:p2:7", "aislo:result:latest:p1", "b05:pipes", "b06:crossw:p1:7", +]) fake.setItem(k, "1"); +process.stdout.write(JSON.stringify({ legacy, keys: s.projectStateKeys("draft", "p1").sort() })); +""" + + +def test_등록부_옛_키는_프로젝트_품음_사진_칸은_그_프로젝트만() -> None: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "-e", DRIVER, str(LOADER), str(ROOT / "A00_Common" / "b_page_state.ts")], + capture_output=True, text=True, encoding="utf-8", cwd=str(ROOT), + ) # fmt: skip + assert run.returncode == 0, run.stderr + out = json.loads(run.stdout) + assert out["legacy"] and all("PX" in key for _, key in out["legacy"]) + assert out["keys"] == ["aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1"] diff --git a/resources/tester/test_design_profile_single.py b/resources/tester/test_design_profile_single.py index 7d028e2ea..73d87d6b0 100644 --- a/resources/tester/test_design_profile_single.py +++ b/resources/tester/test_design_profile_single.py @@ -72,9 +72,10 @@ def test_크기_접기_키는_하나(): assert '["section-panel-height", "profile-height"]' in drag # 옛 값 옮겨 받음 -def test_초안은_등록부_칸으로_옮겨_받음(results): - assert results["adopted"] == {"station_offsets": {"20.000": 0.3}, "curve_radii": {}} - assert results["keysAfterAdopt"] == ["aislo:draft:alignment-draft:p-1:7"] +def test_초안은_등록부_칸_프로젝트_없는_옛_키는_안_받음(results): + # 387-1 — 옛 키(프로젝트 없음)는 다른 프로젝트 초안일 수 있어 안 받음 · 손대지 않음 + assert results["adopted"] is None + assert results["keysAfterAdopt"] == ["b05-profile-alignment-draft:7"] assert results["sameSlot"]["station_offsets"] == {"40.000": 0.1}