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

This commit is contained in:
2026-10-13 18:40:37 +09:00
7 changed files with 316 additions and 3 deletions
@@ -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);
}
@@ -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,
@@ -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<void>;
} {
/** 새로 놓기(54-32) — 고름 · 측점 · 강조를 풀고 군(· 종류)만 남긴 빈 기본값 폼. */
function startNew(group: string, typeId?: string): void {
@@ -224,6 +226,20 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
}
});
/** [구조물 전체 삭제](313) — 확인 한 번 · 목록 전부(자동 횡단배수 · 손 배치) · 하나씩 지우기와 같은 길
* (관 = 관 빼기 · 구조물 = 정본 배열 비우고 알림) · 캐시에만 · 작업본은 [저장] 때. */
async function clearAll(): Promise<void> {
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)
}
+10
View File
@@ -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;
+181
View File
@@ -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));
})();
+1 -1
View File
@@ -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_한_곳():
+93
View File
@@ -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