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:
@@ -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> = {};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user