From 63dfc9a357d8dae83bf8f813dd1827ae124b486c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:28:48 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20205=20B05=20?= =?UTF-8?q?=C2=B7=20B06=20=EC=A2=8C=EC=B8=A1=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=E2=80=94=20=EB=A7=88=EC=A7=80=EB=A7=89?= =?UTF-8?q?=20=ED=95=98=EB=82=98=20=EC=A7=80=EC=9B=80=EC=9D=B4=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EB=92=A4=20=EB=90=98=EC=82=B4?= =?UTF-8?q?=EC=95=84=EB=82=A8(=EC=A1=B0=EC=82=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index f8c8f5a9a..fde5aeb16 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -233,3 +233,4 @@ 202. (원문 · 10-13) 6. B05/B06의 종단그래프 내부 누가토량의 알약과 폰트 색상을 테마에 맞추되 잘보이게 변경하고 알약을 그래프 영역 내부에서 이동이 되게 반영 203. (원문 · 10-13) 7. b09 페이지의 탭의 상세페이지에서 안내문구 영역이 있음. 영역을 텍스트 3줄정도만 보이게 하고 이후는 스크롤로 처리, 테이블을 더 넓게 사용하도록 조절, 이때 제일 하단행 고정기능이나 행이 적을 경우 합계행이 끌려올라가야하니 조심. 이때 안내문구 영역도 끌려올라가야함. 안내문구 영역은 2줄로 바꿀수도 있으니 참고하여 코드 반영 204. (원문 · 10-13) 8. B09의 좌측페널 데이터 점검 및 공기 옵션 컨티이너의 기본값은 접힘상태. 9. B07 좌측패널의 평면도, 종단도, 횡단도는 접힘상태가 기본값이며 B07 초기 접근시 보여주는 페이지는 표지로 변경 10. B06 좌측 패널의 횡단보기 설정 기본값 접힘. 11. B05 좌측 패널의 컨테이너 이름을 계획노선 컨테이너의 기본값은 접힘상태로 변경 12. exp_sub1_device 브랜치 삭제 +205. (원문 · 10-13) 다음은 B05와 B06의 좌측 공용 패널의 내용 중 하단의 구조물 리스트에서 구조물을 하나씩 삭제하면서 리프레시 해봤는데 하나만 남을때까지는 문제없어 보이는데 마지막을 구조물을 삭제한 직후 구조물 리스트가 초기화 되고 리프레시 하면 마지막 하나가 남아있는 상황이야. 확인해줘. 뭔가 저장에 대한 제어가 문제가 있는것같아. 주고 자동생성된 횡단배수 구조물로 봐서는 배수유역에 대한 자동계산이나 영구저장소 문제로 판단됨. From 8cf68188b7443f819b540f1b92bb34c972310889 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:32:50 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20206=20B05=20?= =?UTF-8?q?=C2=B7=20B06=20=EC=A2=8C=EC=B8=A1=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EB=A7=A8=20=EC=95=84=EB=9E=98=20[?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=84=EC=B2=B4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C]=20=C2=B7=20=EB=B9=84=EB=A9=B4=20=EC=88=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index fde5aeb16..ec582a16a 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -234,3 +234,4 @@ 203. (원문 · 10-13) 7. b09 페이지의 탭의 상세페이지에서 안내문구 영역이 있음. 영역을 텍스트 3줄정도만 보이게 하고 이후는 스크롤로 처리, 테이블을 더 넓게 사용하도록 조절, 이때 제일 하단행 고정기능이나 행이 적을 경우 합계행이 끌려올라가야하니 조심. 이때 안내문구 영역도 끌려올라가야함. 안내문구 영역은 2줄로 바꿀수도 있으니 참고하여 코드 반영 204. (원문 · 10-13) 8. B09의 좌측페널 데이터 점검 및 공기 옵션 컨티이너의 기본값은 접힘상태. 9. B07 좌측패널의 평면도, 종단도, 횡단도는 접힘상태가 기본값이며 B07 초기 접근시 보여주는 페이지는 표지로 변경 10. B06 좌측 패널의 횡단보기 설정 기본값 접힘. 11. B05 좌측 패널의 컨테이너 이름을 계획노선 컨테이너의 기본값은 접힘상태로 변경 12. exp_sub1_device 브랜치 삭제 205. (원문 · 10-13) 다음은 B05와 B06의 좌측 공용 패널의 내용 중 하단의 구조물 리스트에서 구조물을 하나씩 삭제하면서 리프레시 해봤는데 하나만 남을때까지는 문제없어 보이는데 마지막을 구조물을 삭제한 직후 구조물 리스트가 초기화 되고 리프레시 하면 마지막 하나가 남아있는 상황이야. 확인해줘. 뭔가 저장에 대한 제어가 문제가 있는것같아. 주고 자동생성된 횡단배수 구조물로 봐서는 배수유역에 대한 자동계산이나 영구저장소 문제로 판단됨. +206. (원문 · 10-13) 1. 조사하는 동안 구조물리스트 제일 하단에 구조물 전체삭제 버튼을 넣어줘. 하나도 없으면 당연히 숨김처리되어야함. 2. 직전 깃 요청이 깃 싱크였어. 전역 깃 싱크였어? From e12c4d6c283d383648ed3914605a7c301fd6986e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:35:54 +0900 Subject: [PATCH 3/7] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=20AO=20=E2=80=94=20312=20=EA=B4=80(=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=ED=9A=A1=EB=8B=A8=EB=B0=B0=EC=88=98)=20=EB=B9=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=E3=80=8C=EC=97=86=EC=9D=8C?= =?UTF-8?q?=E3=80=8D=EC=9C=BC=EB=A1=9C=20=EC=9D=BD=ED=98=80=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=20=ED=95=98=EB=82=98=EA=B0=80=20=EB=90=98?= =?UTF-8?q?=EC=82=B4=EC=95=84=EB=82=A8(=EC=9B=90=EC=9D=B8=20=EB=84=B7=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EC=B9=A0=20=EC=9E=90=EB=A6=AC=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- .../요구_1010_2/조사_AO_구조물목록_빈목록.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/raw/요구_1010_2/조사_AO_구조물목록_빈목록.md diff --git a/docs/raw/요구_1010_2/조사_AO_구조물목록_빈목록.md b/docs/raw/요구_1010_2/조사_AO_구조물목록_빈목록.md new file mode 100644 index 000000000..f5ca82d1a --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AO_구조물목록_빈목록.md @@ -0,0 +1,72 @@ +# 312 분석 — B05 · B06 좌측 구조물 목록: 마지막 하나 지움이 새로고침 뒤 되살아남 + +sub5 · 조사만 · 코드 고치지 않음 · 2026-10-13 + +## 한 줄 결론 + +관(자동 생성 횡단배수 = `pipe_points.json`) 목록에서 **「빈 목록」이 세 군데에서 「값 없음」으로 읽힘** — 화면 초안 읽기 · 서버 계산 · 서버 저장. 그래서 마지막 관을 지우면 화면은 자동 배치로 다시 차고(「초기화」), 새로고침 · [저장] 뒤에는 저장돼 있던 마지막 하나가 되살아남. 손으로 놓은 구조물(`structures.json`)은 빈 목록을 바르게 다룸 — 흠 없음. + +## 재현 + +방법 — 검증_sub5(`bec7fe41-…`) storage **사본**(임시 폴더) + DB 읽기 전용 세션으로 서버 API 를 화면 차례대로 부름(기존 `test_b05_b06_save_roundtrip.py` 와 같은 틀). 화면(ORCA)은 띄우지 않음 — 화면 쪽은 코드 길을 따라 확인. + +시작 상태 — 저장된 관 11개(전부 자동 배치 `spacing`·`stream`) · 구조물 0개. + +| 단계 | 화면에서 하는 일 | 서버 요청 | 결과 | +|---|---|---|---| +| P1 | 하나씩 지우고 [저장] → 2개 | PUT pipe-points [2개] | 저장 2 · 파일 2 ✔ | +| P2 | 하나 더 지우고 [저장] → 1개 | PUT [1개] | 저장 1 · 파일 1 ✔ | +| P3 | **마지막 하나 지움** | POST detail-basins `[]` (화면 다시 나눔) | **응답 관 11개 = 자동 배치** → 목록 「초기화」 | +| P4 | 새로고침([저장] 안 함) | 초안 `[]` 를 「없음」으로 봄 → GET pipe-points | **1개(85.05) 되살아남** | +| P5 | [저장] | PUT `[]` | **저장 1 · 파일 1 그대로** (빈 목록 안 써짐) | +| P6 | (파일이 `points: []` 라면) | GET | **자동 배치 11개 · saved=false** | +| S1~S3 | 손으로 놓은 구조물 1개 → 지우고 [저장] → 새로고침 | PUT structures `[]` → GET | 0개 ✔ (정상) | + +화면 길별 결과(코드 추적): + +| 화면 | [저장] 안 함 · 새로고침 | [저장] 함 | +|---|---|---| +| B05 (배수유역도 · 좌측 목록) | 지운 직후 자동 11개로 참(P3) · 새로고침 → 저장분 1개(P4) | 관 초안 `[]` PUT → 서버가 저장분 1개 유지(P5) · 이어서 `side.savePipes()` 가 **화면 목록(자동 11개)을 PUT** → 자동 11개가 정본에 저장됨 | +| B06 (구조물 목록) | B06 목록은 `??` 라 빈 목록 유지 · 그러나 초안 쓰기 뒤 `side.reload()` → 배수유역도가 저장분 1개를 다시 읽어 B05 쪽 목록 · 종단 알약에 1개 | 관 초안 `[]` PUT → 1개 유지 · `side.savePipes()` 가 배수유역도 목록(1개) PUT → 1개 되살아남 | + +## 원인 — 「빈 목록 = 없음」 자리 + +1. **화면 초안 읽기** — `B05_Profile/B05_Profile_UI_Drainage_Panel.ts:424` `return pending?.length ? computeDetailBasins(...) : fetchDetailPipePoints(...)` — 초안 `[]` 를 「초안 없음」으로 보고 저장분을 읽음. 같은 초안을 B06 은 `readPendingPipes(projectId) ?? 저장분`(`B06_Section_UI_Page_Structures_Panel.ts:388`)으로 읽어 두 보기가 갈림. +2. **서버 관 목록 고르기** — `B04_PreProcess/B04_PreProcess_Router_Basins.py:373` `points = requested or stored or None` — 요청 `[]` 는 저장분으로, 저장분 `[]` 는 `None`(자동 배치)으로 넘어감. PUT 도 이 줄을 거쳐 **빈 목록이 절대 저장되지 않음**(P5). +3. **서버 계산** — 같은 파일 `:70` `chainages = [...] if points else None` · `common_util/common_util_drainage_detail.py:189` `if confirmed_chainages:` — 빈 확정 목록을 「기본 관 + 간격 보충(자동 배치)」으로 바꿈(P3 · P6). 사용자가 말한 「배수유역 자동 계산이 다시 돎」이 이 자리. +4. 덤 — `A00_Common/design/design_save.ts` 의 관 차례는 초안 내보내기 뒤 `side.savePipes()`(`design_shared.ts:301`)로 **배수유역도 화면 목록을 한 번 더 PUT** — 1~3 때문에 화면 목록이 자동 11개 · 저장분 1개로 바뀐 뒤라 그 값이 정본이 됨. + +정상인 자리(빈 목록을 바르게 다룸): 세션 캐시 `readState`(`[]` 그대로) · `flushPendingPipes`(`if (!pending)` — `[]` 도 보냄) · 구조물 PUT · GET(`structures.json` `[]` 저장 · 읽기) · B05 계획선 · 횡단 측점의 관 읽기(`B05_Profile_Engine_Sections.py:175` — `[]` 그대로). `initial_snapshot` · [초기화](DELETE pipe-points)는 이 흐름과 무관. + +곁 흠(같은 꼴 · 표시만) — `B06_Section/B06_Section_Section_Store.ts:81` `if (!drafts || !drafts.length)` — 구조물 초안 `[]` 를 「초안 없음」으로 봐 저장 전 횡단도에 저장분 벽(C군)이 남음. [저장] 뒤에는 정본이 `[]` 라 풀림. + +## 고칠 자리 제안 (한 벌 · ③ 서버 단독 + 화면 한 줄) + +뜻 정함 — **`None`(파일 없음 · 노선 바뀜 · 요청에 칸 없음) = 자동 배치 · `[]` = 사용자가 다 지움(관 0개)**. 자동 배치로 되돌리기는 [초기화](DELETE)만. + +1. 서버 `B04_PreProcess_Router_Basins.py` `_resolve` — 요청 본문에 `points` 칸이 있으면(빈 목록 포함) 그 값 · 없으면 `None`. `points = requested if requested is not None else stored`. 응답 `saved` 판정(`bool(stored) and not requested`)도 `is None` 으로. +2. 서버 같은 파일 `_build:70 · :74` — `if points is not None` 로. +3. 서버 `common_util_drainage_detail.py:189` `build_detail` — `if confirmed_chainages is not None:` (빈 목록이면 관 0 · 유역 0 — 아래 `if not pipes: return detail` 가 이미 받음). +4. 화면 `B05_Profile_UI_Drainage_Panel.ts:424` — `pending !== null` 로(B06 `??` 와 같은 규칙). +5. (선택) 곁 흠 `B06_Section_Section_Store.ts:81` — 「`[]` 도 초안 없음」 주석(2026-09-09)이 막던 결함(저장분 벽을 통째로 지움)을 다시 보고 `drafts === null` 로 바꿀지 판단 필요 — 그 결함의 까닭이 「빈 초안」이 아니라 다른 곳이었는지 먼저 확인. + +짝 파일 — 없음(관 목록 고르기는 서버 단독 · 화면은 한 줄). `load_pipe_points` 는 이미 `[]` 와 `None` 을 가름 — 고칠 것 없음. + +영향: +- B04 배수유역 편집(`B04_PreProcess_UI_Basins.ts:334` 다시 나눔 · `:479` 저장)도 관을 다 지우면 이제 0개로 남음(지금은 자동 배치로 다시 참) — 같은 뜻으로 맞춰짐. +- 관 0개 정본 → B05 계획선 정착 · 구조물 측점 · B06 암거 횡단 · B08 수량에서 관이 빠짐 — 사용자가 다 지운 뜻 그대로. +- 문서 줄 고침 — `computeDetailBasins` 머리 주석(`B04_PreProcess_Api_Fetch.ts:586~588`) · `post_detail_basins` 독스트링 「points 를 비우면 자동 배치」. + +시험 거리(`resources/tester/test_312_empty_pipes.py` 가칭 · 위 재현과 같은 사본 틀): +- PUT `[]` → 응답 0 · 파일 `points: []` · GET 0 · `saved=true`. +- POST detail-basins `[]` → 관 0 · 유역 0. +- 파일 없음 → GET 자동 배치(지금대로) · DELETE 뒤 자동 배치(지금대로). +- 노선 지문 바뀌고 좌표 없는 저장분 → 자동 배치(지금대로 · `None` 길). +- 화면 소스 — `loadSaved` 가 `pending !== null` 로 고르는지(기존 `test_298_add_reanalyze.py` 문자열 시험 그대로 통과). + +## 되돌림 확인 + +- 실제 프로젝트 파일 손 안 댐 — 재현은 임시 사본에서 · 끝나고 사본 지움. +- `pipe_points.json` sha1 `503f60d6…` · `structures.json` sha1 `a12ee824…` — 재현 앞뒤 같음. +- DB — 읽기 전용 세션(`tx_read_only=1`) · 하류 무효화 함수는 가짜로 갈음 · 쓰기 없음. +- 화면 탭 · 서버 재시작 없음 · git 작업 트리 깨끗(커밋 없음). From 3de9ca043aa9301c1a69c2552db935375ea099ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:38:50 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(b05,b06):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=20=EB=A7=A8=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=20[=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=82=AD=EC=A0=9C]=20=E2=80=94=20=EB=B9=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=88=A8=EA=B9=80=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=ED=99=95=EC=9D=B8=EC=B0=BD=20=ED=95=9C=20=EB=B2=88?= =?UTF-8?q?=20=C2=B7=20=ED=95=98=EB=82=98=EC=94=A9=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=EC=99=80=20=EA=B0=99=EC=9D=80=20=EA=B8=B8=20(313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록 그리기(renderStructureList)가 맨 아래에 위험 단추 줄 — 목록이 비면 그리지 않음(다시 생기면 보임) - 확인 「구조물 N개를 모두 지울까요?」(showConfirmDialog) · 취소 = 그대로 - 관(자동 횡단배수 · 손 배치) = 보기마다 관 빼기 길(B05 배수유역도 · B06 관 초안) · 구조물 = 정본 배열 비우고 알림 한 번 → 캐시 · [저장] 때 작업본 - 저장 길(초안 읽기 · [저장] · 서버 · 자동 배치)은 안 고침(312 조사 몫) - 시험: test_313_clear_all 새로 · test_116 기대값(return { startNew, clearAll }) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- B05_Profile/B05_Profile_UI_Structures_List.ts | 12 ++ .../B05_Profile_UI_Structures_Panel.ts | 3 +- .../B05_Profile_UI_Structures_Panel_Events.ts | 18 +- B05_Profile/B05_Profile_UI_Style.css | 10 + resources/tester/helper_313_clear_all.cjs | 181 ++++++++++++++++++ resources/tester/test_116_right_click_add.py | 2 +- resources/tester/test_313_clear_all.py | 93 +++++++++ 7 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 resources/tester/helper_313_clear_all.cjs create mode 100644 resources/tester/test_313_clear_all.py 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 From e40cd1f687ce36bd687b07de8aa51593ff412bd0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:44:03 +0900 Subject: [PATCH 5/7] =?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 From ec6689ecfdd50eca2fb9a063a100f5f7a4493bc7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:47:13 +0900 Subject: [PATCH 6/7] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20207=20[?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=84=EC=B2=B4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C]=20=EB=8B=A8=EC=B6=94=20=EB=86=92=EC=9D=B4=20=3D=20?= =?UTF-8?q?=EC=9C=84=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=A0=84=EC=97=AD=20=EA=B9=83=20?= =?UTF-8?q?=EC=8B=B1=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index ec582a16a..ec8abb2aa 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -235,3 +235,4 @@ 204. (원문 · 10-13) 8. B09의 좌측페널 데이터 점검 및 공기 옵션 컨티이너의 기본값은 접힘상태. 9. B07 좌측패널의 평면도, 종단도, 횡단도는 접힘상태가 기본값이며 B07 초기 접근시 보여주는 페이지는 표지로 변경 10. B06 좌측 패널의 횡단보기 설정 기본값 접힘. 11. B05 좌측 패널의 컨테이너 이름을 계획노선 컨테이너의 기본값은 접힘상태로 변경 12. exp_sub1_device 브랜치 삭제 205. (원문 · 10-13) 다음은 B05와 B06의 좌측 공용 패널의 내용 중 하단의 구조물 리스트에서 구조물을 하나씩 삭제하면서 리프레시 해봤는데 하나만 남을때까지는 문제없어 보이는데 마지막을 구조물을 삭제한 직후 구조물 리스트가 초기화 되고 리프레시 하면 마지막 하나가 남아있는 상황이야. 확인해줘. 뭔가 저장에 대한 제어가 문제가 있는것같아. 주고 자동생성된 횡단배수 구조물로 봐서는 배수유역에 대한 자동계산이나 영구저장소 문제로 판단됨. 206. (원문 · 10-13) 1. 조사하는 동안 구조물리스트 제일 하단에 구조물 전체삭제 버튼을 넣어줘. 하나도 없으면 당연히 숨김처리되어야함. 2. 직전 깃 요청이 깃 싱크였어. 전역 깃 싱크였어? +207. (원문 · 10-13) 구조물 전체 삭제 버튼이 상단 구조물 버튼의 높이와 다름. 변경하고 다시 전역 깃 싱크 From f3c843655166616ab9959f7c93283788c6bbb6e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:50:11 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix(b05,b06):=20[=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A0=84=EC=B2=B4=20=EC=82=AD=EC=A0=9C]=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EB=86=92=EC=9D=B4=20=3D=20=EC=9C=84=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=8B=A8=EC=B6=94=EC=99=80=20?= =?UTF-8?q?=EA=B0=99=EA=B2=8C=20=E2=80=94=20=EA=B3=B5=EC=9A=A9=20--panel-c?= =?UTF-8?q?ontrol-h=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EB=B3=B4=ED=83=AC=20(313-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위 [추가] · [삭제] · [리셋]만 205-1 높이 규칙(--b05-control-h)을 받고 목록 맨 아래 단추는 빠져 34px · 위 26px - 같은 규칙 · 같은 변수에 `.b05-route__irregular-clear` 보탬(px 안 박음) · 위험 색 그대로 - 시험: test_313_clear_all 실제 브라우저 밝음 · 어두움 높이 같음(옛 26 · 34) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B05_Profile_UI_Style_Structures.css | 9 ++-- resources/tester/test_313_clear_all.py | 49 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) 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/resources/tester/test_313_clear_all.py b/resources/tester/test_313_clear_all.py index 1ebfb4bb6..4e5285c56 100644 --- a/resources/tester/test_313_clear_all.py +++ b/resources/tester/test_313_clear_all.py @@ -91,3 +91,52 @@ 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]