From 864114ee2bf2dd71f9a902cfa0945e02e00802e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 17:59:32 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20203=20B09=20?= =?UTF-8?q?=ED=83=AD=20=EC=83=81=EC=84=B8=20=EC=95=88=EB=82=B4=20=EA=B8=80?= =?UTF-8?q?=20=EC=98=81=EC=97=AD=203=EC=A4=84=20+=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=C2=B7=20=ED=91=9C=20=EB=84=93=EA=B2=8C=20=C2=B7=20?= =?UTF-8?q?=ED=95=A9=EA=B3=84=20=EC=A4=84=20=C2=B7=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EA=B8=80=20=EB=81=8C=EB=A0=A4=20=EC=98=AC=EB=9D=BC=EC=98=B4?= 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 b97c3018e..062a451e4 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -231,3 +231,4 @@ 200. (원문 · 10-13) 1. B06 횡단도의 내부의 구조물 그림 수정 및 선택 하이라이트 개선 - 맹암거의 내부 형상에 의해 다른 면적 선택이 안됨. 우선순위 재확인하고 내부 형상이 돌 모양이 교차한 형상으로 변경 - 물넘이 포장과 배관이 인접하여 포장이 배수관 횡단도로 연동되어 넘어간경우 노면의 콘크리트를 선택하면 배관의 노면 콘크리트 선택까진는 좋음. 하지만 배관 형상까지 하이라이트 됨. - 노면공과 물넘이 포장의 경우 종단 측점 세로선, 알약, 좌측패널 횡단배수 측점과 영역을 선택하면 컨크리트 구조물까지 선택됨. 이건 이전에 선택이 어려울때 남은 잔재임. 구조물 형상선택에 따른 좌측 패널 하위컨테이너 활성화는 별개로 볼것. 다른 구조물들과 동일한 로직이 됨. (대신 수정이 복잡하게 엮여있으니 정밀히 분석 후 반영) 2. 깃처리 테스트라는 브랜치는 없음 각 워크트리에는 하나의 브랜치 존재. 일단 sub1까지 일단 전역 싱크하자. sub1이 더 수정했으니 포함하여 싱크 3. 측점이 존재하지 않는 구조물의 경우에는 하이라이트할 횡단도는 없으니 횡단 하이라이트 이동은 안되게(기 요청사항이었음. 로직 확인) 201. (원문 · 10-13) 4. B09의 팝업기능으로 연결정보를 보는 복수 모달창에서 esc키를 누르면 전체 모달창이 닫게하고 최종모달창을 기준으로 다른 곳을 누를 경우에도 닫히게 반영 5. 계획서는 내가 지웠음 202. (원문 · 10-13) 6. B05/B06의 종단그래프 내부 누가토량의 알약과 폰트 색상을 테마에 맞추되 잘보이게 변경하고 알약을 그래프 영역 내부에서 이동이 되게 반영 +203. (원문 · 10-13) 7. b09 페이지의 탭의 상세페이지에서 안내문구 영역이 있음. 영역을 텍스트 3줄정도만 보이게 하고 이후는 스크롤로 처리, 테이블을 더 넓게 사용하도록 조절, 이때 제일 하단행 고정기능이나 행이 적을 경우 합계행이 끌려올라가야하니 조심. 이때 안내문구 영역도 끌려올라가야함. 안내문구 영역은 2줄로 바꿀수도 있으니 참고하여 코드 반영 From 4ecf51c223ed13c7c2b34014a4975a0659c91e4f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:01:49 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(b09):=20=EC=97=B0=EA=B2=B0=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=8C=9D=EC=97=85=20Esc=20=3D=20=EB=AA=A8=EB=91=90?= =?UTF-8?q?=20=EB=8B=AB=ED=9E=98=20=C2=B7=20=EB=A7=A8=20=EC=9C=84=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EB=B0=96=20=EB=88=84=EB=A6=84(=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=ED=8C=9D=EC=97=85=20=C2=B7=20=EB=B9=88=20=EA=B3=B3?= =?UTF-8?q?=20=C2=B7=20=ED=86=A1=20=C2=B7=20=EC=9A=B0=ED=81=B4=EB=A6=AD)?= =?UTF-8?q?=20=3D=20=EB=AA=A8=EB=91=90=20=EB=8B=AB=ED=9E=98=20=C2=B7=20?= =?UTF-8?q?=EA=B7=B8=20=EB=88=84=EB=A6=84=EC=9D=80=20=EC=95=84=EB=9E=98=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EC=95=88=20=EB=84=98=EA=B9=80=20?= =?UTF-8?q?=C2=B7=20=EB=A7=A8=20=EC=9C=84=20=ED=8C=9D=EC=97=85=20=EC=95=88?= =?UTF-8?q?=20=EB=88=84=EB=A6=84=20=C2=B7=20=EB=81=8C=EA=B8=B0=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20(308-1)?= 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_01QfHKQs7J3hQELKMWQMUJEy --- B09_Estimation/B09_Estimation_UI_Popup.ts | 29 +++++++++++-- resources/tester/test_303_b09_row_popup.py | 50 +++++++++++++++++++--- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Popup.ts b/B09_Estimation/B09_Estimation_UI_Popup.ts index d20b4ad27..eb45a731f 100644 --- a/B09_Estimation/B09_Estimation_UI_Popup.ts +++ b/B09_Estimation/B09_Estimation_UI_Popup.ts @@ -5,7 +5,8 @@ * 창 = 열 제목(머리 여러 줄 그대로) + 강조 범위 줄 전체 · 표 짜기 = `…_UI_Builders`(화면과 같은 옵션) * 강조 범위 = 설계내역서 그 줄 + 자손 · 묶음 탭 호표 구간 · 그 밖 한 줄(하이퍼링크 강조와 같음) * 자리 = 누른 줄 아래(모자라면 위 · 화면 안) · 너비 = 표 너비(최대 화면 90%) · 넘치면 창 안 스크롤 - * 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · Esc · × = 맨 위 하나 · 탭 바꿈 · 주소 바뀜 = 모두 + * 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · × = 그 창부터 위로 · 탭 바꿈 · 주소 바뀜 = 모두 + * Esc · 맨 위 창 밖 누름(아래 창 · 빈 곳 · 톡) = 모두(308) — 그 누름은 닫기만(아래 화면에 안 넘김) * 머리 [탭으로] = 하이퍼링크처럼 옮김(모두 닫음) · 창 안 = 보기 전용(고칠 칸 · 메뉴 없음) * ========================================================================== */ @@ -67,6 +68,8 @@ export function popupPlace( /** 열린 창(아래 → 위) */ const stack: HTMLElement[] = []; let listening = false; +/** 바깥 누름으로 닫은 뒤 따라오는 click · 우클릭 메뉴를 먹음(308) */ +let eat = false; /** from 번째 창부터 위로 모두 닫음(0 = 모두) */ export function closePopups(from = 0): void { @@ -78,17 +81,37 @@ export const popupCount = (): number => stack.length; function listen(): void { if (listening) return; listening = true; - // Esc = 맨 위 하나(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔) + // Esc = 모두(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔) window.addEventListener( "keydown", (event) => { if (event.key !== "Escape" || !stack.length) return; event.preventDefault(); event.stopPropagation(); - closePopups(stack.length - 1); + closePopups(); }, true, ); + // 맨 위 창 밖 누름 = 모두 닫음 · 그 누름은 아래로 안 넘김(308) + window.addEventListener( + "pointerdown", + (event) => { + eat = !!stack.length && !stack[stack.length - 1].contains(event.target as Node); + if (!eat) return; + event.preventDefault(); + event.stopPropagation(); + closePopups(); + }, + true, + ); + const swallow = (event: Event): void => { + if (!eat) return; + eat = false; + event.preventDefault(); + event.stopPropagation(); + }; + window.addEventListener("click", swallow, true); + window.addEventListener("contextmenu", swallow, true); window.addEventListener("hashchange", () => closePopups()); } diff --git a/resources/tester/test_303_b09_row_popup.py b/resources/tester/test_303_b09_row_popup.py index a1bfc3fa6..2012e4161 100644 --- a/resources/tester/test_303_b09_row_popup.py +++ b/resources/tester/test_303_b09_row_popup.py @@ -165,15 +165,25 @@ E.prototype.getBoundingClientRect = function () { return this.rect ?? { left: 0, top: 0, right: 0, bottom: 0, width: 400, height: 300 }; }; document.body = new E("body"); -const winKeys = []; -const hashFns = []; +const winFns = { keydown: [], hashchange: [], pointerdown: [], click: [], contextmenu: [] }; +const winKeys = winFns.keydown; +const hashFns = winFns.hashchange; globalThis.window = { innerWidth: 1200, innerHeight: 800, - addEventListener: (t, f) => (t === "keydown" ? winKeys : hashFns).push(f) }; + addEventListener: (t, f) => winFns[t].push(f) }; const esc = () => { const ev = { key: "Escape", stopped: false, preventDefault() {}, stopPropagation() { ev.stopped = true; } }; winKeys.forEach((f) => f(ev)); return ev.stopped; }; +// 308 창 밖 누름 — pointerdown → click(또는 우클릭 메뉴) · 먹힘 = stopped +const press = (target, type = "click") => { + const ev = (t) => ({ type: t, target, stopped: false, preventDefault() {}, stopPropagation() { this.stopped = true; } }); + const down = ev("pointerdown"); + winFns.pointerdown.forEach((f) => f(down)); + const up = ev(type); + winFns[type].forEach((f) => f(up)); + return [down.stopped, up.stopped]; +}; const tables = []; const createDataTable = (o) => { const root = document.createElement("div"); @@ -259,16 +269,16 @@ def test_이어진_창_겹침_Esc_차례(): "\nconst b = boxes();" "\nconst dg = rows(2);" "\nconst e1 = esc(); const c = boxes();" - "\nconst e2 = esc(); const e3 = esc(); const e4 = esc();" - "\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2, e3, e4]," + "\nconst e2 = esc();" + "\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2]," " d: boxes(), gone }));" ) assert out["a"] == ["bill"] assert out["b"] == ["bill", "labor", "dg"] # 겹쳐 위에(뒤 DOM = 위) assert out["dg"] == ["D1", "D1|1", "D1|합계"] # 호표 구간 assert out["top2"] == "362px" # 그 줄(창 안 2번째 줄) 곁 - assert out["c"] == ["bill", "labor"] # Esc = 맨 위 하나 - assert out["e"] == [True, True, True, False] # 창 없으면 Esc 안 가로챔 + assert out["c"] == [] # 308 Esc = 모두 + assert out["e"] == [True, False] # 창 없으면 Esc 안 가로챔 assert out["d"] == [] and out["gone"] == [] @@ -295,6 +305,32 @@ def test_닫기_탭으로_새_누름_주소(): assert out["e"] == [[], 2] +def test_308_맨_위_창_밖_누름_모두_닫힘_누름_먹음(): + out = _popup( + "\nconst outside = document.createElement('td'); document.body.append(outside);" + "\nconst open3 = () => { openRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });" + " tables[tables.length - 1].onCellClick(1, 'name');" + " tables[tables.length - 1].onCellClick(0, 'name'); };" + "\nconst pops = () => boxes().filter(Boolean);" + "\nconst top = () => document.body.children[document.body.children.length - 1];" + "\nconst under = () => document.body.children.filter((b) => b.dataset.b09Popup)[1];" + "\nopen3(); const inTop = press(top().children[1]); const a = pops();" # 맨 위 창 안 = 그대로 + "\nconst right0 = press(top().children[0], 'contextmenu'); const a2 = pops();" + "\nconst low = press(under().children[1]); const b = pops();" # 아래 창 누름 + "\nopen3(); const blank = press(outside); const c = pops();" # 쪽 빈 곳 · 톡 + "\nopen3(); const right = press(outside, 'contextmenu'); const d = pops();" # 우클릭 + "\nconst none = press(outside);" # 창 없음 = 안 가로챔 + "\nconsole.log(JSON.stringify({ inTop, a, right0, a2, low, b, blank, c, right, d, none, gone }));" + ) + full = ["bill", "labor", "dg"] + assert out["inTop"] == [False, False] and out["a"] == full # 맨 위 창 안 = 그대로 + assert out["right0"] == [False, False] and out["a2"] == full + assert out["low"] == [True, True] and out["b"] == [] # 모두 닫힘 · 그 누름 먹음 + assert out["blank"] == [True, True] and out["c"] == [] + assert out["right"] == [True, True] and out["d"] == [] + assert out["none"] == [False, False] and out["gone"] == [] + + def test_창_자리_화면_안(): out = _popup( "\nconst v = { width: 1200, height: 800 };" From 17c785596a7cf79d052744fcd251f1b1f30a1ec3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:03:51 +0900 Subject: [PATCH 3/8] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20204=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=A0=91=ED=9E=98(B09=20=C2=B7=20B07=20=C2=B7=20B06=20=C2=B7?= =?UTF-8?q?=20B05)=20=C2=B7=20B07=20=EC=B2=AB=20=EC=AA=BD=20=ED=91=9C?= =?UTF-8?q?=EC=A7=80=20=C2=B7=20=EC=8B=A4=ED=97=98=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=EC=A7=80=EC=9B=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 062a451e4..f8c8f5a9a 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -232,3 +232,4 @@ 201. (원문 · 10-13) 4. B09의 팝업기능으로 연결정보를 보는 복수 모달창에서 esc키를 누르면 전체 모달창이 닫게하고 최종모달창을 기준으로 다른 곳을 누를 경우에도 닫히게 반영 5. 계획서는 내가 지웠음 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 브랜치 삭제 From 0a8008ce0930f5fd6f3bb76576e6f08c26c3dadc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:07:53 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(B06):=20306=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EB=A7=B9=EC=95=94=EA=B1=B0=20=EC=95=88=20=EC=97=87=EA=B0=88?= =?UTF-8?q?=EB=A0=A4=20=EA=B2=B9=EC=B9=9C=20=EB=8F=8C=20=EB=AC=B4=EB=8A=AC?= =?UTF-8?q?=20=C2=B7=20=EA=B7=B8=EB=A6=BC=20=EC=A0=84=EC=9A=A9=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=A8=EB=A1=80=20=3D=20?= =?UTF-8?q?=EC=95=88=EC=AA=BD=20=ED=8C=90=EC=A0=95=EB=A9=B4=20=EB=A7=A8=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=20=C2=B7=20=ED=85=8C=EB=91=90=EB=A6=AC=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EB=A9=B4=20=ED=8C=90=EC=A0=95=20=EB=A7=A8?= =?UTF-8?q?=20=EC=9C=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 돌 무늬: 켜마다 반 칸 엇갈림 · 이웃 · 위아래와 겹침 · 기울기 ±22° 번갈아 교차 · 맹암거 색(카드 · [상세] 모달 같은 그림) - 맹암거 무리 pointer-events none — 성토 띠 · 포장 · 넓은 관 판정면이 맹암거 안에서도 골라짐 - 차례: 보라 선 > 맹암거 테두리 > 구조물 판정면(작은 것 위) > 그림 · 띠 > 맹암거 안쪽 - 시험 test_306_underdrain_pick Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- .../B06_Section_UI_Cross_Pick_Order.ts | 32 ++- .../B06_Section_UI_Cross_Underdrain.ts | 29 ++- .../B06_Section_UI_Style_Cross_Areas.css | 20 +- .../tester/helper_306_underdrain_pick.cjs | 246 ++++++++++++++++++ resources/tester/test_306_underdrain_pick.py | 106 ++++++++ 5 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 resources/tester/helper_306_underdrain_pick.cjs create mode 100644 resources/tester/test_306_underdrain_pick.py diff --git a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts index e3fd89830..63febf573 100644 --- a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts +++ b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts @@ -5,12 +5,19 @@ * 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) · * 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로. * 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님. + * 맹암거(306-2) = 안쪽 판정면은 맨 아래(겹친 띠 · 구조물이 위) · 테두리 판정(투명 굵은 선)이 면 판정 맨 위. * ========================================================================== */ +import { thickenDown } from "./B06_Section_UI_Cross_Shape_Pick"; + /** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */ const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; /** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */ const LINE_HIT_CLASS = "b06-chart__design-hit"; +/** 맹암거 몸(그림) · 테두리 판정 클래스(306-2). */ +const UNDERDRAIN_GROUP = "b06-chart__underdrain"; +const UNDERDRAIN_BODY = "b06-chart__underdrain-body"; +const EDGE_CLASS = "b06-chart__shape-hit--edge"; /** 끌기로 볼 움직임(px) — 형상 고름(`SHAPE_DRAG_PX`)과 같은 값. */ const DRAG_PX = 3; @@ -33,13 +40,36 @@ export function pointsArea(points: string | null): number { /** 판정면을 넓이 차례로 다시 세우고 보라 선 판정을 맨 위로 · 끌기 누름을 막음. */ export function stackPickTargets(layer: SVGElement): void { const kids = [...layer.children]; + // 맹암거 판정면 = 형상 고름이 몸 둘레(`thickenDown`)로 꽂은 것 + const bodies = new Set( + kids + .filter((el) => el.classList.contains(UNDERDRAIN_GROUP)) + .flatMap((group) => [...group.children]) + .filter((el) => el.classList.contains(UNDERDRAIN_BODY)) + .map((el) => thickenDown(el.getAttribute("points") ?? "")), + ); + const inners = kids.filter( + (el) => + el.classList.contains("b06-chart__shape-hit") && bodies.has(el.getAttribute("points") ?? ""), + ); + const edges = inners.map((inner) => { + layer.prepend(inner); + const edge = inner.cloneNode() as SVGElement; + edge.classList.add(EDGE_CLASS); + edge.addEventListener("click", (event) => { + event.stopPropagation(); + inner.dispatchEvent(new MouseEvent("click")); // 고름은 안쪽 판정면 창구 하나 + }); + return edge; + }); const surfaces = kids .filter((el) => SURFACE_HIT_CLASSES.some((name) => el.classList.contains(name))) + .filter((el) => !inners.includes(el)) .map((el) => ({ el, area: pointsArea(el.getAttribute("points")) })) .sort((a, b) => b.area - a.area) .map(({ el }) => el); const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS)); - layer.append(...surfaces, ...lines); + layer.append(...surfaces, ...edges, ...lines); let down: { x: number; y: number } | null = null; layer.addEventListener( "pointerdown", diff --git a/B06_Section/B06_Section_UI_Cross_Underdrain.ts b/B06_Section/B06_Section_UI_Cross_Underdrain.ts index 162887718..743c564ef 100644 --- a/B06_Section/B06_Section_UI_Cross_Underdrain.ts +++ b/B06_Section/B06_Section_UI_Cross_Underdrain.ts @@ -5,17 +5,39 @@ * · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음. * · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도. * · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남. - * · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(흙막이 돌 그리기 `drawStones`). + * · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 · + * 기울기를 번갈아 서로 교차 · 그림 전용(누름 안 받음 · CSS `.b06-chart__underdrain`). * 높이가 비면 그리지 않음(수치를 지어내지 않음). * ========================================================================== */ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; -import { drawStones } from "./B06_Section_UI_Cross_Wall_Hatch"; +import { cell, courses, type BandFrame } from "./B06_Section_UI_Cross_Wall_Hatch"; import type { UnderdrainSpec } from "@util/common_util_structure_underdrain"; const SVG_NS = "http://www.w3.org/2000/svg"; let clipSeq = 0; +/** 돌 한 칸(m · 보기용) · 이웃과 겹침 배수 · 번갈아 기울기(도). */ +const STONE_M = 0.25; +const STONE_OVERLAP = 1.4; +const STONE_TILT_DEG = 22; + +/** 엇갈려 겹친 돌 무늬(306-1) — 켜(가로줄)마다 반 칸 밀고 · 돌은 칸보다 커서 이웃 · 위아래와 겹침 · + * 기울기를 번갈아 서로 교차. `frame` v = −0.5(노면) ~ 0.5(옵셋선). */ +export function drawCrossedStones(group: SVGElement, frame: BandFrame, heightM: number): void { + const rows = courses(heightM, STONE_M); + const count = courses(frame.spanM, STONE_M); + const across = (heightM / rows) * STONE_OVERLAP; + const along = (frame.spanM / count) * STONE_OVERLAP; + for (let r = 0; r < rows; r += 1) { + const shift = r % 2 ? 0 : 0.5; + for (let i = 0; i + shift <= count; i += 1) { + const tilt = (i + r) % 2 ? STONE_TILT_DEG : -STONE_TILT_DEG; + const lean = { ...frame, leanDegrees: frame.leanDegrees + tilt }; + group.append(cell(lean, (i + shift) / count, (r + 0.5) / rows - 0.5, across, along, 0.45)); + } + } +} type Edge = { offset_m: number; elevation_m: number }; @@ -120,7 +142,7 @@ export function appendUnderdrainOverlay( }; const [ax, ay] = px(at(0, 0)); const [bx, by] = px(at(1, 0)); - drawStones( + drawCrossedStones( stones, { spanM: Math.hypot(hi - lo, grade * (hi - lo)), @@ -128,7 +150,6 @@ export function appendUnderdrainOverlay( point: (u, v) => px(at(u, v)), leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI, }, - false, height, ); group.append(body, clip, stones); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 434e10a85..0fc23a43b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -267,7 +267,25 @@ stroke-width: 1; } -/* 맹암거(288-1): 노면 아래 돌 무늬 형상 */ +/* 맹암거(288-1): 노면 아래 돌 무늬 형상 · 그림 전용 — 누름은 판정면(306-2) */ +.b06-chart__underdrain { + pointer-events: none; +} + +/* 엇갈려 겹친 돌(306-1) — 맹암거 색 · 밝음 · 어두움 모두 읽힘 */ +.b06-chart__underdrain .b06-chart__culvert-stone { + stroke: #3aa0a8; + opacity: 0.75; +} + +/* 맹암거 테두리 판정(306-2) — 면 판정 맨 위 · 안쪽 판정면은 맨 아래 */ +.b06-chart__shape-hit.b06-chart__shape-hit--edge { + fill: none; + stroke: transparent; + stroke-width: 8; + pointer-events: stroke; +} + .b06-chart__underdrain-body { fill: color-mix(in srgb, #3aa0a8 16%, transparent); stroke: #3aa0a8; diff --git a/resources/tester/helper_306_underdrain_pick.cjs b/resources/tester/helper_306_underdrain_pick.cjs new file mode 100644 index 000000000..5b281e5d7 --- /dev/null +++ b/resources/tester/helper_306_underdrain_pick.cjs @@ -0,0 +1,246 @@ +/* PLAN 306 단위검증 헬퍼 — 맹암거 돌 무늬(306-1) · 고르기 차례(306-2). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_306_underdrain_pick.py`. + */ +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 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("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); + 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, name = tag) { + this.tag = tag; + this.name = name; + this.attrs = {}; + this.children = []; + this.listeners = []; + const self = this; + this.classList = { + contains: (c) => (self.attrs.class ?? "").split(/\s+/).includes(c), + add: (c) => (self.attrs.class = `${self.attrs.class ?? ""} ${c}`.trim()), + }; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + hasAttribute(k) { + return k in this.attrs; + } + /** `[속성]` 꼴만 — 형상 고름이 쓰는 것. */ + querySelectorAll(sel) { + const attr = /^\[([\w-]+)\]$/.exec(sel)[1]; + return this.children.flatMap((k) => [ + ...(k.hasAttribute(attr) ? [k] : []), + ...k.querySelectorAll(sel), + ]); + } + append(...kids) { + kids.forEach((k) => { + const at = this.children.indexOf(k); + if (at >= 0) this.children.splice(at, 1); + this.children.push(k); + }); + } + prepend(...kids) { + kids.forEach((k) => { + const at = this.children.indexOf(k); + if (at >= 0) this.children.splice(at, 1); + }); + this.children.unshift(...kids); + } + insertBefore(k, ref) { + const at = this.children.indexOf(k); + if (at >= 0) this.children.splice(at, 1); + const to = ref ? this.children.indexOf(ref) : -1; + this.children.splice(to >= 0 ? to : this.children.length, 0, k); + } + cloneNode() { + const copy = new Node(this.tag, `${this.name}+edge`); + copy.attrs = { ...this.attrs }; + return copy; + } + addEventListener(type, fn) { + this.listeners.push({ type, fn }); + } + dispatchEvent(event) { + this.listeners.filter((l) => l.type === event.type).forEach((l) => l.fn(event)); + return true; + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; +globalThis.MouseEvent = class { + constructor(type) { + this.type = type; + this.clientX = 0; + this.clientY = 0; + } + stopPropagation() {} +}; +const clickEvent = () => ({ type: "click", clientX: 0, clientY: 0, stopPropagation() {} }); + +const draw = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Underdrain.ts")); +const shapePick = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")); +const order = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Pick_Order.ts")); +const out = {}; + +/* ── 306-1 돌 무늬 ────────────────────────────────────────────────────── */ +// 288 시험과 같은 단면 — 좌 절토 · 우 성토 · 노면 6 m · 암거 높이 1 m · 1 m = 20 px +const design = { + road_edges: { + left: { offset_m: 3, elevation_m: 100.15 }, + right: { offset_m: -3, elevation_m: 99.85 }, + }, + design_line: [ + { offset_m: 6, elevation_m: 102.9 }, + { offset_m: 3, elevation_m: 100.15 }, + { offset_m: -3, elevation_m: 99.85 }, + { offset_m: -7, elevation_m: 97.2 }, + ], +}; +const PPM = 20; +const X = (o) => 300 - o * PPM; +const Y = (e) => 2100 - e * PPM; +const svg = new Node("g", "layer"); +const group = draw.appendUnderdrainOverlay( + svg, + { structure_id: "ud-1", chainage_m: 40, height_m: 1, slope_pct: 5 }, + design, + X, + Y, +); +const stones = group.children.find((k) => k.tag === "g").children; +const rotate = (s) => /rotate\(([-\d.]+) ([-\d.]+) ([-\d.]+)\)/.exec(s.attrs.transform); +out.stones = stones.map((s) => { + const [, deg, cx, cy] = rotate(s).map(Number); + return { + cls: s.attrs.class, + deg, + cx, + cy, + w: Number(s.attrs.width), + h: Number(s.attrs.height), + }; +}); +out.groupClass = group.attrs.class; + +/* ── 306-2 고르기 차례 ────────────────────────────────────────────────── */ +const poly = (name, cls, pts) => { + const n = new Node("polygon", name); + n.setAttribute("class", cls); + n.setAttribute("points", pts.map((p) => p.join(",")).join(" ")); + return n; +}; +const box = (x0, y0, x1, y1) => [ + [x0, y0], + [x1, y0], + [x1, y1], + [x0, y1], +]; +// 꽂힌 옛 차례(카드와 같음) — 지면선 · 면적 띠 · 포장 · 맹암거 무리 · 관 판정면 · 보라 선 +const layer = new Node("g", "plot"); +const band = new Node("g", "band"); +band.setAttribute("class", "b06-chart__area b06-chart__area--fill"); +band.append(poly("bandPoly", "", box(300, 100, 460, 160))); +const body = group.children.find((k) => k.attrs.class === "b06-chart__underdrain-body"); +const bodyPts = body.attrs.points.split(" ").map((p) => p.split(",").map(Number)); +layer.append( + band, + group, + poly("bigPipe", "b06-chart__culvert-revet-hit", box(100, 111, 600, 117)), // 맹암거(≈2600 px²)보다 넓음 + poly("smallPipe", "b06-chart__culvert-revet-hit", box(340, 110, 346, 114)), +); +const picked = []; +shapePick.attachShapePick(layer, (id) => picked.push(id)); +layer.children.find((k) => k.classList.contains("b06-chart__shape-hit")).name = "underdrain"; +const line = new Node("polyline", "road"); +line.setAttribute("class", "b06-chart__design-hit"); +line.setAttribute("points", `${X(3)},${Y(100.15)} ${X(-3)},${Y(99.85)}`); +layer.append(line); +// 옛 차례(306 전) — 형상 판정면이 넓이 순으로 면 판정 사이에 섬 +const SURF = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; +const area = (n) => order.pointsArea(n.attrs.points ?? null); +const oldKids = [...layer.children]; +const oldOrder = [ + ...oldKids.filter((k) => !SURF.some((c) => k.classList.contains(c)) && k !== line), + ...oldKids + .filter((k) => SURF.some((c) => k.classList.contains(c))) + .sort((a, b) => area(b) - area(a)), + line, +]; +order.stackPickTargets(layer); +out.order = layer.children.map((k) => k.name); + +/* 누른 자리 → 맨 위에서 받는 것(흉내) — 그림 전용(맹암거 무리)은 안 받음 */ +const inside = (pts, [x, y]) => + pts.reduce((inn, [ax, ay], i) => { + const [bx, by] = pts[(i + 1) % pts.length]; + return ay > y !== by > y && x < ax + ((y - ay) * (bx - ax)) / (by - ay) ? !inn : inn; + }, false); +const segGap = ([px, py], [ax, ay], [bx, by]) => { + const dx = bx - ax; + const dy = by - ay; + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy || 1))); + return Math.hypot(px - ax - t * dx, py - ay - t * dy); +}; +const ptsOf = (n) => n.attrs.points.split(" ").map((p) => p.split(",").map(Number)); +const ringGap = (pts, p) => Math.min(...pts.map((a, i) => segGap(p, a, pts[(i + 1) % pts.length]))); +function receiver(kids, p) { + for (const k of [...kids].reverse()) { + if (k.classList.contains("b06-chart__underdrain")) continue; // pointer-events: none + if (k.classList.contains("b06-chart__design-hit")) { + const pts = ptsOf(k); + if (segGap(p, pts[0], pts[1]) <= 5) return k.name; + } else if (k.classList.contains("b06-chart__shape-hit--edge")) { + if (ringGap(ptsOf(k), p) <= 4) return k.name; + } else if (k.classList.contains("b06-chart__area")) { + if (k.children.some((c) => inside(ptsOf(c), p))) return k.name; + } else if (k.attrs.points && inside(ptsOf(k), p)) return k.name; + } + return null; +} +// 맹암거 몸 = (240,97) (360,103) (392.7,124.6) (263.1,118.2) · 띠 = x 300 부터 · 큰 관 = y 111~117 +const PROBES = { + bandInUnderdrain: [330, 107], // 띠 · 맹암거 겹침(관 밖) + pipeInUnderdrain: [330, 114], // 큰 관 · 띠 · 맹암거 겹침 + smallPipe: [343, 112], + underdrainOnly: [280, 107], // 맹암거만 + border: [320, 121], // 아랫변 위 · 띠 안 + road: [300, 100], // 노면 = 맹암거 윗변 · 보라 선 +}; +out.probesNew = Object.fromEntries( + Object.entries(PROBES).map(([k, p]) => [k, receiver(layer.children, p)]), +); +out.probesOld = Object.fromEntries( + Object.entries(PROBES).map(([k, p]) => [k, receiver(oldOrder, p)]), +); +// 테두리 누름 = 안쪽 판정면 창구 → 맹암거 id +const edge = layer.children.find((k) => k.classList.contains("b06-chart__shape-hit--edge")); +edge.listeners.filter((l) => l.type === "click").forEach((l) => l.fn(clickEvent())); +out.picked = picked; +out.body = bodyPts; +out.edgeCount = layer.children.filter((k) => + k.classList.contains("b06-chart__shape-hit--edge"), +).length; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_306_underdrain_pick.py b/resources/tester/test_306_underdrain_pick.py new file mode 100644 index 000000000..bea0ba96a --- /dev/null +++ b/resources/tester/test_306_underdrain_pick.py @@ -0,0 +1,106 @@ +"""PLAN 306 — B06 횡단 맹암거 안 돌 무늬 · 고르기 차례(요구 200-1). + +- 306-1 돌 무늬 = 켜마다 반 칸 엇갈리고 이웃 · 위아래와 겹친 돌 · 기울기 번갈아 교차 · 그림 전용(누름 안 받음) +- 306-2 고르기 차례 = 맹암거 안쪽 판정면 맨 아래(겹친 띠 · 관이 위) · 테두리 판정 = 면 판정 맨 위 · + 보라 선 맨 위 그대로 · 다른 구조물 차례(작은 것 위) 그대로 + +실제 TS 를 `helper_306_underdrain_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from collections import defaultdict + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_306_underdrain_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _rows(out: dict) -> list[list[dict]]: + """돌을 가로(따라) · 노면 아래 깊이(켜) 좌표로 — 깊이 같은 것끼리 한 켜.""" + (ax, ay), (bx, by) = out["body"][0], out["body"][1] + rows: dict[float, list[dict]] = defaultdict(list) + for s in out["stones"]: + depth = s["cy"] - (ay + (s["cx"] - ax) * (by - ay) / (bx - ax)) + rows[round(depth, 1)].append({**s, "along": s["cx"]}) + return [sorted(rows[k], key=lambda s: s["along"]) for k in sorted(rows)] + + +def test_돌_무늬_엇갈려_겹침_306_1(out): + assert out["groupClass"] == "b06-chart__underdrain" + assert out["stones"] and all(s["cls"] == "b06-chart__culvert-stone" for s in out["stones"]) + rows = _rows(out) + assert len(rows) == 4 # 높이 1 m ÷ 0.25 + step = rows[0][1]["along"] - rows[0][0]["along"] + gap = abs(rows[1][0]["along"] - rows[0][0]["along"]) % step + assert abs(gap - step / 2) < 1e-6 # 이웃 켜는 반 칸 엇갈림 + for row in rows: + # 돌 길이(따라) > 간격 · 돌 높이(가로질러) > 켜 간격 → 이웃 · 위아래와 겹침 + assert all(s["h"] > step * 1.3 for s in row) + degs = [s["deg"] for s in row] + assert all(a != b for a, b in zip(degs, degs[1:])) # 기울기 번갈아 → 교차 + row_gap = 20 / len(rows) # 암거 높이 1 m = 20 px + assert all(s["w"] > row_gap for s in out["stones"]) + assert len({s["deg"] for s in out["stones"]}) == 2 + + +def test_돌_무늬는_누름_안_받음_306_1(): + with open(os.path.join(B06, "B06_Section_UI_Style_Cross_Areas.css"), encoding="utf-8") as f: + css = f.read() + assert re.search(r"\.b06-chart__underdrain \{\s*pointer-events: none;", css) + assert re.search(r"\.b06-chart__shape-hit--edge \{[^}]*pointer-events: stroke;", css) + + +def test_고르기_차례_306_2(out): + # 안쪽 판정면 맨 아래 · 구조물 판정면 넓이 차례(작은 관 위) 그대로 · 테두리 = 면 판정 맨 위 · 보라 선 맨 위 + assert out["order"] == [ + "underdrain", + "band", + "g", + "bigPipe", + "smallPipe", + "underdrain+edge", + "road", + ] + assert out["edgeCount"] == 1 + + +def test_누른_자리_받는_것_306_2(out): + old, new = out["probesOld"], out["probesNew"] + # 옛 차례 — 맹암거 안 성토 띠 · 맹암거보다 넓은 관이 막힘 + assert old["bandInUnderdrain"] == "underdrain" and old["pipeInUnderdrain"] == "underdrain" + assert new == { + "bandInUnderdrain": "band", + "pipeInUnderdrain": "bigPipe", + "smallPipe": "smallPipe", + "underdrainOnly": "underdrain", + "border": "underdrain+edge", + "road": "road", + } + + +def test_테두리_누름은_맹암거_고름_306_2(out): + assert out["picked"] == ["ud-1"] From 0d3bb1154a6ea764c79cbb210cc0ca913bc7cddc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:14:45 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(panel):=20311-1=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EA=B8=B0=EB=B3=B8=20=EC=A0=91=ED=9E=98?= =?UTF-8?q?=20=E2=80=94=20B09=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=20=C2=B7=20=EA=B3=B5=EA=B8=B0=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?/=20B06=20=ED=9A=A1=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20/=20B05=20=EA=B3=84=ED=9A=8D=EB=85=B8=EC=84=A0=20(?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20204)?= 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_013QyNXY2TUKXCFMwAca2APG --- B05_Profile/B05_Profile_UI_Panel_Sections.ts | 2 +- B06_Section/B06_Section_UI_Page_Panel.ts | 2 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 2 +- B09_Estimation/draft/B09_Draft_Schedule_Panel.ts | 2 +- resources/tester/test_203_1_b09_data_check.py | 4 +++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts index b676383b6..2fc33bfd4 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Sections.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -31,7 +31,7 @@ registerPanelSection({ views: ["profile"], order: 30, build: (ctx, view) => { - const frame = createSectionFrame(view, "계획노선"); + const frame = createSectionFrame(view, "계획노선", true); // 311 기본 접힘 frame.body.append(part(ctx, "route").plannedRouteButton); return frame.root; }, diff --git a/B06_Section/B06_Section_UI_Page_Panel.ts b/B06_Section/B06_Section_UI_Page_Panel.ts index ffbc9a703..111b1ca73 100644 --- a/B06_Section/B06_Section_UI_Page_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Panel.ts @@ -50,7 +50,7 @@ registerPanelSection({ views: ["cross"], order: 20, build: (ctx, view) => { - const frame = createSectionFrame(view, L("B06_View_Title")); + const frame = createSectionFrame(view, L("B06_View_Title"), true); // 311 기본 접힘 frame.body.append(part(ctx, "crossView").row); return frame.root; }, diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 81dc5157f..68495bd37 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -332,7 +332,7 @@ export function renderB09Estimation(root: HTMLElement): void { }); reset.dataset.b09BillReset = ""; // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) - const section = createPanelCard("데이터 점검"); + const section = createPanelCard("데이터 점검", true); // 311 기본 접힘 section.append(refresh, check.list); // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][저장][내역 초기화] const actions = createDock([save, reset], () => isGroupTab(current) && show(current)); diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts index 9bd64b379..e68bc0e6a 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts +++ b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts @@ -160,7 +160,7 @@ export function createSchedulePanel(user: () => Row, setUser: (next: Row) => voi holidayCaption.textContent = "공휴일 넣기(그 밖 법정공휴일은 늘 넣음)"; holidayBox.append(holidayCaption, ...checks); - const section = createPanelCard("공기 옵션"); + const section = createPanelCard("공기 옵션", true); // 311 기본 접힘 section.dataset.b09SchedulePanel = ""; section.style.display = "none"; // 원천(서버 공기 자료)이 오면 보임 · hidden 은 공용 카드 flex 가 덮음 section.append( diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py index 804896861..5e63d693e 100644 --- a/resources/tester/test_203_1_b09_data_check.py +++ b/resources/tester/test_203_1_b09_data_check.py @@ -21,7 +21,9 @@ SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") def test_묶음_이름_단추_글_설명_없음(): - assert 'createPanelCard("데이터 점검")' in SHELL and "원가 단가" not in SHELL # 269 공용 카드 + assert ( + 'createPanelCard("데이터 점검", true)' in SHELL and "원가 단가" not in SHELL + ) # 269 공용 카드 assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL # 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검 assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL From 608b6c80d98c78c090a950137145955bef5fc510 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:14:45 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(b07):=20311-2=20=ED=8F=89=EB=A9=B4?= =?UTF-8?q?=EB=8F=84=20=C2=B7=20=EC=A2=85=EB=8B=A8=EB=8F=84=20=C2=B7=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=EB=8F=84=20=EA=B8=B0=EB=B3=B8=20=EC=A0=91?= =?UTF-8?q?=ED=9E=98=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=84=B8=EC=9B=8C?= =?UTF-8?q?=EB=8F=84=20=EC=A0=91=ED=9E=98=20=EC=9D=B4=EC=96=B4=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=B2=AB=20=EC=9E=A5=20=ED=91=9C=EC=A7=80?= =?UTF-8?q?(=EA=B8=B0=EC=96=B5=ED=95=9C=20=EC=9E=A5=20=EC=9E=88=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=20=EC=9E=A5)=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20(=EC=9A=94=EA=B5=AC=20204)?= 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_013QyNXY2TUKXCFMwAca2APG --- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 10 +- .../B07_DesignDetail_UI_Panels.ts | 25 +- .../B07_DesignDetail_UI_PlanKinds.ts | 3 +- resources/tester/helper_311_panel_folded.cjs | 256 ++++++++++++++++++ resources/tester/test_269_panel_form.py | 6 +- resources/tester/test_311_panel_folded.py | 101 +++++++ 6 files changed, 393 insertions(+), 8 deletions(-) create mode 100644 resources/tester/helper_311_panel_folded.cjs create mode 100644 resources/tester/test_311_panel_folded.py diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 4ee7d0a34..368f5c4a3 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -25,6 +25,7 @@ import { buildCrossLayoutControls, buildDesignInfoPanel, buildDrawingSidePanel, + foldedGroups, isCrossSheet, } from "./B07_DesignDetail_UI_Panels"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -91,6 +92,8 @@ let reopenCrossSheet: number | undefined; export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + // 311 다시 세우면 옛 목록 접힘 그대로 — 기다리기 전에 읽음(처음 들어오면 B07 목록이 없어 기본값) + const folded = foldedGroups(root); let workflowState: WorkflowState | undefined; let drawings: DesignDrawingItem[] = []; let drawingError: string | undefined; @@ -565,6 +568,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } : undefined, crossLayoutSlot, + folded, ); drawingListEl = drawingPanel; const confirmActions = document.createElement("div"); @@ -608,7 +612,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { layout.root.classList.add("b07-design-layout"); root.replaceChildren(layout.root); - // 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지. + // 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지. // CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다. if (drawings.length > 0) { const crossSheets = drawings.filter(isCrossSheet); @@ -618,7 +622,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const reopen = drawings.findIndex((item) => item.id === reopenId); reopenDrawingId = undefined; reopenCrossSheet = undefined; - const first = Math.max(reopen, 0); + // 311 기억한 장이 없으면 표지 + const cover = drawings.findIndex((item) => item.kind === "cover"); + const first = reopen >= 0 ? reopen : Math.max(cover, 0); void openTab(drawings[first]).then(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. void prefetchAllDrawings(); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index d19228411..a9eb48d9f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -39,6 +39,19 @@ export const DRAWING_GROUPS: readonly { { label: "토적도(유토곡선)", kind: "mass_haul" }, ]; +/** 311 기본 접힘 묶음 — 그 밖 묶음(토적도 등)은 기본 펼침. */ +const FOLDED_KINDS = new Set(["plan", "longitudinal", "cross"]); + +/** 311 지금 목록의 묶음 접힘(kind → 접힘) — 화면을 다시 세울 때 이어 받음 · B07 목록이 없으면 빈 것. */ +export function foldedGroups(root: ParentNode): Map { + return new Map( + [...root.querySelectorAll(".b07-drawing-group")].map((section) => [ + section.dataset.kind ?? "", + section.classList.contains("is-collapsed"), + ]), + ); +} + /** B06 확정 산출물 기반 도면 목록 패널. */ export function buildDrawingSidePanel( drawings: DesignDrawingItem[], @@ -48,7 +61,10 @@ export function buildDrawingSidePanel( plan?: PlanManageControls, /** 횡단도 묶음 맨 아래에 붙일 자리(153-1 장 배치 칸) — 내용은 화면 조립이 도면을 열 때마다 채움. */ crossTools?: HTMLElement, + /** 311 다시 세울 때 옛 접힘(`foldedGroups`) — 없는 묶음은 기본값. */ + folded = new Map(), ): HTMLDivElement { + const isFolded = (kind: string): boolean => folded.get(kind) ?? FOLDED_KINDS.has(kind); const panel = document.createElement("div"); panel.className = "b07-drawing-list"; const heading = document.createElement("div"); @@ -95,7 +111,12 @@ export function buildDrawingSidePanel( // 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다. const plans = drawings.filter((item) => item.kind === "plan"); panel.append( - buildPlanSection(plans, drawingButton, plan ? () => openPlanManager(plan) : undefined), + buildPlanSection( + plans, + drawingButton, + plan ? () => openPlanManager(plan) : undefined, + isFolded("plan"), + ), ); continue; } @@ -133,7 +154,7 @@ export function buildDrawingSidePanel( continue; } // 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식). - const section = createPanelCard(`${group.label} ${items.length}`); + const section = createPanelCard(`${group.label} ${items.length}`, isFolded(group.kind ?? "")); section.classList.add("b07-drawing-group"); if (group.kind) section.dataset.kind = group.kind; if (cross) { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 3d04ef741..d1f10c410 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -257,9 +257,10 @@ export function buildPlanSection( items: DesignDrawingItem[], makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, onManage?: () => void, + collapsed = false, ): HTMLElement { // 컨테이너 = 좌측 패널 공용 카드(269) - const section = createPanelCard(`평면도 ${items.length}`); + const section = createPanelCard(`평면도 ${items.length}`, collapsed); section.classList.add("b07-drawing-group", "b07-plan"); section.dataset.kind = "plan"; diff --git a/resources/tester/helper_311_panel_folded.cjs b/resources/tester/helper_311_panel_folded.cjs new file mode 100644 index 000000000..4b997e9bb --- /dev/null +++ b/resources/tester/helper_311_panel_folded.cjs @@ -0,0 +1,256 @@ +/* 311 좌측 패널 기본 접힘 헬퍼 — B05 「계획노선」 · B06 「횡단 보기 설정」(한 벌 패널 `design_panel`)과 + * B07 도면 목록(평면도 · 종단도 · 횡단도)을 가짜 DOM 위에 세워 첫 그림 접힘 · 편 뒤 다시 그림 유지를 본다. + * 결과는 JSON 한 벌. 꼴은 `helper_design_view_parity.cjs` 와 같음. + */ +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(); + +// ── 화면 그릇 흉내 — 요소 · 클래스 · 속성 · 글자 · 자식 ────────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = tag; + this.children = []; + this.parent = null; + this.dataset = {}; + this.style = {}; + this.attrs = {}; + this._text = ""; + this._classes = new Set(); + this.hidden = false; + this.disabled = false; + this.title = ""; + this.listeners = {}; + const self = this; + this.classList = { + add: (...n) => n.forEach((x) => self._classes.add(x)), + remove: (...n) => n.forEach((x) => self._classes.delete(x)), + toggle: (n, f) => { + const on = f === undefined ? !self._classes.has(n) : !!f; + on ? self._classes.add(n) : self._classes.delete(n); + return on; + }, + contains: (n) => self._classes.has(n), + }; + } + set className(v) { + this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(v) { + this._text = String(v); + this.children.forEach((c) => (c.parent = null)); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + _adopt(kid) { + const node = + typeof kid === "string" ? Object.assign(new FakeNode("#text"), { _text: kid }) : kid; + if (node.parent) node.parent.children = node.parent.children.filter((c) => c !== node); + node.parent = this; + return node; + } + append(...kids) { + kids.forEach((k) => this.children.push(this._adopt(k))); + } + prepend(...kids) { + this.children.unshift(...kids.map((k) => this._adopt(k))); + } + insertBefore(node, ref) { + const adopted = this._adopt(node); + const at = this.children.indexOf(ref); + if (at < 0) this.children.push(adopted); + else this.children.splice(at, 0, adopted); + } + replaceChildren(...kids) { + this.children = []; + this.append(...kids); + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + removeAttribute(k) { + delete this.attrs[k]; + if (k === "title") this.title = ""; + } + addEventListener(type, handler) { + (this.listeners[type] ||= []).push(handler); + } + click() { + if (this.disabled) return; + (this.listeners.click || []).forEach((h) => h({ target: this })); + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } + querySelectorAll(selector) { + const cls = selector.slice(1); + const out = []; + const walk = (n) => + n.children.forEach((k) => { + if (k.classList && k.classList.contains(cls)) out.push(k); + walk(k); + }); + walk(this); + return out; + } +} +// 공용 attachCollapsible 이 쓰는 closest(`.클래스` 하나) +FakeNode.prototype.closest = function (selector) { + for (let n = this; n; n = n.parent) + if (n.classList && n.classList.contains(selector.slice(1))) return n; + return null; +}; +globalThis.document = { createElement: (tag) => new FakeNode(tag) }; +/** 제목 누름 — 패널 뿌리에 걸린 공용 위임 손잡이로 올려 보냄(가짜 DOM 은 거품이 없음). */ +const press = (root, section) => { + const target = section.querySelector(".ui-collapsible__title"); + (root.listeners.click || []).forEach((h) => h({ target })); +}; + +const node = (tag = "div") => new FakeNode(tag); +const STUBS = { + "@ui/ui_template_locale": { + ui_locales: new Proxy({}, { get: (_, k) => [String(k)] }), + currentLanguageIndex: 0, + }, + "@ui/ui_template_elements": { + createButton: ({ label }) => Object.assign(node("button"), { _text: label }), + createInputField: () => ({ root: node(), input: node("input") }), + createSelectField: () => ({ root: node(), select: { value: "" } }), + showToast: () => undefined, + }, + "@ui/ui_template_checkbox": { createCheckbox: () => ({ root: node("label"), input: {} }) }, + "../B05_Profile/B05_Profile_UI_Structures_Fields": { stationFields: () => ({ wrap: node() }) }, + "./B07_DesignDetail_UI_PlanManage": { openPlanManager: () => undefined }, + "./B07_DesignDetail_Api_Fetch": {}, + "./B06_Section_UI_Page_Common": { L: (key) => key }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request === "@ui/ui_template_collapsible") + return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts")); + if (request.endsWith(".css")) return {}; + if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const out = {}; +const titleOf = (n) => n.children.find((c) => c.tagName === "h3")?.textContent ?? ""; +const folds = (root) => + Object.fromEntries( + root + .querySelectorAll(".ui-collapsible") + .map((s) => [titleOf(s), s.classList.contains("is-collapsed")]), + ); + +// ── B05 · B06 한 벌 패널 ─────────────────────────────────────────────────────── +const design = loadTs(path.join(ROOT, "A00_Common", "design", "design_panel.ts")); +loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Sections.ts")); +loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Panel.ts")); +const text = (t) => Object.assign(node(), { _text: t }); +const run = { run: () => undefined }; +const panel = design.createDesignPanel( + { + projectId: "p", + parts: { + structures: { root: node("section"), listRoot: node() }, + settings: { nodes: [text("a")] }, + route: { + plannedRouteButton: text("plan"), + selectedNodes: [text("sel")], + bindSelectedRoot: () => undefined, + }, + crossView: { row: text("cross-row") }, + standardSlot: text("std"), + }, + dock: { reset: run, save: run, confirm: run, switchView: run }, + }, + "profile", +); +const byTitle = (title) => + panel.root.querySelectorAll(".ui-collapsible").find((s) => titleOf(s) === title); +out.profileFirst = folds(panel.root); +const route = byTitle("계획노선"); +press(panel.root, route); // 사용자가 폄(공용 attachCollapsible) +panel.setView("cross"); +out.crossFirst = folds(panel.root); +press(panel.root, byTitle("B06_View_Title")); +panel.setView("profile"); +panel.setView("cross"); +out.afterRedraw = folds(panel.root); +out.sameRouteNode = byTitle("계획노선") === route; + +// ── B07 도면 목록 ───────────────────────────────────────────────────────────── +const b07 = loadTs(path.join(ROOT, "B07_DesignDetail", "B07_DesignDetail_UI_Panels.ts")); +const d = (id, kind) => ({ id, kind, label: id, confirmed: false }); +const drawings = [ + d("longitudinal", "longitudinal"), + d("longitudinal_2", "longitudinal"), + d("cross_1", "cross"), + d("cross_2", "cross"), + d("plan_a", "plan"), + d("cover", "cover"), + d("cross_standard", "cross_standard"), + d("mass_haul", "mass_haul"), + d("mass_haul_2", "mass_haul"), +]; +const groups = (p) => + Object.fromEntries( + p + .querySelectorAll(".b07-drawing-group") + .map((s) => [s.dataset.kind, s.classList.contains("is-collapsed")]), + ); +const fresh = node(); +out.b07EntryFolded = [...b07.foldedGroups(fresh)]; +const first = b07.buildDrawingSidePanel( + drawings, + () => undefined, + undefined, + false, + undefined, + undefined, + b07.foldedGroups(fresh), +); +out.b07First = groups(first); +const host = node(); +host.append(first); +first + .querySelectorAll(".b07-drawing-group") + .find((s) => s.dataset.kind === "cross") + .classList.remove("is-collapsed"); +first + .querySelectorAll(".b07-drawing-group") + .find((s) => s.dataset.kind === "mass_haul") + .classList.add("is-collapsed"); +const again = b07.buildDrawingSidePanel( + drawings, + () => undefined, + undefined, + false, + undefined, + undefined, + b07.foldedGroups(host), +); +out.b07Redraw = groups(again); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_269_panel_form.py b/resources/tester/test_269_panel_form.py index 33ff60bd2..c2c5cc4b8 100644 --- a/resources/tester/test_269_panel_form.py +++ b/resources/tester/test_269_panel_form.py @@ -82,8 +82,8 @@ def test_공용_카드_함수(): def test_B07_컨테이너_공용_카드_도면_단추_공용_꼴(): - assert "createPanelCard(`${group.label} ${items.length}`)" in B07_PANELS - assert "createPanelCard(`평면도 ${items.length}`)" in B07_PLAN + assert "createPanelCard(`${group.label} ${items.length}`, isFolded(" in B07_PANELS + assert "createPanelCard(`평면도 ${items.length}`, collapsed)" in B07_PLAN for src in (B07_PANELS, B07_PLAN): assert "ui-collapsible ui-sidebar-section" not in src # 손으로 짠 카드 없음 assert B07_PANELS.count('"b07-drawing-button ui-panel-btn"') == 2 @@ -149,7 +149,7 @@ def test_B05_칸_틀_두_보기_모두_공용_양식(): def test_B08_B09_카드_공용_카드(): assert "createPanelCard(L(title))" in B08_OPTIONS - assert 'createPanelCard("데이터 점검")' in B09_SHELL + assert 'createPanelCard("데이터 점검", true)' in B09_SHELL for src in (B08_OPTIONS, B09_SHELL): assert "ui-sidebar-card" not in src diff --git a/resources/tester/test_311_panel_folded.py b/resources/tester/test_311_panel_folded.py new file mode 100644 index 000000000..66aeb65fd --- /dev/null +++ b/resources/tester/test_311_panel_folded.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""311 좌측 패널 기본 접힘 · B07 첫 쪽 = 표지 (요구 204). + +못박는 것 + - 기본 접힘 = B09 「데이터 점검」 · 「공기 옵션」 / B07 「평면도」 · 「종단도」 · 「횡단도」 / + B06 「횡단 보기 설정」 / B05 「계획노선」 · 다른 컨테이너 기본값 그대로. + - 쪽 안에서 편 것은 다시 그려도 그대로 — B05 · B06 = 한 벌 패널이 칸을 한 번만 세움 · B07 = 다시 세울 때 + 옛 목록 접힘을 이어 받음 · B09 = 좌측 패널을 쪽 그릴 때 한 번만 세움. + - B07 처음 들어가면 표지 — 고른 장 기억(모듈 값 `reopenDrawingId` · `reopenCrossSheet`)이 있으면 그 장. + +Node 헬퍼 `helper_311_panel_folded.cjs` 가 가짜 DOM 위에서 B05 · B06 · B07 패널을 돌림. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_311_panel_folded.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_B05_B06_첫_그림_접힘(out): + assert out["profileFirst"] == { + "페이지 설정": True, + "계획노선": True, + "선택 포인트 상세 설정": False, + } + assert out["crossFirst"]["B06_View_Title"] is True + assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로 + + +def test_B05_B06_편_뒤_다시_그림_유지(out): + assert out["crossFirst"]["계획노선"] is False # 편 것 → 보기 바꿔도 그대로 + assert out["afterRedraw"]["계획노선"] is False + assert out["afterRedraw"]["B06_View_Title"] is False + assert out["sameRouteNode"] is True + + +def test_B07_첫_그림_접힘(out): + assert out["b07EntryFolded"] == [] # 들어올 때 = 라우터가 비운 그릇 → 기본값 + assert out["b07First"] == { + "plan": True, + "longitudinal": True, + "cross": True, + "mass_haul": False, + } + + +def test_B07_다시_세워도_접힘_이어_받음(out): + assert out["b07Redraw"] == { + "plan": True, + "longitudinal": True, + "cross": False, + "mass_haul": True, + } + + +def test_B07_다시_세우기는_옛_목록을_기다리기_전에_읽음(): + page = _read("B07_DesignDetail/B07_DesignDetail_UI_Page.ts") + body = page[page.index("export async function renderB07DesignDetail(") :] + assert body.index("const folded = foldedGroups(root);") < body.index("await ") + assert "crossLayoutSlot,\n folded,\n );" in body.replace("\r\n", "\n") + + +def test_B07_첫_장은_표지_기억_있으면_그_장(): + page = _read("B07_DesignDetail/B07_DesignDetail_UI_Page.ts") + assert 'const cover = drawings.findIndex((item) => item.kind === "cover");' in page + assert "const first = reopen >= 0 ? reopen : Math.max(cover, 0);" in page + + +def test_B09_기본_접힘_좌측은_한_번만_세움(): + shell = _read("B09_Estimation/B09_Estimation_UI_Shell.ts") + assert 'createPanelCard("데이터 점검", true)' in shell + assert 'createPanelCard("공기 옵션", true)' in _read( + "B09_Estimation/draft/B09_Draft_Schedule_Panel.ts" + ) + # 다시 그림(show) 은 가운데 판만 바꿈 — 좌측 카드는 쪽 그릴 때 한 번 + show = shell[shell.index("const show = (tab: TabId") : shell.index("const run = async (")] + assert "createPanelCard" not in show and "left." not in show + print_page = _read("B09_Estimation/draft/B09_Draft_Print_Page.ts") + assert 'createPanelCard("영역")' in print_page and 'createPanelCard("쪽 목록")' in print_page From e657067bd368b5d660bf3f17f5b655a2dead878e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:15:53 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat(b09):=20=ED=83=AD=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=20=EA=B8=80=20=EC=98=81=EC=97=AD=20=3D=20?= =?UTF-8?q?=EC=A4=84=20=EC=88=98=20=EB=B3=80=EC=88=98(--b09-hint-lines=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=203)=EB=A7=8C=ED=81=BC=20=C2=B7=20=EB=84=98?= =?UTF-8?q?=EC=B9=98=EB=A9=B4=20=EC=98=81=EC=97=AD=20=EC=95=88=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=C2=B7=20=ED=83=AD=20=ED=91=9C=20=3D=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20fillViewport(=EC=B0=BD=20=EB=81=9D=20?= =?UTF-8?q?=E2=88=92=20=EC=95=88=EB=82=B4=20=EA=B8=80)=20=C2=B7=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=20=EC=95=88=EB=82=B4=20+=20=ED=83=AD=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=ED=95=9C=20=EC=98=81=EC=97=AD=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20spaceBelow=20=ED=98=95=EC=A0=9C=20=EB=B0=94?= =?UTF-8?q?=EA=B9=A5=20=EC=97=AC=EB=B0=B1=EB=8F=84=20=EC=85=88(=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4=207px=20?= =?UTF-8?q?=EC=97=86=EC=95=B0)=20(310=20=C2=B7=20=EC=9A=94=EA=B5=AC=20203)?= 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_01XWh5wNNycMkbwMpACz1rte --- B09_Estimation/B09_Estimation_UI_Page.css | 8 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 8 +- .../B09_Estimation_UI_Tabs_Groups.ts | 2 +- .../tester/test_200_1_b09_box_tool_line.py | 11 +- .../tester/test_200_2_b09_bill_level_bar.py | 4 +- .../tester/test_202_1_table_hint_bottom.py | 5 +- resources/tester/test_203_b09_hint_lines.py | 185 ++++++++++++++++++ .../test_229_1_b09_level_buttons_subbar.py | 4 +- resources/tester/test_236_1_hint_one_line.py | 9 +- ui_template/ui_template_data_table.ts | 5 +- 10 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 resources/tester/test_203_b09_hint_lines.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index fe87eb951..2a2041307 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -1,6 +1,7 @@ /* B09 원가계산 — 탭 줄 · 안내 글. 색은 테마 변수만(밝음 · 어두움 같이). */ .b09-estimation { + --b09-hint-lines: 3; /* 203 안내 글 영역 줄 수 — 이 값만 바꿈(높이 · 스크롤 · 표 칸 같이 맞음) */ display: flex; flex-direction: column; gap: var(--spacing-8); @@ -57,7 +58,7 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { white-space: nowrap; } -/* 202-1 안내 줄 = 표 · 카드 판 아래(묶음 탭은 한 줄 줄임표 · 전문 = 공용 호버) */ +/* 202-1 안내 줄 = 표 · 카드 판 아래 · 203 줄 수 변수만큼 보임 · 넘치면 영역 안 스크롤 */ .b09-estimation .ui-data-table + .ui-general-block__empty, .b09-estimation .ui-group-cards + .ui-general-block__empty { margin: var(--spacing-8) 0 0; @@ -65,9 +66,8 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { .b09-estimation__line-hint { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + max-height: calc(var(--b09-hint-lines) * 1lh); + overflow-y: auto; font-size: var(--text-caption); } diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 81dc5157f..ff7aee710 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -183,6 +183,7 @@ export function renderB09Estimation(root: HTMLElement): void { const makeTable = (extra?: (index: number) => string | null): DataTable => { const made = createDataTable({ ...tableOptionsOf(spec, tab), + fillViewport: true, // 203 표 칸 = 창 끝 − 안내 글 영역(팝업 · 인쇄는 그대로) rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null : spec.rowClass, @@ -230,7 +231,7 @@ export function renderB09Estimation(root: HTMLElement): void { ); return made; }; - const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄 + const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 203 안내 글 = 줄 수 변수만큼 · 넘치면 스크롤 // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 좌측 하단 스위치로 표(304-1) if (isGroupTab(tab)) { return void panel.replaceChildren( @@ -259,7 +260,10 @@ export function renderB09Estimation(root: HTMLElement): void { if (range) unpickOnRelease(table.root); const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null; if (level) bars.setTools(level.tools); - panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : [])); + // 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임 + if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`; + const foot = hint ?? level?.guide; + panel.replaceChildren(table.root, ...(foot ? [foot] : [])); if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 5ffe20bb2..1cc4f2af6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -228,7 +228,7 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine /* ── 그리기 ───────────────────────────────────────────────────────────── */ -/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */ +/** 안내 글 = 줄 수 변수(203 `--b09-hint-lines`)만큼 · 넘치면 영역 안 스크롤 · 가로로 잘렸을 때만 전문(공용 호버 185-3) */ export function lineHint(hint: HTMLElement | null): HTMLElement | null { if (!hint || hint.classList.contains("b09-estimation__line-hint")) return hint; hint.classList.add("b09-estimation__line-hint"); diff --git a/resources/tester/test_200_1_b09_box_tool_line.py b/resources/tester/test_200_1_b09_box_tool_line.py index 203d08e9b..b0c95cb85 100644 --- a/resources/tester/test_200_1_b09_box_tool_line.py +++ b/resources/tester/test_200_1_b09_box_tool_line.py @@ -1,6 +1,6 @@ """200-1 B09 박스보기 안내 글 줄임표 + 호버 · 카드 같은 가로 줄 높이 같게. -- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김) +- 안내 글 = 203 줄 수 변수만큼(넘치면 스크롤) · 가로로 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김) - 카드 격자 = 행 늘림(`align-items: stretch`) · 카드 = 세로 flex · 몸통이 늘어 합계 줄이 아래 - 카드 제목 줄 · 합계 줄 바탕색(200-3) """ @@ -78,13 +78,12 @@ def test_안내_글_없으면_호버도_없음(): assert out["n"] == 1 and out["hintLast"] is False and out["hover"] == 0 -def test_CSS_안내_글_줄임표_한_줄(): +def test_CSS_안내_글_줄_수_변수(): page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") hint = _block(page, ".b09-estimation__line-hint") - for need in ( - "overflow: hidden", - "text-overflow: ellipsis", - "white-space: nowrap", + for need in ( # 203 한 줄 줄임표 → 줄 수 변수만큼 · 넘치면 스크롤 + "max-height: calc(var(--b09-hint-lines) * 1lh)", + "overflow-y: auto", "min-width: 0", ): assert need in hint, need diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 8da7177e0..2aab73e04 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -200,7 +200,9 @@ def test_셸_배선(): "bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src ) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나) assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 - assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src + assert ( + "panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in src + ) # 203 레벨 안내 + 탭 안내 = 한 영역 lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수 assert "b09-estimation__level" not in lvl diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 8cbe24bb4..ff5509be6 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -106,9 +106,8 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): def test_잇기_쉘_안내_줄_아래_도구_비움(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ( - "panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []))" - in shell - ) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 안내 줄 = 맨 아래 + "panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell + ) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래) assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음 assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell) assert "setTools: bars.setTools" in shell diff --git a/resources/tester/test_203_b09_hint_lines.py b/resources/tester/test_203_b09_hint_lines.py new file mode 100644 index 000000000..7d58bdf37 --- /dev/null +++ b/resources/tester/test_203_b09_hint_lines.py @@ -0,0 +1,185 @@ +"""203 B09 탭 상세 안내 글 영역 = 줄 수 변수(`--b09-hint-lines` 기본 3)만큼 · 넘치면 영역 안 스크롤 · 표 칸 = 창 끝까지. + +- CSS: 줄 수 변수 하나 · 안내 글 최대 높이 = 변수 × 줄 높이(1lh) · 세로 스크롤 · 한 줄 줄임표 걷음 +- Shell: 탭 표 = 공용 fillViewport(팝업 · 인쇄 표는 그대로) · 설계내역서 레벨 안내 + 탭 안내 = 한 영역 +- 공용 spaceBelow: 아래 형제 바깥 여백(B09 안내 글 margin-top)도 셈 — 빼면 페이지 스크롤이 생김 +- 실제 브라우저(정적 쪽 · 서버 불필요): 변수 3 · 2 × 줄 많음 · 적음 — 안내 글 높이 · 합계 줄 · 안내 글 자리 · 페이지 스크롤 0 +""" + +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" +UI = ROOT / "ui_template" + + +def _read(path: Path) -> str: + return path.read_text("utf-8") + + +def _block(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_CSS_줄_수_변수_하나(): + css = _read(B09 / "B09_Estimation_UI_Page.css") + assert re.search(r"--b09-hint-lines:\s*3;", _block(css, ".b09-estimation")) + assert css.count("--b09-hint-lines:") == 1 # 고칠 자리 하나 + hint = _block(css, ".b09-estimation__line-hint") + assert "max-height: calc(var(--b09-hint-lines) * 1lh)" in hint + assert "overflow-y: auto" in hint + for gone in ("white-space: nowrap", "text-overflow: ellipsis"): + assert gone not in hint, gone + + +def test_Shell_표_창_끝_안내_한_영역(): + shell = _read(B09 / "B09_Estimation_UI_Shell.ts") + assert "fillViewport: true" in shell + assert "panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell + assert "hint.textContent = `${level.guide.textContent} · ${hint.textContent}`" in shell + # 팝업 · 인쇄 표는 창 끝 셈 안 씀(팝업 = 창 안 max-height · 인쇄 = 펼침) + assert "fillViewport" not in _read(B09 / "B09_Estimation_UI_Builders.ts") + assert "fillViewport" not in _read(B09 / "B09_Estimation_UI_Popup.ts") + + +# ── 실제 브라우저 ───────────────────────────────────────────────────────────── + +TOKENS = ( + ":root { --spacing-4: 4px; --spacing-8: 8px; --spacing-16: 16px; --spacing-24: 24px;" + " --text-caption: 12px; --radius-lg: 8px; }" + " html, body { margin: 0; line-height: 1.5; font-family: sans-serif; }" +) +LONG = " · ".join(["긴 안내 글 시험 문장"] * 40) +SHORT = "줄을 누르면 단가를 낸 탭 · 줄로 이동" + + +def _module_js() -> str: + """공용 표 모듈(TS) → 브라우저 ES 모듈(CSS import 뺌).""" + src = UI / "ui_template_data_table.ts" + code = ( + "const ts = require(process.argv[1]);" + "const fs = require('fs');" + "const out = ts.transpileModule(fs.readFileSync(process.argv[2], 'utf8')," + " { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 } });" + "process.stdout.write(out.outputText);" + ) + ts_path = ROOT / "config" / "node_modules" / "typescript" + proc = subprocess.run( + ["node", "-e", code, str(ts_path), str(src)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return re.sub(r'^import\s+"[^"]+\.css";\s*$', "", proc.stdout, flags=re.M) + + +MEASURE = """async ([rows, lines, text]) => { + const { createDataTable } = window.dt; + const body = document.querySelector('.b09-estimation'); + body.style.setProperty('--b09-hint-lines', String(lines)); + const panel = body.querySelector('.b09-estimation__panel'); + const table = createDataTable({ + columns: [{ key: 'name', label: '이름' }, { key: 'sum', label: '금액', kind: 'number' }], + rows: Array.from({ length: rows }, (_, i) => ({ name: '줄 ' + i, sum: i })), + total: { name: '계', sum: 1 }, + fillViewport: true, + }); + const hint = document.createElement('p'); + hint.className = 'ui-general-block__empty b09-estimation__line-hint'; + hint.textContent = text; + panel.replaceChildren(table.root, hint); + await new Promise((r) => setTimeout(r, 300)); + const t = table.root.getBoundingClientRect(); + const h = hint.getBoundingClientRect(); + const foot = table.root.querySelector('tfoot').getBoundingClientRect(); + return { + vh: innerHeight, + page: document.scrollingElement.scrollHeight, + lh: parseFloat(getComputedStyle(hint).lineHeight), + hintH: h.height, + hintScrolls: hint.scrollHeight > hint.clientHeight, + tableTop: t.top, + tableBottom: t.bottom, + tableScrolls: table.root.scrollHeight > table.root.clientHeight, + maxH: parseFloat(table.root.style.maxHeight), + hintTop: h.top, + hintBottom: h.bottom, + padB: parseFloat(getComputedStyle(body).paddingBottom), + footBottom: foot.bottom, + }; +}""" + + +@pytest.fixture(scope="module") +def page(): + from playwright.sync_api import sync_playwright + + css = _read(UI / "ui_template_data_table.css") + _read(B09 / "B09_Estimation_UI_Page.css") + html = ( + f"" + "
" + "
" + ) + js = _module_js() + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + pg = browser.new_page(viewport={"width": 1280, "height": 900}) + pg.set_content(html) + pg.add_script_tag( + type="module", + content=f"{js}\nwindow.dt = {{ createDataTable }};", + ) + pg.wait_for_function("!!window.dt") + yield pg + browser.close() + + +@pytest.mark.parametrize("lines", [3, 2]) +def test_줄_많음_표_창_끝_합계_줄_고정_안내_글_바닥(page, lines): + out = page.evaluate(MEASURE, [300, lines, LONG]) + assert out["hintH"] == pytest.approx(lines * out["lh"], abs=0.5), out # 높이 = 줄 수 × 줄 높이 + assert out["hintScrolls"], out # 넘친 글 = 영역 안 스크롤 + assert out["tableScrolls"], out + assert out["page"] == out["vh"], out # 페이지 스크롤 0 + assert out["hintBottom"] + out["padB"] == pytest.approx(out["vh"], abs=1.5), out # 창 끝까지 씀 + assert out["hintTop"] == pytest.approx(out["tableBottom"] + 8, abs=0.5), out # 표 바로 아래 + assert out["tableBottom"] - 30 < out["footBottom"] <= out["tableBottom"], ( + out + ) # 합계 줄 = 표 칸 바닥 + + +def test_줄_수_2_이면_표가_한_줄만큼_더_김(page): + three = page.evaluate(MEASURE, [300, 3, LONG]) + two = page.evaluate(MEASURE, [300, 2, LONG]) + assert two["maxH"] - three["maxH"] == pytest.approx(three["lh"], abs=1), (two, three) + + +@pytest.mark.parametrize("lines", [3, 2]) +def test_줄_적음_합계_줄_안내_글_끌려_올라옴(page, lines): + out = page.evaluate(MEASURE, [3, lines, LONG]) + assert not out["tableScrolls"], out + assert out["tableBottom"] - out["tableTop"] < out["maxH"] - 100, out # 내용만큼(창 끝 아님) + assert out["hintTop"] == pytest.approx(out["tableBottom"] + 8, abs=0.5), out # 표 끝에 붙음 + assert out["hintBottom"] < out["vh"] - 300, out # 화면 바닥에 떨어져 있지 않음 + assert out["hintH"] == pytest.approx(lines * out["lh"], abs=0.5), out + assert out["footBottom"] <= out["tableBottom"], out + + +def test_짧은_글은_한_줄만(page): + out = page.evaluate(MEASURE, [300, 3, SHORT]) + assert out["hintH"] == pytest.approx(out["lh"], abs=0.5), out + assert not out["hintScrolls"], out + assert out["page"] == out["vh"], out + + +def test_spaceBelow_형제_바깥_여백_셈(): + src = _read(UI / "ui_template_data_table.ts") + assert "px(m.marginTop) + px(m.marginBottom)" in src # 브라우저 배치는 위 시험이 잼 diff --git a/resources/tester/test_229_1_b09_level_buttons_subbar.py b/resources/tester/test_229_1_b09_level_buttons_subbar.py index 50a605e4a..6d3d1a40a 100644 --- a/resources/tester/test_229_1_b09_level_buttons_subbar.py +++ b/resources/tester/test_229_1_b09_level_buttons_subbar.py @@ -85,7 +85,9 @@ console.log(JSON.stringify({ def test_표_위_도구_줄_없음_쉘_배선(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert "if (level) bars.setTools(level.tools);" in shell - assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in shell + assert ( + "panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell + ) # 203 레벨 안내 + 탭 안내 = 한 영역 assert "...(level ? [level] : [])" not in shell # 표 위 자리 없음 css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") lvl = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") diff --git a/resources/tester/test_236_1_hint_one_line.py b/resources/tester/test_236_1_hint_one_line.py index 1e7ba7e56..df3f087c5 100644 --- a/resources/tester/test_236_1_hint_one_line.py +++ b/resources/tester/test_236_1_hint_one_line.py @@ -2,7 +2,7 @@ - 안내 글 낱말에 제작 · 셈 설명(ROUND · 「 = 」 · 장 번호 · M01 · 곱셈 나눗셈 기호)이 없음 - 안내 글 한 조각 70 자 이하(긴 글은 줄임표 + 공용 호버) -- B09 안내 글(lineHint) · 레벨 안내 · B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표) +- B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표) · B09 안내 글 = 203 줄 수 변수만큼(test_203) - 창 크기(1080 · 768 · 600)마다 한 줄 안내 + 표 칸 = 창 끝(199-1 fillViewport 셈) """ @@ -108,12 +108,13 @@ def _block(css: str, selector: str) -> str: return m.group(1) -def test_B09_안내_글_한_줄(): +def test_B09_안내_글_줄_수_변수(): css = _read(B09 / "B09_Estimation_UI_Page.css") block = _block(css, ".b09-estimation__line-hint") - assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block + # 203 한 줄 줄임표 → 줄 수 변수만큼 · 넘치면 스크롤(test_203_b09_hint_lines) + assert "max-height: calc(var(--b09-hint-lines) * 1lh)" in block and "overflow-y: auto" in block shell = _read(B09 / "B09_Estimation_UI_Shell.ts") - assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 한 줄 + assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 꼴 assert "export function lineHint" in _read(B09 / "B09_Estimation_UI_Tabs_Groups.ts") level = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts") assert 'guide.className = "ui-general-block__empty b09-estimation__line-hint"' in level diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 44c2e69d8..40a7c1a5d 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -383,7 +383,7 @@ function isNode(lv: NonNullable, i: number, n: number const OVER = 40; /* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ──────────── - * 아래 자리 = 칸 뒤 형제 요소 높이 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백. + * 아래 자리 = 칸 뒤 형제 요소 높이 · 바깥 여백 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백. * 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤. * ------------------------------------------------------------------------------------------ */ @@ -407,7 +407,8 @@ export function spaceBelow(el: HTMLElement): number { const gap = px(around.rowGap); for (let s = node.nextElementSibling; s; s = s.nextElementSibling) { const h = s.getBoundingClientRect().height; - if (h) sum += h + gap; + const m = getComputedStyle(s); // 203 형제 위아래 바깥 여백도(B09 안내 글 margin-top) + if (h) sum += h + gap + px(m.marginTop) + px(m.marginBottom); } sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth); } From d3d038349b255197a65d7ac0f4b1ff807547b31f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 18:18:20 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix(b06):=20=EA=B3=A0=EB=A6=84=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=20=EA=B8=B8=EC=9D=80=20=ED=98=95=EC=83=81=20=EC=95=88=20?= =?UTF-8?q?=EC=BC=AC=20=C2=B7=20=EC=97=B0=EB=8F=99=20=EA=B4=80=20=EC=BD=98?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=ED=8A=B8=EB=A7=8C=20=C2=B7=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=97=86=EB=8A=94=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=97=86=EC=9D=8C=20(307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노면공 포장층 강조 = 포장층을 직접 누른 턴만(paveShapeId) · 알약 · 목록 · 측점선 고름은 범위 테두리만 - 물넘이 콘크리트(pipe-…)는 측점 길 강조(highlightShapes)에서 뺌 — 형상 누름 「관」 고름만 켬 · 맹암거 그대로 - 벽 고름 「관」에 콘크리트 표식(형상 누름 ownerM · 관 없는 카드) · 카드 「관」 setter 를 관 그림 / 콘크리트로 가름(splitPipePick) - 연동 관 카드 콘크리트 누름 = 관 형상 안 켬 · 콘크리트 ↔ 관 바꿔 누르면 풀지 않고 옮김 · 좌측 하위 컨테이너 켬 그대로 - 측점 없는 구조물 = 카드 고름 · 스크롤 없음(revealStation 스크롤 걷음 · 넘김 focusPicked) - 시험: test_307_pick_highlight 새로 · 118 · 177_4 · 287 · 294_6 · 294_7 기대값 307 규칙으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- A00_Common/design/design_view_cross.ts | 15 +- .../B06_Section_UI_Cross_Culvert_Wire.ts | 2 + .../B06_Section_UI_Cross_Shape_Pick.ts | 26 ++- B06_Section/B06_Section_UI_Cross_View.ts | 15 +- .../B06_Section_UI_Page_Station_Controls.ts | 11 +- .../B06_Section_UI_Page_Structures_Panel.ts | 20 ++- ...06_Section_UI_Page_Structures_Selection.ts | 2 +- B06_Section/B06_Section_UI_Section_View.ts | 25 ++- .../B06_Section_UI_Section_View_Profile.ts | 2 +- .../B06_Section_UI_Section_View_Types.ts | 2 +- .../tester/helper_307_pick_highlight.cjs | 134 ++++++++++++++ resources/tester/test_118_pipe_pick.py | 5 +- .../tester/test_177_4_pavement_span_box.py | 4 +- .../tester/test_287_ford_pavement_followup.py | 3 +- .../tester/test_294_6_ford_linked_mark.py | 3 +- resources/tester/test_294_7_ford_slab_pick.py | 3 +- resources/tester/test_307_pick_highlight.py | 165 ++++++++++++++++++ 17 files changed, 390 insertions(+), 47 deletions(-) create mode 100644 resources/tester/helper_307_pick_highlight.cjs create mode 100644 resources/tester/test_307_pick_highlight.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 4aaa34321..e05df96aa 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -475,16 +475,11 @@ export async function mountCrossView( handoff, sectionDetail, { - // 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15). - focusStation: (stationId) => { - structuresPanel.prefer(handoff?.structureId ?? null); - sectionView.focusStation(stationId); - structuresPanel.prefer(null); - // 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1) - const sid = handoff?.structureId; - if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid)) - structuresPanel.selectStructure(sid); - }, + // 넘겨받은 구조물 id 가 먼저(52-15) · 폼은 id 로 직접(53-1) · 측점 없는 구조물은 카드 · 스크롤 없음(307) + focusStation: (stationId) => + structuresPanel.focusPicked(handoff?.structureId ?? null, () => + sectionView.focusStation(stationId), + ), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role), diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 7a3dbac79..44b82ef41 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -43,6 +43,8 @@ export interface RevetOffsetControl { highlightFor: (section: CrossSection) => RevetPick | null; /** `ownerM` = 폼에 올릴 시설 자리를 누른 쪽이 앎(물넘이 콘크리트 · 294-7) — 없으면 카드로 셈. */ select: (chainageM: number, key: RevetPick | null, ownerM?: number) => void; + /** 고른 「관」이 물넘이 콘크리트인가 — 그 카드는 콘크리트만 켬 · 관 그림은 안 켬(307). */ + shapePicked?: () => boolean; /** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */ onSelectionChange?: () => void; /** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */ diff --git a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts index 1aa025e3f..79e5a1cd0 100644 --- a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts @@ -12,6 +12,8 @@ * 노면선 보라 판정 띠(굵기 10 · 위아래 5) 바깥 아래를 누르면 형상(294-7). * ========================================================================== */ +import type { RevetHighlightSetter } from "./B06_Section_UI_Cross_Culvert"; + const SVG_NS = "http://www.w3.org/2000/svg"; /** 관 지점이 아닌 구조물(맹암거) 형상 누름 — 카드 밖(구조물 목록)이 id 로 고름(288-2). */ export const SHAPE_PICK_EVENT = "b06-structure-shape-pick"; @@ -105,12 +107,26 @@ export function shapePickRoute( }; } -/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴). */ +/** 카드 「관」 강조를 관 그림 · 물넘이 콘크리트로 가름(307) — 콘크리트를 고름(`concrete`) = 콘크리트만 · + * 관을 고름 = 관만(관 카드에 걸친 물넘이포장 콘크리트를 눌러도 관 형상은 안 켬). */ +export function splitPipePick( + base: RevetHighlightSetter, + setShape: (active: boolean) => void, + concrete: () => boolean, +): RevetHighlightSetter { + return (key) => { + const shape = key === "pipe" && concrete(); + base(shape ? null : key); + setShape(shape); + }; +} + +/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴 · 맹암거). 물넘이 + * 콘크리트(`pipe-…`)는 형상을 직접 누른 「관」 고름만 켬 — 측점 길(측점선 · 알약 · 목록)은 안 켬(307). */ export function highlightShapes(root: ParentNode, structureId: string | null): void { for (const shape of root.querySelectorAll("[data-structure-id]")) { - shape.classList.toggle( - "is-active", - !!structureId && shape.getAttribute("data-structure-id") === structureId, - ); + const id = shape.getAttribute("data-structure-id") ?? ""; + if (id.startsWith("pipe-")) continue; + shape.classList.toggle("is-active", !!structureId && id === structureId); } } diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 2fb647b19..dd7f71188 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -21,7 +21,7 @@ import { appendFordSurfaceDropPlan, } from "./B06_Section_UI_Cross_Ford_Pavement"; import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire"; -import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick"; +import { attachShapePick, shapePickRoute, splitPipePick } from "./B06_Section_UI_Cross_Shape_Pick"; import { stackPickTargets } from "./B06_Section_UI_Cross_Pick_Order"; import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; @@ -232,9 +232,11 @@ export function createCrossSectionCard( }, selectCard: () => onSelect(section.station_id), }); + const shapePicked = (): boolean => revetOffset?.shapePicked?.() ?? false; const toggleRevet = (key: RevetPick, ownerM?: number): void => { const wasSelected = isSelected; - activeRevet = activeRevet === key ? null : key; + // 같은 「관」이라도 콘크리트 ↔ 관 그림을 바꿔 누르면 풀지 않고 옮김(307) + activeRevet = activeRevet === key && (ownerM !== undefined) === shapePicked() ? null : key; showRevetControl(activeRevet !== null); // 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를 // 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다. @@ -595,12 +597,9 @@ export function createCrossSectionCard( revetOffset && shapePickRoute(svg, (at) => toggleRevet("pipe", at)), ); if (setShapeActive) { - const baseSetActive = setRevetActive; - setRevetActive = (key) => { - baseSetActive(key); - setShapeActive(key === "pipe"); - }; - setShapeActive(highlightRevet === "pipe"); + // 「관」 = 관 그림 또는 콘크리트 하나만(307 — 연동 관 카드 콘크리트 누름에 관 형상 안 켬) + setRevetActive = splitPipePick(setRevetActive, setShapeActive, shapePicked); + setRevetActive(highlightRevet); } // 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2). const lineDesign = section.design; diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 79bc25659..390a81de5 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -183,8 +183,8 @@ export function createStationControls(deps: StationControlDeps): StationControls ); const revetShifts = revetSession.values; /** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편: - * 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */ - let revetSelection: { at: string; key: RevetPick } | null = null; + * 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). `shape` = 「관」이 물넘이 콘크리트(307). */ + let revetSelection: { at: string; key: RevetPick; shape?: boolean } | null = null; const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`; function loadRevetShifts(): void { @@ -268,14 +268,17 @@ export function createStationControls(deps: StationControlDeps): StationControls if (!owner || !here || Math.abs(here.chainage_m - owner.chainage_m) > 0.01) return null; return wallStandsAt(owner, section, revetSelection.key) ? revetSelection.key : null; }, - select: (chainageM, key) => { - revetSelection = key ? { at: chainageM.toFixed(2), key } : null; + select: (chainageM, key, ownerM) => { + // 「관」 = 물넘이 콘크리트 — 형상 누름(ownerM) · 관 없는 카드 · 걸친 관 그림은 안 켬(307) + const shape = key === "pipe" && (ownerM !== undefined || !sectionAt(chainageM)?.culvert); + revetSelection = key ? { at: chainageM.toFixed(2), key, shape } : null; // 벽을 고르면 같은 측점 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1) const { ford, box } = bodyControls; if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null); if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null); revetOffsetControl.onSelectionChange?.(); }, + shapePicked: () => revetSelection?.shape === true, syncApplied: (chainageM, role, applied) => { // 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. const key = revetKey(chainageM, role); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 2b18e04d5..4cd5736e3 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -87,7 +87,7 @@ export interface B06StructuresPanelDeps { wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null; /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ stationInterval: () => number; - /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지 않고 스크롤만 · 177-4). */ + /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지도 스크롤도 않음 · 측점 없는 구조물 · 307). */ focusChainage: (chainageM: number, revealOnly?: boolean) => void; /** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */ releaseCard?: () => void; @@ -130,8 +130,11 @@ export interface B06StructuresPanel { preferred: () => string | null; /** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */ selectStructure: (structureId: string) => void; - /** 측점 없는 관(물넘이포장) 하나를 폼에 올림 — 카드 안 고름 · 스크롤만 · 알약 · 고름 값에 알약 id(요구 178). */ + /** 측점 없는 관 하나를 폼에 올림 — 카드 안 고름 · 스크롤 없음 · 알약 · 고름 값에 알약 id(요구 178 · 307). */ selectPipeAt: (chainageM: number) => void; + /** 넘김값(B05 알약 · 3D · 지도)의 카드 고름 — `focus` 가 그 카드를 고름 · 넘긴 구조물이 먼저(52-15) · + * 폼은 id 로 직접 엶(53-1) · 측점 없는 구조물은 카드 고름 · 스크롤 없이 폼만(요구 178 · 307). */ + focusPicked: (structureId: string | null, focus: () => void) => void; /** 배수유역도가 관 초안에 채운 빈 월류 높이(126-1)를 목록 · 폼에 받는다(298-1). */ adoptFordHeights: () => void; /** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */ @@ -224,7 +227,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2) // 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다. - // 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 그 카드로 스크롤만 · 폼 유지(177-4) + // 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 스크롤 없음 · 폼 유지(177-4 · 307) // 측점 없는 구조물(제 측점을 안 심음)도 같은 길 — 근처 카드 측점선을 켜지 않음(요구 178) const revealOnly = !coverable(structure) || !plantsStation(structure, markTypes); const id = structure.structure_id ?? null; @@ -586,6 +589,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }, preferred: () => preferredId, selectStructure: (structureId) => section.selectById(structureId), + focusPicked: (structureId, focus) => { + const known = structures.find((entry) => entry.structure_id === structureId); + if (!known || plantsStation(known, markTypes)) { + preferredId = structureId; + focus(); + preferredId = null; + } + if (known) section.selectById(structureId); + }, showAtChainage: (chainageM) => { currentChainageM = null; const selection: DesignSelection = { chainageM, structureId: preferredId }; @@ -616,7 +628,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ); if (!pipe) return; const id = `pipe-${pipe.chainage_m.toFixed(2)}`; - deps.focusChainage(pipe.chainage_m, true); // 카드 안 고름 · 스크롤만 — 옛 카드 · 측점선 풂(177-4 길) + deps.focusChainage(pipe.chainage_m, true); // 카드 안 고름 · 스크롤 없음 — 옛 카드 · 측점선 풂(177-4 길 · 307) currentChainageM = pipe.chainage_m; refreshSpecDefaults(pipe.chainage_m); section.selectPipeByChainage(pipe.chainage_m); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index 73181ca11..954eb5130 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -90,7 +90,7 @@ export function wireStructureSelection( ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined)); // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) if (key) markWallSelecting(owner); - origRevet(chainageM, key); + origRevet(chainageM, key, ownerM); // ownerM = 콘크리트 형상 누름 표식(307) if (key) remember?.(owner, key); if (key) panel.showAtChainage(owner); else if (!nested) { diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 92a38767a..e0570dda1 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -244,8 +244,11 @@ export function createSectionView( let spanMarks: ReadonlyArray = []; let spanTypes: ReadonlyArray = []; let spanMarkId: string | null = null; - /** 고른 구조물(알약 · 목록 · 폼) — 물넘이포장 · 맹암거 형상 강조(287-5 · 288-2). */ + /** 고른 구조물(알약 · 목록 · 폼) — 맹암거 형상 강조(288-2 · 물넘이 콘크리트는 「관」 고름 몫 · 307). */ let pickedMarkId: string | null = null; + /** 포장층을 직접 누른 노면공 — 그 포장층만 강조 · 측점 길(알약 · 목록) 고름은 안 켬(307). */ + let paveShapeId: string | null = null; + let paveShapeTurn = false; const inSpan = (section: CrossSection, mark: StructureInstance | undefined): boolean => { const { start_m: start, end_m: end } = mark ?? {}; if (start == null || end == null || start === end) return false; @@ -261,7 +264,7 @@ export function createSectionView( ? spanMarks.find((entry) => entry.structure_id === spanMarkId) : undefined; const span = inSpan(section, mark); - const pave = span && isPavement(spanMarkId); + const pave = span && isPavement(spanMarkId) && paveShapeId === spanMarkId; card.classList.toggle("b06-cross-card--span", span); highlightPavement(card, pave ? mark : undefined); highlightShapes(card, pickedMarkId); @@ -430,7 +433,14 @@ export function createSectionView( const pave = pavementAt(event.target, spanMarks); if (!pave?.structure_id || !isPavement(pave.structure_id)) return; event.stopPropagation(); - pavementSelectListener?.(pave.structure_id); + paveShapeTurn = true; + try { + pavementSelectListener?.(pave.structure_id); + } finally { + paveShapeTurn = false; + } + paveShapeId = pave.structure_id; + syncSpanHighlights(); }, true, ); @@ -605,19 +615,20 @@ export function createSectionView( ); } }, - revealStation(stationId) { - // 노면공 고름 = 카드 하나 안 고름 · 옛 카드는 리스너 없이 풂(폼 유지) · 그 카드로 스크롤만(177-4) + revealStation() { + // 측점 없는 구조물(노면공 등) 고름 = 카드 하나 안 고름 · 옛 카드는 리스너 없이 풂(폼 유지) · + // 강조할 횡단도가 없어 스크롤도 안 함(177-4 · 요구 178 · 307) const previous = selectedStationId; selectedStationId = null; activeAreaKey = null; markCard(previous); profile.showCard(null); - revealCard(stationId, "smooth"); }, selectMark: (structureId, span = true) => { - // span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2) + // span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 범위 테두리는 늘(165-2) · 포장층은 누름만(307) spanMarkId = span || isPavement(structureId) ? structureId : null; pickedMarkId = structureId; + if (!paveShapeTurn) paveShapeId = null; // 포장층 강조 = 포장층 누름 턴만(307) syncSpanHighlights(); profile.selectMark(structureId); }, diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index ccf3c0b14..a6ece2772 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -248,7 +248,7 @@ export function createSectionProfile( edit.selectStructure(hit.structure_id); return; } - // 측점 없는 관(물넘이포장) = 카드 안 고름 · 그 자리로 스크롤만 · 그 관 폼 · 알약만(요구 178) + // 측점 없는 관 = 카드 안 고름 · 스크롤 없음 · 그 관 폼 · 알약만(요구 178 · 307) if (edit?.selectPipe && !plantsStation(hit, types)) { edit.selectPipe(structureAnchorM(hit)); return; diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index a67c0d508..5a96a7a4d 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -48,7 +48,7 @@ export interface SectionViewController { /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; - /** 카드를 고르지 않고 스크롤만 — 고른 카드는 알림 없이 풂(노면공 목록 고름 · 177-4). */ + /** 카드를 고르지 않음 · 스크롤 없음 — 고른 카드는 알림 없이 풂(측점 없는 구조물 고름 · 177-4 · 307). */ revealStation: (stationId: string) => void; /** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). * `span` = 구간형 범위 안 카드 전부 테두리(기본) · 끄면 카드는 고른 측점 하나(137-4). */ diff --git a/resources/tester/helper_307_pick_highlight.cjs b/resources/tester/helper_307_pick_highlight.cjs new file mode 100644 index 000000000..cf10fb03d --- /dev/null +++ b/resources/tester/helper_307_pick_highlight.cjs @@ -0,0 +1,134 @@ +/* 307 B06 고름 강조 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로. + * ① 「관」 setter 가름(splitPipePick) ② 측점 길 형상 강조(highlightShapes) ③ 벽 고름 콘크리트 표식(Station_Controls). + */ +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 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("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.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; +}; +const memory = new Map(); +globalThis.sessionStorage = { + getItem: (k) => (memory.has(k) ? memory.get(k) : null), + setItem: (k, v) => memory.set(k, String(v)), + removeItem: (k) => memory.delete(k), +}; +globalThis.window = { setTimeout, clearTimeout, sessionStorage: globalThis.sessionStorage }; + +const B = (f) => path.join(ROOT, "B06_Section", f); +const shapePick = load(B("B06_Section_UI_Cross_Shape_Pick.ts")); + +// ① 「관」 setter — 콘크리트 고름 = 콘크리트만 · 관 고름 = 관만 · 다른 벽 = 그대로 +const split = (concrete, key) => { + const calls = { base: [], shape: [] }; + const setter = shapePick.splitPipePick( + (k) => calls.base.push(k), + (on) => calls.shape.push(on), + () => concrete, + ); + setter(key); + return calls; +}; + +// ② 측점 길 형상 강조 — 맹암거는 id 대로 · 물넘이 콘크리트(pipe-…)는 안 건드림 +class Node { + constructor(id, cls = "") { + this.attrs = { "data-structure-id": id, class: cls }; + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + get classList() { + const names = () => (this.attrs.class ?? "").split(" ").filter(Boolean); + return { + contains: (c) => names().includes(c), + toggle: (c, on) => { + const rest = names().filter((n) => n !== c); + this.attrs.class = (on ? [...rest, c] : rest).join(" "); + }, + }; + } +} +const drain = new Node("ud-1"); +const concreteLit = new Node("pipe-90.00", "is-active"); // 형상 누름 「관」이 켠 콘크리트 +const concreteOff = new Node("pipe-90.00"); +const root = { querySelectorAll: () => [drain, concreteLit, concreteOff] }; +shapePick.highlightShapes(root, "pipe-90.00"); // 물넘이포장 측점 길(알약 · 목록 · 측점선) = 알약 id +const byStationPath = { + concreteOff: concreteOff.classList.contains("is-active"), + concreteLitKept: concreteLit.classList.contains("is-active"), +}; +shapePick.highlightShapes(root, "ud-1"); +const drainLit = drain.classList.contains("is-active"); +shapePick.highlightShapes(root, null); // 풂(Esc · 다른 것) — 콘크리트는 「관」 고름 풂이 끔 +const afterRelease = { + drain: drain.classList.contains("is-active"), + concreteLit: concreteLit.classList.contains("is-active"), +}; + +// ③ 벽 고름 콘크리트 표식 — 관 카드(80) · 물넘이 제 카드(90 · 관 없음) +const stations = load(B("B06_Section_UI_Page_Station_Controls.ts")); +const sections = [ + { station_id: "s80", chainage_m: 80, culvert: { inlet: {}, outlet: {} }, samples: [] }, + { station_id: "s90", chainage_m: 90, ford_pavement: { chainage_m: 90 }, samples: [] }, +]; +let changes = 0; +const controls = stations.createStationControls({ + sessionKey: () => null, + refreshCard: () => {}, + detail: () => ({ cross_sections: sections }), + crossHalfWidth: () => undefined, + sampledHalfWidth: () => 20, + ensureSampledWidth: async () => true, + projectId: () => null, +}); +const revet = controls.revetOffset; +revet.onSelectionChange = () => (changes += 1); +const flag = (chainageM, key, ownerM) => { + revet.select(chainageM, key, ownerM); + return { + shape: revet.shapePicked(), + selected: revet.selectedFor(sections[chainageM === 80 ? 0 : 1]), + }; +}; +const revetCases = { + pipeOnPipeCard: flag(80, "pipe"), // 관 그림 누름 = 관 + concreteOnPipeCard: flag(80, "pipe", 90), // 연동 관 카드 콘크리트 누름 = 콘크리트 + concreteOnOwnCard: flag(90, "pipe", 90), // 물넘이 제 카드 콘크리트 누름 + handoffOnOwnCard: flag(90, "pipe"), // 3D 넘김 「관」(관 없는 카드) = 콘크리트 + wall: flag(80, "inlet"), + released: flag(80, null), + changes: 0, +}; +revetCases.changes = changes; + +process.stdout.write( + JSON.stringify({ + split: { + concretePipe: split(true, "pipe"), + pipePipe: split(false, "pipe"), + concreteWall: split(true, "inlet"), + none: split(false, null), + }, + byStationPath, + drainLit, + afterRelease, + revetCases, + }), +); diff --git a/resources/tester/test_118_pipe_pick.py b/resources/tester/test_118_pipe_pick.py index 8a5254b20..5a6b3683d 100644 --- a/resources/tester/test_118_pipe_pick.py +++ b/resources/tester/test_118_pipe_pick.py @@ -106,7 +106,10 @@ def test_좌측_관_상자_강조_스크롤() -> None: def test_벽_본체_동시_고름_금지_그대로() -> None: """107-1 — 관도 같은 고름 저장소라 세월교 · 박스 고름과 함께 서지 않음.""" station = _src("B06_Section/B06_Section_UI_Page_Station_Controls.ts") - assert "let revetSelection: { at: string; key: RevetPick } | null = null;" in station + assert ( + "let revetSelection: { at: string; key: RevetPick; shape?: boolean } | null = null;" + in station + ) assert ( "if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);" in station diff --git a/resources/tester/test_177_4_pavement_span_box.py b/resources/tester/test_177_4_pavement_span_box.py index fe4782090..02381f337 100644 --- a/resources/tester/test_177_4_pavement_span_box.py +++ b/resources/tester/test_177_4_pavement_span_box.py @@ -60,12 +60,12 @@ def test_범위_카드_테두리는_노면공도(): def test_스크롤만_길은_리스너를_안_거침(): view = _read("B06_Section", "B06_Section_UI_Section_View.ts") - body = re.search(r"revealStation\(stationId\) \{(.*?)\n \},", view, re.S) + body = re.search(r"revealStation\(\) \{(.*?)\n \},", view, re.S) assert body, "revealStation 없음" text = body.group(1) assert "selectedStationId = null;" in text assert "profile.showCard(null);" in text - assert 'revealCard(stationId, "smooth");' in text + assert "revealCard" not in text # 측점 없는 구조물 = 스크롤도 없음(307) assert "stationSelectListener" not in text # 폼을 닫지 않음 cross = _read("A00_Common", "design", "design_view_cross.ts") assert "if (revealOnly) sectionView.revealStation(nearest.station_id);" in cross diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 01700dfde..0762b3afe 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -115,7 +115,8 @@ def test_형상_누름은_고름_끌기는_고름_아님(ts): def test_형상_강조와_표식(ts): assert ts["activeAfterSet"] is True - assert ts["activeAfterOther"] is False and ts["activeAfterSame"] is True + # 물넘이 콘크리트는 「관」 고름(형상 누름) 몫 — 측점 길 강조(highlightShapes)가 끄지도 켜지도 않음(307) + assert ts["activeAfterOther"] is True and ts["activeAfterSame"] is True assert ts["fordTag"] == ["pipe-230.00"] # 포장층 표식 = 관 지점 알약 id diff --git a/resources/tester/test_294_6_ford_linked_mark.py b/resources/tester/test_294_6_ford_linked_mark.py index f825fa3fb..d4b76d423 100644 --- a/resources/tester/test_294_6_ford_linked_mark.py +++ b/resources/tester/test_294_6_ford_linked_mark.py @@ -60,7 +60,8 @@ def test_주인_카드_옛_꼴(out): def test_고름은_주인_구조물(out): for card in (out["neighbor"], out["owner"]): assert card["pick"] == ["pipe-90.00"] - assert "is-active" in card["classes"][0].split() + # 측점 길 고름(알약 · 목록 id)은 콘크리트를 안 켬 — 형상 누름 「관」 고름만(307) + assert "is-active" not in card["classes"][0].split() def test_CSS_연결_꼴_강조가_먼저(): diff --git a/resources/tester/test_294_7_ford_slab_pick.py b/resources/tester/test_294_7_ford_slab_pick.py index 28349da3d..66597f863 100644 --- a/resources/tester/test_294_7_ford_slab_pick.py +++ b/resources/tester/test_294_7_ford_slab_pick.py @@ -79,7 +79,8 @@ def test_배선_경사각도_상자(): view = _read("B06_Section_UI_Cross_View.ts") assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view assert "revetOffset?.select(section.chainage_m, activeRevet, ownerM);" in view - assert 'setShapeActive(key === "pipe");' in view + # 「관」 = 관 그림 · 콘크리트 하나만(307) + assert "splitPipePick(setRevetActive, setShapeActive, shapePicked)" in view assert len(view.splitlines()) <= 700 shape = _read("B06_Section_UI_Cross_Shape_Pick.ts") assert 'thickenDown(shape.getAttribute("points") ?? "")' in shape diff --git a/resources/tester/test_307_pick_highlight.py b/resources/tester/test_307_pick_highlight.py new file mode 100644 index 000000000..e14f611de --- /dev/null +++ b/resources/tester/test_307_pick_highlight.py @@ -0,0 +1,165 @@ +"""307 B06 고름 강조 — 측점 고름은 형상 안 켬 · 연동 관 = 콘크리트만 · 측점 없는 구조물 = 카드 · 스크롤 없음. + +공통(「관」 setter 가름 · 벽 고름 콘크리트 표식)은 진짜 TS 를 Node 로 돌려 보고(helper_307), +길마다(측점선 · 알약 · 좌측 목록 측점 · 영역 × 노면공 · 물넘이포장 · 연동 관 · 측점 없는 구조물)는 +그 길이 모이는 자리의 배선을 본다. +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_307_pick_highlight.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_공통_관_setter_콘크리트와_관_가름(out): + split = out["split"] + assert split["concretePipe"] == {"base": [None], "shape": [True]} # 콘크리트만 + assert split["pipePipe"] == {"base": ["pipe"], "shape": [False]} # 관만 + assert split["concreteWall"] == {"base": ["inlet"], "shape": [False]} # 다른 벽 그대로 + assert split["none"] == {"base": [None], "shape": [False]} + + +def test_공통_벽고름_콘크리트_표식(out): + cases = out["revetCases"] + assert cases["pipeOnPipeCard"] == {"shape": False, "selected": "pipe"} + assert cases["concreteOnPipeCard"] == {"shape": True, "selected": "pipe"} + assert cases["concreteOnOwnCard"] == {"shape": True, "selected": "pipe"} + assert cases["handoffOnOwnCard"] == {"shape": True, "selected": "pipe"} + assert cases["wall"] == {"shape": False, "selected": "inlet"} + assert cases["released"] == {"shape": False, "selected": None} + assert cases["changes"] == 6 # 고를 때마다 연동 카드 다시 칠함(그대로) + + +# ── 물넘이포장 × 측점 길 넷 ──────────────────────────────────────────── + + +def test_물넘이포장_측점길은_콘크리트_안켬(out): + # 측점선 · 알약 · 좌측 목록 측점 · 영역 = 끝에 `selectMark("pipe-…")` → highlightShapes + assert out["byStationPath"] == {"concreteOff": False, "concreteLitKept": True} + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + assert "highlightShapes(card, pickedMarkId);" in view + shape = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts") + assert 'if (id.startsWith("pipe-")) continue;' in shape + # 네 길이 모이는 자리 — 카드 고름 리스너 · 목록 관 줄 · 폼 관 열기 · 측점 없는 관 + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "sectionView.selectMark(mark, false);" in cross + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);" in panel # 카드 → 폼 + assert "deps.markStructure?.(chainageM === null ? null : `pipe-${" in panel # 목록 관 줄 + # 측점 길은 「관」 벽 고름을 안 엶 — 카드만 고르면 벽 고름을 비움(그대로) + assert "stationControls.revetOffset.select(target.chainage_m, null);" in cross + + +def test_물넘이포장_형상누름은_콘크리트(out): + view = _read("B06_Section", "B06_Section_UI_Cross_View.ts") + assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view + assert "splitPipePick(setRevetActive, setShapeActive, shapePicked)" in view + assert "setRevetActive(highlightRevet);" in view + assert out["revetCases"]["concreteOnOwnCard"]["shape"] is True + + +# ── 노면공 × 측점 길 넷 ────────────────────────────────────────────── + + +def test_노면공_포장층은_포장층_누름만(): + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + assert "const pave = span && isPavement(spanMarkId) && paveShapeId === spanMarkId;" in view + # 알약 · 목록 · 측점 길 = 누름 턴 밖 selectMark → 포장층 표식 풂 + assert "if (!paveShapeTurn) paveShapeId = null;" in view + # 포장층 누름 = 턴 안에서 목록 고름 길을 타고 · 끝나면 그 노면공 포장층만 켬 + grid = re.search( + r"const pave = pavementAt\(event\.target, spanMarks\);(.*?)\n \},", view, re.S + ) + assert grid, "포장층 누름 처리 없음" + body = grid.group(1) + assert body.index("paveShapeTurn = true;") < body.index("pavementSelectListener?.(") + assert body.index("paveShapeTurn = false;") < body.index("paveShapeId = pave.structure_id;") + assert body.index("paveShapeId = pave.structure_id;") < body.index("syncSpanHighlights();") + # 범위 테두리(--span)는 그대로 — 다른 구간형 구조물과 같은 길 + assert 'card.classList.toggle("b06-cross-card--span", span);' in view + assert "spanMarkId = span || isPavement(structureId) ? structureId : null;" in view + + +# ── 연동 관 ─────────────────────────────────────────────────────────── + + +def test_연동관_콘크리트누름은_관형상_안켬(out): + assert out["revetCases"]["concreteOnPipeCard"] == {"shape": True, "selected": "pipe"} + assert out["split"]["concretePipe"]["base"] == [None] # 관 그림 setter 엔 「관」 안 감 + select = _read("B06_Section", "B06_Section_UI_Page_Structures_Selection.ts") + assert "origRevet(chainageM, key, ownerM);" in select + view = _read("B06_Section", "B06_Section_UI_Cross_View.ts") + # 콘크리트 ↔ 관 그림 바꿔 누름 = 풀지 않고 옮김 · 같은 것 다시 누름 = 풂 + assert ( + "activeRevet = activeRevet === key && (ownerM !== undefined) === shapePicked() ? null : key;" + in view + ) + # 좌측 하위 컨테이너 켬(경사각도 상자)은 그대로 — 「관」 벽 고름 길 + dock = _read("B06_Section", "B06_Section_UI_Adjust_Dock.ts") + assert 'side === "pipe" && (!own || own.closest("[hidden]")) ? visibleBox(SLOPE_BOX)' in dock + + +# ── 측점 없는 구조물 ────────────────────────────────────────────────── + + +def test_측점없는_구조물_카드_스크롤_없음(): + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + body = re.search(r"revealStation\(\) \{(.*?)\n \},", view, re.S) + assert body, "revealStation 없음" + assert "revealCard" not in body.group(1) + assert "markCard(previous);" in body.group(1) # 옛 카드는 풂 + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + # B06 알약 · 좌측 목록 = 폼 onSelect · 측점 없는 관 = selectPipeAt — 둘 다 revealOnly 길 + assert ( + "const revealOnly = !coverable(structure) || !plantsStation(structure, markTypes);" in panel + ) + assert "deps.focusChainage(pipe.chainage_m, true);" in panel + # B05 알약 · 3D · 지도 넘김 = focusPicked — 측점 없는 구조물은 카드 고름을 안 부름 + focus = re.search(r"focusPicked: \(structureId, focus\) => \{(.*?)\n \},", panel, re.S) + assert focus, "focusPicked 없음" + assert "if (!known || plantsStation(known, markTypes)) {" in focus.group(1) + assert "if (known) section.selectById(structureId);" in focus.group(1) + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "structuresPanel.focusPicked(handoff?.structureId ?? null, () =>" in cross + profile = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert "edit.selectStructure(hit.structure_id);" in profile + + +# ── 지킬 것 ─────────────────────────────────────────────────────────── + + +def test_맹암거_형상강조_그대로(out): + assert out["drainLit"] is True + assert out["afterRelease"] == {"drain": False, "concreteLit": True} + + +def test_줄_한도(): + assert len(_read("A00_Common", "design", "design_view_cross.ts").splitlines()) <= 685 + for name in ( + "B06_Section_UI_Cross_View.ts", + "B06_Section_UI_Section_View.ts", + "B06_Section_UI_Page_Station_Controls.ts", + "B06_Section_UI_Page_Structures_Panel.ts", + ): + assert len(_read("B06_Section", name).splitlines()) <= 700, name