diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index b406cc303..89744f458 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -620,14 +620,16 @@ export async function fetchDetailPipePoints(projectId: string): Promise { return requestJson(`/projects/${projectId}/drainage/detail-basins`, { method: "POST", - body: JSON.stringify({ points }), + body: JSON.stringify(auto ? { points, auto } : { points }), }); } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts index 1978588af..7a82c1545 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts @@ -7,6 +7,7 @@ * 배선(토글·포인터·그리기)은 전부 본체가 맡는다. * ========================================================================== */ +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createProgressCircle } from "@ui/ui_template_progress"; @@ -20,6 +21,8 @@ export interface DrainageChrome { analyzeButton: HTMLButtonElement; deleteButton: HTMLButtonElement; autoButton: HTMLButtonElement; + /** [자동 제안](369) — 고름 창 · 배선은 `B05_Profile_UI_Drainage_Live`. */ + proposeButton: HTMLButtonElement; viewport: HTMLElement; backgroundImage: HTMLImageElement; canvas: HTMLCanvasElement; @@ -88,13 +91,18 @@ export function createDrainageChrome( deleteButton.textContent = labels.deleteSelected; deleteButton.title = labels.deleteSelectedTip; deleteButton.disabled = true; - // 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정. + // 배관 배치 초기화 — 편집한 배관 배치를 버리고 세류선 교차 자리만(초안 · 369). const autoButton = document.createElement("button"); autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = labels.auto; autoButton.title = labels.autoTip; - header.append(analyzeButton, deleteButton, autoButton); + const proposeButton = document.createElement("button"); + proposeButton.type = "button"; + proposeButton.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__propose"; + proposeButton.textContent = ui_locales.B05_Drainage_Btn_Propose[currentLanguageIndex]; + proposeButton.title = ui_locales.B05_Drainage_Btn_Propose_Tip[currentLanguageIndex]; + header.append(analyzeButton, deleteButton, autoButton, proposeButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -155,6 +163,7 @@ export function createDrainageChrome( analyzeButton, deleteButton, autoButton, + proposeButton, viewport, backgroundImage, canvas, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts index 9bbff9a3e..17c60e01e 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Live.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -14,9 +14,13 @@ * - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 3 s 뒤. * - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림. * - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로. + * - [자동 제안](369) — 고름 창 ① 전체 자동(all) ② 지정 포함 추가(add) · [배관 배치 초기화] = 세류선 교차 + * 관만 · 셋 다 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은 답도 앞 걸음에 안 묻힘) · + * 낡은 동안 [자동 제안] 끔. * ========================================================================== */ 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 { openModal } from "@ui/ui_template_modal"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; @@ -37,6 +41,7 @@ import { } from "./logic/B05_Profile_Drainage_Live"; import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run"; import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest"; +import { pageHistory } from "./B05_Profile_UI_Profile_History"; const MODE_KEY = "drainage-mode"; @@ -47,7 +52,7 @@ export interface DrainageLiveDeps { basinList: HTMLElement; pipeRequest: () => DetailPipeInput[]; runner: DetailRunner; - /** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담음. */ + /** [적용] · [자동 제안] 답이 화면에 오른 뒤 — 세션 초안에 담음. */ rememberPipes: () => void; /** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */ loadSaved: () => Promise; @@ -62,6 +67,8 @@ export interface DrainageLive { markStale(): void; /** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */ markFresh(): void; + /** [배관 배치 초기화] — 세류선 교차 관만으로 초안(369 · 저장분은 [저장] 때). */ + resetToStreams(): void; readonly stale: boolean; suggest: DrainageSuggest; } @@ -88,16 +95,27 @@ export function readDrainageMode(): DrainageMode { return parseDrainageMode(readStateRaw(MODE_KEY)); } +const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; + export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const viewport = deps.root.querySelector(".b05-drainage__viewport") as HTMLElement; const header = deps.root.querySelector(".b05-drainage__header") as HTMLElement; - const suggest = createDrainageSuggest(deps.pipeRequest, (points) => { + const propose = header.querySelector(".b05-drainage__propose"); + /** 관 초안 요청 — 답이 화면에 오르면 세션 초안 + 사진첩 한 걸음(400 ms 넘은 답도 제 걸음). */ + const draft = (request: (projectId: string) => Promise): void => { const projectId = deps.runner.projectId(); if (!projectId) return; void deps.runner - .run(() => computeDetailBasins(projectId, points)) - .then((ok) => ok && deps.rememberPipes()); - }); + .run(() => request(projectId)) + .then((ok) => { + if (!ok) return; + deps.rememberPipes(); + pageHistory()?.record(); + }); + }; + const suggest = createDrainageSuggest(deps.pipeRequest, (points) => + draft((projectId) => computeDetailBasins(projectId, points)), + ); deps.basinList.before(suggest.root); let mode = readDrainageMode(); let route: unknown = null; @@ -136,6 +154,10 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const paint = (): void => { deps.root.classList.toggle("is-stale", stale && mode === "manual"); suggest.setStale(stale); + if (propose) { + propose.disabled = stale; + propose.title = L(stale ? "B05_Drainage_Btn_Propose_Stale" : "B05_Drainage_Btn_Propose_Tip"); + } veil.hidden = !stale || mode === "auto" || inflight !== null; busy.root.hidden = inflight === null; }; @@ -223,6 +245,36 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { }); } + /** [자동 제안] 고름 창 — ⚙ 창과 같은 틀 · 고르면 닫고 그 방식으로 초안. */ + function openPropose(): void { + if (stale) return; + openModal({ + title: L("B05_Drainage_Propose_Title"), + closeLabel: "닫기", + mount(body, close) { + const choices: Array<["all" | "add", string, string]> = [ + ["all", L("B05_Drainage_Propose_All"), L("B05_Drainage_Propose_All_Hint")], + ["add", L("B05_Drainage_Propose_Add"), L("B05_Drainage_Propose_Add_Hint")], + ]; + choices.forEach(([value, label, hint], order) => { + const row = document.createElement("label"); + row.className = "b05-drainage__mode"; + const input = document.createElement("input"); + input.type = "radio"; + input.name = "b05-drainage-propose"; + input.addEventListener("change", () => { + close(); + draft((projectId) => computeDetailBasins(projectId, deps.pipeRequest(), value)); + }); + const text = document.createElement("span"); + text.textContent = `${order === 0 ? "①" : "②"} ${label} — ${hint}`; + row.append(input, text); + body.append(row); + }); + }, + }); + } + function setMode(next: DrainageMode): void { mode = next; writeStateRaw(MODE_KEY, next); @@ -234,6 +286,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { update.addEventListener("click", refresh); gear.addEventListener("click", openSettings); + propose?.addEventListener("click", openPropose); return { stage(ctx) { @@ -261,6 +314,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { stale = false; paint(); }, + resetToStreams: () => draft((projectId) => computeDetailBasins(projectId, null)), get stale() { return stale; }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 1dbc5ccf5..20efdb083 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -5,7 +5,6 @@ import { computeDetailBasins, fetchDetailPipePoints, getVWorldMapUrl, - resetDetailPipePoints, saveDetailPipePoints, type DetailBasin, type DetailBasinResponse, @@ -416,10 +415,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra : fetchDetailPipePoints(projectId as string); }; - /** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때 - * 옛 관이 살아난다(2026-08-02 사용자 보고). */ - const resetPipes = (): Promise => run(() => resetDetailPipePoints(projectId as string)); - /** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김). * 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */ const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest()))); @@ -435,7 +430,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra }); analyzeButton.addEventListener("click", () => void analyze()); deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); - autoButton.addEventListener("click", () => void resetPipes()); + autoButton.addEventListener("click", live.resetToStreams); // 세류선만 · 초안(369) /** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인). * diff --git a/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts index 71bd39b57..379702fa1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B05_Profile_UI_Drainage_Suggest.ts - * 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관 초안으로. + * 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관경 · 시설만 + * 관 초안으로(관 자리 = [자동 제안] · 369). * 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음. * 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판). * ========================================================================== */ @@ -26,7 +27,8 @@ const facilityName = (id: string): string => FACILITY_NAME[id] ?? id; /** 제안 한 줄 글. */ export function suggestionText(item: Suggestion): string { const at = `측점 ${Math.round(item.chainageM)} m`; - if (item.kind === "position") return `${at} — 관 자리 후보(흐름이 모이는 오목 자리)`; + if (item.kind === "position") + return `${at} — 관 자리 후보(흐름이 모이는 오목 자리 · [자동 제안])`; if (item.kind === "facility") return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`; return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`; @@ -56,13 +58,19 @@ export function createDrainageSuggest( apply.className = "b05-drainage__analyze"; apply.textContent = "적용"; const applyTitle = - "제안을 관 초안에 한 번에 얹습니다(관 더함 · 관경 · 시설 바꿈) — [저장] 때 남습니다."; + "관경 · 시설 제안을 관 초안에 한 번에 얹습니다 — [저장] 때 남습니다(관 자리는 [자동 제안])."; apply.title = applyTitle; head.append(title, apply); const list = document.createElement("ul"); list.className = "b05-drainage__suggest-list"; root.append(head, list); let suggestions: Suggestion[] = []; + let stale = false; + /** [적용] 켜짐 — 낡지 않고 관경 · 시설 제안이 있을 때(관 자리 후보만이면 할 일 없음). */ + const paintApply = (): void => { + apply.disabled = stale || !suggestions.some((item) => item.kind !== "position"); + apply.title = stale ? "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용" : applyTitle; + }; apply.addEventListener("click", () => { if (suggestions.length && !apply.disabled) @@ -77,6 +85,7 @@ export function createDrainageSuggest( : collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []); root.hidden = suggestions.length === 0; title.textContent = `제안 ${suggestions.length}건`; + paintApply(); list.replaceChildren( ...suggestions.map((item) => { const row = document.createElement("li"); @@ -85,9 +94,9 @@ export function createDrainageSuggest( }), ); }, - setStale(stale) { - apply.disabled = stale; - apply.title = stale ? "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용" : applyTitle; + setStale(next) { + stale = next; + paintApply(); }, candidates: () => suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM), diff --git a/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts b/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts index 891d4e695..28497e164 100644 --- a/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts +++ b/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts @@ -1,11 +1,12 @@ /* ============================================================================= * B05_Profile/logic/B05_Profile_Drainage_Suggest.ts - * 배수유역 제안(347-3) — 관경 · 시설 종류 · 관 자리 후보를 모으고 [적용] 때 관 초안에 한 번에 얹음. + * 배수유역 제안(347-3) — 관경 · 시설 종류 · 관 자리 후보를 모으고 [적용] 때 관경 · 시설만 관 초안에 얹음. * 순수 함수만(화면 = `B05_Profile_UI_Drainage_Suggest.ts`). * * - 관경 = 유역 추천이 배관이고 추천 관경이 지금 관경과 다를 때(「Ø→Ø 배관 제안」). * - 시설 = 유역 추천이 BOX암거 · 세월교이고 지금 관이 그 종류가 아닐 때. - * - 관 자리 = 서버 `suggest_pipe_positions`(간격 보충 규칙 · 기존 관에서 먼 곳). + * - 관 자리 = 서버 `suggest_pipe_positions`(간격 보충 규칙 · 기존 관에서 먼 곳) — 보이기만 · 더하기는 + * [자동 제안] ②(369 · 서버가 새 관에 등록부 기본값). * - 자동 바꿈 없음 — 모으기만 하고 바꾸는 것은 `applySuggestions`([적용])뿐. * ========================================================================== */ @@ -60,7 +61,7 @@ export function collectSuggestions( return found.sort((a, b) => a.chainageM - b.chainageM); } -/** [적용] — 제안을 관 초안에 한 번에(관 더함 · 관경 · 시설 바꿈). 입력 목록은 안 고침. */ +/** [적용] — 제안을 관 초안에 한 번에(관경 · 시설 바꿈 · 관 자리는 안 더함 · 369). 입력 목록은 안 고침. */ export function applySuggestions

( pipes: ReadonlyArray

, suggestions: ReadonlyArray, @@ -69,15 +70,11 @@ export function applySuggestions

( (pipe) => ({ ...pipe, options: pipe.options && { ...pipe.options } }) as P, ); suggestions.forEach((item) => { - if (item.kind === "position") { - if (!next.some((pipe) => near(pipe.chainage_m, item.chainageM))) - next.push({ chainage_m: item.chainageM, source: "spacing" } as P); - return; - } + if (item.kind === "position") return; const pipe = next.find((candidate) => near(candidate.chainage_m, item.chainageM)); if (!pipe) return; if (item.kind === "facility") pipe.facility = item.to; else pipe.options = { ...(pipe.options ?? {}), pipe_diameter_mm: item.to }; }); - return next.sort((a, b) => a.chainage_m - b.chainage_m); + return next; } diff --git a/resources/tester/helper_349_drainage_live_flow.cjs b/resources/tester/helper_349_drainage_live_flow.cjs index 519020dd4..50f56af99 100644 --- a/resources/tester/helper_349_drainage_live_flow.cjs +++ b/resources/tester/helper_349_drainage_live_flow.cjs @@ -86,7 +86,9 @@ const flush = async () => { /* ---- 모듈 읽기(그 자리 트랜스파일 · 바깥 모듈은 가짜) ---- */ const state = new Map(); const detailCalls = []; -let modeRow = 1; // ⚙ 창에서 고를 줄 — 0 수동 · 1 자동 +const detailAutos = []; // [자동 제안](369) 방식 — 없으면 null +let records = 0; // 사진첩 한 걸음(369) +let modeRow = 1; // ⚙ · [자동 제안] 창에서 고를 줄 — ⚙ 0 수동 · 1 자동 / 제안 0 전체 · 1 지정 포함 const stubs = { "@config/config_frontend": { API_BASE_URL: "/api" }, "@ui/ui_template_progress": { createProgressCircle: () => ({ root: el("progress") }) }, @@ -98,14 +100,20 @@ const stubs = { body.children[modeRow].children[0].fire("change"); }, }, + "@ui/ui_template_locale": { + currentLanguageIndex: 0, + ui_locales: new Proxy({}, { get: (_target, key) => [key, key] }), + }, + "./B05_Profile_UI_Profile_History": { pageHistory: () => ({ record: () => (records += 1) }) }, "../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) => { + computeDetailBasins: async (projectId, points, auto) => { detailCalls.push(points); - return { basins: [], pipe_points: points, stale: null }; + detailAutos.push(auto ?? null); + return { basins: [], pipe_points: points ?? [], stale: null }; }, }, "./B05_Profile_UI_Drainage_Parts": { analyzeFailed: (_error, fallback) => fallback }, @@ -135,6 +143,8 @@ async function main() { const viewport = el("viewport"); const header = el("header"); const root = el("root"); + const propose = el("button"); + header.querySelector = (selector) => (selector === ".b05-drainage__propose" ? propose : null); root.querySelector = (selector) => selector === ".b05-drainage__viewport" ? viewport @@ -280,7 +290,12 @@ async function main() { out.autoSavedDone = look(); // 349-0i 되돌리기 — 자동: 끌기 → 낡음 → Ctrl+Z(초안 없어짐) = 3 s 셈 대신 저장분 - const savedOk = { basins: [], pipe_points: pipes, stale: null }; + // 저장분 답에도 관경 제안 하나 — [적용] 켜짐은 낡음 · 관경/시설 제안 유무로 갈림(369) + const savedOk = { + basins: [{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 }], + pipe_points: pipes, + stale: null, + }; stage(draft(20, 300)); advance(1000); stage(null); @@ -308,6 +323,31 @@ async function main() { savedAnswer({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" }); await flush(); out.undoSavedStale = { ...look(), savedLoads, mode: state.get("drainage-mode") }; + // 369 [자동 제안] — 낡은 동안 끔 · ① all · ② add · [배관 배치 초기화] = 관 목록 없이(세류선만) + out.proposeStale = { off: propose.disabled, title: propose.title }; + const beforeStale = detailCalls.length; + propose.fire("click"); + await flush(); + out.proposeStale.calls = detailCalls.length - beforeStale; + live.markFresh(); + out.proposeFresh = { off: propose.disabled, title: propose.title }; + const n0 = detailCalls.length; + const remembered0 = remembered; + const records0 = records; + modeRow = 0; + propose.fire("click"); + await flush(); + modeRow = 1; + propose.fire("click"); + await flush(); + live.resetToStreams(); + await flush(); + out.propose = { + autos: detailAutos.slice(n0), + points: detailCalls.slice(n0).map((points) => (points === null ? null : points.length)), + remembered: remembered - remembered0, + records: records - records0, + }; // 입력 지문 — 판 번호 · 관 차례는 안 봄 · 꼭짓점 · 관 자리(cm) · id 는 봄 const { inputKey } = load(path.join(B05, "logic", "B05_Profile_Drainage_Live.ts")); const a = { id: "a1", chainage_m: 100 }; diff --git a/resources/tester/test_298_add_reanalyze.py b/resources/tester/test_298_add_reanalyze.py index 872c99a0b..a11e4f16c 100644 --- a/resources/tester/test_298_add_reanalyze.py +++ b/resources/tester/test_298_add_reanalyze.py @@ -104,7 +104,7 @@ def test_B06_초안_쓰면_판이_한_번_다시_나눔(): def test_다시_나눔은_세션_초안으로_계산만_저장_안_함(): - load = _body(PANEL, "const loadSaved = ", "const resetPipes") + load = _body(PANEL, "const loadSaved = ", "const analyze = ") assert "readPendingPipes(projectId as string)" in load assert "computeDetailBasins(projectId as string, pending)" in load analyze = _body(PANEL, "const analyze = ", "analyzeButton.addEventListener") diff --git a/resources/tester/test_312_empty_pipes.py b/resources/tester/test_312_empty_pipes.py index 7158eafdc..f83c0f428 100644 --- a/resources/tester/test_312_empty_pipes.py +++ b/resources/tester/test_312_empty_pipes.py @@ -161,7 +161,7 @@ def _body(rel: str, start: str, end: str) -> str: def test_화면_초안_빈_목록은_저장분으로_안_넘어감() -> None: load = _body( - "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", "const loadSaved = ", "const resetPipes" + "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", "const loadSaved = ", "const analyze = " ) assert "pending !== null" in load and "pending?.length" not in load # B06 목록도 같은 규칙(`??`) diff --git a/resources/tester/test_347_2_drainage_live_ui.py b/resources/tester/test_347_2_drainage_live_ui.py index 77940dcd9..ef94e14fd 100644 --- a/resources/tester/test_347_2_drainage_live_ui.py +++ b/resources/tester/test_347_2_drainage_live_ui.py @@ -51,8 +51,7 @@ def test_suggestions_collected(r): def test_apply_once_without_touching_input(r): assert r["untouched"] applied = {pipe["chainage_m"]: pipe for pipe in r["applied"]} - assert sorted(applied) == [100, 200, 300, 500] + assert sorted(applied) == [100, 300, 500] # 관 자리 후보(200)는 안 더함 — [자동 제안](369) assert applied[100]["options"]["pipe_diameter_mm"] == 1200 - assert applied[200]["source"] == "spacing" assert applied[300]["facility"] == "ford_bridge" assert applied[500]["facility"] == "box_culvert" # 이미 그 종류 — 그대로 diff --git a/resources/tester/test_349_drainage_live_flow.py b/resources/tester/test_349_drainage_live_flow.py index c3583ee28..944424f58 100644 --- a/resources/tester/test_349_drainage_live_flow.py +++ b/resources/tester/test_349_drainage_live_flow.py @@ -64,10 +64,25 @@ def test_suggestions_and_apply(r): 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 200 not in points # 관 자리 후보는 [적용]이 안 더함 — [자동 제안] 몫(369) assert r["applied"]["remembered"] == 1 +def test_자동_제안_두_방식_초기화_세류선만_한_걸음(r): + assert r["proposeStale"] == { + "off": True, + "title": "B05_Drainage_Btn_Propose_Stale", + "calls": 0, # 낡은 동안 끔 — 눌러도 요청 없음 + } + assert r["proposeFresh"] == {"off": False, "title": "B05_Drainage_Btn_Propose_Tip"} + assert r["propose"] == { + "autos": ["all", "add", None], + "points": [1, 1, None], # ① ② = 지금 관 목록 함께 · 초기화 = 목록 없이(세류선만) + "remembered": 3, # 셋 다 초안 + "records": 3, # 답마다 사진첩 한 걸음 + } + + def test_move_while_busy_cancels(r): moved = r["movedWhileBusy"] assert moved["aborted"] and moved["veil"] and not moved["busy"] diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 3470286fa..d9aec5bd6 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -34,8 +34,8 @@ export const ui_locales_b2 = { // (2026-08-19 사용자 지시 4). B05_Drainage_Btn_Analyze: ["유역 재분할", "Recompute sub-basins"], B05_Drainage_Btn_Analyze_Tip: [ - "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", - "Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.", + "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관을 더하지 않습니다 — 간격 보충은 [자동 제안]. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", + "Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts without adding any — use [Auto suggest] to fill spacing gaps. Run the analysis in B04 first if none exists.", ], B05_Drainage_Btn_DeleteSelected: ["관 삭제", "Delete selected culvert"], B05_Drainage_Btn_DeleteSelected_Tip: [ @@ -44,8 +44,8 @@ export const ui_locales_b2 = { ], B05_Drainage_Btn_Auto: ["배관 배치 초기화", "Reset culvert layout"], B05_Drainage_Btn_Auto_Tip: [ - "손으로 넣거나 옮기거나 지운 배관을 모두 버리고, 계곡부·간격 기준으로 프로그램이 자동 배치한 상태로 되돌립니다. 유역 구획도 함께 다시 나뉩니다.", - "Discards every manual culvert edit and restores the automatic placement (valley crossings + spacing rule); sub-basins are re-split as well.", + "손으로 넣거나 옮기거나 지운 배관을 모두 버리고, 세류선(계곡)이 노선과 만나는 자리에만 배관을 둡니다. 유역 구획도 함께 다시 나뉩니다. 초안이라 [저장] 때 남습니다.", + "Discards every manual culvert edit and keeps culverts only where streams cross the route; sub-basins are re-split as well. This is a draft — it is kept on [Save].", ], B05_Drainage_Menu_Add: ["배수관 추가", "Add culvert"], B05_Drainage_Menu_Delete: ["배수관 삭제", "Remove culvert"], @@ -1047,4 +1047,24 @@ export const ui_locales_b2 = { ], B06_Std_Note_Rock: ["암", "Rock"], B06_Std_Note_Paved: ["포장", "Paved"], + B05_Drainage_Btn_Propose: ["자동 제안", "Auto suggest"], + B05_Drainage_Btn_Propose_Tip: [ + "관 자리를 프로그램이 정합니다(세류선 교차 + 최대 간격 보충) — 두 방식 중 고름. 초안이라 [저장] 때 남습니다.", + "Let the program place culverts (stream crossings + max spacing rule) — choose one of two ways. This is a draft — it is kept on [Save].", + ], + B05_Drainage_Btn_Propose_Stale: [ + "노선 · 관 자리가 바뀌어 배수유역이 낡음 — [업데이트] 뒤 자동 제안", + "Drainage basins are out of date — run [Update] first", + ], + B05_Drainage_Propose_Title: ["관 자리 자동 제안", "Auto-suggest culvert positions"], + B05_Drainage_Propose_All: ["전체 자동", "Fully automatic"], + B05_Drainage_Propose_All_Hint: [ + "세류선 교차 + 최대 간격 규칙으로 관 자리를 새로 정함(같은 자리 관은 시설 · 관경 유지)", + "Re-place every culvert by stream crossings + max spacing (culverts at the same spot keep their facility and size)", + ], + B05_Drainage_Propose_Add: ["지정 포함 추가", "Add to my culverts"], + B05_Drainage_Propose_Add_Hint: [ + "지금 관은 그대로 두고 간격이 먼 구간에만 관을 더함", + "Keep the current culverts and add culverts only where the spacing is too long", + ], } as const satisfies Record;