diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts index 0714734cb..503b39851 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Live.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -7,6 +7,8 @@ * 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음). * - 노선 초안이 있으면 「낡음」 — 자동 = 3 s 조용한 뒤 셈 · 수동 = 칸만 흐림 + 가운데 [업데이트]. * - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s. + * 묶음이 같은 노선 · 관으로 다시 돌면(입력 지문 같음) 그대로 둠 — 떠 있는 답을 받음. + * - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 3 s 뒤. * - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림. * - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로. * ========================================================================== */ @@ -24,6 +26,7 @@ import { import { acceptLive, draftMakesStale, + inputKey, liveRequestBody, parseDrainageMode, QUIET_MS, @@ -50,6 +53,8 @@ export interface DrainageLive { stage: StageFn<"drainage">; /** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */ refresh(): void; + /** 서버 답이 「낡음」 — 흐림 + [업데이트](자동이면 3 s 뒤 셈). 이미 낡음이면 그대로. */ + markStale(): void; /** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */ markFresh(): void; readonly stale: boolean; @@ -93,6 +98,8 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { let route: unknown = null; let stale = false; let generation = 0; + /** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */ + let key = ""; let timer = 0; let inflight: AbortController | null = null; @@ -134,16 +141,16 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const projectId = deps.runner.projectId(); if (!projectId) return; cancel(); - const sent = generation; + const sent = { generation: ++generation, key }; const controller = new AbortController(); inflight = controller; paint(); - const body = liveRequestBody(sent, route, deps.pipeRequest()); + const body = liveRequestBody(sent.generation, route, deps.pipeRequest()); void deps.runner .run(async () => { const response = await fetchLive(projectId, body, controller.signal); // 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌. - if (!acceptLive(response, sent, generation)) + if (!acceptLive(response, sent, { generation, key })) throw new DOMException("superseded", "AbortError"); return response; }, true) @@ -157,7 +164,6 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { /** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */ function invalidate(): void { - generation += 1; cancel(); stale = true; if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS); @@ -208,11 +214,20 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { return { stage(ctx) { route = ctx.inputs.route; + const next = inputKey(route, ctx.inputs.pipes); + if (next === key) return null; + key = next; if (draftMakesStale(route) || stale) invalidate(); return null; }, refresh, suggest, + markStale() { + if (stale) return; + stale = true; + if (mode === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS); + paint(); + }, markFresh() { cancel(); stale = false; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index ec29e8a39..d6ec5155a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -340,6 +340,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra function apply(response: DetailBasinResponse): void { basins = response.basins; live.suggest.update(response); + if (response.stale) live.markStale(); // 노선 [저장] 뒤 등 — 흐림 + [업데이트](자동이면 3 s 뒤) mainBoundary = response.main_polygon_lonlat ?? []; upstreamLines = (response.upstream_lonlat ?? []) as Array>; flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css index 67a1699d5..2ca878278 100644 --- a/B05_Profile/B05_Profile_UI_Style_Drainage.css +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -291,6 +291,7 @@ } .b05-drainage__veil .b05-drainage__update { + margin-left: 0; /* 머리 줄 단추의 오른쪽 붙임(auto) 풂 — 흐린 칸 가운데 */ pointer-events: auto; } diff --git a/B05_Profile/logic/B05_Profile_Drainage_Live.ts b/B05_Profile/logic/B05_Profile_Drainage_Live.ts index 16ab31a16..caf190ffd 100644 --- a/B05_Profile/logic/B05_Profile_Drainage_Live.ts +++ b/B05_Profile/logic/B05_Profile_Drainage_Live.ts @@ -5,7 +5,8 @@ * - 대상 = 평면(노선 초안) · 관 자리만 — 종단 · 횡단 조작은 배수유역 결과를 안 바꿈(묶음 deps 와 같음). * - 노선 초안이 없으면(저장 노선 그대로) 낡음 아님 — 관 조작은 세부 유역 나눔(0.2 s)이 바로 맞춤. * - 자동 = 조용한 뒤 `QUIET_MS` · 수동 = 흐림 + [업데이트] · 기본 수동. - * - 답 채택 = 보낸 차례가 지금 차례와 같고 서버가 그만두지 않았을 때만(늦은 답 버림). + * - 묶음이 같은 입력으로 다시 돌면(입력 지문 같음) 낡음 · 떠 있는 요청을 그대로 둠. + * - 답 채택 = 보낸 차례 · 입력 지문이 지금과 같고 서버가 그만두지 않았을 때만(늦은 답 버림). * ========================================================================== */ export type DrainageMode = "manual" | "auto"; @@ -20,6 +21,15 @@ export const parseDrainageMode = (raw: string | null | undefined): DrainageMode /** 묶음 drainage 단계 입력 → 낡았는가 — 노선 초안이 있을 때만. */ export const draftMakesStale = (route: unknown): boolean => route !== null && route !== undefined; +/** 묶음 drainage 입력 지문 — 노선 초안(판 번호 rev 뺌) · 관 id · 자리(cm). */ +export function inputKey(route: unknown, pipes: unknown): string { + const draft = route && typeof route === "object" ? { ...route, rev: undefined } : null; + const spots = Array.isArray(pipes) + ? pipes.map((pipe) => `${pipe?.id ?? ""}@${Math.round(Number(pipe?.chainage_m) * 100)}`).sort() + : []; + return JSON.stringify([draft, spots]); +} + /** 「지금 노선으로 셈」 몸통 — 서버 `post_drainage_live`. */ export function liveRequestBody

( generation: number, @@ -29,15 +39,16 @@ export function liveRequestBody

( return { generation, route: route ?? null, points }; } -/** 답을 화면에 올리는가 — 서버가 그만둔 답 · 옛 차례 답은 버림. */ +/** 답을 화면에 올리는가 — 서버가 그만둔 답 · 옛 차례 · 입력이 바뀐 뒤 답은 버림. */ export function acceptLive( response: { status?: string; generation?: number }, - sentGeneration: number, - currentGeneration: number, + sent: { generation: number; key: string }, + now: { generation: number; key: string }, ): boolean { return ( response.status !== "superseded" && - sentGeneration === currentGeneration && - (response.generation === undefined || response.generation === sentGeneration) + sent.generation === now.generation && + sent.key === now.key && + (response.generation === undefined || response.generation === sent.generation) ); } diff --git a/resources/tester/helper_347_2_drainage_live.cjs b/resources/tester/helper_347_2_drainage_live.cjs index 940201632..ab70c09ca 100644 --- a/resources/tester/helper_347_2_drainage_live.cjs +++ b/resources/tester/helper_347_2_drainage_live.cjs @@ -31,6 +31,7 @@ const basins = [ ]; const candidates = [{ chainage_m: 200 }, { chainage_m: 500.2 }]; const before = JSON.stringify(pipes); +const at = (generation) => ({ generation, key: "k" }); // 349 — 차례 + 입력 지문 const suggestions = sug.collectSuggestions(pipes, basins, candidates); const applied = sug.applySuggestions(pipes, suggestions); @@ -44,10 +45,10 @@ process.stdout.write( ], stale: [live.draftMakesStale(null), live.draftMakesStale({ rev: 1 })], accept: [ - live.acceptLive({ status: "success", generation: 3 }, 3, 3), - live.acceptLive({ status: "success", generation: 3 }, 3, 4), - live.acceptLive({ status: "superseded" }, 3, 3), - live.acceptLive({ status: "success", generation: 2 }, 3, 3), + live.acceptLive({ status: "success", generation: 3 }, at(3), at(3)), + live.acceptLive({ status: "success", generation: 3 }, at(3), at(4)), + live.acceptLive({ status: "superseded" }, at(3), at(3)), + live.acceptLive({ status: "success", generation: 2 }, at(3), at(3)), ], body: live.liveRequestBody(5, undefined, [{ chainage_m: 1 }]), suggestions, diff --git a/resources/tester/helper_349_drainage_live_flow.cjs b/resources/tester/helper_349_drainage_live_flow.cjs new file mode 100644 index 000000000..b8ece017b --- /dev/null +++ b/resources/tester/helper_349_drainage_live_flow.cjs @@ -0,0 +1,286 @@ +/* 349 배수유역 자동/수동 흐름 헬퍼 — 화면 파일 `B05_Profile_UI_Drainage_Live.ts` · `_Run.ts` · + * `_Suggest.ts` 를 그 자리에서 트랜스파일해 가짜 DOM · 시계 · fetch 로 돌리고 단계별 상태를 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"); + +/* ---- 가짜 DOM ---- */ +function el(tag) { + const set = new Set(); + return { + tag, + children: [], + hidden: false, + className: "", + textContent: "", + title: "", + disabled: false, + checked: false, + listeners: {}, + classList: { + toggle: (name, on) => (on ? set.add(name) : set.delete(name)), + add: (name) => set.add(name), + contains: (name) => set.has(name), + }, + append(...kids) { + this.children.push(...kids); + }, + before(kid) { + this.beforeNode = kid; + }, + replaceChildren(...kids) { + this.children = kids; + }, + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + }, + fire(type) { + (this.listeners[type] ?? []).forEach((fn) => fn()); + }, + }; +} +global.document = { createElement: el }; + +/* ---- 가짜 시계 ---- */ +let clock = 0; +let timerId = 0; +const timers = new Map(); +global.window = { + setTimeout(fn, ms) { + timers.set(++timerId, { fn, at: clock + ms }); + return timerId; + }, + clearTimeout(id) { + timers.delete(id); + }, +}; +function advance(ms) { + clock += ms; + for (const [id, timer] of [...timers]) { + if (timer.at <= clock) { + timers.delete(id); + timer.fn(); + } + } +} + +/* ---- 가짜 fetch(배수유역 live) ---- */ +const calls = []; +global.fetch = (url, init) => + new Promise((resolve, reject) => { + const call = { url, body: JSON.parse(init.body), aborted: false }; + call.answer = (payload) => resolve({ ok: true, status: 200, json: async () => payload }); + init.signal.addEventListener("abort", () => { + call.aborted = true; + reject(new DOMException("aborted", "AbortError")); + }); + calls.push(call); + }); +const flush = async () => { + for (let i = 0; i < 10; i += 1) await new Promise((r) => setImmediate(r)); +}; + +/* ---- 모듈 읽기(그 자리 트랜스파일 · 바깥 모듈은 가짜) ---- */ +const state = new Map(); +const detailCalls = []; +const stubs = { + "@config/config_frontend": { API_BASE_URL: "/api" }, + "@ui/ui_template_progress": { createProgressCircle: () => ({ root: el("progress") }) }, + // ⚙ 창 — 둘째 줄(자동) 라디오를 고름 + "@ui/ui_template_modal": { + openModal: (options) => { + const body = el("modal"); + options.mount(body, () => undefined); + body.children[1].children[0].fire("change"); + }, + }, + "../A00_Common/b_page_state": { + readStateRaw: (key) => state.get(key) ?? null, + writeStateRaw: (key, value) => state.set(key, value), + }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + computeDetailBasins: async (projectId, points) => { + detailCalls.push(points); + return { basins: [], pipe_points: points, stale: null }; + }, + }, + "./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 { createDrainageLive } = load(path.join(B05, "B05_Profile_UI_Drainage_Live.ts")); +const { createDetailRunner } = load(path.join(B05, "B05_Profile_UI_Drainage_Run.ts")); + +async function main() { + const out = {}; + const viewport = el("viewport"); + const header = el("header"); + const root = el("root"); + root.querySelector = (selector) => + selector === ".b05-drainage__viewport" + ? viewport + : selector === ".b05-drainage__header" + ? header + : null; + let pipes = [ + { chainage_m: 100, id: "a1", source: "stream", options: { pipe_diameter_mm: 1000 } }, + ]; + let applied = 0; + let remembered = 0; + let live; + const status = el("status"); + const runner = createDetailRunner({ + projectId: () => "p1", + analyzeButton: el("button"), + status, + showProgress: () => undefined, + apply: (response) => { + applied += 1; + live.suggest.update(response); + if (response.stale) live.markStale(); // 패널 apply 와 같은 줄 + }, + basinCount: () => 0, + scheduleDraw: () => undefined, + label: (key) => key, + }); + live = createDrainageLive({ + root, + basinList: el("list"), + pipeRequest: () => pipes, + runner, + rememberPipes: () => (remembered += 1), + }); + const [veil, busy] = viewport.children; + const update = veil.children[0]; + const gear = header.children[0]; + const look = () => ({ + stale: live.stale, + blur: root.classList.contains("is-stale"), + veil: !veil.hidden, + busy: !busy.hidden, + fetches: calls.length, + }); + const draft = (rev, x) => ({ + rev, + nodes: [ + [0, 0], + [x, 300], + ], + curveOn: [true, true], + }); + const stage = (route) => live.stage({ inputs: { route, pipes } }); + const answer = (call, extra = {}) => + call.answer({ + status: "success", + generation: call.body.generation, + basins: [{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 }], + pipe_points: pipes, + pipe_candidates: [{ chainage_m: 200 }], + stale: null, + live: true, + ...extra, + }); + + // 수동 — 꼭짓점 옮김 = 흐림 + [업데이트] · 판 번호만 바뀐 같은 노선 = 그대로 + stage(draft(1, 50)); + out.moved = look(); + stage(draft(2, 50)); + out.sameAgain = look(); + // [업데이트] → 요청 하나 · 묶음이 같은 입력으로 다시 돌아도 답을 받음 + update.fire("click"); + out.sent = { ...look(), body: calls[0].body }; + stage(draft(3, 50)); + out.rerunWhileBusy = { ...look(), aborted: calls[0].aborted }; + answer(calls[0]); + await flush(); + out.answered = { ...look(), applied }; + out.suggest = { + shown: !live.suggest.root.hidden, + title: live.suggest.root.children[0].children[0].textContent, + rows: live.suggest.root.children[1].children.map((row) => row.textContent), + }; + // 제안 [적용] — 관 초안으로 세부 유역 다시 나눔 · 세션 초안에 담음 + live.suggest.root.children[0].children[1].fire("click"); + await flush(); + out.applied = { points: detailCalls[0], remembered }; + + // 다시 옮김 → 요청 → 그 사이 또 옮김 = 앞 요청 끊김 · 다시 흐림 + stage(draft(4, 80)); + update.fire("click"); + stage(draft(5, 90)); + out.movedWhileBusy = { ...look(), aborted: calls[1].aborted }; + update.fire("click"); + answer(calls[2]); + await flush(); + out.fresh = look(); + + // 노선 [저장] 뒤 — 서버 답이 「낡음」 = 흐림 + [업데이트](수동) + stage(null); + await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" })); + out.savedStale = { ...look(), status: status.textContent }; + + // ⚙ 자동 — 바꾸는 순간 3 s 뒤 셈 · 흐림 막 · [업데이트] 없음 + gear.fire("click"); + out.autoSwitch = look(); + advance(2999); + out.auto2999 = look(); + advance(1); + out.auto3000 = { ...look(), route: calls[3].body.route }; + answer(calls[3]); + await flush(); + out.autoDone = look(); + // 자동 · 꼭짓점 옮김 = 조용히 3 s 뒤 · 그 사이 또 옮기면 처음부터 + stage(draft(6, 120)); + advance(2000); + stage(draft(7, 130)); + advance(2000); + out.autoRestart = look(); + advance(1000); + answer(calls[4]); + await flush(); + out.autoMoved = { ...look(), mode: state.get("drainage-mode") }; + // 자동 · 저장 뒤 낡음 = 3 s 뒤 조용히 + await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" })); + out.autoSavedStale = look(); + advance(3000); + answer(calls[5]); + await flush(); + out.autoSavedDone = look(); + // 입력 지문 — 판 번호 · 관 차례는 안 봄 · 꼭짓점 · 관 자리(cm) · id 는 봄 + const { inputKey } = load(path.join(B05, "logic", "B05_Profile_Drainage_Live.ts")); + const a = { id: "a1", chainage_m: 100 }; + const b = { id: "b2", chainage_m: 250 }; + const base = inputKey(draft(1, 50), [a, b]); + out.keys = { + rev: inputKey(draft(9, 50), [a, b]) === base, + order: inputKey(draft(1, 50), [b, a]) === base, + node: inputKey(draft(1, 51), [a, b]) === base, + pipe: inputKey(draft(1, 50), [a, { ...b, chainage_m: 250.2 }]) === base, + id: inputKey(draft(1, 50), [a, { ...b, id: "c3" }]) === base, + saved: inputKey(null, null) === inputKey(undefined, undefined), + }; + 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_349_drainage_live_flow.py b/resources/tester/test_349_drainage_live_flow.py new file mode 100644 index 000000000..f247bd8ba --- /dev/null +++ b/resources/tester/test_349_drainage_live_flow.py @@ -0,0 +1,87 @@ +"""349 배수유역 자동/수동 흐름 — 화면 파일(`B05_Profile_UI_Drainage_Live` · `_Run` · `_Suggest`)을 +가짜 DOM · 시계 · fetch 로 돌림(`helper_349_drainage_live_flow.cjs`). + +- 수동: 꼭짓점 옮김 = 흐림 + [업데이트] · [업데이트] 뒤 묶음이 같은 노선 · 관으로 다시 돌아도 답을 받음 + (입력 지문) · 답이 오면 흐림 · 「계산 중」 사라짐 · 제안 「제안 N건」 · [적용]. +- 요청 중 또 옮기면 앞 요청 끊고 다시 흐림. +- 노선 [저장] 뒤 서버 답이 「낡음」 = 수동은 흐림 + [업데이트] · 자동은 3 s 뒤 조용히. +- ⚙ 자동: 3 s 조용한 뒤 셈 · 그 사이 또 옮기면 처음부터 · [업데이트] 막 없음. +""" + +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_349_drainage_live_flow.cjs")], + capture_output=True, + text=True, + timeout=60, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def state(stale, veil, busy, fetches): + return {"stale": stale, "blur": stale, "veil": veil, "busy": busy, "fetches": fetches} + + +def test_manual_update_answer_lands_after_same_rerun(r): + assert r["moved"] == state(True, True, False, 0) + assert r["sameAgain"] == state(True, True, False, 0) # 판 번호만 바뀜 — 다시 안 흐림 + assert r["sent"]["fetches"] == 1 and r["sent"]["busy"] and not r["sent"]["veil"] + rerun = r["rerunWhileBusy"] + assert not rerun["aborted"] and rerun["busy"] # 같은 입력 다시 돎 — 요청 그대로 + assert r["answered"] == {**state(False, False, False, 1), "applied": 1} + + +def test_suggestions_and_apply(r): + assert r["suggest"]["shown"] and r["suggest"]["title"] == "제안 2건" + assert len(r["suggest"]["rows"]) == 2 + points = {p["chainage_m"]: p for p in r["applied"]["points"]} + assert points[100]["options"]["pipe_diameter_mm"] == 1200 + assert points[200]["source"] == "spacing" + assert r["applied"]["remembered"] == 1 + + +def test_move_while_busy_cancels(r): + moved = r["movedWhileBusy"] + assert moved["aborted"] and moved["veil"] and not moved["busy"] + assert r["fresh"] == state(False, False, False, 3) + + +def test_saved_stale_shows_update(r): + assert {k: v for k, v in r["savedStale"].items() if k != "status"} == state( + True, True, False, 3 + ) + assert r["savedStale"]["status"] == "배수유역 낡음" + + +def test_auto_mode(r): + assert r["autoSwitch"] == state(True, False, False, 3) # 자동 — 흐림만 · [업데이트] 없음 + assert r["auto2999"]["fetches"] == 3 + assert r["auto3000"]["fetches"] == 4 and r["auto3000"]["route"] is None + assert r["autoDone"] == state(False, False, False, 4) + assert r["autoRestart"] == state(True, False, False, 4) # 2 s + 2 s — 처음부터 다시 3 s + assert r["autoMoved"] == {**state(False, False, False, 5), "mode": "auto"} + assert r["autoSavedStale"] == state(True, False, False, 5) + assert r["autoSavedDone"] == state(False, False, False, 6) + + +def test_input_key(r): + assert r["keys"] == { + "rev": True, + "order": True, + "node": False, + "pipe": False, + "id": False, + "saved": True, + }