diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 1535f8fc4..810c7b6f5 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -599,8 +599,32 @@ export function clearResults(projectId: string | null, routeId?: number | string * 값이 빈 객체(`{}`)면 없는 것으로 본다. */ export function hasDrafts(projectId: string | null, routeId?: number | string | null): boolean { - return namesInBucket("draft").some((name) => { - const raw = readStateRaw(name, projectId, routeId); - return raw !== null && raw !== "{}" && raw !== "[]" && raw !== ""; - }); + return namesInBucket("draft").some((name) => + draftFilled(readStateRaw(name, projectId, routeId), name, projectId), + ); +} + +/** 저장분이 비어 있지 않은 목록 칸(관 · 구조물) — 페이지가 저장분을 받을 때 적음(469-3 · 새 요청 없음). */ +const savedLists = new Set(); +export function noteSavedList(projectId: string, name: StateName, nonEmpty: boolean): void { + const key = `${name}:${projectId}`; + if (nonEmpty) savedLists.add(key); + else savedLists.delete(key); +} +export function forgetSavedLists(projectId: string): void { + for (const key of [...savedLists]) if (key.endsWith(`:${projectId}`)) savedLists.delete(key); +} + +/** + * 초안 칸이 고침인가 — 떠남 확인(`design_leave`)과 `hasDrafts` 가 쓰는 한 규칙. + * 칸 없음 · "" · "{}" = 빈 칸 · "[]" = 저장분이 비어 있지 않은 목록 칸일 때만 고침(모두 지움 ≠ 저장분). + */ +export function draftFilled( + raw: string | null, + name: StateName, + projectId: string | null, +): boolean { + if (raw === null || raw === "" || raw === "{}") return false; + if (raw === "[]") return !!projectId && savedLists.has(`${name}:${projectId}`); + return true; } diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts index 5d260a924..49126e933 100644 --- a/A00_Common/design/design_leave.ts +++ b/A00_Common/design/design_leave.ts @@ -16,6 +16,7 @@ import { t as L } from "@ui/ui_template_locale"; import { guardProjectLeave } from "../b_project_switch"; import { clearState, + draftFilled, namesInBucket, readByKey, routeStateKeys, @@ -38,9 +39,6 @@ export function editDraftNames(): StateName[] { return namesInBucket("draft").filter((name) => !NOT_EDITS.has(name) && !OTHER_PAGE.test(name)); } -const filled = (raw: string | null): raw is string => - raw !== null && raw !== "" && raw !== "{}" && raw !== "[]"; - /** 고침 칸 지문 — 모든 노선 것 · 빈 칸 뺌 · 고침이 없으면 "". */ export function editSignature(projectId: string): string { const parts: string[] = []; @@ -49,7 +47,7 @@ export function editSignature(projectId: string): string { const keys = own ? [own] : routeStateKeys(name, projectId); for (const key of keys.sort()) { const raw = readByKey(key); - if (filled(raw)) parts.push(`${key}=${raw}`); + if (raw !== null && draftFilled(raw, name, projectId)) parts.push(`${key}=${raw}`); } } return parts.join("\n"); diff --git a/A00_Common/design/design_page_data.ts b/A00_Common/design/design_page_data.ts index 70ba17f67..9eb0a3c3a 100644 --- a/A00_Common/design/design_page_data.ts +++ b/A00_Common/design/design_page_data.ts @@ -15,6 +15,7 @@ import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { SectionContextResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import { forgetSavedLists, noteSavedList } from "../b_page_state"; import { setPageRouteId } from "./design_page_route"; export { pageRouteId } from "./design_page_route"; import { @@ -36,11 +37,17 @@ function once(key: string, load: () => Promise): Promise { /** 저장 구조물(structures.json · 판번호 · 정의 판). */ export const pageStructures = (projectId: string): ReturnType => - once(`structures:${projectId}`, () => fetchStructures(projectId)); + once(`structures:${projectId}`, () => fetchStructures(projectId)).then((saved) => { + noteSavedList(projectId, "structures", !!saved?.structures?.length); // 떠남 확인의 「[]」 판정(469-3) + return saved; + }); /** 저장 관(pipe_points.json · 유역). */ export const pagePipes = (projectId: string): ReturnType => - once(`pipes:${projectId}`, () => fetchDetailPipePoints(projectId)); + once(`pipes:${projectId}`, () => fetchDetailPipePoints(projectId)).then((saved) => { + noteSavedList(projectId, "pipes", !!saved?.pipe_points?.length); + return saved; + }); /** 정의 판이 바뀌었으면 종류 캐시 버림 — 페이지마다 한 번(탭이 다시 보일 때는 `onStaleStructureTypes`). */ export const pageTypesChecked = (projectId: string): Promise => @@ -49,6 +56,7 @@ export const pageTypesChecked = (projectId: string): Promise => /** 저장 정본이 바뀜 · 페이지를 떠남(`leaving` = 노선 id 칸도) — 다음에 부르면 한 번 다시 받음. */ export function forgetPageData(projectId: string, leaving = false): void { for (const key of [...memo.keys()]) if (key.endsWith(`:${projectId}`)) memo.delete(key); + forgetSavedLists(projectId); if (leaving) setPageRouteId(projectId, null); } diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 8dbe4da29..6a9cf673d 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -56,6 +56,7 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { fetchSectionContext: async () => ({ route_id: 1 }), }, "./design_page_route": loadTs("A00_Common/design/design_page_route.ts"), // 노선 id 한 칸(386-2b) + "../b_page_state": { noteSavedList: () => undefined, forgetSavedLists: () => undefined }, }), "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => draftPipes }, "../B05_Profile/B05_Profile_Api_PlusStations": { readPlusDraft: () => null }, diff --git a/resources/tester/helper_384_2_page_data.cjs b/resources/tester/helper_384_2_page_data.cjs index ac83ae4dc..f60c8869d 100644 --- a/resources/tester/helper_384_2_page_data.cjs +++ b/resources/tester/helper_384_2_page_data.cjs @@ -15,6 +15,7 @@ const routeBox = { exports: {} }; new Function("exports", "module", "require", routeJs)(routeBox.exports, routeBox, () => ({})); const STUBS = { "./design_page_route": routeBox.exports, // 노선 id 한 칸(386-2b · 진짜) + "../b_page_state": { noteSavedList: () => undefined, forgetSavedLists: () => undefined }, "../../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => { calls.pipes += 1; diff --git a/resources/tester/helper_469_3_list_draft.cjs b/resources/tester/helper_469_3_list_draft.cjs new file mode 100644 index 000000000..0936dc72f --- /dev/null +++ b/resources/tester/helper_469_3_list_draft.cjs @@ -0,0 +1,69 @@ +/* 469-3 빈 목록 초안("[]") 떠남 판정 헬퍼 — `design_leave` · `b_page_state` 를 한 번씩만 읽어(같은 모듈 한 벌) + * 저장분 있음/없음에 따라 designDirty · hasDrafts 가 같게 나오는지. 결과 JSON 을 stdout 으로. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); + +const map = new Map(); +const session = { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, +}; +globalThis.window = { sessionStorage: session, localStorage: session }; +globalThis.sessionStorage = session; + +const STUBS = { + "@config/config_frontend": { + ROUTES: { B05_PROFILE: "/b05-profile", B06_SECTION: "/b06-section" }, + }, + "@ui/ui_template_locale": { t: (key) => key }, + "../b_project_switch": { guardProjectLeave: () => undefined }, +}; +const { loadTs } = makeLoader({ + root: ROOT, + requireFor: (full, file) => (request) => { + if (request in STUBS) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`)); + return require(request); + }, +}); +const state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); +const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts")); +const P = "p1"; +const set = (name, raw) => session.setItem(`aislo:draft:${name}:${P}`, raw); +const unset = (name) => session.removeItem(`aislo:draft:${name}:${P}`); +const both = () => [leave.designDirty(P), state.hasDrafts(P)]; + +const out = {}; +set("pipes", "[]"); +out.unknownSaved = both(); // 저장분을 모름 = 빈 칸(전과 같음) +state.noteSavedList(P, "pipes", false); +out.savedEmpty = both(); // 처음부터 관 0 +state.noteSavedList(P, "pipes", true); +out.savedFilled = both(); // 관 있던 곳에서 모두 지움 +unset("pipes"); +set("structures", "[]"); +out.structuresSavedEmpty = both(); +state.noteSavedList(P, "structures", true); +out.structuresSavedFilled = both(); +leave.discardDesignEdits(P); +out.afterDiscard = [both(), session.getItem(`aislo:draft:structures:${P}`)]; +set("pipes", "[]"); +state.forgetSavedLists(P); +out.afterForget = both(); +for (const raw of ["{}", ""]) { + set("pipes", raw); + state.noteSavedList(P, "pipes", true); + out[`raw_${raw || "empty"}`] = both(); +} +state.noteSavedList(P, "pipes", true); +set("pipes", '[{"id":1}]'); +out.withItems = both(); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_469_3_list_draft_leave.py b/resources/tester/test_469_3_list_draft_leave.py new file mode 100644 index 000000000..a5e380718 --- /dev/null +++ b/resources/tester/test_469_3_list_draft_leave.py @@ -0,0 +1,44 @@ +"""469-3 빈 목록 초안("[]") 떠남 판정 — 저장분이 있으면 고침 · 없으면 빈 칸 · 떠남 확인과 hasDrafts 한 규칙. + +./venv/Scripts/python.exe -m pytest resources/tester/test_469_3_list_draft_leave.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HELPER = Path(__file__).resolve().parent / "helper_469_3_list_draft.cjs" + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8" + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_저장분이_비었거나_모르면_빈_칸(out) -> None: + assert out["unknownSaved"] == [False, False] + assert out["savedEmpty"] == [False, False] + assert out["structuresSavedEmpty"] == [False, False] + assert out["afterForget"] == [False, False] + + +def test_저장분이_있는데_모두_지웠으면_고침(out) -> None: + assert out["savedFilled"] == [True, True] + assert out["structuresSavedFilled"] == [True, True] + + +def test_폐기는_빈_목록_칸도_지움(out) -> None: + assert out["afterDiscard"] == [[False, False], None] + + +def test_빈_객체_빈_글은_그대로_빈_칸_항목이_있으면_고침(out) -> None: + assert out["raw_{}"] == [False, False] and out["raw_empty"] == [False, False] + assert out["withItems"] == [True, True]