Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_1

This commit is contained in:
2026-10-18 12:26:40 +09:00
11 changed files with 311 additions and 29 deletions
+7
View File
@@ -35,6 +35,9 @@ import { pinDraftProject } from "./design_store";
import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center";
import { reconcilePagePipeline } from "./design_pipeline_page";
import { forgetPageData, onPageBusy } from "./design_page_data";
import { pageRouteId } from "./design_page_route";
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
import { peekSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import "./design_page.css";
export type { DesignPageShared } from "./design_shared";
@@ -113,6 +116,10 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
// 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06)
setPipeSectionsSource(
() => peekSectionDetail(projectId, pageRouteId(projectId))?.cross_sections ?? null,
);
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
const left = document.createElement("div");
+4
View File
@@ -39,6 +39,7 @@ import {
} from "../../B06_Section/B06_Section_Structure_Layouts";
import {
holdSectionDetail,
invalidateSectionDetail,
restoreSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
@@ -382,6 +383,9 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
return;
}
// 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를
// 받음(416 · 보기마다 비우면 앞 보기가 받은 새 상세를 뒤 보기가 또 버림)
invalidateSectionDetail(shared.projectId);
await profileSave?.done?.(false);
await crossSave?.done?.(false);
showToast(L("B06_Profile_Save_Success"), "success");
-3
View File
@@ -79,7 +79,6 @@ import {
setPipeExtraCountSource,
setPipeInletSource,
setPipeRevetChoiceBridge,
setPipeSectionsSource,
} from "./design_structure_pipe_calc";
import { pipeRevetChoiceBridge } from "../../B06_Section/B06_Section_UI_Page_Link_Session";
import {
@@ -103,8 +102,6 @@ export async function mountCrossView(
const projectId = shared.projectId || null;
let currentRouteId: number | null = null;
let sectionDetail: SectionDetailResponse | null = null;
// 배관 명세 관 길이(자동) = 이 횡단 자료의 기하(PLAN 52-1)
setPipeSectionsSource(() => sectionDetail?.cross_sections ?? null);
let stationInterval: number | undefined;
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
+1 -3
View File
@@ -59,7 +59,7 @@ import {
type PageActionContext,
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
import { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc";
import { setPipeCrossOpener } from "./design_structure_pipe_calc";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import "../../B05_Profile/B05_Profile_UI_Style.css";
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
@@ -245,8 +245,6 @@ export async function mountProfileView(
routeReady: false,
restoring: true,
};
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
setPipeSectionsSource(() => state.detail?.cross_sections ?? null);
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
setPipeCrossOpener(() => shared.ensureView("cross"));
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
+4 -3
View File
@@ -9,7 +9,6 @@
import { confirmRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch";
import type { DesignViewSave } from "../A00_Common/design/design_shared";
import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { pageRouteId } from "../A00_Common/design/design_page_route";
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
@@ -74,10 +73,12 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave {
},
},
// 횡단 본문은 늘 횡단 보기 몫 — [저장]이 숨긴 채라도 세움(386-6 · 옛 캐시 패치 길 지움)
// 공유 캐시는 한 벌(`design_shared`)이 저장 끝에 한 번 비움 — 여기서는 새 자료를 다시 읽어 종단 ·
// 평면 오른쪽 판을 그림(416 · 노선 안 바뀐 [저장]도 · B06 다녀오지 않아도 면적 = B06)
done: async () => {
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다.
invalidateSectionDetail(projectId);
ctx.renderLatest(await ctx.loadLatest(true));
const routeId = pageRouteId(projectId);
if (routeId !== null) await ctx.restoreSections(routeId);
},
};
}
@@ -32,6 +32,7 @@ import type { FordControl } from "../B06_Section/B06_Section_UI_Cross_Ford_Panel
import { createCrossSectionCard } from "../B06_Section/B06_Section_UI_Cross_View";
import type { RockBoundaryControl } from "../B06_Section/B06_Section_UI_Cross_Design";
import { designElevationAt } from "../B06_Section/B06_Section_UI_Section_Common";
import { readRockBoundaryDefault } from "../B06_Section/B06_Section_UI_Standard_Panel";
/** 판 하나가 그릴 측점 — 그때 사본(이전 판으로 내려도 그대로). */
export interface RouteCrossSource {
@@ -40,18 +41,24 @@ export interface RouteCrossSource {
link: CulvertLink | undefined;
/** 보기 반폭(m) — B06 입력칸과 같은 값 · 그릴 때마다 읽음(B06 에서 바꾸면 다음 그림부터). */
halfWidthM: () => number;
/** 암 경계 기본 오프셋(m · config). */
rockDefaultM: number;
}
const none = (): void => undefined;
/** 암 경계선 = 정본 오프셋(없으면 기본 −0.5 · 0 을 넘지 않음 — B06 저장소 규칙) · 조정 없음. */
const rockBoundary: RockBoundaryControl = {
/** 암 경계선 = 정본 오프셋(없으면 config 기본 · 0 을 넘지 않음 — B06 저장소 규칙) · 조정 없음. */
const rockBoundary = (defaultOffsetM: number): RockBoundaryControl => ({
stepM: 0.1,
defaultOffsetM: -0.5,
offsetFor: (section) => Math.min(section.design?.rock_boundary_offset_m ?? -0.5, 0),
defaultOffsetM,
offsetFor: (section) => Math.min(section.design?.rock_boundary_offset_m ?? defaultOffsetM, 0),
adjust: none,
reset: none,
};
});
/** config 암 경계 기본(`STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M` · 종단 보기가 context 로 기억 · 416) —
* context 전이면 서버 Schema 기본과 같은 값. */
const CONTEXT_PENDING_ROCK_M = -0.5;
/** 그 측점 단면 사본 — 상세가 없거나(초안 중 새 상세 전) 측점이 없으면 null. */
export function routeCrossSource(
@@ -60,8 +67,10 @@ export function routeCrossSource(
chainageM: number,
pane: string,
): RouteCrossSource | null {
const routeId = pageRouteId(projectId);
const detail =
pipelineDetail(projectId) ?? (pipe?.input("route") ? null : peekSectionDetail(projectId));
pipelineDetail(projectId) ??
(pipe?.input("route") ? null : peekSectionDetail(projectId, routeId));
const sections = detail?.cross_sections ?? [];
const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m);
if (!detail || !found) return null;
@@ -78,6 +87,7 @@ export function routeCrossSource(
? undefined
: culvertLinkFor(found, sections, (at) => designElevationAt(profiles, at) ?? null),
halfWidthM: () => displayHalfWidth(projectId, pageRouteId(projectId), sections),
rockDefaultM: readRockBoundaryDefault(projectId) ?? CONTEXT_PENDING_ROCK_M,
};
}
@@ -122,7 +132,7 @@ export function routeCrossCard(
plotHeightPx,
source.designZ,
undefined,
rockBoundary,
rockBoundary(source.rockDefaultM),
null,
undefined,
undefined,
+6 -9
View File
@@ -302,15 +302,12 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo
}
}
/** 받아 둔 상세 중 노선 번호가 가장 큰 것(최신) — 노선 번호를 모르는 쪽(B05 평면 횡단 판 · 393-2). */
export function peekSectionDetail(projectId: string): SectionDetailResponse | null {
const prefix = `${projectId}:`;
let best: { routeId: number; detail: SectionDetailResponse } | null = null;
for (const [key, detail] of cache) {
const routeId = Number(key.slice(prefix.length));
if (key.startsWith(prefix) && (!best || routeId > best.routeId)) best = { routeId, detail };
}
return best?.detail ?? null;
/** 받아 둔 그 노선 상세(받지 않고 · 없으면 null) — 노선 = 페이지 노선 id(B05 평면 횡단 판 · 관 길이 · 416). */
export function peekSectionDetail(
projectId: string,
routeId: number | null,
): SectionDetailResponse | null {
return routeId === null ? null : (cache.get(keyOf(projectId, routeId)) ?? null);
}
/**
+1 -3
View File
@@ -14,7 +14,6 @@ import {
type SectionContextResponse,
type SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "./B06_Section_Section_Store";
import { onAnySwap } from "./B06_Section_Swap_Bus";
import type { DesignViewSave } from "../A00_Common/design/design_shared";
import { buildCrossPatches, type CrossPatchSources } from "./logic/B06_Section_UI_Page_Patches";
@@ -520,8 +519,7 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave {
// 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다
// (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음).
// 저장 도중 바뀜 신호는 안 그렸음 — 비운 캐시로 서버 새 상세를 받아 한 번 다시(323-9)
const routeId = ctx.routeId();
if (ctx.projectId && routeId !== null) invalidateSectionDetail(ctx.projectId, routeId);
// 캐시 비우기 = 한 벌(`design_shared`)이 저장 끝에 한 번(416 · 두 보기가 같은 새 객체를 받게)
await ctx.redraw();
},
};
@@ -128,7 +128,7 @@ function loadTs(full) {
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const store = loadTs(B06("B06_Section_Section_Store"));
const { writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
const storeDetail = () => store.peekSectionDetail(P);
const storeDetail = () => store.peekSectionDetail(P, ROUTE);
/** 저장 정본 — 격자 0 · 20 · 40 · 60(지금 계획선으로 셈한 설계) · 계획선 평평 100. */
const canon = () => ({
+184
View File
@@ -0,0 +1,184 @@
/* 416 헬퍼 — 한 페이지 끊김 ② : 진짜 상세 가게(`B06_Section_Section_Store`) · 진짜 평면 횡단 판 자료
* (`B05_Profile_UI_RouteEdit_Cross_Card.routeCrossSource`) · 진짜 관 길이(`design_structure_pipe_calc`) ·
* 진짜 페이지 노선 칸. 서버 · 그림 · 묶음은 가짜. 짝 = `test_416_page_cache.py`.
* L2 — 판 = 페이지 노선 상세(더 큰 노선 번호 상세가 캐시에 있어도)
* M5 — 관 길이 = 공용 캐시(페이지 노선) · 저장 뒤 비우고 다시 받으면 새 값
* M1 — 비운 뒤 두 보기가 함께 받으면 같은 새 객체 하나
* 암선 기본 = config(기억해 둔 context 값)
*/
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 = "P416";
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) });
/** 가짜 서버 — 노선마다 관 길이 하나(40 m 측점 관) · 받을 때마다 새 객체 · 받은 수. */
const server = { pipeLength: { 3: 8, 9: 30 }, fetches: 0 };
const detailOf = (routeId) => ({
longitudinal: { stations: [], design_profiles: [] },
cross_sections: [0, 20, 40].map((chainage_m) => ({
station_id: `r${routeId}-${chainage_m}`,
chainage_m,
samples: [],
culvert: chainage_m === 40 ? { chainage_m: 40 } : undefined,
design: { pipe_length_m: chainage_m === 40 ? server.pipeLength[routeId] : undefined },
})),
});
let rockDefault = null; // 종단 보기가 기억한 context 값
let draftRoute = null; // 묶음 노선 초안
const STUBS = {
"design_pipeline_page.ts": {
reconcilePagePipeline: async () => 0,
pipelineOwnsCross: () => false,
pagePipeline: () => null,
pipelineCross: () => undefined,
pipelineDetail: () => null,
},
"design_pipeline_cross.ts": { forceCross: () => undefined },
"B06_Section_Section_Drafts.ts": {
forgetDraftStations: () => undefined,
withDraftShapes: async () => undefined,
withDraftStations: async () => undefined,
},
"B05_Profile_Api_PlusStations.ts": {
loadSavedPlusStations: async () => undefined,
plusStationsOf: () => [],
},
"B05_Profile_Api_Pipes_Draft.ts": { readPendingPipes: () => null },
"design_page_data.ts": { pagePipes: async () => ({ pipe_points: [] }) },
"B06_Section_Api_Fetch.ts": anyModule({
fetchSectionDetail: async (_projectId, routeId) => {
server.fetches += 1;
return detailOf(routeId);
},
}),
"B06_Section_Api_Culvert_Options.ts": anyModule({ STRUCTURE_ADJUST_KEYS: [] }),
"B06_Section_Swap_Bus.ts": anyModule(),
"B06_Section_Station_Keys.ts": anyModule({ KEYED_STATES: [] }),
"B06_Section_UI_Page_Persist.ts": anyModule(),
"B06_Section_UI_Standard_Panel.ts": anyModule({
readRockBoundaryDefault: () => rockDefault,
}),
"B06_Section_Cross_Design_Session.ts": anyModule(),
"common_util_cross_design.ts": anyModule(),
"B06_Section_Structure_Layouts.ts": anyModule({ computeStoredLayouts: () => null }),
"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_Cross_View.ts": anyModule(),
"B06_Section_UI_Section_Common.ts": anyModule({ designElevationAt: () => undefined }),
"B06_Section_Display_Width.ts": anyModule({ displayHalfWidth: () => 10 }),
"B06_Section_UI_Style_Cross_Controls.css": {},
"B06_Section_UI_Cross_Culvert_Basin.ts": anyModule(),
"B06_Section_UI_Cross_Culvert_Const.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 cache = new Map();
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;
}
const store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts"));
const pageRoute = loadTs(path.join(ROOT, "A00_Common", "design", "design_page_route.ts"));
const pipeCalc = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe_calc.ts"));
const card = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Cross_Card.ts"));
const fakePipe = { input: (name) => (name === "route" ? draftRoute : null) };
async function run() {
const out = {};
// 열 때 — 페이지 노선 3 · 캐시에 더 큰 노선 9 도 있음(다른 탭 · 옛 판)
pageRoute.setPageRouteId(P, 3);
await store.loadSectionDetail(P, 9);
const shown = await store.loadSectionDetail(P, 3);
const source = card.routeCrossSource(P, fakePipe, 40, "cur");
out.l2 = {
station: source?.section.station_id ?? null,
noRoute: store.peekSectionDetail(P, null),
exact: store.peekSectionDetail(P, 3) === shown,
};
draftRoute = { v: 1 };
out.l2.draftNoSaved = card.routeCrossSource(P, fakePipe, 40, "cur"); // 노선 초안 중 = 저장 상세 안 씀
draftRoute = null;
// 암선 기본 = config(기억값) · 기억 전 = 서버 Schema 기본과 같은 값
out.rock = { pending: source.rockDefaultM };
rockDefault = -0.8;
out.rock.config = card.routeCrossSource(P, fakePipe, 40, "cur").rockDefaultM;
// M5 — 관 길이 = 공용 캐시(페이지 노선) 한 벌(design_page 가 거는 그 함수)
pipeCalc.setPipeSectionsSource(
() => store.peekSectionDetail(P, pageRoute.pageRouteId(P))?.cross_sections ?? null,
);
out.m5 = { before: pipeCalc.pipeLengthM(40) };
// [저장] — 서버가 관 길이를 다시 셈(8 → 11) · 한 벌이 캐시를 한 번 비움 → 두 보기가 함께 다시 받음
server.pipeLength[3] = 11;
store.invalidateSectionDetail(P);
out.m5.cleared = pipeCalc.pipeLengthM(40);
const fetchesBefore = server.fetches;
const [profileView, crossView] = await Promise.all([
store.loadSectionDetail(P, 3),
store.loadSectionDetail(P, 3),
]);
out.m1 = {
sameObject: profileView === crossView,
fetched: server.fetches - fetchesBefore,
fresh: profileView !== shown,
panelSeesFresh: card.routeCrossSource(P, fakePipe, 40, "cur")?.section.station_id ?? null,
};
out.m5.after = pipeCalc.pipeLengthM(40);
return out;
}
run()
.then((out) => console.log(JSON.stringify(out)))
.catch((error) => {
console.error(error);
process.exit(1);
});
+86
View File
@@ -0,0 +1,86 @@
"""PLAN 416 sub5 — 한 페이지 끊김 ② : 저장 뒤 새 자료(M1) · 관 길이 출처(M5) · 오른쪽 판 노선 id(L2) · 암선 기본 config.
짝: `helper_416_page_cache.cjs`(진짜 상세 가게 · 평면 횡단 판 자료 · 관 길이 · 페이지 노선 칸 · 서버 · 그림 가짜).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def r() -> dict:
proc = subprocess.run( # noqa: S603, S607 — 고정 실행 파일
["node", str(HERE / "helper_416_page_cache.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_L2_판은_페이지_노선_상세(r):
assert r["l2"]["station"] == "b05-cur-r3-40" # 캐시에 더 큰 노선 9 가 있어도
assert r["l2"]["noRoute"] is None and r["l2"]["exact"] is True
assert r["l2"]["draftNoSaved"] is None # 노선 초안 중 = 저장 상세 안 씀
def test_암선_기본은_config(r):
assert r["rock"] == {"pending": -0.5, "config": -0.8}
src = _read("B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts")
assert "readRockBoundaryDefault(projectId)" in src
assert "?? -0.5" not in src
def test_M5_관_길이는_공용_캐시(r):
assert r["m5"] == {"before": 8, "cleared": None, "after": 11}
page = _read("A00_Common/design/design_page.ts")
assert "peekSectionDetail(projectId, pageRouteId(projectId))?.cross_sections" in page
found = subprocess.run( # noqa: S603, S607 — 고정 실행 파일
["git", "grep", "-l", "setPipeSectionsSource(", "--", "*.ts"],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
).stdout.split()
# 거는 곳 = 페이지 하나(보기마다 걸면 마지막 연 보기 사본)
assert sorted(found) == [
"A00_Common/design/design_page.ts",
"A00_Common/design/design_structure_pipe_calc.ts",
]
def test_M1_비운_뒤_두_보기는_새_객체_하나(r):
assert r["m1"] == {
"sameObject": True,
"fetched": 1,
"fresh": True,
"panelSeesFresh": "b05-cur-r3-40",
}
def test_M1_저장_끝_한_번_비우고_종단_다시_읽음():
shared = _read("A00_Common/design/design_shared.ts")
done = shared[shared.index("done: async (isConfirm)") :]
assert done.index("invalidateSectionDetail(shared.projectId)") < done.index("profileSave?.done")
actions = _read("B05_Profile/B05_Profile_UI_Page_Actions.ts")
tail = actions[actions.index("done: async") :]
assert tail.index("ctx.loadLatest(true)") < tail.index("ctx.restoreSections(routeId)")
assert "invalidateSectionDetail" not in actions
persist = _read("B06_Section/B06_Section_UI_Page_Persist.ts")
assert (
"invalidateSectionDetail" not in persist
) # 보기마다 비우면 앞 보기가 받은 새 상세를 또 버림