From e40cd1f687ce36bd687b07de8aa51593ff412bd0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:44:03 +0900 Subject: [PATCH] =?UTF-8?q?fix(b05=C2=B7b06):=20=EA=B4=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D=20=3D=20=EA=B4=80=20?= =?UTF-8?q?0=EA=B0=9C=20=E2=80=94=20=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=EA=B4=80=20=EC=A7=80=EC=9B=80=EC=9D=B4=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=EB=92=A4?= =?UTF-8?q?=20=EB=90=98=EC=82=B4=EC=95=84=EB=82=98=EB=8D=98=20=EA=B2=83=20?= =?UTF-8?q?(312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 _resolve · _build · build_detail: 빈 목록과 None 을 가름 — None(칸 없음 · 파일 없음 · 노선 바뀜) = 자동 배치 · [] = 관 0개 · 자동으로 되돌리기는 [초기화](DELETE)만 - B05 배수유역도 초안 읽기: 빈 초안을 저장분으로 안 넘김(B06 ?? 규칙과 같음) - B06 횡단 벽 초안: 빈 목록 = 다 지움(목록과 같은 뜻) · 09-09 옛 결함 까닭(옛 배열 참조가 [] 알림) 막이 시험 - 시험 test_312_empty_pipes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 2 +- .../B04_PreProcess_Router_Basins.py | 22 ++- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 2 +- B06_Section/B06_Section_Section_Store.ts | 8 +- common_util/common_util_drainage_detail.py | 6 +- resources/tester/test_312_empty_pipes.py | 175 ++++++++++++++++++ 6 files changed, 197 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_312_empty_pipes.py 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/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/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