From eedafe8db99f88e22731cf22a0786517710904ba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:37:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(451=20B06=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=ED=8C=90):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=92=A4=EC=97=90=EC=84=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=ED=8C=90=20=ED=99=95=EC=9D=B8=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B4=EB=A9=B4=20=EC=84=9C=EB=B2=84=20=EA=B0=92=20=EC=9C=84?= =?UTF-8?q?=20=EB=82=B4=20=EC=B9=B8=EB=A7=8C=20=C2=B7=20=EC=88=A8=EC=9D=80?= =?UTF-8?q?=20=EC=A2=85=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20=EA=B4=80=20id=20?= =?UTF-8?q?=EC=9E=A5=EB=B6=80=20=C2=B7=20384=5F3=20=EB=8B=A8=EC=96=B8=2042?= =?UTF-8?q?7=20=EC=9D=98=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 433-16: 새로고침 · 새 탭 = 세션 상세로 먼저 섬 · 뒤에서 상세 GET(ETag 304) · 판이 다르면 서버 값 위에 이 탭에서 고친 사용자 칸만 · [저장] patch 바탕도 새 판 · 노선 초안 hold 는 버릴 때 새 판 - 440-3: 숨은 종단 보기도 관 세션 id 장부 돌림(평면 마커 누름 = 초안 자리) · 그리기는 보일 때 - 384_3: pvi 단언 = 저장 꼭짓점 + 심은 측점마다 세운 꼭짓점(427) - 384_7: 세션 쓰기 = 받을 때 · 판 확인 새 판 둘 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_view_profile.ts | 4 +- B06_Section/B06_Section_Section_Store.ts | 62 ++++- resources/tester/helper_384_3_planted.mjs | 1 + resources/tester/helper_451_session_canon.cjs | 225 ++++++++++++++++++ resources/tester/test_384_3_planted_base.py | 5 +- resources/tester/test_384_7_section_canon.py | 3 +- resources/tester/test_451_session_canon.py | 71 ++++++ 7 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_451_session_canon.cjs create mode 100644 resources/tester/test_451_session_canon.py diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 902e38bb1..db9d53e9c 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -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); diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index ac87151c4..dd16e9ea0 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -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 { + 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(); + +/** 측점마다 정본 사본과 다른 사용자 칸(`USER_TOUCHED_KEYS`) — 이 탭에서 고친 것. */ +function userEdits( + detail: SectionDetailResponse, + canonRows: ReadonlyArray<{ chainage_m: number; design: CrossSection["design"] }>, +): Array<{ chainage_m: number; cells: Record }> { + const out: Array<{ chainage_m: number; cells: Record }> = []; + for (const section of detail.cross_sections) { + if (!section.design) continue; + const now = section.design as unknown as Record; + const row = canonRows.find((r) => Math.abs(r.chainage_m - section.chainage_m) < 0.01); + const was = (row?.design ?? {}) as unknown as Record; + const cells: Record = {}; + 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>(); /** 상세 객체가 갈아끼워짐(측점 수가 바뀜 · 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 && diff --git a/resources/tester/helper_384_3_planted.mjs b/resources/tester/helper_384_3_planted.mjs index 4b1bc6b95..7e1d546be 100644 --- a/resources/tester/helper_384_3_planted.mjs +++ b/resources/tester/helper_384_3_planted.mjs @@ -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], }), ); diff --git a/resources/tester/helper_451_session_canon.cjs b/resources/tester/helper_451_session_canon.cjs new file mode 100644 index 000000000..80e16b9e2 --- /dev/null +++ b/resources/tester/helper_451_session_canon.cjs @@ -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); +}); diff --git a/resources/tester/test_384_3_planted_base.py b/resources/tester/test_384_3_planted_base.py index ce67887a4..3b4fb5f21 100644 --- a/resources/tester/test_384_3_planted_base.py +++ b/resources/tester/test_384_3_planted_base.py @@ -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): diff --git a/resources/tester/test_384_7_section_canon.py b/resources/tester/test_384_7_section_canon.py index 957ee2712..45b95eb2a 100644 --- a/resources/tester/test_384_7_section_canon.py +++ b/resources/tester/test_384_7_section_canon.py @@ -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" ) diff --git a/resources/tester/test_451_session_canon.py b/resources/tester/test_451_session_canon.py new file mode 100644 index 000000000..0848624e7 --- /dev/null +++ b/resources/tester/test_451_session_canon.py @@ -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 From c4f5530a4999807f73d2068fcc9a1ad70c9b30f6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:43:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(451=20B06=20=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20hold):=20=EB=85=B8=EC=84=A0=20=EC=B4=88=EC=95=88=20?= =?UTF-8?q?=EC=A0=84=20=EC=83=81=ED=83=9C=20=EC=84=B8=EC=85=98=20=ED=95=9C?= =?UTF-8?q?=20=EB=B2=8C=20=E2=80=94=20=EC=B4=88=EC=95=88=20=EB=91=94=20?= =?UTF-8?q?=EC=B1=84=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=ED=95=B4?= =?UTF-8?q?=EB=8F=84=20=EC=B4=88=EC=95=88=20=EC=A0=84=20=EC=97=B4=EC=87=A0?= =?UTF-8?q?=20=C2=B7=20=EC=B9=B8=20=C2=B7=20=EA=B4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=BC=20(440-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 초안 시작 hold = 측점 열쇠 · 횡단 초안 칸 · 가게 관 · 구조물 · 세션 한 칸(route-draft-hold)에도 - 초안 둔 채 새로고침 = 그 한 벌(초안이 옮겨 쓴 세션 칸 · 초안 자리 관을 처음 값으로 안 뜸) - 묶음 초안 전 가게(beforeDraft) = hold 관 · 구조물 — 버리면 초안 전 관 자리로 되씀 - 되돌림 = 초안 얹기 다시(되쓴 관) 뒤 초안 전 열쇠 · 세션 한 벌 지움 Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_page_state.ts | 2 + A00_Common/design/design_pipeline_page.ts | 5 +- .../design/design_pipeline_page_types.ts | 3 + A00_Common/design/design_shared.ts | 3 +- B06_Section/B06_Section_Section_Store.ts | 32 ++- resources/tester/helper_451_refresh_hold.cjs | 205 ++++++++++++++++++ resources/tester/test_451_refresh_hold.py | 77 +++++++ 7 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 resources/tester/helper_451_refresh_hold.cjs create mode 100644 resources/tester/test_451_refresh_hold.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index c8f068986..453b721dc 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -212,6 +212,8 @@ export const STATE_REGISTRY = { /** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`). * 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */ "route-draft": { bucket: "draft", scope: "project" }, + /** 노선 초안 전 상태(440-4) — 측점 열쇠 · 횡단 초안 칸 · 관 · 구조물 · 초안 둔 채 새로고침해도 버리면 이것으로. */ + "route-draft-hold": { bucket: "draft", scope: "project" }, /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 04d89ca2d..486d57300 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -437,11 +437,12 @@ function createPagePipeline( Object.assign(record, { restored: why.startsWith("saved"), restoreWhy: why }); return why; }; - /** 노선 초안 시작 — 지금 저장 상세 · 되돌리는 함수를 맡김(횡단 초안 칸 · 측점 열쇠는 맡은 쪽이 떠 둠). */ + /** 노선 초안 시작 — 지금 저장 상세 · 되돌리는 함수를 맡김(횡단 초안 칸 · 측점 열쇠 · 관 · 구조물은 맡은 쪽이 떠 둠). */ const holdNow = (): typeof held => { const { detail = null, routeId } = savedSource(); if (typeof routeId !== "number") return null; - return { detail, restore: options.holdDraft?.(routeId, detail) ?? null }; + const restore = options.holdDraft?.(routeId, detail, (f) => (beforeDraft ??= f)); // 440-4 + return { detail, restore: restore ?? null }; }; // 사용자 노선 조작 뒤 follow 가 다시 매긴 관 · 구조물 = 가게(목록 · 알약 · 횡단 보기가 바로 새 측점) · // 초안을 버리면 초안 전 값 · 열 때(start 차례)는 안 씀 — 알림 밖(틱 뒤)에서 씀(쓰기 고리 막음과 안 부딪힘) diff --git a/A00_Common/design/design_pipeline_page_types.ts b/A00_Common/design/design_pipeline_page_types.ts index 18010750d..b3ee12e04 100644 --- a/A00_Common/design/design_pipeline_page_types.ts +++ b/A00_Common/design/design_pipeline_page_types.ts @@ -16,6 +16,7 @@ import type { } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import type { BermAt } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import type { DesignStoreValues } from "./design_store"; /** 종단도(`design_profile`)가 묶음에 내주는 것. */ export interface ProfileLink { @@ -50,6 +51,8 @@ export interface PagePipelineOptions { holdDraft?: ( routeId: number, saved: SectionDetailResponse | null, + /** 초안 전 가게 관 · 구조물(초안 둔 채 새로고침도 · 440-4) — 묶음이 초안을 버릴 때 되씀. */ + seed?: (facilities: Pick) => void, ) => (stations: PipelineStation[]) => Promise; /** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */ onUnstable?: () => void; diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 66069900c..ae5253e73 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -253,8 +253,9 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo applyStructureAreaRows(rows, structureAreaRows(rows)); }, // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움 - holdDraft: (routeId, saved) => { + holdDraft: (routeId, saved, seed) => { const hold = saved && holdSectionDetail(projectId, routeId, saved); + if (hold) seed?.(hold.facilities); // 초안 전 관 · 구조물(440-4) return async (stations) => { if (!hold) { await swapSectionStations(projectId, routeId, stations); diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index dd16e9ea0..b4d565544 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -40,6 +40,7 @@ import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options"; import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types"; +import type { DesignStoreValues } from "../A00_Common/design/design_store"; import { carryStationKeys, KEYED_STATES, @@ -288,26 +289,44 @@ function recrossSaved( .catch(() => undefined); } -/** 노선 초안 전 상태(349-0o) — 상세(초안 전 객체 그대로 · 되돌리면 캐시) · 측점 열쇠 · 측점 열쇠 칸 다섯(횡단 초안). */ +/** 노선 초안 전 상태(349-0o) — 상세(초안 전 객체 그대로 · 되돌리면 캐시) · 측점 열쇠 · 측점 열쇠 칸 다섯(횡단 초안) · + * 가게 관 · 구조물(440-4 · 묶음이 버릴 때 되씀). */ export interface SectionHold { detail: SectionDetailResponse; keys: Keyed[]; cells: Array<[KeyedState, unknown]>; + facilities: Pick; } +type HeldDraft = Omit & { routeId: number }; -/** 노선 초안 시작 — 초안 전 상세(`detail` · 객체 그대로 — 갈아끼움은 설계를 사본으로 씀) · 지금 측점 열쇠 · 횡단 초안 칸을 떠 둠. */ +/** + * 노선 초안 시작 — 초안 전 상세(`detail` · 객체 그대로 — 갈아끼움은 설계를 사본으로 씀) · 지금 측점 열쇠 · 횡단 초안 칸 · + * 관 · 구조물을 떠 둠 · 세션에도 한 벌(440-4). 초안 둔 채 새로고침이면 그 한 벌 — 세션 칸 · 열쇠 · 관은 이미 초안 + * 자리로 옮겨 쓴 값이라 처음 값으로 안 뜸. + */ export function holdSectionDetail( projectId: string, routeId: number, detail: SectionDetailResponse, ): SectionHold { + const kept = readState("route-draft-hold", projectId); + if (kept?.routeId === routeId && readState("route-draft", projectId)) { + const { keys, cells, facilities } = kept; + return { detail, keys, cells, facilities }; + } const sections = detail.cross_sections; const keys = registeredKeys(projectId, routeId, sections, stationIntervalOf(detail)); - return { - detail, + const held: HeldDraft = { + routeId, keys: sections.map((section, i) => ({ key: keys[i], chainageM: section.chainage_m })), cells: KEYED_STATES.map((name) => [name, readState(name, projectId, routeId)]), + facilities: { + pipes: readState("pipes", projectId), + structures: readState("structures", projectId), + }, }; + writeState("route-draft-hold", held, projectId); + return { detail, keys: held.keys, cells: held.cells, facilities: held.facilities }; } /** @@ -344,8 +363,11 @@ export async function restoreSectionDetail( moves.set(text(section.chainage_m), text(to.chainageM)); }); for (const [name, value] of hold.cells) writeState(name, value ?? null, projectId, routeId); - useStationKeyList(projectId, routeId, hold.keys); + clearState("route-draft-hold", projectId); cache.set(key, saved); // 세션 상세는 정본 그대로(387-7) + // 초안 둔 채 새로고침 = 초안 전 상세에 초안 자리 관이 얹혀 있었음 — 되쓴 관 · 구조물로 다시 얹음(440-4) + await withDraftStations(saved, projectId, routeId); + useStationKeyList(projectId, routeId, hold.keys); await withDraftShapes(saved, projectId); withStructureAreas(saved); if (cache.get(key) !== saved) return null; // 그 사이 다른 갈아끼움 · 새로 받음 diff --git a/resources/tester/helper_451_refresh_hold.cjs b/resources/tester/helper_451_refresh_hold.cjs new file mode 100644 index 000000000..f090d9f7c --- /dev/null +++ b/resources/tester/helper_451_refresh_hold.cjs @@ -0,0 +1,205 @@ +/* 451 헬퍼(440-4) — 노선 초안 둔 채 새로고침: 진짜 상세 가게 · 진짜 측점 열쇠 · 세션(모듈을 다시 세워도 남음). + * 초안 시작 hold = 세션 한 벌(열쇠 · 횡단 초안 칸 · 관 · 구조물) · 새로고침 뒤 hold = 그 한 벌(초안으로 옮겨 쓴 세션 아님) · + * 버림 = 초안 전 칸 · 초안 얹기 다시(되쓴 관으로) 뒤 초안 전 열쇠. 서버 · 초안 얹기는 가짜. 짝 = `test_451_refresh_hold.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 (detail) => { + // 초안 얹기 흉내 — 가게 관 자리(세션 pipes)마다 그 측점 관 표시 · 부른 차례를 적음 + const pipes = state.readState("pipes", P) ?? []; + calls.push(`stations:${pipes.map((p) => p.chainage_m).join(",")}`); + for (const s of detail.cross_sections) + s.pipeAt = pipes.some((p) => p.chainage_m === s.chainage_m); + }, + 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(), + "ui_template_elements.ts": anyModule(), + "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: [] }), +}; + +let cache = new Map(); +const calls = []; +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; +} + +let store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts")); +const state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); +let keys = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Station_Keys.ts")); +/** 새로고침 흉내 — 모듈 메모리를 비우고 다시 세움(세션 · 서버는 그대로). */ +function refresh() { + const keep = cache.get(path.join(ROOT, "A00_Common", "b_page_state.ts")); + cache = new Map([[path.join(ROOT, "A00_Common", "b_page_state.ts"), keep]]); + store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts")); + keys = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Station_Keys.ts")); +} + +const sec = (c, kind) => ({ + chainage_m: c, + kind, + samples: [], + design: { design_elevation_m: 100 }, +}); +// 저장 상세: 340 · 관 351 · 360 +const saved = { + longitudinal: { design_profiles: [], stations: [] }, + cross_sections: [sec(340, "regular"), sec(351, "irregular"), sec(360, "regular")], +}; +const at = (c) => ({ key: `at:${c.toFixed(2)}`, chainageM: c }); +const savedPipes = [{ id: "p1", chainage_m: 351 }]; +const draftPipes = [{ id: "p1", chainage_m: 361 }]; +const cell = () => state.readState("crossdesign", P, ROUTE); +const keysNow = () => + keys.registeredKeys(P, ROUTE, store.peekSectionDetail(P, ROUTE).cross_sections, 20); + +(async () => { + const out = {}; + server = saved; + state.writeState("section-detail", saved, P, ROUTE); + state.writeState("pipes", savedPipes, P); + state.writeState("crossdesign", { before: true }, P, ROUTE); + // 탭 1 — 열기 · 노선 초안 시작(hold) · 초안이 세션 칸 · 관을 초안 자리로 옮겨 씀 + const opened = await store.loadSectionDetail(P, ROUTE); + const first = store.holdSectionDetail(P, ROUTE, opened); + const kept = state.readState("route-draft-hold", P); + out.start = { + kept: !!kept && kept.routeId === ROUTE, + cells: JSON.stringify(first.cells.find(([n]) => n === "crossdesign")[1]), + pipes: first.facilities.pipes.map((p) => p.chainage_m), + keys: first.keys.map((k) => k.chainageM), + }; + state.writeState("route-draft", { nodes: [], rev: 1 }, P); + state.writeState("crossdesign", { draft: true }, P, ROUTE); + state.writeState("pipes", draftPipes, P); + + // 새로고침(초안 둔 채) — 새 모듈 · 세션 그대로 · 상세 = 세션(초안 관 자리로 얹힘) + refresh(); + calls.length = 0; + const reopened = await store.loadSectionDetail(P, ROUTE); + const hold = store.holdSectionDetail(P, ROUTE, reopened); + out.refresh = { + cells: JSON.stringify(hold.cells.find(([n]) => n === "crossdesign")[1]), + pipes: hold.facilities.pipes.map((p) => p.chainage_m), + keys: JSON.stringify(hold.keys) === JSON.stringify(first.keys), + pipeShownAt: reopened.cross_sections.filter((s) => s.pipeAt).map((s) => s.chainage_m), + }; + // 묶음이 초안을 버림 — 가게 관 = hold 관(묶음 몫 흉내) → 되돌림 + state.writeState("route-draft", null, P); + state.writeState("pipes", hold.facilities.pipes, P); + calls.length = 0; + const back = await store.restoreSectionDetail( + P, + ROUTE, + hold, + saved.cross_sections.map((s) => at(s.chainage_m)), + ); + out.drop = { + same: back && back.same, + cells: JSON.stringify(cell()), + kept: state.readState("route-draft-hold", P), + calls: [...calls], + pipeShownAt: back.detail.cross_sections.filter((s) => s.pipeAt).map((s) => s.chainage_m), + keys: JSON.stringify(keysNow()) === JSON.stringify(first.keys.map((k) => k.key)), + }; + + // 새 초안(route-draft 없음) = 지금 값을 다시 뜸 · 다른 노선 hold = 안 씀 + state.writeState("crossdesign", { later: true }, P, ROUTE); + const fresh = store.holdSectionDetail(P, ROUTE, back.detail); + state.writeState("route-draft", { nodes: [], rev: 2 }, P); + const other = store.holdSectionDetail(P, ROUTE + 1, back.detail); + out.next = { + cells: JSON.stringify(fresh.cells.find(([n]) => n === "crossdesign")[1]), + otherRoute: state.readState("route-draft-hold", P).routeId, + }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/test_451_refresh_hold.py b/resources/tester/test_451_refresh_hold.py new file mode 100644 index 000000000..77f789732 --- /dev/null +++ b/resources/tester/test_451_refresh_hold.py @@ -0,0 +1,77 @@ +"""451 노선 초안 둔 채 새로고침 hold(440-4). + +① 초안 시작 hold = 세션 한 벌(측점 열쇠 · 횡단 초안 칸 · 가게 관 · 구조물) +② 새로고침(모듈 다시 세움 · 세션 그대로) 뒤 hold = 그 한 벌 — 초안이 옮겨 쓴 세션 칸 · 초안 자리 관이 아님 +③ 초안 버림 = 초안 전 칸 · 초안 얹기 다시(되쓴 관 · 저장 자리 351) 뒤 초안 전 열쇠 · 세션 한 벌 지움 +④ 새 초안(노선 초안 없음) = 지금 값을 다시 뜸 · 다른 노선 한 벌은 안 씀 +⑤ 묶음 = hold 가 돌려준 관 · 구조물로 초안 전 가게(`beforeDraft`)를 채움(버릴 때 되씀) +TS 셈은 `helper_451_refresh_hold.cjs`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_451_refresh_hold.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_refresh_hold.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_초안_시작_hold_는_세션에도(got) -> None: + start = got["start"] + assert start["kept"] is True + assert start["cells"] == '{"before":true}' + assert start["pipes"] == [351] and start["keys"] == [340, 351, 360] + + +def test_새로고침_hold_는_초안_전_한_벌(got) -> None: + refresh = got["refresh"] + assert refresh["cells"] == '{"before":true}' # 초안이 옮겨 쓴 {"draft":true} 아님 + assert refresh["pipes"] == [351] # 초안 자리 361 아님 + assert refresh["keys"] is True + + +def test_버리면_초안_전_칸_관_열쇠(got) -> None: + drop = got["drop"] + assert drop["same"] is True + assert drop["cells"] == '{"before":true}' + assert drop["kept"] is None + assert drop["calls"] == ["stations:351"] # 되쓴 관으로 초안 얹기 다시 + assert drop["pipeShownAt"] == [351] # 관 제원 = 제 카드 + assert drop["keys"] is True + + +def test_새_초안은_다시_뜸(got) -> None: + assert got["next"]["cells"] == '{"later":true}' + assert got["next"]["otherRoute"] == 4 + + +def test_묶음이_초안_전_가게를_hold_로() -> None: + page = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8") + hold = page[page.index("const holdNow") :] + hold = hold[: hold.index("};")] + assert "options.holdDraft?.(routeId, detail, (f) => (beforeDraft ??= f))" in hold + shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") + draft = shared[shared.index("holdDraft: (routeId, saved, seed) => {") :] + assert "if (hold) seed?.(hold.facilities);" in draft[: draft.index("return async")] From db80a22f67589c182de8b0bc368b5b100d754edc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 22:03:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(451=20B06=20=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20hold):=20=EB=90=98=EB=8F=8C=EB=A6=BC=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EA=B0=99=EC=9D=8C=20=ED=8C=90=EC=A0=95=20=3D=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=EC=96=B9=EA=B8=B0=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=92=A4=20=EC=B8=A1=EC=A0=90=EC=9C=BC=EB=A1=9C=20(440-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 초안 둔 채 새로고침 뒤 버림 기록이 saved-diff 로 남던 것 — 다시 얹은 뒤 견줌 · saved Co-Authored-By: Claude Opus 5.5 --- B06_Section/B06_Section_Section_Store.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index b4d565544..538a19724 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -345,10 +345,6 @@ export async function restoreSectionDetail( const current = cache.get(key); if (!current) return null; const saved = renewed.get(hold.detail) ?? hold.detail; // 그 사이 서버 판이 바뀌었으면 새 판(433-16) - const sections = saved.cross_sections; - const same = - sections.length === stations.length && - stations.every((station, i) => Math.abs(station.chainageM - sections[i].chainage_m) < 1e-3); const oldKeys = registeredKeys( projectId, routeId, @@ -371,6 +367,10 @@ export async function restoreSectionDetail( await withDraftShapes(saved, projectId); withStructureAreas(saved); if (cache.get(key) !== saved) return null; // 그 사이 다른 갈아끼움 · 새로 받음 + const sections = saved.cross_sections; // 다시 얹은 뒤 측점(440-4) + const same = + sections.length === stations.length && + stations.every((station, i) => Math.abs(station.chainageM - sections[i].chainage_m) < 1e-3); emitSwap(saved, moves); listeners.get(key)?.forEach((fn) => fn(saved, moves)); return { detail: saved, same };