fix(구조물표): 323-6 고침 — 지우고 새로 바꾸는 종류 바꿈 = 새 종류가 놓인 뒤에만 옛 것을 지움(못 놓으면 옛 것 · 옛 폼 그대로 + 알림) · [추가] 무반응 = 막힌 칸 이름 알림

- 반사경 → 가드레일(길이 빈칸)이 옛 것을 먼저 지우고 새 것은 못 놓아 반사경이 사라지던 것 — 패널 addAt 이 놓였는지 돌려줌 · Events replaceAt = 먼저 놓고 확인 뒤 옛 것 지움 · 못 놓으면 옛 기록 폼(관은 관 폼) + 「반사경 그대로 — 가드레일(으)로 못 바꿈」
- commit 의 조용한 return 여섯 곳 = 막힌 칸으로 커서 + [추가] 때 「「길이」 칸이 비었거나 맞지 않아 못 놓음」(실시간 반영은 알림 없음)
- 패널 줄 수 지킴(setter 한 줄로 · 697)
- 시험: test_319 바꿈 막힘 둘(구조물 · 관) · 비동기 바꿈 · test_323_6 막힌 칸 알림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 18:32:46 +09:00
co-authored by Claude Opus 5.5
parent 529baca3ff
commit 6744524669
7 changed files with 119 additions and 65 deletions
@@ -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();
@@ -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 = {
@@ -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<boolean> | 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<void> {
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<string, string | number>;
summary_item?: unknown;
@@ -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<boolean> | void;
/** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */
moveById: (structureId: string, toChainageM: number) => boolean;
removeById: (structureId: string) => boolean;
+42 -16
View File
@@ -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"),
+12 -2
View File
@@ -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"]
+8
View File
@@ -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"