refactor(387-7): 세션 상세 = 저장 정본만 · 열 때 세션 상세 한 번만 풂

- 새 Section_Canon — 정본 설계 사본 · 세션 글 풂 한 번(상세 가게 · 묶음 cross 입력 같이 봄)
- 갈아끼움 · 되돌림은 세션 상세 안 씀(메모리만) · 받을 때만 씀
- 묶음 cross 입력이 세션 글을 따로 안 풂(storedText 지움)
- 새 test_384_7_section_canon · test_365_1 세션 = 정본 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-17 18:28:38 +09:00
co-authored by Claude Opus 5.5
parent 6a3f9647f3
commit 01a4a54bdf
5 changed files with 182 additions and 19 deletions
+4 -11
View File
@@ -12,7 +12,8 @@
* 암 경사 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2 · 365-5). 부를 때마다 모음.
* ========================================================================== */
import { readState, readStateRaw } from "../b_page_state";
import { readState } from "../b_page_state";
import { savedDesigns } from "../../B06_Section/B06_Section_Section_Canon";
import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import {
effectiveStandardCross,
@@ -41,14 +42,6 @@ const SPEC_KEYS = [
/** 같은 측점으로 보는 누가거리 차(m) — 측점 열쇠 글 자릿수(0.01 m). */
const SAME_M = 0.01;
/** 세션 저장 상세(노선 초안 동안 안 덮음 · 365-1) — 글이 같으면 앞 해석 그대로. */
let storedText: { text: string | null; detail: SectionDetailResponse | null } | null = null;
function storedDetail(projectId: string, routeId: number | null): SectionDetailResponse | null {
const text = routeId === null ? null : readStateRaw("section-detail", projectId, routeId);
if (storedText?.text !== text) storedText = { text, detail: text ? JSON.parse(text) : null };
return storedText.detail;
}
/** 짧은 지문(FNV-1a) — 제원 묶음이 같은지만 견줌. */
function digest(text: string): string {
let hash = 0x811c9dc5;
@@ -147,9 +140,9 @@ export function pipelineCrossSource(
const specs = new Map<string, SectionSpecs>();
// 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 365-5) ·
// 시설 제원 = 화면 상세(초안 형상을 얹은 것)
const stored = storedDetail(projectId, routeId);
const stored = routeId === null ? null : savedDesigns(projectId, routeId); // 세션 다시 안 풂(387-7)
const storedAt = (chainageM: number) =>
stored?.cross_sections.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design;
stored?.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design;
const put = (key: string, section: CrossSection, at = section.chainage_m): void => {
byKey[key] = carried(section, storedAt(at));
const own: Record<string, unknown> = {};
+66
View File
@@ -0,0 +1,66 @@
/* =============================================================================
* B06_Section_Section_Canon.ts
* 세션 상세(`section-detail`) = **저장 정본만**(387-7) — 서버에서 받은 그대로 한 벌 · 갈아끼움 · 되돌림 ·
* 다시 셈 결과는 안 씀(메모리 캐시 몫). 세션 글은 **한 번만** 풂 — 상세 가게(`_Section_Store`)와 계산 묶음
* cross 입력(`design_pipeline_cross`)이 이 파일을 함께 봄(예전에는 둘이 따로 1.5 MB 를 풂).
* 가벼운 자리 — 상세 가게의 화면 줄기를 안 끌고 묶음 입력이 부름.
* ========================================================================== */
import { readState } from "../A00_Common/b_page_state";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
/** 정본 측점 설계 — 받을 때 뜬 사본(다시 셈 · 초안 얹기가 고치는 캐시 객체와 따로). */
export type CanonRow = { chainage_m: number; design: CrossSection["design"] };
const canon = new Map<string, CanonRow[]>();
/** 묶음이 먼저 푼 세션 상세 — 상세 가게가 처음 읽을 때 가져감(다시 안 풂). */
const parsed = new Map<string, SectionDetailResponse>();
const keyOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`;
/** 서버에서 받은(또는 세션에서 푼) 정본의 설계 사본을 떠 둠. */
export function keepCanon(projectId: string, routeId: number, detail: SectionDetailResponse): void {
const key = keyOf(projectId, routeId);
parsed.delete(key);
canon.set(
key,
detail.cross_sections.map(({ chainage_m, design }) => ({
chainage_m,
design: design ? { ...design } : design,
})),
);
}
/** 새로고침 — 세션 상세(묶음이 먼저 풀었으면 그 객체) · 정본 사본도 뜸. */
export function sessionDetail(projectId: string, routeId: number): SectionDetailResponse | null {
const key = keyOf(projectId, routeId);
const ready = parsed.get(key);
if (ready) {
parsed.delete(key);
return ready;
}
const stored = readState<SectionDetailResponse>("section-detail", projectId, routeId);
if (stored) keepCanon(projectId, routeId, stored);
return stored;
}
/** 정본 측점 설계(묶음 cross 입력 · 365-5) — 아직 안 읽었으면 세션에서 한 번(상세 가게가 그 객체를 이어 씀). */
export function savedDesigns(projectId: string, routeId: number): CanonRow[] | null {
const key = keyOf(projectId, routeId);
if (!canon.has(key)) {
const stored = readState<SectionDetailResponse>("section-detail", projectId, routeId);
if (!stored) return null;
keepCanon(projectId, routeId, stored);
parsed.set(key, stored);
}
return canon.get(key) ?? null;
}
/** 정본이 바뀜(새로 받을 것) — 사본을 버림. 노선을 안 주면 그 프로젝트 전부. */
export function forgetCanon(projectId: string, routeId?: number): void {
for (const map of [canon, parsed])
for (const key of [...map.keys()])
if (
routeId === undefined ? key.startsWith(`${projectId}:`) : key === keyOf(projectId, routeId)
)
map.delete(key);
}
+7 -7
View File
@@ -19,7 +19,6 @@
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { useDesignStore } from "../A00_Common/design/design_store";
import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station";
import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations";
import {
@@ -30,6 +29,7 @@ import {
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import { withDraftAdjusts } from "./B06_Section_Section_Adjusts";
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { forgetCanon, keepCanon, sessionDetail } from "./B06_Section_Section_Canon";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options";
@@ -104,7 +104,7 @@ export async function loadSectionDetail(
const inFlight = pending.get(key);
if (inFlight) return inFlight;
// 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다.
const stored = readState<SectionDetailResponse>("section-detail", projectId, routeId);
const stored = sessionDetail(projectId, routeId); // 세션 글 = 한 번만 풂(387-7)
if (stored) {
cache.set(key, stored);
return withDraftWalls(stored, projectId, routeId);
@@ -114,6 +114,7 @@ export async function loadSectionDetail(
.then(async (detail) => {
cache.set(key, detail);
writeState("section-detail", detail, projectId, routeId);
keepCanon(projectId, routeId, detail);
return withDraftWalls(detail, projectId, routeId);
})
.finally(() => {
@@ -162,10 +163,8 @@ export async function swapSectionStations(
const nextKeys = stations.map((station) => ({ key: station.key, chainageM: station.chainageM }));
if (swap.moved) carryStationKeys(projectId, routeId, nextKeys);
else useStationKeyList(projectId, routeId, nextKeys);
// 메모리만 — 세션 상세 = 저장 정본만(387-7 · 새로고침 · B06 바로 열기 = 묶음이 다시 갈아끼움 · 365-1)
cache.set(key, swap.detail);
// 노선 초안 동안 = 메모리만(365-1) — 세션 상세는 저장 노선 그대로(새로고침 · B06 바로 열기 = 묶음이 초안 다시 셈)
if (!useDesignStore(projectId, null).get("routeDraft"))
writeState("section-detail", swap.detail, projectId, routeId);
await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4)
withStructureAreas(swap.detail);
if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음
@@ -231,8 +230,7 @@ export async function restoreSectionDetail(
});
for (const [name, value] of hold.cells) writeState(name, value ?? null, projectId, routeId);
useStationKeyList(projectId, routeId, hold.keys);
cache.set(key, saved);
writeState("section-detail", saved, projectId, routeId);
cache.set(key, saved); // 세션 상세는 정본 그대로(387-7)
await withDraftShapes(saved, projectId);
withStructureAreas(saved);
if (cache.get(key) !== saved) return null; // 그 사이 다른 갈아끼움 · 새로 받음
@@ -249,6 +247,7 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo
forgetDraftStations(projectId);
if (routeId !== undefined) {
cache.delete(keyOf(projectId, routeId));
forgetCanon(projectId, routeId);
clearState("section-detail", projectId, routeId);
return;
}
@@ -256,6 +255,7 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo
for (const key of [...cache.keys()]) {
if (!key.startsWith(prefix)) continue;
cache.delete(key);
forgetCanon(projectId, Number(key.slice(prefix.length)));
clearState("section-detail", projectId, Number(key.slice(prefix.length)));
}
}
+2 -1
View File
@@ -229,4 +229,5 @@ def test_365_1_초안_동안_세션_상세_안_덮음(swap):
assert d["swapped"] and d["cache"] == [0, 20, 30, 40, 60] # 메모리 = 초안 측점
assert d["session"] == [0, 20, 40, 60] # 세션 = 저장 노선 그대로
s = swap["saved"]
assert s["swapped"] and s["session"] == s["cache"] == [0, 20, 50, 60] # 초안 없으면 세션도
# 387-7 — 초안 없어도 세션 = 저장 정본만(갈아끼움은 메모리 · 새로고침이면 묶음이 다시 갈아끼움)
assert s["swapped"] and s["cache"] == [0, 20, 50, 60] and s["session"] == [0, 20, 40, 60]
@@ -0,0 +1,103 @@
"""387-7(384 단계 7) 결과 층 — 세션 상세 = 저장 정본만 · 열 때 세션 상세 JSON.parse 한 번.
· 상세 가게(새로고침)와 묶음 cross 입력이 어느 쪽이 먼저 읽어도 세션 글은 한 번만 풂(`_Section_Canon`)
· 정본 사본은 캐시 객체와 따로 — 다시 셈 · 초안 얹기가 캐시를 고쳐도 묶음 입력의 정본은 그대로
· 갈아끼움 · 되돌림은 세션 상세를 안 씀(`test_365_page_pipeline` 365-1 · 이 파일은 소스로)
./venv/Scripts/python.exe -m pytest resources/tester/test_384_7_section_canon.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"
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 canon = loadTs(process.argv[2]);
const state = loadTs(process.argv[3]);
const detail = {
longitudinal: {},
cross_sections: [
{ chainage_m: 0, design: { ground_type: "soil", cut_area_m2: 1 } },
{ chainage_m: 20, design: { ground_type: "rock", cut_area_m2: 2 } },
],
};
let parses = 0;
const parse = JSON.parse;
JSON.parse = (text, ...rest) => {
if (typeof text === "string" && text.includes("cross_sections")) parses += 1;
return parse(text, ...rest);
};
const out = {};
// 묶음 입력이 먼저 → 상세 가게
state.writeState("section-detail", detail, "p", 1);
const rows = canon.savedDesigns("p", 1);
const shown = canon.sessionDetail("p", 1);
out.cross = { parses, rows: rows.length };
// 정본 사본 = 캐시 객체와 따로
shown.cross_sections[0].design.cut_area_m2 = 99;
out.canonKept = canon.savedDesigns("p", 1)[0].design.cut_area_m2;
// 상세 가게가 먼저 → 묶음 입력
parses = 0;
state.writeState("section-detail", detail, "p", 2);
canon.sessionDetail("p", 2);
canon.savedDesigns("p", 2);
out.storeFirst = parses;
// 버림 → 다시 받기 전엔 정본 없음
canon.forgetCanon("p");
state.writeState("section-detail", null, "p", 1);
out.forgot = canon.savedDesigns("p", 1);
process.stdout.write(JSON.stringify(out));
"""
def _run() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
"-e",
DRIVER,
str(LOADER),
str(ROOT / "B06_Section" / "B06_Section_Section_Canon.ts"),
str(ROOT / "A00_Common" / "b_page_state.ts"),
],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_열_때_세션_상세_한_번만_풂() -> None:
out = _run()
assert out["cross"] == {"parses": 1, "rows": 2}
assert out["storeFirst"] == 1
assert out["canonKept"] == 1 # 캐시를 고쳐도 정본 사본 그대로
assert out["forgot"] is None
def test_세션_상세_쓰는_곳_받을_때_하나() -> None:
store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8")
code = re.sub(r"/\*.*?\*/|//[^\n]*", "", store, flags=re.S)
assert code.count('writeState("section-detail"') == 1 # 서버에서 받은 정본만
cross = (ROOT / "A00_Common" / "design" / "design_pipeline_cross.ts").read_text(
encoding="utf-8"
)
assert "section-detail" not in re.sub(r"/\*.*?\*/|//[^\n]*", "", cross, flags=re.S)