From 01a4a54bdf8376587f0182a9c45fd65c068c74be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 18:28:38 +0900 Subject: [PATCH] =?UTF-8?q?refactor(387-7):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=3D=20=EC=A0=80=EC=9E=A5=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=EB=A7=8C=20=C2=B7=20=EC=97=B4=20=EB=95=8C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=83=81=EC=84=B8=20=ED=95=9C=20=EB=B2=88=EB=A7=8C?= =?UTF-8?q?=20=ED=92=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Section_Canon — 정본 설계 사본 · 세션 글 풂 한 번(상세 가게 · 묶음 cross 입력 같이 봄) - 갈아끼움 · 되돌림은 세션 상세 안 씀(메모리만) · 받을 때만 씀 - 묶음 cross 입력이 세션 글을 따로 안 풂(storedText 지움) - 새 test_384_7_section_canon · test_365_1 세션 = 정본 기대값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- A00_Common/design/design_pipeline_cross.ts | 15 +-- B06_Section/B06_Section_Section_Canon.ts | 66 ++++++++++++ B06_Section/B06_Section_Section_Store.ts | 14 +-- resources/tester/test_365_page_pipeline.py | 3 +- resources/tester/test_384_7_section_canon.py | 103 +++++++++++++++++++ 5 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 B06_Section/B06_Section_Section_Canon.ts create mode 100644 resources/tester/test_384_7_section_canon.py diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 416c40087..580b2e3b9 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -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(); // 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 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 = {}; diff --git a/B06_Section/B06_Section_Section_Canon.ts b/B06_Section/B06_Section_Section_Canon.ts new file mode 100644 index 000000000..85f3a3014 --- /dev/null +++ b/B06_Section/B06_Section_Section_Canon.ts @@ -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(); +/** 묶음이 먼저 푼 세션 상세 — 상세 가게가 처음 읽을 때 가져감(다시 안 풂). */ +const parsed = new Map(); +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("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("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); +} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 562b38f8d..28318c965 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -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("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))); } } diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index 7bbc1c325..861b0e8ec 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -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] diff --git a/resources/tester/test_384_7_section_canon.py b/resources/tester/test_384_7_section_canon.py new file mode 100644 index 000000000..957ee2712 --- /dev/null +++ b/resources/tester/test_384_7_section_canon.py @@ -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)