diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index 5151d8e16..4efc915fc 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -49,6 +49,8 @@ export interface StructureListParams { selectedPipeChainage: number | null; onSelectStructure: (structure: StructureInstance) => void; onSelectPipe: (pipe: PipeFacilityItem) => void; + /** 맨 아래 [구조물 전체 삭제](313) — 목록이 비면 단추 없음. */ + onClearAll?: () => void; } /** 구조물 한 건의 측점 표기 — 점형은 기준 측점, 구간형은 시작~종료 측점. */ @@ -242,4 +244,14 @@ export function renderStructureList(params: StructureListParams): void { item.append(lampCell(lamps[index]), station, name); list.append(item); }); + if (!params.onClearAll) return; + const clear = document.createElement("li"); + clear.className = "b05-route__irregular-clear"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-route__irregular-btn is-danger"; + button.textContent = "구조물 전체 삭제"; + button.addEventListener("click", params.onClearAll); + clear.append(button); + list.append(clear); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index c57c1bc3f..6c28e06a5 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -437,6 +437,7 @@ export function createStructuresSection( intervalM: interval(), editingId, selectedPipeChainage, + onClearAll: () => void clearAll(), // 맨 아래 [구조물 전체 삭제](313) // 재선택 = 해제(2026-08-18 복구) — 해제는 리셋과 같은 전체 초기화다. onSelectStructure: (structure) => { if (structure.structure_id !== null && structure.structure_id === editingId) { @@ -527,7 +528,7 @@ export function createStructuresSection( setSummary: (value) => summary.set(value), groupBoxes, }; - const { startNew } = bindStructuresEvents(panelContext); + const { startNew, clearAll } = bindStructuresEvents(panelContext); // 집계 항목(본체 · 옵션 그룹) — [고르기]는 21-5 모달 · 배선은 `_Structures_Groups` bindSummaryPickers({ summary, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 04f2dc9cd..e59a91ff8 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -14,6 +14,7 @@ import { asRoad } from "./B05_Profile_UI_Structures_Define"; import { readState, writeState } from "../A00_Common/b_page_state"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "../A00_Common/design/design_selection"; import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item"; +import { showConfirmDialog } from "@ui/ui_template_elements"; /** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */ /** 배선에 필요한 값 — 저장 쪽(`StructuresCommitContext`)과 겹치는 것은 그대로 물려받는다. */ @@ -38,6 +39,7 @@ export interface StructuresEventContext extends StructuresCommitContext { export function bindStructuresEvents(ctx: StructuresEventContext): { startNew: (group: string, typeId?: string) => void; + clearAll: () => Promise; } { /** 새로 놓기(54-32) — 고름 · 측점 · 강조를 풀고 군(· 종류)만 남긴 빈 기본값 폼. */ function startNew(group: string, typeId?: string): void { @@ -224,6 +226,20 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { } }); + /** [구조물 전체 삭제](313) — 확인 한 번 · 목록 전부(자동 횡단배수 · 손 배치) · 하나씩 지우기와 같은 길 + * (관 = 관 빼기 · 구조물 = 정본 배열 비우고 알림) · 캐시에만 · 작업본은 [저장] 때. */ + async function clearAll(): Promise { + const count = ctx.structures.length + ctx.pipeFacilities().length; + if (!count || !(await showConfirmDialog(`구조물 ${count}개를 모두 지울까요?`, "모두 삭제"))) + return; + ctx.resetForm(); // 임시 배치 취소 · 폼 · 고름 풂 + ctx.callbacks.onListRelease?.(); + for (const pipe of [...ctx.pipeFacilities()]) ctx.callbacks.onPipeRemove(pipe.chainage_m); + if (!ctx.structures.length) return; + ctx.structures.splice(0, ctx.structures.length); + ctx.emit(); + } + ctx.memoField.addEventListener("change", () => ctx.liveCommit()); - return { startNew }; // 우클릭 추가도 새로 놓기에서 시작(116-1) + return { startNew, clearAll }; // 우클릭 추가도 새로 놓기에서 시작(116-1) } diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 763fe83d6..97fa3b1c1 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -441,6 +441,16 @@ html.is-bm-route .b05-route__view-controls { list-style: none; } +/* 목록 맨 아래 [구조물 전체 삭제](313) — 좌측 패널 위험 단추 꼴 · 목록 폭 · 비면 그리지 않음 */ +.b05-route__irregular-clear { + display: flex; + list-style: none; +} + +.b05-route__irregular-clear .b05-route__irregular-btn { + flex: 1; +} + .b05-route__check { display: flex; align-items: center; diff --git a/resources/tester/helper_313_clear_all.cjs b/resources/tester/helper_313_clear_all.cjs new file mode 100644 index 000000000..43801ce9a --- /dev/null +++ b/resources/tester/helper_313_clear_all.cjs @@ -0,0 +1,181 @@ +/* 313 [구조물 전체 삭제] 헬퍼 — 진짜 TS(목록 그리기 · 입력 배선)를 Node 로 돌려 표준출력 JSON 한 벌로. + * 확인창(@ui showConfirmDialog)은 답을 정해 둔 가짜로 막음. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const confirms = []; +let answer = true; +const UI = { + showConfirmDialog: async (message, ok) => { + confirms.push([message, ok]); + return answer; + }, + showToast: () => {}, +}; +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@ui/")) return UI; + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); + if (m.endsWith(".json")) return require(path.join(path.dirname(file), m)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +// DOM 흉내 — 목록이 쓰는 것만 +class Node { + constructor(tag) { + this.tag = tag; + this.children = []; + this.className = ""; + this.textContent = ""; + this.style = { setProperty() {} }; + this.listeners = {}; + this.title = ""; + } + get classList() { + const names = () => this.className.split(" ").filter(Boolean); + return { + add: (c) => (this.className = [...names(), c].join(" ")), + toggle: (c, on) => { + const rest = names().filter((n) => n !== c); + this.className = (on ? [...rest, c] : rest).join(" "); + }, + contains: (c) => names().includes(c), + }; + } + append(...kids) { + this.children.push(...kids); + } + replaceChildren(...kids) { + this.children = [...kids]; + } + addEventListener(type, fn) { + this.listeners[type] = fn; + } + scrollIntoView() {} +} +globalThis.document = { createElement: (tag) => new Node(tag), activeElement: null }; +globalThis.requestAnimationFrame = (fn) => fn(); +const memory = new Map(); +globalThis.window = { + sessionStorage: { + getItem: (k) => memory.get(k) ?? null, + setItem: (k, v) => memory.set(k, String(v)), + removeItem: (k) => memory.delete(k), + }, + requestAnimationFrame: (fn) => fn(), +}; +globalThis.sessionStorage = globalThis.window.sessionStorage; + +const B05 = (f) => load(path.join(ROOT, "B05_Profile", `${f}.ts`)); +const { renderStructureList } = B05("B05_Profile_UI_Structures_List"); +const { bindStructuresEvents } = B05("B05_Profile_UI_Structures_Panel_Events"); + +const typeMap = new Map([ + ["sign", { type_id: "sign", name: "표지", group: "E" }], + ["pipe", { type_id: "pipe", name: "배수관", group: "A" }], +]); +const sign = { structure_id: "s1", type_id: "sign", placement: "point", chainage_m: 40 }; +const pipe = { chainage_m: 80, facility: "pipe", source: "auto" }; +const draw = (structures, pipes, withClear = true) => { + const list = new Node("ul"); + let cleared = 0; + renderStructureList({ + list, + structures, + pipeFacilities: pipes, + typeMap, + intervalM: 20, + editingId: null, + selectedPipeChainage: null, + onSelectStructure: () => {}, + onSelectPipe: () => {}, + ...(withClear ? { onClearAll: () => (cleared += 1) } : {}), + }); + const last = list.children.at(-1); + const button = last?.children?.[0]; + const clear = last?.className === "b05-route__irregular-clear"; + if (clear) button.listeners.click(); + return { + rows: list.children.length, + lastClass: last?.className ?? null, + button: clear + ? { text: button.textContent, className: button.className, type: button.type } + : null, + cleared, + }; +}; +const listCases = { + filled: draw([sign], [pipe]), + empty: draw([], []), + noHandler: draw([sign], [pipe], false), +}; + +// 입력 배선 — 가짜 ctx(누름 듣개는 담기만) +const el = () => new Node("div"); +const makeCtx = (structures, pipes) => { + const calls = { reset: 0, release: 0, removed: [], emit: 0 }; + let pipeList = pipes.map((p) => ({ ...p })); + const ctx = { + groupSelect: el(), + typeSelect: el(), + primary: el(), + removeButton: el(), + resetButton: el(), + positionRow: el(), + memoField: el(), + optionRow: el(), + optionInputs: [], + anchorFields: { station: el(), remainder: el() }, + startFields: { station: el(), remainder: el() }, + endFields: { station: el(), remainder: el() }, + facilityOptions: { root: el() }, + structures, + pipeFacilities: () => pipeList, + projectId: "p", + callbacks: { + onListRelease: () => (calls.release += 1), + onPipeRemove: (at) => { + calls.removed.push(at); + pipeList = pipeList.filter((p) => p.chainage_m !== at); // 하나씩 빼기 길이 목록을 갈아 끼움 + }, + }, + resetForm: () => (calls.reset += 1), + emit: () => (calls.emit += 1), + }; + return { ctx, calls, left: () => structures.length + pipeList.length }; +}; +const runClear = async (structures, pipes, ok) => { + answer = ok; + confirms.length = 0; + const { ctx, calls, left } = makeCtx(structures, pipes); + const { clearAll } = bindStructuresEvents(ctx); + await clearAll(); + return { confirms: [...confirms], calls, left: left() }; +}; + +(async () => { + const pipes = [pipe, { chainage_m: 160.37, facility: "ford_pavement", source: "user" }]; + const result = { + listCases, + confirmed: await runClear([{ ...sign }, { ...sign, structure_id: "s2" }], pipes, true), + cancelled: await runClear([{ ...sign }], pipes, false), + pipesOnly: await runClear([], [pipe], true), + nothing: await runClear([], [], true), + }; + process.stdout.write(JSON.stringify(result)); +})(); diff --git a/resources/tester/test_116_right_click_add.py b/resources/tester/test_116_right_click_add.py index 6bb6acca7..8b42baedd 100644 --- a/resources/tester/test_116_right_click_add.py +++ b/resources/tester/test_116_right_click_add.py @@ -98,7 +98,7 @@ def test_addAt_은_새로_놓기_패널_폄_후_판정(): events.index("function startNew(") : events.index("ctx.groupSelect.addEventListener") ] assert "ctx.resetForm()" in new and "onListRelease" in new - assert "return { startNew }" in events + assert "return { startNew, clearAll }" in events # [구조물 전체 삭제](313) 함께 def test_세_길이_모두_패널_addAt_한_곳(): diff --git a/resources/tester/test_313_clear_all.py b/resources/tester/test_313_clear_all.py new file mode 100644 index 000000000..1ebfb4bb6 --- /dev/null +++ b/resources/tester/test_313_clear_all.py @@ -0,0 +1,93 @@ +"""313 B05 · B06 좌측 구조물 목록 맨 아래 [구조물 전체 삭제] — 목록 있음 · 없음 · 누름 뒤 0 · 취소 · B05 · B06. + +목록 그리기 · 입력 배선은 진짜 TS 를 Node 로 돌려 보고(helper_313), B05 · B06 이 같은 목록 한 벌에 +각자 관 빼기 길을 거는지는 배선 글로 본다. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_313_clear_all.cjs") + + +def _read(*parts: str) -> str: + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", cwd=ROOT + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_목록_있음_맨아래_단추(out): + filled = out["listCases"]["filled"] + assert filled["rows"] == 3 # 구조물 1 + 관 1 + 단추 줄 + assert filled["lastClass"] == "b05-route__irregular-clear" + assert filled["button"] == { + "text": "구조물 전체 삭제", + "className": "b05-route__irregular-btn is-danger", # 좌측 패널 위험 단추 꼴 + "type": "button", + } + assert filled["cleared"] == 1 + + +def test_목록_없음_단추_숨김(out): + empty = out["listCases"]["empty"] + assert empty == { + "rows": 1, + "lastClass": "b05-route__irregular-empty", + "button": None, + "cleared": 0, + } + # 단추를 안 건 목록(다른 쓰임)은 그대로 + assert out["listCases"]["noHandler"]["button"] is None + + +def test_누름_확인_뒤_0(out): + done = out["confirmed"] + assert done["confirms"] == [["구조물 4개를 모두 지울까요?", "모두 삭제"]] + assert done["left"] == 0 + # 하나씩 지우기와 같은 길 — 관은 관 빼기(자동 · 손 배치 모두) · 구조물은 비우고 알림 한 번 + assert done["calls"] == {"reset": 1, "release": 1, "removed": [80, 160.37], "emit": 1} + pipes_only = out["pipesOnly"] + assert pipes_only["left"] == 0 and pipes_only["calls"]["emit"] == 0 + + +def test_취소면_그대로(out): + cancelled = out["cancelled"] + assert cancelled["confirms"] == [["구조물 3개를 모두 지울까요?", "모두 삭제"]] + assert cancelled["left"] == 3 + assert cancelled["calls"] == {"reset": 0, "release": 0, "removed": [], "emit": 0} + assert out["nothing"]["confirms"] == [] # 빈 목록 = 확인창도 안 뜸 + + +def test_B05_B06_같은_목록_각자_관빼기(): + panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") + assert "onClearAll: () => void clearAll()," in panel + assert "const { startNew, clearAll } = bindStructuresEvents(panelContext);" in panel + assert len(panel.splitlines()) <= 700 + # 좌측 「구조물 배치」 폼 · 목록 = 두 보기 한 벌 · 관 빼기 알림은 지금 보기로 + shared = _read("A00_Common", "design", "design_shared.ts") + assert "onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM)," in shared + b06 = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "onPipeRemove: (chainageM) => removePipeAt(chainageM)," in b06 # B06 = 관 초안 캐시 + b05 = _read("A00_Common", "design", "design_view_profile.ts") + assert "onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage)," in b05 + # 공용 확인창 · 저장 길(초안 → [저장])은 안 고침 + events = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Events.ts") + assert 'import { showConfirmDialog } from "@ui/ui_template_elements";' in events + assert "ctx.emit();" in events + + +def test_CSS_단추_줄(): + css = _read("B05_Profile", "B05_Profile_UI_Style.css") + assert ".b05-route__irregular-clear {" in css + assert ".b05-route__irregular-clear .b05-route__irregular-btn {" in css