perf(520-7): B05 배수유역 되돌리기 때 저장분 GET 세 번 → 한 번 · 이음매 대조 차이 0

- B05 Drainage_Saved 새 파일 — 저장분 받기를 패널 밖으로 뗌(Panel 700 → 688 줄) · 떠 있는 받기는 같이 씀 · 화면이 마지막 저장분 그대로면 초안 버림은 GET 없이 그 답을 다시 올림 · 뒤이어 온 reload 가 밀어내면 그 답을 기다림(낡음 남지 않음)
- 원인 — Ctrl+Z 한 번에 관 자리 되돌림(setPipeChainages) · 시설 따라감(reload) · 묶음 초안 버림(backToSaved) 셋이 저장분을 따로 받음
- 관 자리 되돌림 건너뜀 조건 — 요청 몸통만 견주던 것을 「화면이 저장분 답 그대로」까지 봄(지금 노선 [업데이트] 답이 오른 뒤엔 다시 받음)
- 지킴 시험 test_520_7 — 세 부름 = GET 한 번 · 초안 버림 먼저여도 올림 · 저장분 그대로면 GET 없음 · 세션 관 초안은 세부 유역 계산
- 낡은 시험 고침 — test_298 · test_472_d · test_b05_structures_form_summary 가 패널 원문에서 찾던 저장분 받기 줄이 새 파일로 옮겨짐
- 이음매 대조(tmp 일회) — primary-region 길 대 저장 노선 [업데이트] 길 네 프로젝트 도로로 오는 셀 · road_slot · 관별 면적 차이 0 · 초안 2.2~2.3 s · 저장 노선 2.1~2.6 s · Ctrl+Z GET 1 · 낡음 꺼짐 249~258 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 08:57:37 +09:00
co-authored by Claude Opus 5.5
parent 3979b50e4d
commit 0292c8c722
7 changed files with 297 additions and 37 deletions
+10 -22
View File
@@ -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);
@@ -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<boolean>;
/** 초안 버림 — 화면이 마지막 저장분 그대로면 그 답을 다시 올림(GET 없음) · 아니면 `run`. */
back(): Promise<boolean>;
/** 답이 화면에 오른 뒤 — 저장분 답이면 그때 요청 몸통을 적어 둠 · 아니면 「화면 ≠ 저장분」. */
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<boolean> | null = null;
/** 마지막으로 화면에 오른 답이 `saved.response` 인가. */
let showing = false;
const load = (projectId: string): Promise<DetailBasinResponse> => {
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<boolean> => {
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;
},
};
}
@@ -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);
});
+6 -3
View File
@@ -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 [유역 재분할] 걷음
+7 -9
View File
@@ -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")
@@ -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]}
@@ -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(")