diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 35c48980e..a86c5b3e1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -3,7 +3,6 @@ import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import { viewOf } from "../common_util/common_util_view_window"; import { computeDetailBasins, - fetchDetailPipePoints, getVWorldMapUrl, type DetailBasin, type DetailBasinResponse, @@ -37,6 +36,7 @@ import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report"; import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { createDetailRunner } from "./B05_Profile_UI_Drainage_Run"; +import { createSavedLoader } from "./B05_Profile_UI_Drainage_Saved"; import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live"; import { createDrainagePropose } from "./B05_Profile_UI_Drainage_Propose"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; @@ -335,7 +335,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra !live.stale && fillFordHeights(response.pipe_points, response.basins, fordWaits()); facilityStore.replaceFromResponse(response.pipe_points); if (fordFilled) rememberPipes(); - if (saved?.response === response) saved.request = JSON.stringify(pipeRequest()); + savedLoad.applied(response); // 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직 // 비어 폼이 열리지 못했고, 중복 억제 가드에 걸려 두 번째 기회를 잃는다. lastNotifiedPipeChainage = null; @@ -371,25 +371,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra })), ); - /** 마지막 저장분 답 · 그 답을 실은 뒤 요청 몸통 — 초안 없는 걸음으로 되돌릴 때 견줌(472 덧). */ - let saved: { response: DetailBasinResponse; request: string | null } | null = null; - /** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */ function rememberPipes(): void { if (!projectId) return; writePendingPipes(projectId, pipeRequest()); } - /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이 - * 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */ - const loadSaved = () => { - const pending = readPendingPipes(projectId as string); - return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감 - ? computeDetailBasins(projectId as string, pending) - : fetchDetailPipePoints(projectId as string).then( - (response) => ((saved = { response, request: null }), response), - ); - }; + /** 저장된 관 지점(없으면 자동 배치) — 세션 초안이 있으면 그 관 목록으로(21-10 흠 3) · 떠 있으면 같이(520-7). */ + const savedLoad = createSavedLoader(runner, pipeRequest); /** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김). * 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */ @@ -403,7 +392,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeRequest, runner, rememberPipes: () => (rememberPipes(), callbacks.onPipesDrafted?.()), // + 사진첩 한 걸음(369-4) - loadSaved: () => run(loadSaved, true), // 초안 버림 = 저장분 조용히(349-0i) + loadSaved: () => savedLoad.back(), // 초안 버림 = 저장분 조용히(349-0i · 520-7) onStale: () => propose.close(), }); // [자동 제안](510) — 서버 후보 · 고른 것만 관 넣기 길(세류선 교차 = 라벨 실음) @@ -503,7 +492,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra showProgress(2 / 3, L("B05_Drainage_Status_Analyzing")); // B04가 확정해 둔 관 지점을 그대로 불러온다 — 저장분이 없으면 백엔드가 자동 배치를 준다. // 두 화면이 같은 파일을 보므로 B04에서 옮긴 관이 여기서도 같은 자리에 있다. - void run(loadSaved); + void savedLoad.run(); } catch (error) { if (sequence !== loadSequence) return; status.hidden = false; @@ -570,13 +559,13 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra root, load(nextProjectId: string) { if (projectId === nextProjectId && meta) return; - if (projectId !== nextProjectId) saved = null; + if (projectId !== nextProjectId) savedLoad.reset(); projectId = nextProjectId; void loadLayers(); }, reload(fresh = false) { if (fresh) live.markFresh(); // [확정] 뒤 — 낡음 걷음(응답이 아직 낡았으면 다시 닮) - if (projectId && meta) void run(loadSaved); + if (projectId && meta) void savedLoad.run(); }, pipeChainages: () => pipeEditor.chainages(), followPipes: (pipes) => followEditorPipes(pipeEditor, facilityStore, pipes), @@ -606,15 +595,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra }, setPipeChainages(chainages) { // 초안 없는 걸음 = 저장분 — 마지막 저장분 그대로면 끝 · 아니면 조용히 다시 읽음(472 덧) - if (chainages === null) - return void (JSON.stringify(pipeRequest()) !== saved?.request && run(loadSaved, true)); + if (chainages === null) return void (!savedLoad.matches() && savedLoad.run(true)); const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m); const next = reconcilePipes(pipeEditor.pipes(), chainages); if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다 // 되돌리기 = 그 걸음 초안의 속성 · id · 초안 없는 걸음은 저장분(369) · 구조물 폼은 속성 데려감 const replaying = callbacks.isReplaying?.() ?? false; // 종단쪽 약속(369-4) const pending = replaying ? readPendingPipes(projectId as string) : null; - if (replaying && !pending) return void run(loadSaved, true); + if (replaying && !pending) return void savedLoad.run(true); const draft = adoptDraftPipes(pending, next); const after = next.map((pipe) => pipe.chainage_m); if (draft) facilityStore.replaceFromResponse(draft); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Saved.ts b/B05_Profile/B05_Profile_UI_Drainage_Saved.ts new file mode 100644 index 000000000..aa28ec7fe --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Saved.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Saved.ts + * 저장 배수유역 다시 받기(열 때 · 초안 버림 · 되돌리기) — 패널 본체가 700줄이라 나눔(520-7). + * + * - 세션 관 초안이 있으면 그 관 목록으로 세부유역(저장 안 함) · 없으면 저장분 GET. + * - 되돌리기 한 번에 셋(관 자리 되돌림 · 시설 따라감 `reload` · 묶음 초안 버림)이 같이 부름 — + * 떠 있는 받기가 있으면 그 답을 같이 씀(모두 같은 「올렸나」를 받음). + * - 마지막 저장분 답 · 그 답을 실은 뒤 요청 몸통 — 화면이 그 답 그대로(뒤에 다른 답이 안 올랐고 관 + * 목록 같음)면 관 자리 되돌림은 건너뜀(472 덧) · 초안 버림(`back`)은 그 답을 다시 올림(GET 없음 · + * 낡음 표시만 다시 셈). `reload` 는 늘 받음(B06 관 쓰기 · [확정] 뒤). + * ========================================================================== */ + +import { + computeDetailBasins, + fetchDetailPipePoints, + type DetailBasinResponse, + type DetailPipeInput, +} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { readPendingPipes } from "./B05_Profile_Api_Pipes_Draft"; +import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run"; + +export interface SavedLoader { + /** 저장분(세션 초안이 있으면 그 관 목록) 받기 — 답을 화면에 올렸으면 true. */ + run(quiet?: boolean): Promise; + /** 초안 버림 — 화면이 마지막 저장분 그대로면 그 답을 다시 올림(GET 없음) · 아니면 `run`. */ + back(): Promise; + /** 답이 화면에 오른 뒤 — 저장분 답이면 그때 요청 몸통을 적어 둠 · 아니면 「화면 ≠ 저장분」. */ + applied(response: DetailBasinResponse): void; + /** 화면이 마지막 저장분 답 그대로이고 지금 요청 몸통도 같은가. */ + matches(): boolean; + /** 프로젝트가 바뀜 — 옛 저장분 잊음. */ + reset(): void; +} + +export function createSavedLoader( + runner: DetailRunner, + pipeRequest: () => DetailPipeInput[], +): SavedLoader { + let saved: { response: DetailBasinResponse; request: string | null } | null = null; + let inflight: Promise | null = null; + /** 마지막으로 화면에 오른 답이 `saved.response` 인가. */ + let showing = false; + + const load = (projectId: string): Promise => { + const pending = readPendingPipes(projectId); + return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감 + ? computeDetailBasins(projectId, pending) + : fetchDetailPipePoints(projectId).then( + (response) => ((saved = { response, request: null }), response), + ); + }; + + const matches = (): boolean => + showing && !!saved && JSON.stringify(pipeRequest()) === saved.request; + + const run = (quiet = false): Promise => { + const projectId = runner.projectId(); + if (!projectId) return Promise.resolve(false); + // ponytail: 떠 있는 동안 온 부름은 그 답을 같이 — 그 사이 서버 쓰기(B06 관 쓰기 뒤 reload)는 + // 다음 부름까지 안 보임. 잦아지면 쓰기 뒤 부름만 새로 받게 표시를 넘김. + inflight ??= runner.run(() => load(projectId), quiet).finally(() => (inflight = null)); + return inflight; + }; + + return { + run, + back() { + if (inflight || !matches()) return run(true); + const response = saved!.response; + // 뒤이어 온 받기(`reload`)가 이 답을 밀어내면 그 답을 기다림 — 「안 올림」으로 낡음이 남지 않게. + return runner + .run(() => Promise.resolve(response), true) + .then((applied) => applied || (inflight ?? Promise.resolve(false))); + }, + applied(response) { + showing = saved?.response === response; + if (showing) saved!.request = JSON.stringify(pipeRequest()); + }, + matches, + reset() { + saved = null; + showing = false; + }, + }; +} diff --git a/resources/tester/helper_520_7_drainage_saved_once.cjs b/resources/tester/helper_520_7_drainage_saved_once.cjs new file mode 100644 index 000000000..92e1fb044 --- /dev/null +++ b/resources/tester/helper_520_7_drainage_saved_once.cjs @@ -0,0 +1,135 @@ +/* 520-7 배수유역 저장분 받기 헬퍼 — `B05_Profile_UI_Drainage_Saved.ts` · `_Run.ts` 를 그 자리에서 + * 트랜스파일해 가짜 fetch 로 돌리고 단계별 GET 수 · 「올렸나」를 JSON 으로 냄. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); + +/* ---- 가짜 저장분 GET(답은 시험이 정할 때) ---- */ +const gets = []; +let pending = null; // 세션 관 초안 +const detailCalls = []; +const stubs = { + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: () => new Promise((resolve) => gets.push(resolve)), + computeDetailBasins: async (_projectId, points) => ( + detailCalls.push(points), + { basins: [], pipe_points: points, stale: null } + ), + }, + "./B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => pending }, + "./B05_Profile_UI_Drainage_Parts": { analyzeFailed: (_error, fallback) => fallback }, +}; +const cache = new Map(); +function load(file) { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const local = (name) => { + if (stubs[name]) return stubs[name]; + if (name.startsWith(".")) return load(path.join(path.dirname(file), `${name}.ts`)); + return require(name); + }; + new Function("exports", "module", "require", js)(box.exports, box, local); + return box.exports; +} + +const { createSavedLoader } = load(path.join(B05, "B05_Profile_UI_Drainage_Saved.ts")); +const { createDetailRunner } = load(path.join(B05, "B05_Profile_UI_Drainage_Run.ts")); +const flush = async () => { + for (let i = 0; i < 10; i += 1) await new Promise((r) => setImmediate(r)); +}; +const el = () => ({ hidden: false, textContent: "", disabled: false }); + +async function main() { + const out = {}; + let pipes = [{ chainage_m: 386, source: "stream" }]; + const shown = []; + let loader; + const runner = createDetailRunner({ + projectId: () => "p1", + updateButton: el(), + status: el(), + showProgress: () => undefined, + apply: (response) => (shown.push(response), loader.applied(response)), // 패널 takePipes 와 같은 줄 + basinCount: () => 1, + scheduleDraw: () => undefined, + label: (key) => key, + }); + loader = createSavedLoader(runner, () => pipes); + const answer = (tag) => ({ tag, basins: [], pipe_points: pipes, stale: null }); + const results = []; + const keep = (promise) => promise.then((ok) => results.push(ok)); + + // 열 때 — GET 한 번 + keep(loader.run()); + await flush(); + gets[0](answer("saved1")); + await flush(); + out.open = { gets: gets.length, results: [...results], matches: loader.matches() }; + + // 되돌리기 ① — 관 자리가 노선 따라 옮겨진 뒤(몸통 다름): 관 자리 되돌림 · reload · 초안 버림 한 번에 + pipes = [{ chainage_m: 386.4, source: "stream" }]; + results.length = 0; + const before = gets.length; + keep(loader.matches() ? Promise.resolve(true) : loader.run(true)); // setPipeChainages(null) + keep(loader.run()); // followFacilities → reload() + keep(loader.back()); // 묶음 초안 버림 → backToSaved + await flush(); + pipes = [{ chainage_m: 386, source: "stream" }]; + gets[gets.length - 1](answer("saved2")); + await flush(); + out.undoMoved = { gets: gets.length - before, results: [...results], shown: shown.at(-1).tag }; + + // 되돌리기 ② — 화면이 저장분 그대로 · 초안 버림이 먼저 오고 reload 가 뒤따름 + results.length = 0; + const before2 = gets.length; + const shownBefore = shown.length; + keep(loader.back()); + keep(loader.run()); + await flush(); + gets[gets.length - 1](answer("saved3")); + await flush(); + out.backThenReload = { + gets: gets.length - before2, + results: [...results], + shown: shown.slice(shownBefore).map((r) => r.tag), + }; + + // 초안 버림만 — 화면이 저장분 그대로면 GET 없이 그 답을 다시 올림 + results.length = 0; + const before3 = gets.length; + keep(loader.back()); + await flush(); + out.backOnly = { gets: gets.length - before3, results: [...results], shown: shown.at(-1).tag }; + + // 다른 답(지금 노선 [업데이트] 등)이 오른 뒤 초안 버림 — 다시 받음 + await runner.run(async () => answer("live"), true); + results.length = 0; + const before4 = gets.length; + keep(loader.back()); + await flush(); + gets[gets.length - 1](answer("saved4")); + await flush(); + out.afterLive = { gets: gets.length - before4, results: [...results], shown: shown.at(-1).tag }; + + // 세션 관 초안이 있으면 그 관 목록으로(GET 아님) + pending = [{ chainage_m: 500, source: "user" }]; + const before5 = gets.length; + results.length = 0; + keep(loader.run(true)); + await flush(); + out.pending = { gets: gets.length - before5, detail: detailCalls.length, results: [...results] }; + + process.stdout.write(JSON.stringify(out)); +} + +main().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/test_298_add_reanalyze.py b/resources/tester/test_298_add_reanalyze.py index de24be33b..49dc87017 100644 --- a/resources/tester/test_298_add_reanalyze.py +++ b/resources/tester/test_298_add_reanalyze.py @@ -104,9 +104,12 @@ def test_B06_초안_쓰면_판이_한_번_다시_나눔(): def test_다시_나눔은_세션_초안으로_계산만_저장_안_함(): - load = _body(PANEL, "const loadSaved = ", "const analyze = ") - assert "readPendingPipes(projectId as string)" in load - assert "computeDetailBasins(projectId as string, pending)" in load + # 저장분 받기 = 패널 밖 `_Drainage_Saved`(520-7) + saved = _read("B05_Profile", "B05_Profile_UI_Drainage_Saved.ts") + load = _body(saved, "const load = ", "const matches = ") + assert "readPendingPipes(projectId)" in load + assert "computeDetailBasins(projectId, pending)" in load + assert "const savedLoad = createSavedLoader(runner, pipeRequest);" in PANEL analyze = _body( PANEL, "const analyze = ", "const live = createDrainageLive(" ) # 326-6 [유역 재분할] 걷음 diff --git a/resources/tester/test_472_d_b05_pipe_undo.py b/resources/tester/test_472_d_b05_pipe_undo.py index a8298c066..7ee64e745 100644 --- a/resources/tester/test_472_d_b05_pipe_undo.py +++ b/resources/tester/test_472_d_b05_pipe_undo.py @@ -65,16 +65,14 @@ def test_삭제_직후_되돌리기_단추_켜짐(out) -> None: def test_배수_판_저장분_견줌() -> None: panel = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts") + saved = _read("B05_Profile", "B05_Profile_UI_Drainage_Saved.ts") # 저장분 받기(520-7) # 저장분 답을 받은 때만 견줄 몸통을 적음 · 초안 없는 걸음은 다르면 조용히 다시 읽음 - assert "(response) => ((saved = { response, request: null }), response)" in panel - assert ( - "if (saved?.response === response) saved.request = JSON.stringify(pipeRequest());" in panel - ) - assert ( - "return void (JSON.stringify(pipeRequest()) !== saved?.request && run(loadSaved, true));" - in panel - ) - assert "if (projectId !== nextProjectId) saved = null;" in panel + assert "(response) => ((saved = { response, request: null }), response)" in saved + assert "showing = saved?.response === response;" in saved + assert "if (showing) saved!.request = JSON.stringify(pipeRequest());" in saved + assert "savedLoad.applied(response);" in panel + assert "return void (!savedLoad.matches() && savedLoad.run(true));" in panel + assert "if (projectId !== nextProjectId) savedLoad.reset();" in panel tools = _read("B05_Profile", "B05_Profile_UI_Profile_Panel_Tools.ts") assert "initialPipes" not in tools profile = _read("A00_Common", "design", "design_profile.ts") diff --git a/resources/tester/test_520_7_drainage_saved_once.py b/resources/tester/test_520_7_drainage_saved_once.py new file mode 100644 index 000000000..f98083cdc --- /dev/null +++ b/resources/tester/test_520_7_drainage_saved_once.py @@ -0,0 +1,51 @@ +"""520-7 배수유역 저장분 받기 — 되돌리기(Ctrl+Z) 한 번에 셋이 부르는 저장분 GET 을 한 번으로. + +화면 파일(`B05_Profile_UI_Drainage_Saved` · `_Run`)을 가짜 fetch 로 돌림(`helper_520_7_drainage_saved_once.cjs`). + +- 관 자리 되돌림 · 시설 따라감 reload · 묶음 초안 버림이 한 번에 = GET 한 번 · 셋 다 「올렸음」. +- 초안 버림이 먼저 · reload 가 뒤따름 = GET 한 번 · 초안 버림도 「올렸음」(낡음 남지 않음). +- 화면이 저장분 그대로면 초안 버림 = GET 없이 그 답 다시 올림 · 다른 답이 오른 뒤면 다시 받음. +- 세션 관 초안이 있으면 그 관 목록으로 세부 유역(GET 아님). +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_520_7_drainage_saved_once.cjs")], + capture_output=True, + text=True, + timeout=60, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_open_fetches_once(r): + assert r["open"] == {"gets": 1, "results": [True], "matches": True} + + +def test_undo_three_callers_one_get(r): + assert r["undoMoved"] == {"gets": 1, "results": [True, True, True], "shown": "saved2"} + + +def test_back_before_reload_still_applied(r): + assert r["backThenReload"] == {"gets": 1, "results": [True, True], "shown": ["saved3"]} + + +def test_back_reuses_shown_saved_answer(r): + assert r["backOnly"] == {"gets": 0, "results": [True], "shown": "saved3"} + assert r["afterLive"] == {"gets": 1, "results": [True], "shown": "saved4"} + + +def test_pending_pipes_use_detail_not_get(r): + assert r["pending"] == {"gets": 0, "detail": 1, "results": [True]} diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 303fe0132..dc85dd348 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -140,10 +140,10 @@ def test_집계_항목만_바뀌면_폼_검증과_무관하게_기록에_반영( def test_배수유역_화면은_들어올_때_세션_초안_관을_먼저_씀(): # 흠 3 — B05→B06→B05 를 오가도 저장 전 지정 · 옵션이 옛값으로 안 돌아감 source = open( - os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Panel.ts"), + os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Saved.ts"), encoding="utf-8", - ).read() - body = source.split("const loadSaved", 1)[1].split("/**", 1)[0] + ).read() # 저장분 받기 = 패널 밖(520-7) + body = source.split("const load =", 1)[1].split("const matches", 1)[0] assert "readPendingPipes(" in body and "computeDetailBasins(" in body assert body.index("readPendingPipes(") < body.index("fetchDetailPipePoints(")