From c924ee20420a8034bf18fa04692129a0662b4666 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:17:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=EB=8F=84=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EB=8F=8C=EB=B6=99=EC=9E=84=20=EB=9D=A0=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EA=B0=95=EC=A1=B0=20=E2=80=94=20=EA=B4=80?= =?UTF-8?q?=20=EA=B3=A0=EB=A6=84=20=EA=BC=B4(152-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그린 띠마다 투명 판정면(벽 · 관 판정면 아래 · 보라 선 위) · 고름 키 chute · 강조 setter - 좌측 「도수로/돌붙임」 상자 강조 · 스크롤 · 상자 고치면 띠 고름 · 자기 측점 폼으로 - 조정창 없음 · B05 3D chute 키 → B06 고름 · 새 시험 test_152_4_chute_pick - 받는 줄 문자열 시험 54-29 고침(152-2 OWN_HEIGHT_ROW) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B06_Section/B06_Section_UI_Adjust_Dock.ts | 31 +- B06_Section/B06_Section_UI_Cross_Culvert.ts | 4 +- .../B06_Section_UI_Cross_Structure_Panel.ts | 6 +- B06_Section/B06_Section_UI_Cross_View.ts | 14 + .../B06_Section_UI_Cross_Wall_Hatch.ts | 28 ++ .../B06_Section_UI_Page_Structure_Pick.ts | 4 +- ...06_Section_UI_Page_Structures_Selection.ts | 18 +- .../B06_Section_UI_Style_Cross_Areas.css | 12 + resources/tester/helper_152_4_chute_pick.cjs | 278 ++++++++++++++++++ resources/tester/test_118_pipe_pick.py | 2 +- resources/tester/test_152_4_chute_pick.py | 66 +++++ .../tester/test_54_29_pick_same_panel.py | 6 +- 12 files changed, 445 insertions(+), 24 deletions(-) create mode 100644 resources/tester/helper_152_4_chute_pick.cjs create mode 100644 resources/tester/test_152_4_chute_pick.py diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 577561ef9..e194f2270 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -47,10 +47,15 @@ const EXTRA_ROWS = [ ".b06-structure-panel__struct--span", ].join(", "); -/** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) · - * 단 수 · 옵션 · 집수정 내공(24-4a). */ +/** 도수로 · 돌붙임 상자 — 배관 · 박스 · 세월교 chute_add · 물넘이 chute(152-4). */ +const CHUTE_BOX = + 'fieldset[data-spec-container="chute_add"], fieldset[data-spec-container="chute"]'; + /** 연동을 푼 옆 카드의 「이 측점 높이」 줄 — 어느 칸이든 받음(152-2). */ const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height"; + +/** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) · + * 단 수 · 옵션 · 집수정 내공(24-4a). */ const SIDE_ROWS = [ OWN_HEIGHT_ROW, ".b06-structure-panel__struct--readout", @@ -159,14 +164,20 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { const side = panelRoot.dataset.side; // 세월교 · BOX 본체 = 옮길 줄 없이 그 명세의 본체 상자만 강조(66-1) — 날개벽 칸이 든 명세 폼에서 찾음 // 관 = 배관 명세 폼의 「관」 상자만 강조(118-1) - if (side === "body" || side === "pipe") { - const body = (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet) - ?.closest(".b05-structure__spec") - ?.querySelector( - side === "pipe" - ? 'fieldset[data-spec-container="pipe"]' - : 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]', - ); + // 도수로 띠 = 보이는 명세의 「도수로/돌붙임」 상자(배관 · 박스 · 세월교 chute_add · 물넘이 chute · 152-4) + if (side === "body" || side === "pipe" || side === "chute") { + const body = + side === "chute" + ? [...document.querySelectorAll(CHUTE_BOX)].find( + (box) => !box.closest("[hidden]"), + ) + : (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet) + ?.closest(".b05-structure__spec") + ?.querySelector( + side === "pipe" + ? 'fieldset[data-spec-container="pipe"]' + : 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]', + ); if (!body || body.closest("[hidden]")) return; setGroupLabel(body, ""); body.classList.add("is-active"); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index a8fedd0e0..6f74b9983 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -60,8 +60,8 @@ function polygon( // "own" = 배관과 무관한 **독립 기슭막이**(구조물 정본 D군). 조정창·조작값 저장은 // 배관 벽과 같은 체계를 그대로 쓴다(2026-08-28 사용자: 배관측점처럼 이동). export type RevetKey = "inlet" | "outlet" | "own" | `extra${number}` | `bextra${number}`; -/** 고름 전용 키 — 벽 키 + 관(118-1 · 조정창 없음 · 강조만). 조정 함수에는 `RevetKey` 만. */ -export type RevetPick = RevetKey | "pipe"; +/** 고름 전용 키 — 벽 키 + 관(118-1) · 도수로 띠(152-4) — 조정창 없음 · 강조만. 조정 함수에는 `RevetKey` 만. */ +export type RevetPick = RevetKey | "pipe" | "chute"; /** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */ export type RevetHighlightSetter = (key: RevetPick | null) => void; diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 5380cb425..b5549919b 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -496,11 +496,11 @@ export function buildStructurePanel( return { root, show: (pick) => { - // 관 = 조정창 없음 · 좌측 「관」 상자만 강조(118-1 · 세월교 본체 꼴) - const key = pick === "pipe" ? null : pick; + // 관 · 도수로 띠 = 조정창 없음 · 좌측 「관」 · 「도수로/돌붙임」 상자만 강조(118-1 · 152-4) + const key = pick === "pipe" || pick === "chute" ? null : pick; current = key; root.classList.toggle("is-hidden", key === null); - root.dataset.side = pick === "pipe" ? "pipe" : ""; + root.dataset.side = pick === "pipe" || pick === "chute" ? pick : ""; document.removeEventListener("keydown", onArrowKey); drag.detach(); if (!key) return; diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index ab9d0181d..4a61949d4 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -22,6 +22,7 @@ import { } from "./B06_Section_UI_Cross_Ford_Pavement"; import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; +import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch"; import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; import { @@ -572,6 +573,19 @@ export function createCrossSectionCard( if (highlightRevet) setRevetActive(highlightRevet); } raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5) + // 도수로 · 돌붙임 띠 고름 — 관 고름 꼴(152-4) + const setChuteActive = attachChuteBandPick( + plotLayer, + revetOffset && (() => toggleRevet("chute")), + ); + if (setChuteActive) { + const baseSetActive = setRevetActive; + setRevetActive = (key) => { + baseSetActive(key); + setChuteActive(key === "chute"); + }; + setChuteActive(highlightRevet === "chute"); + } // 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2). const lineDesign = section.design; linePick = attachDesignLinePick({ diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts index 2a982696c..ab62c86d3 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -424,3 +424,31 @@ export function appendChuteBand( layer.append(band, clip, group); } } + +/** + * 도수로 · 돌붙임 띠 고름(152-4 · 118-1 관 고름 꼴) — 그린 띠(관 · 박스 · 세월교 · 물넘이)마다 투명 판정면. + * 벽 판정면 아래 · 보라 선 판정 위(판정면 클래스를 같이 씀). 띠가 없으면 null · 강조 setter 반환. + */ +export function attachChuteBandPick( + layer: SVGElement, + onPick?: () => void, +): ((active: boolean) => void) | null { + const kids = [...layer.children]; + const bands = kids.filter((el) => el.classList.contains("b06-chart__culvert-pitching")); + if (!bands.length) return null; + const firstHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit")); + for (const band of onPick ? bands : []) { + const hit = document.createElementNS(SVG_NS, "polygon"); + hit.setAttribute("points", band.getAttribute("points") ?? ""); + hit.setAttribute("class", "b06-chart__culvert-revet-hit b06-chart__chute-hit"); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = "도수로 · 돌붙임 선택"; + hit.append(title); + hit.addEventListener("click", (event) => { + event.stopPropagation(); + onPick?.(); + }); + layer.insertBefore(hit, firstHit ?? null); + } + return (active) => bands.forEach((band) => band.classList.toggle("is-active", active)); +} diff --git a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts index 97698499b..4ec8b2bb4 100644 --- a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts +++ b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts @@ -23,8 +23,8 @@ import { type SelectableStructure, } from "../A00_Common/design/design_selection"; -/** 벽 고름이 받는 부재키 — 관은 강조만(118-1) · 집수정·본체는 여기서 빠진다. */ -const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+|pipe)$/; +/** 벽 고름이 받는 부재키 — 관 · 도수로 띠는 강조만(118-1 · 152-4) · 집수정·본체는 여기서 빠진다. */ +const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+|pipe|chute)$/; interface PickTargets { focusStation: (stationId: string) => void; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index bfdb42d82..bdd0f2c51 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -73,10 +73,12 @@ export function wireStructureSelection( // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) if (key) markWallSelecting(); origRevet(chainageM, key); - if (key) remember?.(ownerChainageOf(chainageM), key); - if (key) panel.showAtChainage(ownerChainageOf(chainageM)); + // 도수로 띠는 자기 측점 시설 — 이웃 관 연장 안이어도 그 관 폼을 안 엶(152-4) + const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM); + if (key) remember?.(owner, key); + if (key) panel.showAtChainage(owner); else if (!nested) { - remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 + remember?.(owner, ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 panel.releasePipe(); } if (key) syncInletStructure(); @@ -88,14 +90,20 @@ export function wireStructureSelection( const keyOf = (box: HTMLElement): RevetPick | null => { const id = box.dataset.specContainer; if (id === "inlet" || id === "outlet" || id === "pipe") return id; + if (id === "chute_add" || id === "chute") return "chute"; // 도수로 띠(152-4) return id === "soil_add" ? `extra${Number(box.dataset.specIndex)}` : null; }; const pickBox = (box: HTMLElement): void => { const chainageM = panel.currentChainage(); const target = chainageM === null ? undefined : sectionAt(chainageM); const key = keyOf(box); - if (!key || !target?.culvert || stationControls.revetOffset.highlightFor(target) === key) - return; + const chute = + target?.culvert?.chute ?? + target?.box?.chute ?? + target?.ford?.chute ?? + target?.ford_pavement?.chute; + if (!key || !target || !(key === "chute" ? chute : target.culvert)) return; + if (stationControls.revetOffset.highlightFor(target) === key) return; stationControls.revetOffset.select(target.chainage_m, key); refreshCard?.(target.chainage_m); }; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index de87ddbcc..e4bbacfa9 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -384,6 +384,18 @@ stroke-linejoin: round; } +/* 도수로 · 돌붙임 띠 고름(152-4) — 관 고름과 같은 강조 · 판정면은 굵은 투명 테두리로 여유 */ +.b06-chart__culvert-pitching.is-active { + fill: color-mix(in srgb, var(--color-danger) 30%, transparent); + stroke: var(--color-danger); + stroke-width: 2.2; + stroke-dasharray: none; +} +.b06-chart__culvert-revet-hit.b06-chart__chute-hit { + stroke: transparent; + stroke-width: 8; +} + /* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */ .b06-design__rockb-btn { width: 22px; diff --git a/resources/tester/helper_152_4_chute_pick.cjs b/resources/tester/helper_152_4_chute_pick.cjs new file mode 100644 index 000000000..d18e1a16d --- /dev/null +++ b/resources/tester/helper_152_4_chute_pick.cjs @@ -0,0 +1,278 @@ +/* PLAN 152-4 단위검증 헬퍼 — B06 도수로 · 돌붙임 띠 고름 · 강조(118-1 관 고름 꼴) · 조정창 숨김 · 3D chute 키. + * 화면 TS 를 그 자리에서 트랜스파일해 흉내 DOM 에서 돌림 · 결과 = 표준출력 JSON 한 벌. + * 입력 = `fixtures/b07_cross_overlay_sections.json` 배관 측점(도수로 상자를 붙인 사본). 짝 = `test_152_4_chute_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(); +let STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, + "@ui/ui_template_elements": { showToast: () => undefined }, +}; + +// ── 화면 그릇 흉내 — 오버레이 · 조정창이 쓰는 만큼만 ───────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = String(tag).toUpperCase(); + this.children = []; + this.attrs = {}; + this.dataset = {}; + this.listeners = {}; + this._text = ""; + this._classes = new Set(); + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(n)), + toggle: (name, force) => { + const on = force === undefined ? !self._classes.has(name) : !!force; + on ? self._classes.add(name) : self._classes.delete(name); + return on; + }, + contains: (name) => self._classes.has(name), + }; + this.style = { setProperty: () => {} }; + } + set className(value) { + this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(value) { + this._text = String(value); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(k)); + } + insertBefore(kid, ref) { + const at = ref ? this.children.indexOf(ref) : -1; + at < 0 ? this.children.push(kid) : this.children.splice(at, 0, kid); + return kid; + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + replaceChildren(...kids) { + this.children = kids; + } + setAttribute(name, value) { + this.attrs[name] = String(value); + if (name === "class") this.className = value; + } + getAttribute(name) { + return name === "class" ? this.className : (this.attrs[name] ?? null); + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + removeEventListener() {} + click() { + let stopped = false; + const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} }; + (this.listeners.click ?? []).forEach((fn) => fn(event)); + return stopped; + } + querySelectorAll(selector) { + const found = []; + const walk = (node) => + node.children.forEach((kid) => { + if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid); + walk(kid); + }); + walk(this); + return found; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } + getClientRects() { + return []; + } +} +globalThis.document = { + createElement: (tag) => new FakeNode(tag), + createElementNS: (_ns, tag) => new FakeNode(tag), + createComment: () => new FakeNode("#comment"), + addEventListener: () => {}, + removeEventListener: () => {}, +}; +globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} }; +globalThis.requestAnimationFrame = (fn) => fn(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts")); +const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert")); +const { attachChuteBandPick } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch")); +const { applyStructurePick } = loadTs(B06("B06_Section_UI_Page_Structure_Pick")); + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +); +const sections = fixture.cross_sections; +const plain = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); +const pipeSection = JSON.parse(JSON.stringify(plain)); +pipeSection.culvert.chute = { structure: "돌붙임", thickness_m: 0.3 }; +const listWith = sections.map((s) => (s === plain ? pipeSection : s)); +const out = {}; +const cls = (n) => n.className; + +// ① 오버레이 + 띠 고름 — 판정면 차례 · 누름 · 강조 +function draw(section, list, onPick) { + const layer = new FakeNode("g"); + const layout = computeStoredLayouts(section, list).culvert; + const setter = appendCulvertOverlay( + layer, + layout, + (o) => -o * 10, + (e) => -e * 10, + () => {}, + false, + ); + const picked = []; + const setChute = attachChuteBandPick(layer, onPick ? () => picked.push("chute") : undefined); + return { layer, setter, setChute, picked }; +} +const drawn = draw(pipeSection, listWith, true); +const kids = drawn.layer.children; +const bands = kids.filter((n) => n.classList.contains("b06-chart__culvert-pitching")); +const hits = kids.filter((n) => n.classList.contains("b06-chart__culvert-revet-hit")); +const chuteHits = hits.filter((n) => n.classList.contains("b06-chart__chute-hit")); +const stopped = chuteHits[0].click(); +drawn.setChute(true); +const on = bands.map((b) => b.classList.contains("is-active")); +drawn.setChute(false); +const off = bands.map((b) => b.classList.contains("is-active")); +out.overlay = { + bands: bands.length, + chuteHits: chuteHits.length, + // 판정면 차례 — 띠 판정면이 관 · 벽 판정면보다 먼저(아래) + hitOrder: hits.map((n) => (n.classList.contains("b06-chart__chute-hit") ? "chute" : "other")), + sameShape: chuteHits.every((h, i) => h.attrs.points === bands[i].attrs.points), + picked: drawn.picked, + stopped, + on, + off, +}; +// 고름 배선 없음(조작 없는 카드) — 판정면 없이 강조만 · 띠 없으면 null +const bare = draw(pipeSection, listWith, false); +out.bare = { + hits: bare.layer.children.filter((n) => n.classList.contains("b06-chart__chute-hit")).length, + setter: typeof bare.setChute, +}; +out.noChute = draw(plain, sections, true).setChute === null; + +// ② B05 3D 띠 고름(chute 키) → B06 고름 길로 +const calls = []; +applyStructurePick( + { at: pipeSection.chainage_m, key: "chute" }, + { cross_sections: [pipeSection] }, + { + focusStation: () => {}, + refreshCard: () => {}, + revetSelect: (c, k) => calls.push(["revet", k]), + fordSelect: (c, k) => calls.push(["ford", k]), + boxSelect: (c, k) => calls.push(["box", k]), + }, +); +out.pick = calls; + +// ③ 조정창 — show("chute") = 숨김 · side "chute" +const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base")); +STUBS = { + ...STUBS, + "./B06_Section_UI_Section_Common": { L: (key) => key }, + "./B06_Section_UI_Cross_Panel_Base": { + ...base, + bindDragHandle: () => ({ attach: () => {}, detach: () => {} }), + }, +}; +const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel")); +const touched = []; +const spy = (name, value) => () => { + touched.push(name); + return value; +}; +const panel = buildStructurePanel({ + scrollKey: "t152", + adjustFor: spy("adjustFor", { x: 0, d: null, h: null, m: null }), + nudge: spy("nudge"), + nudgeSlope: spy("nudgeSlope"), + nudgeHeight: spy("nudgeHeight"), + heightFor: spy("heightFor", 2), + formFor: spy("formFor", "돌쌓기(메)"), + setForm: spy("setForm"), + reset: spy("reset"), + close: () => {}, + structureFor: () => "revet", + basinAdjustFor: () => ({ innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 }), + updateBasin: () => {}, + nudgeBasinLateral: () => {}, + resetBasin: () => {}, + canNudge: spy("canNudge", true), + fineMove: spy("fineMove", false), + dragAxes: spy("dragAxes", null), + extraState: () => ({ canAdd: false, count: 0 }), + isLinkedCard: () => false, + basinExtraState: () => ({ canAdd: false, count: 0 }), + hiddenPipe: () => false, + bodyGuard: () => false, + setExtraCount: () => {}, + setBasinExtraCount: () => {}, + equalizeExtras: () => {}, + spanFor: spy("spanFor", null), + setSpan: () => {}, + linkState: () => null, + setLinked: () => {}, + followGrade: () => true, + setFollowGrade: () => {}, +}); +panel.show("chute"); +out.panel = { + hidden: panel.root.classList.contains("is-hidden"), + side: panel.root.dataset.side, + touched, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_118_pipe_pick.py b/resources/tester/test_118_pipe_pick.py index 0bf896f9a..42c96a478 100644 --- a/resources/tester/test_118_pipe_pick.py +++ b/resources/tester/test_118_pipe_pick.py @@ -92,7 +92,7 @@ def test_관은_조정창_없음(out) -> None: def test_좌측_관_상자_강조_스크롤() -> None: dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") - assert 'if (side === "body" || side === "pipe") {' in dock + assert 'if (side === "body" || side === "pipe" || side === "chute") {' in dock # 152-4 도수로 assert "'fieldset[data-spec-container=\"pipe\"]'" in dock assert 'body.classList.add("is-active");' in dock assert 'if (scroll) body.scrollIntoView({ block: "nearest" });' in dock diff --git a/resources/tester/test_152_4_chute_pick.py b/resources/tester/test_152_4_chute_pick.py new file mode 100644 index 000000000..36bf47d36 --- /dev/null +++ b/resources/tester/test_152_4_chute_pick.py @@ -0,0 +1,66 @@ +"""152-4 도수로 · 돌붙임 띠 고름 · 강조 — 118-1 관 고름 꼴(판정면 · setter · 좌측 상자 · 3D 키). + +화면 TS 를 흉내 DOM 에서 돌린 값(`helper_152_4_chute_pick.cjs`) + 배선 문자열. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_152_4_chute_pick.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def _src(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_띠마다_판정면_벽_관_아래(out: dict) -> None: + overlay = out["overlay"] + assert overlay["bands"] == overlay["chuteHits"] == 2 + assert overlay["sameShape"] + n = overlay["chuteHits"] + assert overlay["hitOrder"][:n] == ["chute"] * n # 벽 · 관 판정면이 위(겹치면 이김) + assert "chute" not in overlay["hitOrder"][n:] + + +def test_띠_누름_고름_강조(out: dict) -> None: + overlay = out["overlay"] + assert overlay["picked"] == ["chute"] and overlay["stopped"] + assert overlay["on"] == [True, True] and overlay["off"] == [False, False] + assert out["bare"] == {"hits": 0, "setter": "function"} + assert out["noChute"] + + +def test_조정창_없음_3D_키(out: dict) -> None: + assert out["panel"] == {"hidden": True, "side": "chute", "touched": []} + assert out["pick"] == [["revet", "chute"]] + + +def test_좌측_상자_강조_배선() -> None: + view = _src("B06_Section/B06_Section_UI_Cross_View.ts") + assert "attachChuteBandPick(" in view and 'toggleRevet("chute")' in view + assert 'setChuteActive(key === "chute");' in view + dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") + assert ( + 'fieldset[data-spec-container="chute_add"], fieldset[data-spec-container="chute"]' in dock + ) + selection = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts") + assert 'if (id === "chute_add" || id === "chute") return "chute";' in selection + assert 'const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM);' in selection + css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") + assert ".b06-chart__culvert-pitching.is-active {" in css diff --git a/resources/tester/test_54_29_pick_same_panel.py b/resources/tester/test_54_29_pick_same_panel.py index 7622d62af..4ee156b77 100644 --- a/resources/tester/test_54_29_pick_same_panel.py +++ b/resources/tester/test_54_29_pick_same_panel.py @@ -25,7 +25,11 @@ def test_배관_칸은_옵션_읽기_줄을_안_받음() -> None: assert inlet assert "struct--options" not in inlet.group(1) assert "struct--readout" not in inlet.group(1) - assert 'const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";' in dock + # 152-2 — 풀린 연동 카드 「이 측점 높이」 줄만 더 받음 + assert ( + "const PIPE_OUTLET_ROWS = `${OWN_HEIGHT_ROW}, .b06-structure-panel__struct--basin-size`;" + in dock + ) def test_배관_칸_이름표는_그대로() -> None: