diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts index afe825847..1bb82b400 100644 --- a/A00_Common/design/design_structure_ford_calc.ts +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -25,6 +25,18 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null; } +/** 배수유역도가 낡은 동안(요구 328-3) 설계유량 = 옛 유역 측점 값이라 「낡음」으로 가림 · 높이도 안 채움. + * 판정은 배수유역 판 하나(`B05_Profile_UI_Drainage_Live` 가 부름) · 바뀌면 열린 폼을 다시 그림. */ +let drainageStale = false; +// ponytail: 폼마다 하나씩 쌓임(페이지 다시 열 때만 · 떨어진 폼 다시 그림은 헛일) — 많아지면 폼 닫을 때 뺌 +const staleRepaints = new Set<() => void>(); +export const FORD_FLOW_STALE_HINT = "설계유량 낡음 — 배수유역도 [업데이트] 뒤 셈"; +export function setDrainageStale(next: boolean): void { + if (drainageStale === next) return; + drainageStale = next; + staleRepaints.forEach((repaint) => repaint()); +} + /** * 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3). * · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로. @@ -41,7 +53,8 @@ export function overflowSync( let pending = false; const values = () => form().state()[box]?.[0] ?? {}; const width = () => Number(values().width ?? ""); - const need = (): number | null => fordHeightM(flow(), width()); + const need = (): number | null => (drainageStale ? null : fordHeightM(flow(), width())); + staleRepaints.add(() => form()?.refresh()); return { arm: (fresh: boolean): void => { pending = fresh; @@ -67,7 +80,7 @@ export function overflowSync( if (c.id !== box) return []; const hint = document.createElement("p"); hint.className = "b05-structure__flow-hint"; - hint.textContent = fordHeightHint(flow(), width()); + hint.textContent = drainageStale ? FORD_FLOW_STALE_HINT : fordHeightHint(flow(), width()); return [hint]; }, }; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts index 5504e3f80..bd0520232 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Live.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -20,6 +20,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; +import { setDrainageStale } from "../A00_Common/design/design_structure_ford_calc"; import type { StageFn } from "../A00_Common/design/logic/design_pipeline_types"; import { computeDetailBasins, @@ -124,6 +125,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const paint = (): void => { deps.root.classList.toggle("is-stale", stale); + setDrainageStale(stale); // 관 카드 설계유량 가림(요구 328-3) update.classList.toggle("is-pending", stale); update.disabled = inflight !== null; update.textContent = inflight ? "계산 중…" : UPDATE_LABEL; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index f91f86466..35c48980e 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -330,8 +330,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra id: pipe.id, })), ); - // 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음). - const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits()); + // 시설 확장 정보는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이 먼저 · 낡은 답이면 뒤로 · 328-3). + const fordFilled = + !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()); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 9487f6718..a2501148b 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -118,6 +118,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ? inside.reduce((best, at) => (Math.abs(at - anchor) < Math.abs(best - anchor) ? at : best)) : anchor; }; + /** 담당 유역 설계유량 — 배수유역도 지금 답(관 보고 `shared.lastPipes`)이 먼저 · 아직 없으면 저장 유역 + * ([업데이트] 뒤 B06 폼도 새 값 · 낡은 동안 가림은 `design_structure_ford_calc` · 요구 328-3). */ + const flowAt = ( + chainageM: number, + basins: ReadonlyArray<{ chainage_m: number; design_flow_m3s?: number | null }> = [], + ): number | null => { + const near = (entry: { chainage_m: number }) => + Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M; + const live = deps.shared.lastPipes; + return (live ? live.find(near) : basins.find(near))?.design_flow_m3s ?? null; + }; /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). 알약 · 측점선 · 관 id 장부 = 페이지 * 시설 줄 한 벌(462 · 숨은 B05 평면 마커도 따라감) · `carryEdits` = 사용자 고침(불러옴은 false). */ const pushMarks = (carryEdits = true): void => { @@ -353,10 +364,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 담당 유역의 설계유량 — 물넘이·세월교 개략 단면(필요 수심 = 월류 높이 // 자동값)이 이 값에서 나온다. B05와 같은 규칙으로 유역을 측점으로 짝짓는다 // (2026-08-30 사용자: 월류 폭을 넣어도 높이가 자동 계산되지 않았다). - design_flow_m3s: - pipeResponse.basins.find( - (basin) => Math.abs(basin.chainage_m - pipe.chainage_m) < PIPE_MATCH_M, - )?.design_flow_m3s ?? null, + design_flow_m3s: flowAt(pipe.chainage_m, pipeResponse.basins), })); // 옛 소수 관 자리 = 정수 m 초안(요구 167 · B05 `snapOldStations` 와 같은 셈) — B06 을 바로 열어도 // 카드 · 측점이 B05 를 거친 것과 같음 · 정본은 [저장]에서 @@ -549,9 +557,16 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc entry.options = { ...entry.options, ford_height_m: height }; return true; }); - if (!filled.length) return; + // 설계유량도 새 답으로(요구 328-3) + const flowed = pipeFacilities.filter((entry) => { + const flow = flowAt(entry.chainage_m); + if (!deps.shared.lastPipes || flow === (entry.design_flow_m3s ?? null)) return false; + entry.design_flow_m3s = flow; + return true; + }); + if (!filled.length && !flowed.length) return; section.setPipeFacilities(pipeFacilities); - const open = filled.find((entry) => entry.chainage_m === currentChainageM); + const open = [...filled, ...flowed].find((entry) => entry.chainage_m === currentChainageM); if (open) section.selectPipeByChainage(open.chainage_m); }, releasePipe: () => { diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 71b5e9c2b..4aaea35c4 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -183,6 +183,7 @@ export function createSectionProfile( p.facility, p.options?.pipe_diameter_mm ?? null, p.options?.ford_height_m ?? null, + p.design_flow_m3s ?? null, // [업데이트] 뒤 설계유량(요구 328-3) ]), ); const changed = pipeSignature !== null && signature !== pipeSignature; diff --git a/resources/tester/helper_514_2_stale_flow.cjs b/resources/tester/helper_514_2_stale_flow.cjs new file mode 100644 index 000000000..cb34fa0cf --- /dev/null +++ b/resources/tester/helper_514_2_stale_flow.cjs @@ -0,0 +1,62 @@ +/* 514-2 낡은 유량 가림 헬퍼 — 진짜 `design_structure_ford_calc.ts` 를 Node 로 돌려 JSON 한 벌로(요구 328-3). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const 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 near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; +const calc = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts")); + +globalThis.document = { createElement: () => ({ textContent: "", className: "" }) }; +const state = { ford: [{ width: "10", height: "" }] }; +let refreshed = 0; +const form = { + state: () => state, + setValue: (c, i, v) => (state[c][0][i] = v), + refresh: () => (refreshed += 1), +}; +const sync = calc.overflowSync( + () => form, + "ford", + () => 2.54, + () => undefined, +); +const hint = () => sync.fields({ id: "ford" })[0].textContent; +const edit = (item, value) => { + state.ford[0][item] = value; + sync.onChange({ id: "ford" }, { id: item }); + return state.ford[0].height; +}; + +const fresh = { hint: hint(), height: edit("width", "10") }; +calc.setDrainageStale(true); +state.ford[0].height = ""; +const stale = { refreshed, hint: hint(), height: edit("width", "8") }; +sync.arm(true); +sync.fill(); +stale.afterFill = state.ford[0].height; +let before = refreshed; +calc.setDrainageStale(true); // 같은 값 = 다시 안 그림 +stale.repaintSame = refreshed - before; +before = refreshed; +calc.setDrainageStale(false); +const back = { repaint: refreshed - before, hint: hint() }; +sync.fill(); +back.afterFill = state.ford[0].height; + +process.stdout.write(JSON.stringify({ fresh, stale, back })); diff --git a/resources/tester/test_514_2_stale_flow.py b/resources/tester/test_514_2_stale_flow.py new file mode 100644 index 000000000..af9726aab --- /dev/null +++ b/resources/tester/test_514_2_stale_flow.py @@ -0,0 +1,60 @@ +"""514-2 배수유역도 낡은 동안 관 카드 설계유량 「낡음」 가림(요구 328-3). + +판정 한 곳 = 배수유역 판 `live.stale` → `setDrainageStale` · 세월교 · 물넘이 칸 안내 · 높이 자동값이 그것을 봄. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_514_2_stale_flow.cjs") +STALE_HINT = "설계유량 낡음 — 배수유역도 [업데이트] 뒤 셈" + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def run() -> dict: + out = subprocess.run(["node", str(HELPER)], capture_output=True, check=True, cwd=ROOT).stdout + return json.loads(out.decode("utf-8")) + + +def test_안_낡으면_값(run): + assert run["fresh"]["hint"].startswith("필요 수심 ") + assert run["fresh"]["height"] != "" + + +def test_낡으면_가림_높이_안_채움(run): + stale = run["stale"] + assert stale["refreshed"] == 1 # 열린 폼 다시 그림 + assert stale["hint"] == STALE_HINT + assert stale["height"] == "" and stale["afterFill"] == "" + assert stale["repaintSame"] == 0 + + +def test_업데이트_뒤_값(run): + back = run["back"] + assert back["repaint"] == 1 + assert back["hint"].startswith("필요 수심 ") + assert back["afterFill"] != "" # 기다리던 새 시설 높이 = 그때 채움 + + +def test_판정은_배수유역_판_한_곳(): + live = _read("B05_Profile", "B05_Profile_UI_Drainage_Live.ts") + assert "setDrainageStale(stale);" in live + panel = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts") + assert "!live.stale && fillFordHeights(" in " ".join(panel.split()) + + +def test_B06_폼도_업데이트_뒤_새_유량(): + b06 = " ".join(_read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts").split()) + assert "design_flow_m3s: flowAt(pipe.chainage_m, pipeResponse.basins)," in b06 + assert "const live = deps.shared.lastPipes;" in b06 + assert "entry.design_flow_m3s = flow;" in b06 # adoptFordHeights 가 새 답 유량을 받음 + view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert "p.design_flow_m3s ?? null," in view # 유량만 바뀌어도 pipesChanged