fix(451 B06 세션 캐시 판): 세션 상세 뒤에서 서버 판 확인 · 다르면 서버 값 위 내 칸만 · 숨은 종단 보기 관 id 장부 · 384_3 단언 427 의도

- 433-16: 새로고침 · 새 탭 = 세션 상세로 먼저 섬 · 뒤에서 상세 GET(ETag 304) · 판이 다르면 서버 값 위에 이 탭에서 고친 사용자 칸만 · [저장] patch 바탕도 새 판 · 노선 초안 hold 는 버릴 때 새 판
- 440-3: 숨은 종단 보기도 관 세션 id 장부 돌림(평면 마커 누름 = 초안 자리) · 그리기는 보일 때
- 384_3: pvi 단언 = 저장 꼭짓점 + 심은 측점마다 세운 꼭짓점(427)
- 384_7: 세션 쓰기 = 받을 때 · 판 확인 새 판 둘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 21:37:07 +09:00
co-authored by Claude Opus 5.5
parent d0779af302
commit eedafe8db9
7 changed files with 366 additions and 5 deletions
+3 -1
View File
@@ -159,7 +159,8 @@ export async function mountProfileView(
const followed = designStore.get("pipes");
if (followed) profilePanel.drainage.followPipes(followed);
else if (profilePanel.drainage.pipeChainages().length) profilePanel.drainage.reload();
if (!host.visible()) return;
// 숨어도 관 세션 id 장부는 따라감 — 평면 마커 누름이 초안 자리를 고름(440-3) · 그리기는 보일 때
if (!host.visible()) return void (lastPipes && shownPipes(lastPipes));
if (lastPipes) applyPipes(lastPipes, false);
bridge.takeStructures(designStore.get("structures"));
};
@@ -214,6 +215,7 @@ export async function mountProfileView(
onPipesChanged: (pipes) => {
lastPipes = pipes;
if (host.visible()) applyPipes(pipes);
else shownPipes(pipes); // 숨어도 관 id 장부만(440-3)
},
onStructureMove: (from, to, station) => {
if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to);
+60 -2
View File
@@ -17,7 +17,7 @@
* 쓰는 조작(재생성·계획선 편집 저장·확정)은 그 응답/재조회로 `replace`·`invalidate`한다.
* ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { clearState, readState, readStateRaw, writeState } from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station";
import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations";
@@ -30,6 +30,7 @@ import {
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import { withDraftAdjusts, withPipeOptions } from "./B06_Section_Section_Adjusts";
import type {
CrossSection,
CrossSectionPatch,
SectionDetailResponse,
StandardCrossSection,
@@ -126,6 +127,7 @@ export async function loadSectionDetail(
const stored = sessionDetail(projectId, routeId); // 세션 글 = 한 번만 풂(387-7)
if (stored) {
cache.set(key, stored);
void checkSessionCanon(projectId, routeId, stored).catch(() => undefined);
return withDraftWalls(stored, projectId, routeId);
}
}
@@ -143,6 +145,62 @@ export async function loadSectionDetail(
return request;
}
/**
* 세션 상세 판 확인(433-16) — 새로고침 · 새 탭 첫 화면은 세션 한 벌로 서고, 뒤에서 서버에 한 번 물음(ETag ·
* 같으면 304). 다른 탭 · 다른 PC 가 저장해 판이 다르면 서버 값 위에 **이 탭에서 고친 사용자 칸만**(세션 정본
* 사본과 다른 칸) 얹음 — 낡은 캐시가 [저장] patch 바탕(445)으로 서버 값을 되쓰지 않게. 새 상세로 갈아끼워 듣는
* 쪽이 다시 그림 · 노선 초안이 옛 상세를 쥐고 있으면(hold) 버릴 때 새 판으로(`renewed`).
*/
async function checkSessionCanon(
projectId: string,
routeId: number,
stored: SectionDetailResponse,
): Promise<void> {
const fresh = await fetchSectionDetail(projectId, routeId);
if (!Array.isArray(fresh?.cross_sections)) return;
const raw = readStateRaw("section-detail", projectId, routeId);
if (raw === null || raw === JSON.stringify(fresh)) return; // 같은 판 · 그 사이 버림
const key = keyOf(projectId, routeId);
const current = cache.get(key);
const edits = userEdits(current ?? stored, savedDesigns(projectId, routeId) ?? []);
writeState("section-detail", fresh, projectId, routeId);
keepCanon(projectId, routeId, fresh);
for (const section of fresh.cross_sections) {
const edit = edits.find((entry) => Math.abs(entry.chainage_m - section.chainage_m) < 0.01);
if (edit && section.design) section.design = { ...section.design, ...edit.cells };
}
renewed.set(stored, fresh);
if (current !== stored) return; // 노선 초안 갈아끼움 중 · 그 사이 새로 받음 — 초안을 버리면 새 판
cache.set(key, fresh);
await withDraftWalls(fresh, projectId, routeId);
if (cache.get(key) !== fresh) return;
emitSwap(fresh, new Map());
listeners.get(key)?.forEach((fn) => fn(fresh, new Map()));
}
/** 판이 바뀐 세션 상세 → 새 판 상세(433-16) — 노선 초안 hold 가 쥔 옛 객체를 되돌릴 때. */
const renewed = new WeakMap<SectionDetailResponse, SectionDetailResponse>();
/** 측점마다 정본 사본과 다른 사용자 칸(`USER_TOUCHED_KEYS`) — 이 탭에서 고친 것. */
function userEdits(
detail: SectionDetailResponse,
canonRows: ReadonlyArray<{ chainage_m: number; design: CrossSection["design"] }>,
): Array<{ chainage_m: number; cells: Record<string, unknown> }> {
const out: Array<{ chainage_m: number; cells: Record<string, unknown> }> = [];
for (const section of detail.cross_sections) {
if (!section.design) continue;
const now = section.design as unknown as Record<string, unknown>;
const row = canonRows.find((r) => Math.abs(r.chainage_m - section.chainage_m) < 0.01);
const was = (row?.design ?? {}) as unknown as Record<string, unknown>;
const cells: Record<string, unknown> = {};
for (const name of USER_TOUCHED_KEYS)
if (now[name] !== undefined && JSON.stringify(now[name]) !== JSON.stringify(was[name]))
cells[name] = now[name];
if (Object.keys(cells).length) out.push({ chainage_m: section.chainage_m, cells });
}
return out;
}
const listeners = new Map<string, Set<DetailListener>>();
/** 상세 객체가 갈아끼워짐(측점 수가 바뀜 · 346-2 4파) — 쥔 옛 객체를 버리고 새것으로 다시 그릴 것. */
@@ -267,7 +325,7 @@ export async function restoreSectionDetail(
const key = keyOf(projectId, routeId);
const current = cache.get(key);
if (!current) return null;
const saved = hold.detail;
const saved = renewed.get(hold.detail) ?? hold.detail; // 그 사이 서버 판이 바뀌었으면 새 판(433-16)
const sections = saved.cross_sections;
const same =
sections.length === stations.length &&
@@ -119,6 +119,7 @@ process.stdout.write(
both: ids(both),
noDetail: noDetail === viewBase,
pvi: both.basePvi.length,
pviAt: both.basePvi.map((node) => node.chainage_m),
kinds: [on.type_id, off.type_id],
}),
);
@@ -0,0 +1,225 @@
/* 451 헬퍼(433-16) — 세션 상세 판 확인: 진짜 상세 가게(`B06_Section_Section_Store`)가 세션 한 벌로 먼저 서고
* 뒤에서 서버 판을 물음 · 다르면 서버 값 위에 이 탭에서 고친 사용자 칸만 · 같으면 그대로 · 노선 초안 hold 는 버릴 때
* 새 판. 서버 창구 · 초안 얹기 · 열쇠는 가짜. 짝 = `test_451_session_canon.py`.
*/
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 = "P451";
const ROUTE = 3;
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) });
/** 서버 — 부를 때마다 지금 `server` 사본 · `gate` 가 풀릴 때 답함. */
let server = null;
let gate = Promise.resolve();
let asked = 0;
const keyAt = (sections) => sections.map((s) => `at:${s.chainage_m.toFixed(2)}`);
const STUBS = {
"design_pipeline_page.ts": {
reconcilePagePipeline: async () => 0,
pipelineOwnsCross: () => false,
pagePipeline: () => null,
},
"design_pipeline_cross.ts": { forceCross: () => undefined },
"B06_Section_Section_Drafts.ts": {
forgetDraftStations: () => undefined,
withDraftShapes: async () => undefined,
withDraftStations: async () => undefined,
supersedeDraftStations: () => undefined,
},
"B05_Profile_Api_PlusStations.ts": {
loadSavedPlusStations: async () => undefined,
plusStationsOf: () => [],
},
"B06_Section_Api_Fetch.ts": anyModule({
fetchSectionDetail: async () => {
asked += 1;
await gate;
return JSON.parse(JSON.stringify(server));
},
}),
"B06_Section_Api_Culvert_Options.ts": anyModule({ STRUCTURE_ADJUST_KEYS: [] }),
"B06_Section_Swap_Bus.ts": anyModule(),
"B06_Section_Station_Keys.ts": anyModule({
KEYED_STATES: [],
registeredKeys: (_p, _r, sections) => keyAt(sections),
stationIntervalOf: () => 20,
}),
"B06_Section_UI_Page_Persist.ts": anyModule({ readCutSlopeSession: () => ({}) }),
"B06_Section_UI_Standard_Panel.ts": anyModule(),
"B06_Section_Cross_Design_Session.ts": anyModule({ crossDesignChoices: () => new Map() }),
"B06_Section_Section_Adjusts.ts": anyModule({ withPipeOptions: async () => undefined }),
"common_util_cross_design.ts": anyModule(),
"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_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 state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
const tick = () => new Promise((r) => setTimeout(r, 10));
/** 정본 — 측점 0 · 20 · 40 · 60 · 측점 20 집수정 조절 · 40 메모 · 계획고. */
const canon = (adjust, note, elevation) => ({
longitudinal: { design_profiles: [], stations: [] },
cross_sections: [0, 20, 40, 60].map((c) => ({
chainage_m: c,
kind: c === 0 ? "bp" : "regular",
samples: [],
design: {
design_elevation_m: elevation,
...(c === 20 ? { basin_adjust: { x: adjust } } : {}),
...(c === 40 ? { structure_note: note } : {}),
},
})),
});
const designAt = (detail, c) => detail.cross_sections.find((s) => s.chainage_m === c).design;
const patchAt = (patches, c) => patches.find((p) => p.chainage_m === c) ?? null;
const sessionNow = () => state.readState("section-detail", P, ROUTE);
const open = () => {
let release;
gate = new Promise((r) => (release = r));
return release;
};
(async () => {
const out = {};
// ① 낡은 세션(조절 1 · 메모 A · 계획고 100) + 다른 탭이 저장(조절 2 · 계획고 101) → 새 탭(새로고침)
state.writeState("section-detail", canon(1, "A", 100), P, ROUTE);
server = canon(2, "A", 101);
let release = open();
let heard = 0;
store.subscribeSectionDetail(P, ROUTE, () => (heard += 1));
const first = await store.loadSectionDetail(P, ROUTE);
out.firstFromSession = designAt(first, 20).basin_adjust.x === 1 && asked === 1;
// 서버가 답하기 전 이 탭에서 고친 칸 — 측점 40 메모
designAt(first, 40).structure_note = "mine";
release();
await tick();
const now = store.peekSectionDetail(P, ROUTE);
const patches = store.cachedCrossPatches(P, ROUTE);
out.stale = {
swapped: now !== first,
heard,
adjust: designAt(now, 20).basin_adjust.x,
elevation: designAt(now, 60).design_elevation_m,
note: designAt(now, 40).structure_note,
patchAdjust: patchAt(patches, 20)?.basin_adjust?.x ?? null,
patchNote: patchAt(patches, 40)?.structure_note ?? null,
sessionAdjust: designAt(sessionNow(), 20).basin_adjust.x,
sessionNote: designAt(sessionNow(), 40).structure_note,
};
// ② 같은 판 — 새로고침(메모리 비움 흉내) · 서버 = 세션 → 그대로(갈아끼움 · 알림 없음)
store.invalidateSectionDetail(P, ROUTE);
state.writeState("section-detail", server, P, ROUTE);
release = open();
heard = 0;
const again = await store.loadSectionDetail(P, ROUTE);
release();
await tick();
out.same = { kept: store.peekSectionDetail(P, ROUTE) === again, heard };
// ③ 서버 실패 · 엉뚱한 답 — 세션 그대로
store.invalidateSectionDetail(P, ROUTE);
state.writeState("section-detail", canon(1, "A", 100), P, ROUTE);
const saved = server;
server = null;
release = open();
const broken = await store.loadSectionDetail(P, ROUTE);
release();
await tick();
out.broken = store.peekSectionDetail(P, ROUTE) === broken;
server = saved;
// ④ 노선 초안 hold(옛 상세) · 갈아끼움 뒤 서버가 답함 → 초안을 버리면 새 판
store.invalidateSectionDetail(P, ROUTE);
state.writeState("section-detail", canon(1, "A", 100), P, ROUTE);
release = open();
const held = await store.loadSectionDetail(P, ROUTE);
const hold = store.holdSectionDetail(P, ROUTE, held);
const draftStations = [0, 25, 45, 60].map((c) => ({
key: `at:${c}`,
chainageM: c,
kind: "grid",
x: c,
y: 0,
left: [0, 1],
azimuthDeg: 0,
ground: null,
}));
await store.swapSectionStations(P, ROUTE, draftStations);
const swappedDraft = store.peekSectionDetail(P, ROUTE) !== held;
release();
await tick();
const draftKept = store.peekSectionDetail(P, ROUTE).cross_sections.map((s) => s.chainage_m);
const back = await store.restoreSectionDetail(
P,
ROUTE,
hold,
[0, 20, 40, 60].map((c) => ({ key: `at:${c}`, chainageM: c })),
);
out.hold = {
swappedDraft,
draftKept,
renewed: back && back.detail !== held,
adjust: back && designAt(back.detail, 20).basin_adjust.x,
same: back && back.same,
};
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});
+4 -1
View File
@@ -71,7 +71,10 @@ def test_저장_기준_입력이_심는_측점(planted):
"both"
] # 꺼진 종류도
assert planted["noDetail"] is True # 상세 없음 = 종단도 기준
assert planted["pvi"] == 2
# 저장 꼭짓점(양 끝) + 심은 측점마다 세운 꼭짓점(427 · seatBornNodes)
born = [c for k, c in planted["both"] if k == "irregular" and 0 < c < 60]
assert planted["pviAt"] == sorted([0, 60, *born])
assert planted["pvi"] == 2 + len(born)
def test_늦게_심어도_안_돎(r):
+2 -1
View File
@@ -96,7 +96,8 @@ def test_열_때_세션_상세_한_번만_풂() -> 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 # 서버에서 받은 정본만
# 서버에서 받은 정본만 — 받을 때 · 세션 판 확인(451 · 433-16)이 받은 새 판
assert re.findall(r'writeState\("section-detail", (\w+),', code) == ["detail", "fresh"]
cross = (ROOT / "A00_Common" / "design" / "design_pipeline_cross.ts").read_text(
encoding="utf-8"
)
@@ -0,0 +1,71 @@
"""451 B06 세션 캐시 판 · 숨은 종단 보기 관 id 장부(433-16 · 440-3).
① 새로고침 · 새 탭 = 세션 상세로 먼저 섬 · 뒤에서 서버 판을 물음 — 다른 탭이 저장해 다르면 서버 값 위에
이 탭에서 고친 사용자 칸만 · [저장] patch 바탕도 서버 값 · 세션 = 서버 정본 그대로
② 같은 판 · 서버 실패 = 세션 그대로(갈아끼움 · 알림 없음)
③ 노선 초안이 옛 상세를 쥔 채 서버 판이 바뀜 — 초안을 버리면 새 판
④ 숨은 종단 보기도 관 세션 id 장부를 돌림(평면 마커 누름 = 초안 자리) · 그리기는 보일 때
TS 셈은 `helper_451_session_canon.cjs`.
./venv/Scripts/python.exe -m pytest resources/tester/test_451_session_canon.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_451_session_canon.cjs")
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_첫_화면은_세션_뒤에서_판_확인(got) -> None:
assert got["firstFromSession"] is True
def test_판이_다르면_서버_값_위에_내_칸만(got) -> None:
stale = got["stale"]
assert stale["swapped"] is True and stale["heard"] == 1
assert stale["adjust"] == 2 and stale["elevation"] == 101 # 다른 탭이 저장한 값
assert stale["note"] == "mine" # 이 탭에서 고친 칸
assert stale["patchAdjust"] == 2 and stale["patchNote"] == "mine" # [저장] 바탕
assert stale["sessionAdjust"] == 2 and stale["sessionNote"] == "A" # 세션 = 서버 정본
def test_같은_판_실패는_그대로(got) -> None:
assert got["same"] == {"kept": True, "heard": 0}
assert got["broken"] is True
def test_초안_hold_는_버리면_새_판(got) -> None:
hold = got["hold"]
assert hold["swappedDraft"] is True and hold["draftKept"] == [0, 25, 45, 60]
assert hold["renewed"] is True and hold["adjust"] == 2 and hold["same"] is True
def test_숨은_종단_보기도_관_id_장부() -> None:
view = (ROOT / "A00_Common/design/design_view_profile.ts").read_text(encoding="utf-8")
follow = view[view.index("const followFacilities") : view.index("const selectGraphAt")]
assert "if (!host.visible()) return void (lastPipes && shownPipes(lastPipes));" in follow
changed = view[view.index("onPipesChanged: (pipes) => {") :]
changed = changed[: changed.index("},")]
assert "else shownPipes(pipes);" in changed