Merge remote-tracking branch 'origin/CODEX' into CODEX
This commit is contained in:
@@ -585,7 +585,7 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
|
||||
|
||||
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||||
*
|
||||
* `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */
|
||||
* 빈 목록 = 관 0개(312) · 기본 관 + 자동 보충으로 되돌리기는 `resetDetailPipePoints`(DELETE)만. */
|
||||
export async function computeDetailBasins(
|
||||
projectId: string,
|
||||
points: DetailPipeInput[],
|
||||
|
||||
@@ -62,16 +62,16 @@ def _build(
|
||||
) -> 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):
|
||||
|
||||
@@ -421,7 +421,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
const loadSaved = (): Promise<void> =>
|
||||
run(() => {
|
||||
const pending = readPendingPipes(projectId as string);
|
||||
return pending?.length
|
||||
return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감
|
||||
? computeDetailBasins(projectId as string, pending)
|
||||
: fetchDetailPipePoints(projectId as string);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<string, unknown>>,
|
||||
|
||||
@@ -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:
|
||||
# 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다.
|
||||
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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_한_곳():
|
||||
|
||||
@@ -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
|
||||
@@ -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 = (
|
||||
"<div style='width:320px'>"
|
||||
"<section class='b05-route__panel-section b05-structure-section ui-collapsible"
|
||||
" ui-sidebar-section ui-panel-form'><div class='b05-route__irregular-actions'>"
|
||||
"<button class='b05-route__irregular-btn is-primary'>추가</button>"
|
||||
"<button class='b05-route__irregular-btn is-danger' id='top'>삭제</button>"
|
||||
"<button class='b05-route__irregular-btn'>리셋</button></div></section>"
|
||||
"<div class='b05-route__dock ui-sidebar-actions'><ul class='b05-route__irregular-list'>"
|
||||
"<li class='b05-route__irregular-item'><strong>NO.1</strong><span>배수관</span></li>"
|
||||
"<li class='b05-route__irregular-clear'><button type='button'"
|
||||
" class='b05-route__irregular-btn is-danger' id='clear'>구조물 전체 삭제</button></li>"
|
||||
"</ul></div></div>"
|
||||
)
|
||||
|
||||
|
||||
@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"<!doctype html><html data-theme='{theme}'><head><style>{css}</style></head>"
|
||||
f"<body>{MARKUP}</body></html>"
|
||||
)
|
||||
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]
|
||||
Reference in New Issue
Block a user