feat(B05 배수유역): 369-2 [자동 제안] 두 방식 · [배관 배치 초기화] = 세류선만 초안 · 제안 [적용] = 관경 · 시설만

- 배수유역 머리 [자동 제안] → 고름 창(⚙ 창 틀) ① 전체 자동(all) ② 지정 포함 추가(add) · 낡은 동안 끔
- [배관 배치 초기화] — 저장분 즉시 지움 없앰 · 관 목록 없이 셈(세류선 교차만) · 초안
- 셋 다 답이 오면 세션 초안 + 사진첩 한 걸음(400 ms 넘은 답도 제 걸음)
- 제안 목록 [적용] — 관 자리 후보는 안 더함(점선 원 · 목록 그대로) · 관경 · 시설 제안 없으면 꺼짐
- computeDetailBasins(points | null, auto) · 글 = 툴팁 두 값 고침 · 새 글 끝에 새 키
- 시험 — 349 흐름에 자동 제안 · 347_2 · 298 · 312 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-17 12:00:43 +09:00
co-authored by Claude Opus 5.5
parent c52a98a115
commit e5dc007b40
12 changed files with 184 additions and 44 deletions
+5 -3
View File
@@ -620,14 +620,16 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
*
* 빈 목록 = 관 0개(312) · 기본 관 + 자동 보충으로 되돌리기는 `resetDetailPipePoints`(DELETE)만. */
* 빈 목록 = 관 0개(312) · null = 세류선 교차 관만(369) · `auto`([자동 제안]) = "all" 전체 자동
* (같은 자리 관은 속성 유지) · "add" 지금 관 + 빈 구간 보충. */
export async function computeDetailBasins(
projectId: string,
points: DetailPipeInput[],
points: DetailPipeInput[] | null,
auto?: "all" | "add",
): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/detail-basins`, {
method: "POST",
body: JSON.stringify({ points }),
body: JSON.stringify(auto ? { points, auto } : { points }),
});
}
+11 -2
View File
@@ -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,
+59 -5
View File
@@ -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<boolean>;
@@ -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<HTMLElement>(".b05-drainage__viewport") as HTMLElement;
const header = deps.root.querySelector<HTMLElement>(".b05-drainage__header") as HTMLElement;
const suggest = createDrainageSuggest(deps.pipeRequest, (points) => {
const propose = header.querySelector<HTMLButtonElement>(".b05-drainage__propose");
/** 관 초안 요청 — 답이 화면에 오르면 세션 초안 + 사진첩 한 걸음(400 ms 넘은 답도 제 걸음). */
const draft = (request: (projectId: string) => Promise<DetailBasinResponse>): 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;
},
+1 -6
View File
@@ -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<boolean> => 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)
/** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인).
*
+15 -6
View File
@@ -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),
@@ -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<P extends PipeLike>(
pipes: ReadonlyArray<P>,
suggestions: ReadonlyArray<Suggestion>,
@@ -69,15 +70,11 @@ export function applySuggestions<P extends PipeLike>(
(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;
}
@@ -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 };
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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 목록도 같은 규칙(`??`)
@@ -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" # 이미 그 종류 — 그대로
@@ -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"]
+24 -4
View File
@@ -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<string, LocaleEntry>;