diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index a0486897e..f94f563b5 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -585,7 +585,7 @@ export async function fetchDetailPipePoints(projectId: string): Promise tuple[DrainageDetail | None, list[PipePoint]]: """세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다. - `points`가 없으면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다. + `points`가 None 이면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다. 있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다. """ directory = drainage_dir(stored_path) - chainages = [point.chainage_m for point in points] if points else None + chainages = [point.chainage_m for point in points] if points is not None else None detail = build_detail(directory, context.vertices, chainages) if detail is None: return None, [] - if points: + if points is not None: _retag(detail.pipes, points) rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes] # 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관 @@ -357,26 +357,30 @@ def _basin_features( async def _resolve( project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool ) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse: - """요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.""" + """요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다. + + 빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312). + """ context, reason = await load_drainage_context(project_id) if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) try: - requested = parse_pipe_points((payload or {}).get("points")) + raw = (payload or {}).get("points") + requested = None if raw is None else parse_pipe_points(raw) except ValueError as error: # 집계 항목(summary_item) 모양이 틀림 return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) signature = route_signature(context.vertices) stored = ( load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None ) - points = requested or stored or None + points = requested if requested is not None else stored result = await asyncio.to_thread(_build, context.stored_path, context, points) detail, resolved = result if detail is None: return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS}) - return context, detail, resolved, bool(stored) and not requested + return context, detail, resolved, requested is None and stored is not None @router.get("/{project_id}/drainage/pipe-points", response_model=None) @@ -395,8 +399,8 @@ async def post_detail_basins( ) -> dict[str, Any] | JSONResponse: """화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). - `points`를 비우고 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 — - 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. + `points` 칸 없이 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 — + 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312). """ resolved = await _resolve(project_id, payload, use_stored=False) if isinstance(resolved, JSONResponse): diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 532bbf907..74f3c0574 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -421,7 +421,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const loadSaved = (): Promise => run(() => { const pending = readPendingPipes(projectId as string); - return pending?.length + return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감 ? computeDetailBasins(projectId as string, pending) : fetchDetailPipePoints(projectId as string); }); 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/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index a874d14e7..640125d02 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -360,7 +360,8 @@ * 값은 theme `--panel-control-h` 한 곳(좌측 패널 공용 양식이 함께 씀 · 269). 기준은 조정창에서 옮겨 온 행의 * 컨트롤 높이(26px)다 — 사용자가 그 크기를 기준으로 정했다. */ .b05-structure-section, -.b05-drainage__facility { +.b05-drainage__facility, +.b05-route__irregular-clear { --b05-control-h: var(--panel-control-h); } @@ -588,7 +589,8 @@ } /* 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄(205-1) — 단추 높이 = 상자 안 작은 단추(`--b05-control-h`) · - * 폼이 길어도 스크롤 영역 아래 끝에 붙어 늘 보임(자리 고정 · 뒤 내용이 비치지 않게 불투명 배경) */ + * 폼이 길어도 스크롤 영역 아래 끝에 붙어 늘 보임(자리 고정 · 뒤 내용이 비치지 않게 불투명 배경) · + * 목록 맨 아래 [구조물 전체 삭제]도 같은 높이(313-2) */ .b05-structure-section .b05-route__irregular-actions { position: sticky; bottom: 0; @@ -596,7 +598,8 @@ background: var(--color-surface-raised); } -.b05-structure-section .b05-route__irregular-actions > button { +.b05-structure-section .b05-route__irregular-actions > button, +.b05-route__irregular-clear > button { box-sizing: border-box; height: var(--b05-control-h); min-height: 0; diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 38b9ddbf6..4af1cb327 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -74,11 +74,11 @@ async function withDraftWalls( setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1) await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2) const drafts = readPendingStructures(projectId); - // ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이 - // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 - // 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인). + // 빈 목록 = 사용자가 다 지움 → 저장분 벽 · 노면 · 맹암거도 걷음(목록 `??` 규칙과 같음 · 312). + // 옛 「놓아도 안 보임」(09-09)은 옛 배열 참조가 `[]` 를 알리던 것 — 지금 `setStructures` 는 + // 배열 안을 채우고(갈아끼우지 않음) 알리지 않음 · 시험 `test_312_empty_pipes` 가 지킴. await withDraftPipeKinds(detail, projectId); - if (!drafts || !drafts.length) return withStructureAreas(detail); + if (!drafts) return withStructureAreas(detail); // 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`) attachUnderdrainSpecs( detail.cross_sections as unknown as Array>, diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 7ae990503..f369dcdc7 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -179,14 +179,14 @@ def build_detail( ) -> DrainageDetail | None: """B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다. - `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 B04의 - 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다. + `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집 · 빈 목록 = 관 0개), None 이면 + B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다. """ routing = read_road_routing(directory) if routing is None or len(vertices) < 2: return None - if confirmed_chainages: + if confirmed_chainages is not None: pipes = pipes_from_chainages(vertices, confirmed_chainages) else: # 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다. 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_312_empty_pipes.py b/resources/tester/test_312_empty_pipes.py new file mode 100644 index 000000000..d41809943 --- /dev/null +++ b/resources/tester/test_312_empty_pipes.py @@ -0,0 +1,175 @@ +"""312 — 관 목록 「빈 목록 = 관 0개」 · 「None = 자동 배치」. + +마지막 관을 지우면 화면이 자동 배치로 다시 차고 새로고침 · [저장] 뒤 저장분 하나가 되살아나던 것. +서버는 검증_sub5 storage **사본** + 읽기 전용 DB 로 부름(원본 · DB 안 바뀜). +""" + +from __future__ import annotations + +import json +import re +import shutil +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +PROJECT_ID = "bec7fe41-83e7-4df7-86a3-fdddaa1f8fec" # OWNERS.md — laptop-sub5 `검증_sub5` +SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID +PIPES_FILE = "B04_PreProcess/drainage/edits/pipe_points.json" +BASE = f"/api/projects/{PROJECT_ID}" + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _copy_project(dest: Path) -> None: + for rel in ("B04_PreProcess/drainage", "B05_Profile"): + shutil.copytree(SOURCE / rel, dest / rel) + for rel in ("B04_PreProcess/models", "B04_PreProcess/processed"): + (dest / rel).mkdir(parents=True, exist_ok=True) + for item in (SOURCE / rel).iterdir(): + if item.is_file() and item.stat().st_size <= 20 * 1024 * 1024: + shutil.copy2(item, dest / rel / item.name) + for item in SOURCE.iterdir(): + if item.is_file(): + shutil.copy2(item, dest / item.name) + + +@pytest.fixture() +def api(tmp_path, monkeypatch): + if not (SOURCE / PIPES_FILE).is_file(): + pytest.skip("배정 storage 프로젝트가 없음") + aiomysql = pytest.importorskip("aiomysql") + from fastapi import FastAPI + from fastapi.testclient import TestClient + + import B04_PreProcess.B04_PreProcess_Router_Basins as basins + import common_util.common_util_storage as storage + import config.config_db as config_db + from config import config_system as cfg + + storage_root = tmp_path / "storage" + root = storage_root / "1" / "3" / PROJECT_ID + _copy_project(root) + monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(storage_root)) + + @asynccontextmanager + async def lifespan(_app): + try: + pool = await aiomysql.create_pool( + host=cfg.DB_HOST, + port=cfg.DB_PORT, + db=cfg.DB_NAME, + user=cfg.DB_USER, + password=cfg.DB_PASSWORD, + minsize=1, + maxsize=4, + autocommit=True, + charset="utf8mb4", + init_command="SET time_zone='+00:00', SESSION tx_read_only=1", + ) + except Exception as exc: # DB 가 안 닿는 곳 + pytest.skip(f"DB 에 안 닿음: {exc}") + config_db.db_pool = pool + try: + yield + finally: + config_db.db_pool = None + pool.close() + await pool.wait_closed() + + app = FastAPI(lifespan=lifespan) + app.include_router(basins.router) + with TestClient(app) as client: + client.root = root # type: ignore[attr-defined] + yield client + + +def _file_points(api) -> list[dict]: + return json.loads((api.root / PIPES_FILE).read_text(encoding="utf-8"))["points"] + + +def _stored(api) -> list[dict]: + got = api.get(f"{BASE}/drainage/pipe-points").json()["pipe_points"] + return [{k: v for k, v in p.items() if k != "lonlat"} for p in got] + + +def test_PUT_빈_목록은_관_0개로_저장(api) -> None: + keep = _stored(api) + assert len(keep) >= 2 + one = api.put(f"{BASE}/drainage/pipe-points", json={"points": keep[:1]}).json() + assert one["saved_count"] == 1 + res = api.put(f"{BASE}/drainage/pipe-points", json={"points": []}).json() + assert res["saved_count"] == 0 and res["pipe_points"] == [] and res["basins"] == [] + assert _file_points(api) == [] + got = api.get(f"{BASE}/drainage/pipe-points").json() + assert got["pipe_points"] == [] and got["saved"] is True # 새로고침 = 0 · 되살아나지 않음 + + +def test_POST_빈_목록은_관_0_유역_0(api) -> None: + res = api.post(f"{BASE}/drainage/detail-basins", json={"points": []}).json() + assert res["pipe_points"] == [] and res["basins"] == [] + assert len(_file_points(api)) > 0 # 계산만 · 저장 안 함 + + +def test_칸_없음_파일_없음_DELETE_노선_바뀜은_자동_배치(api) -> None: + auto = api.post(f"{BASE}/drainage/detail-basins", json={}).json()["pipe_points"] + assert len(auto) > 0 # 요청에 칸 없음 = 자동 배치 + api.put(f"{BASE}/drainage/pipe-points", json={"points": []}) + reset = api.delete(f"{BASE}/drainage/pipe-points").json() + assert len(reset["pipe_points"]) == len(auto) and reset["saved"] is False # [초기화] + assert not (api.root / PIPES_FILE).exists() + got = api.get(f"{BASE}/drainage/pipe-points").json() # 파일 없음 + assert len(got["pipe_points"]) == len(auto) and got["saved"] is False + # 노선 바뀜(지문 다름 · 좌표 없는 옛 저장분) = None → 자동 배치 + (api.root / PIPES_FILE).parent.mkdir(parents=True, exist_ok=True) + (api.root / PIPES_FILE).write_text( + json.dumps({"route_signature": "old", "points": [{"chainage_m": 50.0}]}), + encoding="utf-8", + ) + got = api.get(f"{BASE}/drainage/pipe-points").json() + assert len(got["pipe_points"]) == len(auto) and got["saved"] is False + + +def test_빈_목록과_None_을_가르는_자리() -> None: + src = _read("common_util/common_util_drainage_detail.py") + assert "if confirmed_chainages is not None:" in src + router = _read("B04_PreProcess/B04_PreProcess_Router_Basins.py") + assert "points = requested if requested is not None else stored" in router + assert "requested or stored" not in router + + +def _body(rel: str, start: str, end: str) -> str: + text = _read(rel) + return text[text.index(start) : text.index(end, text.index(start))] + + +def test_화면_초안_빈_목록은_저장분으로_안_넘어감() -> None: + load = _body( + "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", "const loadSaved = ", "const resetPipes" + ) + assert "pending !== null" in load and "pending?.length" not in load + # B06 목록도 같은 규칙(`??`) + b06 = _read("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") + assert "(pendingPipes ?? pipeResponse.pipe_points)" in b06 + + +def test_횡단_벽_초안_빈_목록은_다_지움_그리고_옛_결함_막이() -> None: + store = _body( + "B06_Section/B06_Section_Section_Store.ts", "async function withDraftWalls(", "\n}\n" + ) + assert "if (!drafts) return withStructureAreas(detail);" in store + assert "!drafts.length" not in store + # 옛 「놓아도 안 보임」(09-09) — 옛 배열 참조가 `[]` 를 알리던 것. 정본 주입은 배열 안을 + # 채우고(갈아끼우지 않음) onChange 를 울리지 않아야 함. + inject = _body( + "B05_Profile/B05_Profile_UI_Structures_Panel.ts", " setStructures(next) {", " },\n" + ) + assert "structures.splice(0, structures.length" in inject + assert not re.search(r"\bemit\(|onChange\(", inject) + # 알림은 지금 목록 그대로를 실어 보냄(빈 배열을 따로 만들지 않음) + commit = _read("B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts") + assert "ctx.callbacks.onChange([...ctx.structures]);" in commit diff --git a/resources/tester/test_313_clear_all.py b/resources/tester/test_313_clear_all.py new file mode 100644 index 000000000..4e5285c56 --- /dev/null +++ b/resources/tester/test_313_clear_all.py @@ -0,0 +1,142 @@ +"""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 + + +# ── 313-2 실제 브라우저(정적 쪽 · 서버 불필요) — 위 [삭제] 단추와 같은 높이 · 같은 위험 색 ── + +MARKUP = ( + "
" + "
" + "" + "" + "
" + "
    " + "
  • NO.1배수관
  • " + "
  • " + "
" +) + + +@pytest.mark.parametrize("theme", ["light", "dark"]) +def test_단추_높이_위_삭제와_같음(theme): + from playwright.sync_api import sync_playwright + + css = "".join( + p.read_text("utf-8") + for folder in ("ui_template", "B05_Profile", "B06_Section") + for p in sorted((ROOT / folder).glob("*.css")) + ) + html = ( + f"" + f"{MARKUP}" + ) + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + page = browser.new_page() + page.set_content(html) + out = page.evaluate( + """() => ['top', 'clear'].map((id) => { + const el = document.getElementById(id); + return [el.getBoundingClientRect().height, getComputedStyle(el).color]; + })""" + ) + browser.close() + (top_h, top_color), (clear_h, clear_color) = out + assert top_h == clear_h, out # 두 단추 높이 px 같음(공용 --panel-control-h) + assert clear_color == top_color, out # 위험 색 꼴 그대로 + css_rule = _read("B05_Profile", "B05_Profile_UI_Style_Structures.css") + assert ".b05-route__irregular-clear > button {" in css_rule + assert "px" not in css_rule.split(".b05-route__irregular-clear {")[-1].split("}")[0]