Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-17 12:48:03 +09:00
8 changed files with 82 additions and 10 deletions
@@ -67,6 +67,7 @@ router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"])
_NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요."
# 노선 지문이 분석 때와 다름(옛 저장분은 지문 없음) — 옛 격자로 나누면 틀린 유역이 나온다(342-3).
# 머리 「배수유역 낡음」 = 화면 `STALE_PREFIX`(B05 Drainage_Live · 369) 짝.
# [자동 제안] 방식(369) — None = 지금 목록 그대로(없으면 세류선 교차만) · all · add
AUTO_MODES = (None, "all", "add")
STALE_MESSAGE = (
+15 -2
View File
@@ -98,6 +98,9 @@ export function readDrainageMode(): DrainageMode {
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
/** 서버 「낡음」 글 머리 — 짝 `B04_PreProcess_Router_Basins.STALE_MESSAGE`. */
const STALE_PREFIX = "배수유역 낡음";
/** [자동 제안] · [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */
type Proposal = { points: DetailPipeInput[] | null; auto?: "all" | "add" };
@@ -205,10 +208,20 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
});
}
/** [자동 제안] · [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(detail-basins). */
/** [자동 제안] · [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(detail-basins) ·
* 그 길이 「낡음」(409 · 화면이 아직 몰랐던 낡음)이면 곧바로 지금 노선으로 셈 길로 다시. */
function proposeDraft(proposal: Proposal): void {
if (analysisStale || draftMakesStale(route)) return refresh(proposal);
draft((projectId) => computeDetailBasins(projectId, proposal.points, proposal.auto));
draft(async (projectId) => {
try {
return await computeDetailBasins(projectId, proposal.points, proposal.auto);
} catch (error) {
if (!(error instanceof Error) || !error.message.startsWith(STALE_PREFIX)) throw error;
analysisStale = true;
queueMicrotask(() => refresh(proposal)); // 이 요청은 조용히 버림(뒤 차례가 받음)
throw new DOMException("stale", "AbortError");
}
});
}
/** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */
+9 -5
View File
@@ -34,6 +34,7 @@ import { snapStructureStation } from "../common_util/common_util_structure_stati
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./logic/B05_Profile_UI_Drainage_Facility_Merge";
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { pageReplaying } from "./B05_Profile_UI_Profile_History";
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";
@@ -58,6 +59,7 @@ import {
summaryText,
basinIndexOfPipe,
pipeMarkerColor,
adoptDraftPipes,
reconcilePipes,
WIDTH_KEY,
type DrainageLayer,
@@ -591,11 +593,13 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m);
const next = reconcilePipes(pipeEditor.pipes(), chainages);
if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다
// 되돌리기 · 구조물 폼으로 옮겨진 관도 속성을 데려감
facilityStore.follow(
before,
next.map((pipe) => pipe.chainage_m),
);
// 되돌리기 = 그 걸음 초안의 속성 · id · 초안 없는 걸음은 저장분(369) · 구조물 폼은 속성 데려감
const pending = pageReplaying() ? readPendingPipes(projectId as string) : null;
if (pageReplaying() && !pending) return void run(loadSaved, true);
const draft = adoptDraftPipes(pending, next);
const after = next.map((pipe) => pipe.chainage_m);
if (draft) facilityStore.replaceFromResponse(draft);
else facilityStore.follow(before, after);
pipeEditor.setPipes(next);
void analyze();
},
@@ -343,6 +343,22 @@ export function mountDrainageToggles(
/** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다.
* 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */
/** 되돌리기(369) — 되살린 걸음의 관 초안(세션)이 `next` 와 같은 자리면 그 초안(속성 정본) · id 도
* `next` 에 옮김. 자리가 다르면(구조물 폼이 옮김 등) null — 부르는 쪽이 속성을 따라 옮김. */
export function adoptDraftPipes<D extends { chainage_m: number; id?: string }>(
draft: ReadonlyArray<D> | null,
next: PipePoint[],
): ReadonlyArray<D> | null {
const same =
draft?.length === next.length &&
draft.every((item, index) => Math.abs(item.chainage_m - next[index].chainage_m) < 0.01);
if (!draft || !same) return null;
next.forEach((pipe, index) => {
if (draft[index].id) pipe.id = draft[index].id;
});
return draft;
}
export function reconcilePipes(
before: ReadonlyArray<PipePoint>,
chainages: ReadonlyArray<number>,
@@ -21,7 +21,7 @@ function loadTs(file, real) {
return box.exports;
}
const { reconcilePipes } = loadTs(
const { adoptDraftPipes, reconcilePipes } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Parts.ts"),
["design_selection"],
);
@@ -35,5 +35,19 @@ process.stdout.write(
moved: reconcilePipes(before, [100, 250]),
added: reconcilePipes(before, [100, 200, 300]),
twoMoved: reconcilePipes(before, [150, 250]),
// 369 되돌리기 — 그 걸음 초안이 같은 자리면 초안 속성 · id 를 씀 · 다르면 null
adopt: (() => {
const next = [
{ chainage_m: 100, reason: "user" },
{ chainage_m: 200, reason: "user" },
];
const draft = [
{ chainage_m: 100, id: "a1", facility: "ford_bridge" },
{ chainage_m: 200.004, id: "b2" },
];
return { draft: adoptDraftPipes(draft, next), next };
})(),
adoptOther: adoptDraftPipes([{ chainage_m: 120, id: "z" }], [{ chainage_m: 100 }]),
adoptNone: adoptDraftPipes(null, [{ chainage_m: 100 }]),
}),
);
@@ -88,6 +88,7 @@ const state = new Map();
const detailCalls = [];
const detailAutos = []; // [자동 제안](369) 방식 — 없으면 null
let records = 0; // 사진첩 한 걸음(369)
let detailStale = false; // detail-basins 가 409 낡음으로 답함(369 — 화면이 몰랐던 낡음)
let modeRow = 1; // ⚙ · [자동 제안] 창에서 고를 줄 — ⚙ 0 수동 · 1 자동 / 제안 0 전체 · 1 지정 포함
const stubs = {
"@config/config_frontend": { API_BASE_URL: "/api" },
@@ -113,6 +114,7 @@ const stubs = {
computeDetailBasins: async (projectId, points, auto) => {
detailCalls.push(points);
detailAutos.push(auto ?? null);
if (detailStale) throw new Error("배수유역 낡음 — 노선이 분석 때와 다릅니다.");
return { basins: [], pipe_points: points ?? [], stale: null };
},
},
@@ -348,6 +350,21 @@ async function main() {
remembered: remembered - remembered0,
records: records - records0,
};
// 화면이 몰랐던 낡음 — 빠른 길이 409 낡음이면 곧바로 지금 노선으로 셈 길로 다시
detailStale = true;
modeRow = 0;
const fallbackFrom = calls.length;
propose.fire("click");
await flush();
const fallbackCall = calls[calls.length - 1];
out.proposeFallback = {
sent: calls.length - fallbackFrom,
auto: fallbackCall.body.auto,
status: status.textContent,
};
answer(fallbackCall);
await flush();
detailStale = false;
// 저장 분석이 옛 노선(서버 낡음 답) → [업데이트] 뒤 [자동 제안] = 지금 노선으로 셈 길에 방식 실음
await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" }));
update.fire("click");
+4
View File
@@ -95,3 +95,7 @@ def test_screen_reconcile_carries_id():
assert result["moved"][1]["reason"] == "confirmed"
assert [p.get("id") for p in result["added"]] == ["a1", "b2", None]
assert [p.get("id") for p in result["twoMoved"]] == [None, None]
adopt = result["adopt"]
assert [p["id"] for p in adopt["next"]] == ["a1", "b2"] # 369 되돌리기 — 그 걸음 초안 id
assert adopt["draft"][0]["facility"] == "ford_bridge"
assert result["adoptOther"] is None and result["adoptNone"] is None
@@ -88,9 +88,12 @@ def test_자동_제안_두_방식_초기화_세류선만_한_걸음(r):
"resetPoints": None,
"resetAuto": None,
"detailCalls": 0,
"remembered": 5,
"records": 5,
"remembered": 6, # 앞 「409 → 다시」 한 번 포함
"records": 6,
}
fallback = r["proposeFallback"]
assert fallback["sent"] == 1 and fallback["auto"] == "all"
assert "낡음" not in fallback["status"] # 409 글을 오류로 안 띄움(조용히 다시)
def test_move_while_busy_cancels(r):