diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index 1d6c0eabe..5217705d5 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -3,6 +3,8 @@ * D 예정노선 · E 안정성평가 단계 묶음(요구 345 · PLAN 529-1) — B05~B09 틀(`createWorkflowLayout`)을 * 단계 목록 · 아이콘 · 주소 인자로 그대로 씀. 빈 단계 = 틀 · 좌측 패널([?] [<] [>]) · 단계 막대만. * 페이지 길 = 각 폴더 `draft/<폴더>_Route.ts` · 본문이 생기면 그 폴더 페이지로 갈아 끼움. + * 떠남(560-4) = `beforeLeave` 가 true 일 때만 — 단계 아이콘 · [<] [>] 는 기다린 뒤 이동 · + * 머리 메뉴 · 뒤로 가기는 같은 함수를 `setLeaveGuard` 로(false = 머묾). * ========================================================================== */ import { @@ -15,7 +17,7 @@ import { import { el, hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { navigateTo, type PageRenderer } from "./router"; +import { navigateTo, setLeaveGuard, type PageRenderer } from "./router"; interface GroupFlow { routes: readonly RoutePath[]; @@ -55,11 +57,18 @@ export function groupFlowOf( export function mountGroupStep( root: HTMLElement, route: RoutePath, - parts: { mainContent?: HTMLElement; leftPanel?: HTMLElement } = {}, + parts: { + mainContent?: HTMLElement; + leftPanel?: HTMLElement; + /** 떠나기 전 — 저장 등 · false = 머묾 */ + beforeLeave?: () => Promise; + } = {}, ): void { const flow = FLOWS.find((f) => f.routes.includes(route)); if (!flow) throw new Error(`단계 묶음 밖 주소: ${route}`); const active = flow.routes.indexOf(route); + const leave = parts.beforeLeave; + if (leave) setLeaveGuard(async () => !root.isConnected || (await leave())); const layout = createWorkflowLayout({ title: t(flow.labels[active]), steps: flow.labels.map((key) => t(key)), @@ -69,7 +78,10 @@ export function mountGroupStep( leftPanel: parts.leftPanel, mainContent: parts.mainContent ?? el("p", { className: "app-placeholder", text: t("DE_Step_Empty") }), - onStepClick: (_index, next) => navigateTo(next as RoutePath), + onStepClick: (_index, next) => { + if (!leave) return navigateTo(next as RoutePath); + void leave().then((ok) => ok && navigateTo(next as RoutePath)); + }, }); root.append(layout.root); } diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts index 5d7ff3008..10821c0a9 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -2,8 +2,10 @@ * D02_PlanBase_UI_Page.ts * D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점. * 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈) - * · [>] = 저장 성공 뒤 D03 · 자동저장 없음(CLAUDE.md 5장). - * 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · [>] = 그냥 D03. + * · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 저장 뒤 · 실패면 머묾(560-4) + * · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선). + * 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · 떠남 = 그냥. + * 첫 불러오기가 실패해도 지도 · 표는 세움(빈 화면 없음). * 지도 [일반][위성](560-3) = 카카오 HYBRID · 고른 값 = 화면 취향(`aislo:pref:`) 기억. * ========================================================================== */ @@ -25,7 +27,6 @@ import { t } from "@ui/ui_template_locale"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { navigateTo } from "../A00_Common/router"; interface LonLat { lon: number; @@ -82,10 +83,14 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { }); const actions = el("div", { className: "ui-sidebar-actions", children: [save] }); const left = el("div", { className: "ui-sidebar-panel d02-panel", children: [card, actions] }); - mountGroupStep(root, ROUTES.D02_PLAN_BASE, { mainContent: mapHost, leftPanel: left }); + mountGroupStep(root, ROUTES.D02_PLAN_BASE, { + mainContent: mapHost, + leftPanel: left, + beforeLeave: async () => !editable || !dirty() || store(), + }); let view: PointsView | null = null; - let points: Array = []; + let points: Array = [null, null]; let active: number | null = null; // 지도 누름이 놓을 지점 let editable = false; // 산림청만 고침 @@ -94,8 +99,10 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { const i = points.findIndex((p) => !p); return i < 0 ? null : i; }; + const serverPoints = (): Array => (view ? boundaryPoints(view) : [null, null]); + const dirty = (): boolean => JSON.stringify(points) !== JSON.stringify(serverPoints()); const remember = (): void => - sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ points })); + sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ base: serverPoints(), points })); const map = editMap({ hint: t("D02_Map_Hint"), @@ -215,7 +222,6 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { } async function store(): Promise { - if (!view) return false; showLoadingOverlay(); try { const body = { start: points[0], end: points[1] }; @@ -238,14 +244,6 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { } } - // [>] = 저장 성공 뒤 D03(지형 받기 시작은 D03 몫) · 실패면 머묾 - left.addEventListener("ui-step-nav", (event) => { - if ((event as CustomEvent).detail !== 1) return; - event.stopPropagation(); - if (!editable) return navigateTo(ROUTES.D03_PLAN_ANALYSIS); - void store().then((ok) => ok && navigateTo(ROUTES.D03_PLAN_ANALYSIS)); - }); - try { editable = !!(await fetchSessionUser())?.is_forest_service; save.hidden = !editable; @@ -256,12 +254,14 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { } adopt(await request(`/projects/${projectId}/plan/points`)); const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as { + base?: Array; points?: Array; } | null; - if (editable && cached?.points?.length === points.length) points = cached.points; - active = firstEmpty(); - paint(); + const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points); + if (editable && sameBase && cached?.points?.length === 2) points = cached.points; } catch (error) { - table.replaceChildren(createText(error instanceof Error ? error.message : "", "muted")); + showToast(error instanceof Error ? error.message : String(error), "error"); } + active = firstEmpty(); + paint(); } diff --git a/resources/tester/test_560_4_leave_save.py b/resources/tester/test_560_4_leave_save.py new file mode 100644 index 000000000..a47f156d2 --- /dev/null +++ b/resources/tester/test_560_4_leave_save.py @@ -0,0 +1,27 @@ +"""560-4 D 단계 이동 = 저장 뒤 이동 지킴 시험 — 서버 · 화면 없이 소스만 본다. +단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로가 한 함수(beforeLeave)를 거치고 · D02 첫 불러오기 실패에도 지도를 세움.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _src(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def test_group_step_waits_before_leave(): + ts = _src("A00_Common/group_workflow.ts") + assert "beforeLeave?: () => Promise" in ts + assert "setLeaveGuard(" in ts # 머리 메뉴 · 뒤로 + assert "leave().then((ok) => ok && navigateTo(" in ts # 단계 아이콘 · [<] [>] + + +def test_d02_leave_saves_and_builds_map_on_fail(): + ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts") + assert "beforeLeave:" in ts and "ui-step-nav" not in ts # 따로 듣개 없음 + tail = ts[ts.rindex(" try {") :] + # 불러오기 catch 뒤에 paint() — 실패해도 지도 · 표를 세움 + assert re.search(r"\} catch \(error\) \{[^}]*\}\s*active = firstEmpty\(\);\s*paint\(\);", tail) + assert "sameBase" in ts # 옛 캐시는 그때 서버 값이 지금과 같을 때만