diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 55dbc07e3..05e25caf4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -508,17 +508,14 @@ export function createStructuresSection( readBeforeM, syncFacilityForm: (options, designFlow) => syncFacilityForm(options, designFlow), editingId: () => editingId, - setEditingId: (value) => { - editingId = value; - }, + setEditingId: (value) => void (editingId = value), selectedPipeChainage: () => selectedPipeChainage, setSelectedPipeChainage: (value) => { selectedPipeChainage = value; }, tempPipeChainage: () => tempPipeChainage, - setTempPipeChainage: (value) => { - tempPipeChainage = value; - }, + setTempPipeChainage: (value) => void (tempPipeChainage = value), + selectPipe: (at) => api.selectPipeByChainage(at), // 종류 바꿈 못 함 = 옛 관 폼으로(323-6) positionConfirmed: () => positionConfirmed, setPositionConfirmed: (value) => { positionConfirmed = value; @@ -670,7 +667,7 @@ export function createStructuresSection( renderOptionFields(); syncFacilityForm(); if (addsAtOnce(type, optionInputs)) - return void finishAdd({ type, anchorM: chainageM, structures, commit, pick: this }); + return finishAdd({ type, anchorM: chainageM, structures, commit, pick: this }); // 임시 배치도 즉시 진행(A군). if (type.managed_by) commitTempPipe(true); syncButtons(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 7e3c6c64b..342f44e2c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -13,6 +13,7 @@ import { type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; +import { showToast } from "@ui/ui_template_elements"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; @@ -249,15 +250,19 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis const type = ctx.currentType(); if (!type) return; const step = ctx.interval(); + /** 막힌 칸으로 커서 · [추가](실시간 반영 아님)면 어느 칸인지 알림(323-6 · 무반응이던 것). */ + const stop = (input: HTMLElement | null | undefined, field: string): void => { + input?.focus(); + if (!live) showToast(`「${field}」 칸이 비었거나 맞지 않아 못 놓음 — 칸을 채우세요.`, "error"); + }; + const labelOf = (key: string): string => + type.options.find((option) => option.key === key)?.label ?? key; // 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다 // (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다. if (type.managed_by) { const anchor = ctx.anchorFields.read(step, true); - if (anchor === null) { - ctx.anchorFields.station.focus(); - return; - } + if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점"); // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; const options = readOptions(ctx); @@ -288,14 +293,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis // 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이 // (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절. anchor = ctx.anchorFields.read(step, true); - if (anchor === null) { - ctx.anchorFields.station.focus(); - return; - } + if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점"); const length = ctx.readLengthM(); if (length === null) { - ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); - return; + const input = ctx.optionInputs.find((entry) => entry.key === "length_m")?.input; + return stop(input, labelOf("length_m")); } // 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인 // 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`). @@ -303,8 +305,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis if (anchor - before < -0.005) { const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input; beforeInput?.classList.add("is-invalid"); - beforeInput?.focus(); - return; + return stop(beforeInput, labelOf("before_m")); } // 시작 · 끝도 정수 m · 끝은 1 m 이상(234-1 · 167) start = snapStructureStation(Math.max(anchor - before, 0)); @@ -312,10 +313,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis } else if (placement === "interval") { start = ctx.startFields.read(step, true); end = ctx.endFields.read(step, true); - if (start === null || end === null || end <= start) { - (start === null ? ctx.startFields.station : ctx.endFields.station).focus(); - return; - } + if (start === null || end === null || end <= start) + return stop( + start === null ? ctx.startFields.station : ctx.endFields.station, + "시작 · 끝 측점", + ); // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. // 범위 직접 입력형(노면공 · 87-1) = 기준 칸 없음 → 늘 시점. anchor = STRUCTURE_SPECS[type.type_id]?.byRange @@ -323,24 +325,17 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis : (ctx.anchorFields.read(step, false) ?? start); if (anchor < start || anchor > end) { ctx.anchorFields.station.classList.add("is-invalid"); - ctx.anchorFields.station.focus(); - return; + return stop(ctx.anchorFields.station, "기준 측점(시작 ~ 끝 안)"); } } else { anchor = ctx.anchorFields.read(step, true); - if (anchor === null) { - ctx.anchorFields.station.focus(); - return; - } + if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점"); } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). // 실시간 반영은 막지 않음 — 빈 칸은 표시 · 안내만(`markRequired` · 54-12), 막기는 [추가] · 서버 [저장] const missing = ctx.optionInputs.find((entry) => entry.required && entry.isEmpty()); - if (missing && !live) { - missing.input.focus(); - return; - } + if (missing && !live) return stop(missing.input, labelOf(missing.key)); const summary = ctx.summary(); const base = { diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 4b0209d90..a9e3ebaad 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -39,8 +39,10 @@ export interface StructuresEventContext extends StructuresCommitContext { commitPosition: () => void; liveCommit: () => void; loadForm: (target: StructureInstance | null) => void; - /** 우클릭 추가와 같은 길 — 그 측점에 그 종류를 놓고 고름(패널 `addAt`). */ - addAt: (chainageM: number, typeId: string) => void; + /** 우클릭 추가와 같은 길 — 그 측점에 그 종류를 놓고 고름(패널 `addAt` · 놓였으면 true). */ + addAt: (chainageM: number, typeId: string) => Promise | void; + /** 관 하나를 폼에 엶(패널 `selectPipeByChainage`). */ + selectPipe: (chainageM: number) => void; emit: () => void; optionRow: HTMLElement; } @@ -95,16 +97,7 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { // 기본값 · 구간 = 기본 길이 · 요구 217) · 옛 조정값은 318-4 대로 버림 if (!swapsInPlace(prev.type_id, next.type_id)) { const at = record ? structureAnchorM(record) : pipeItem!.chainage_m; - if (record) { - ctx.structures.splice(index, 1); - ctx.setEditingId(null); - ctx.emit(); - } else { - ctx.callbacks.onPipeRemove(at); - ctx.setSelectedPipeChainage(null); - } - forgetStationAdjusts(ctx.projectId, at, prev.type_id, next.type_id); - return ctx.addAt(at, next.type_id); + return void replaceAt(at, prev, next, record); } const key = record ? `s:${id}` : `p:${pipe}`; // 구조물은 정본 기록(안 보이는 상세 칸까지) · 관은 아직 옛 종류로 그려진 폼에서 읽음 @@ -145,6 +138,31 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { } } + /** 지우고 같은 기준 측점에 새로 — 새 종류가 기본값으로 놓인 뒤에만 옛 것을 지움. 못 놓으면(빈 칸 · + * [추가] 가 막음) 옛 것 · 옛 폼 그대로 + 알림(323-6 고침 · 조용히 사라지던 것). */ + async function replaceAt( + at: number, + prev: { type_id: string; name: string }, + next: { type_id: string; name: string }, + record: StructureInstance | null, + ): Promise { + if (!(await ctx.addAt(at, next.type_id))) { + if (record) ctx.loadForm(record); + else ctx.selectPipe(at); + showToast( + `${prev.name} 그대로 — ${next.name}(으)로 못 바꿈(빈 칸 때문에 바로 못 놓음)`, + "error", + ); + return; + } + if (record) { + const index = ctx.structures.findIndex((e) => e.structure_id === record.structure_id); + if (index >= 0) ctx.structures.splice(index, 1); + ctx.emit(); + } else ctx.callbacks.onPipeRemove(at); + forgetStationAdjusts(ctx.projectId, at, prev.type_id, next.type_id); + } + type TypeValues = { options?: Record; summary_item?: unknown; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 73856b16f..9f99606ef 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -174,8 +174,8 @@ export interface StructuresSection { selection: DesignSelection, coverable?: (entry: StructureInstance) => boolean, ) => ResolvedSelection; - /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ - addAt: (chainageM: number, typeId: string) => void; + /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. 바로 놓으면 놓였는지(true) · 폼만이면 없음. */ + addAt: (chainageM: number, typeId: string) => Promise | void; /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ moveById: (structureId: string, toChainageM: number) => boolean; removeById: (structureId: string) => boolean; diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs index 4242bf606..6eb4ee982 100644 --- a/resources/tester/helper_319_kind_swap.cjs +++ b/resources/tester/helper_319_kind_swap.cjs @@ -83,7 +83,7 @@ function makeLoader(real, stubs = {}) { const out = {}; // ── 1. 패널 종류 바꿈(319-2) — 정본 다른 종류 = 지우고 같은 측점 addAt ───────────── -{ +const part1 = (async () => { const load = makeLoader( [ B05("B05_Profile_UI_Structures_Panel_Events"), @@ -114,13 +114,15 @@ const out = {}; }); const fields = () => ({ station: el(), remainder: el(), wrap: { hidden: false } }); // commitOk = [수정] 반영이 받아 줌(패널은 기록을 새 것으로 갈아 끼움) · false = 막힘(필수 칸 등 · 323-6 알림) - const run = ({ + // addOk = 새 종류가 기본값으로 바로 놓임(패널 `addAt` 이 true) · false = 빈 칸 때문에 폼만(323-6 고침) + const run = async ({ structures = [], pipes = [], editing = null, pipeAt = null, next, commitOk = true, + addOk = true, }) => { toasts.length = 0; const calls = { @@ -185,11 +187,13 @@ const out = {}; }, loadForm: (target) => calls.loaded.push(target?.type_id ?? null), emit: () => (calls.emit += 1), - addAt: (at, typeId) => calls.addAt.push([at, typeId]), + addAt: async (at, typeId) => (calls.addAt.push([at, typeId]), addOk), + selectPipe: (at) => calls.loaded.push(`pipe@${at}`), interval: () => 20, }; bindStructuresEvents(ctx); ctx.typeSelect.listeners.change(); + await new Promise((resolve) => setTimeout(resolve)); // 바꿈(지우고 새로)은 놓인 뒤에 옛 것을 지움 return { ...calls, left: structures.map((s) => s.type_id), toasts: [...toasts] }; }; const store = window.sessionStorage; @@ -199,52 +203,60 @@ const out = {}; JSON.stringify({ "80.00": { a: 1 }, "300.00": { b: 2 } }), ); out.pipe_to_underdrain = { - ...run({ pipes: [{ chainage_m: 80, facility: "pipe" }], pipeAt: 80, next: "cross_underdrain" }), + ...(await run({ + pipes: [{ chainage_m: 80, facility: "pipe" }], + pipeAt: 80, + next: "cross_underdrain", + })), kindreset: kindreset(), culvertopt: JSON.parse(store.getItem("aislo:draft:culvertopt:P:1")), }; // 새로 놓인 맹암거(같은 측점) → 처음 종류(배관)로 되돌림 = 비울 자리 기록이 빠짐(318-4 규칙) out.underdrain_back_to_pipe = { - ...run({ + ...(await run({ structures: [ { structure_id: "n1", type_id: "cross_underdrain", placement: "point", chainage_m: 80 }, ], editing: "n1", next: "pipe", - }), + })), kindreset: kindreset(), }; out.open_ditch_to_box = { - ...run({ + ...(await run({ structures: [ { structure_id: "s1", type_id: "sign", placement: "point", chainage_m: 10 }, { structure_id: "s9", type_id: "open_ditch", placement: "point", chainage_m: 120 }, ], editing: "s9", next: "box_culvert", - }), + })), kindreset: kindreset(), }; - out.pipe_to_box = run({ + out.pipe_to_box = await run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: "box_culvert", }); - out.underdrain_to_open_ditch = run({ + out.underdrain_to_open_ditch = await run({ structures: [ { structure_id: "s2", type_id: "cross_underdrain", placement: "point", chainage_m: 60 }, ], editing: "s2", next: "open_ditch", }); - out.to_blank = run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: null }); + out.to_blank = await run({ + pipes: [{ chainage_m: 200, facility: "pipe" }], + pipeAt: 200, + next: null, + }); // 323-6 — E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로(구간 = 기본 길이는 `addAt`) · 같은 자리꼴 = 제자리 - out.point_to_interval = run({ + out.point_to_interval = await run({ structures: [{ structure_id: "m1", type_id: "mirror", placement: "point", chainage_m: 140 }], editing: "m1", next: "guardrail", }); - out.interval_to_point = run({ + out.interval_to_point = await run({ structures: [ { structure_id: "g1", @@ -258,7 +270,7 @@ const out = {}; editing: "g1", next: "mirror", }); - out.interval_same = run({ + out.interval_same = await run({ structures: [ { structure_id: "r1", @@ -272,18 +284,32 @@ const out = {}; editing: "r1", next: "work_yard", }); + // 바꿈(지우고 새로)인데 새 종류를 바로 못 놓음(빈 칸) = 옛 것 · 옛 폼 그대로 + 알림(323-6 고침 · 반사경 → 가드레일) + out.replace_blocked = await run({ + structures: [{ structure_id: "m2", type_id: "mirror", placement: "point", chainage_m: 300 }], + editing: "m2", + next: "guardrail", + addOk: false, + }); + out.replace_blocked_pipe = await run({ + pipes: [{ chainage_m: 200, facility: "pipe" }], + pipeAt: 200, + next: "cross_underdrain", + addOk: false, + }); // 막힘 = 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰) - out.blocked = run({ + out.blocked = await run({ structures: [{ structure_id: "b1", type_id: "mirror", placement: "point", chainage_m: 30 }], editing: "b1", next: "curb", commitOk: false, }); -} +})(); // ── 2. 일반 측점 자리(grid) — 종류 바꿈 = 새로 뜬 횡단 · 다 빠짐 = 시설 뺀 사본(319-2) ────── const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedStructures: [] }; (async () => { + await part1; const load = makeLoader( [ B06("B06_Section_Section_Drafts"), diff --git a/resources/tester/test_319_kind_swap.py b/resources/tester/test_319_kind_swap.py index dc9fbdee1..25e54e60b 100644 --- a/resources/tester/test_319_kind_swap.py +++ b/resources/tester/test_319_kind_swap.py @@ -46,7 +46,7 @@ def _src(*parts: str) -> str: def test_pipe_to_underdrain_removes_pipe_and_places_at_same_station(result: dict) -> None: swap = result["pipe_to_underdrain"] assert swap["removed"] == [80] - assert swap["pipeSet"] == [None] + assert swap["pipeSet"] == [] # 새 것이 놓인 뒤 옛 관만 지움(고름은 새 것 · 323-6 고침) assert swap["addAt"] == [[80, "cross_underdrain"]] assert swap["reset"] == 0 and swap["live"] == 0 # 새로 놓기 폼으로 빠지지 않음 assert swap["kindreset"] == {"80.00": "pipe"} # [저장] 때 그 측점 옛 조정값 비움(318-4) @@ -55,7 +55,7 @@ def test_pipe_to_underdrain_removes_pipe_and_places_at_same_station(result: dict def test_back_to_first_kind_drops_reset_mark(result: dict) -> None: back = result["underdrain_back_to_pipe"] - assert back["left"] == [] and back["emit"] == 1 and back["editingSet"] == [None] + assert back["left"] == [] and back["emit"] == 1 and back["editingSet"] == [] assert back["addAt"] == [[80, "pipe"]] assert back["kindreset"] is None @@ -85,6 +85,16 @@ def test_point_interval_switch_replaces_at_same_anchor(result: dict) -> None: assert same["addAt"] == [] and same["live"] == 1 and same["left"] == ["work_yard"] +def test_replace_keeps_old_when_new_cannot_be_placed(result: dict) -> None: + # 반사경 → 가드레일(빈 칸 때문에 바로 못 놓음) = 반사경 · 반사경 폼 그대로 + 알림 한 줄(조용히 사라지던 것) + kept = result["replace_blocked"] + assert kept["left"] == ["mirror"] and kept["addAt"] == [[300, "guardrail"]] + assert kept["loaded"] == ["mirror"] and kept["emit"] == 0 + assert len(kept["toasts"]) == 1 and "반사경" in kept["toasts"][0][0] + pipe = result["replace_blocked_pipe"] + assert pipe["removed"] == [] and pipe["loaded"] == ["pipe@200"] and len(pipe["toasts"]) == 1 + + def test_blocked_switch_restores_with_notice(result: dict) -> None: blocked = result["blocked"] assert blocked["left"] == ["mirror"] and blocked["loaded"] == ["mirror"] diff --git a/resources/tester/test_323_6_change.py b/resources/tester/test_323_6_change.py index 233ecc636..e15175cc9 100644 --- a/resources/tester/test_323_6_change.py +++ b/resources/tester/test_323_6_change.py @@ -73,6 +73,14 @@ def test_drawing_does_not_create_adjusts() -> None: assert "if (!stored || sameAdjust(stored, applied)) return;" in body +def test_add_names_blocking_field() -> None: + # [추가] 무반응 → 막힌 칸 이름 알림(323-6 고침 · 실시간 반영은 알림 없음) + commit = _src("B05_Profile", "B05_Profile_UI_Structures_Panel_Commit.ts") + assert "if (!live) showToast(" in commit + assert "if (missing && !live) return stop(missing.input, labelOf(missing.key));" in commit + assert 'return stop(input, labelOf("length_m"));' in commit + + def test_old_replay_removed() -> None: assert "replayDraftPipeOptions" not in _src( "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"