From b400993b8203a971b4ef08ab5d07637afab3764a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 00:01:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20[=EC=83=81=EC=84=B8]=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=ED=9C=A0=20=EC=A4=8C=20=C2=B7=20=EA=B0=80=EC=9A=B4?= =?UTF-8?q?=EB=8D=B0=20=EB=8B=A8=EC=B6=94=20=ED=8C=AC=20=C2=B7=20=ED=91=9C?= =?UTF-8?q?=20=EA=B0=95=EC=A1=B0=20=EB=B6=89=EC=9D=80=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C(164-1=20=C2=B7=20164-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복제 자식을 g 하나로 감싸 공용 b_svg_zoom_pan(wheelZoom · 커서 고정 · 상한 16 · 1배 팬) · [+ − ⤢] · 칸 비율 = viewBox - 강조 띠 · 판정 선 · 십자 · 사토장 모두 --color-danger · 고른 칸 붉은 테두리 · 줄 표시 · hover 옅게 - 그림에 대상 없는 칸 data-empty 회색 · 누름 없음(presentKeys) · 이름 칸도 그 줄 첫 키로 눌림 - 시험 test_164_detail_zoom 새로 · test_150_detail_pick presentKeys 사례 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B06_Section/B06_Section_UI_Cross_Detail.ts | 68 ++++++++-- .../B06_Section_UI_Cross_Detail_Pick.ts | 35 ++++-- .../B06_Section_UI_Style_Cross_Detail.css | 54 ++++++-- resources/tester/helper_150_detail_pick.cjs | 8 ++ resources/tester/helper_164_detail_zoom.cjs | 117 ++++++++++++++++++ resources/tester/test_150_detail_pick.py | 8 +- resources/tester/test_164_detail_zoom.py | 103 +++++++++++++++ 7 files changed, 361 insertions(+), 32 deletions(-) create mode 100644 resources/tester/helper_164_detail_zoom.cjs create mode 100644 resources/tester/test_164_detail_zoom.py diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index e8a67dd2c..79165830a 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -7,6 +7,8 @@ * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). + * 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) — + * 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임. * 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤. * ========================================================================== */ @@ -19,8 +21,14 @@ import type { CrossSection, SectionDetailResponse, } from "./B06_Section_Api_Fetch"; -import { inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common"; -import { DETAIL_TARGETS, highlightDetail } from "./B06_Section_UI_Cross_Detail_Pick"; +import { CROSS_PAD, inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common"; +import { DETAIL_TARGETS, highlightDetail, presentKeys } from "./B06_Section_UI_Cross_Detail_Pick"; +import { + attachSvgZoomPan, + type ZoomPanHandle, + type ZoomPanState, +} from "../A00_Common/b_svg_zoom_pan"; +import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ export interface CrossDetailHost { @@ -94,12 +102,13 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT headRow.append(cell("th", label, undefined, key), cell("td", value(key), undefined, key)); layout.left.forEach(([groupL, subL, keyL], index) => { const row = table.insertRow(); - if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 })); + // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) + if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }, keyL)); else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }, keyL)); if (subL !== null) row.append(cell("th", subL, undefined, keyL)); row.append(cell("td", value(keyL), undefined, keyL)); const [groupM, subM, keyM] = layout.middle[index]; - if (groupM !== null) row.append(cell("th", groupM, { row: 2 })); + if (groupM !== null) row.append(cell("th", groupM, { row: 2 }, keyM)); row.append(cell("th", subM, undefined, keyM), cell("td", value(keyM), undefined, keyM)); const [labelR, keyR] = layout.right[index]; row.append(cell("th", labelR ?? "", undefined, keyR), cell("td", value(keyR), undefined, keyR)); @@ -107,6 +116,35 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT return table; } +/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ + +/** 복제 자식을 g 하나로 감싸 공용 줌 · 팬을 검(축 · 눈금째 한 장) · 휠 = 커서 고정 줌. */ +export function attachDetailZoom( + clone: SVGSVGElement, + initial: ZoomPanState | undefined, + onChange: (state: ZoomPanState) => void, +): ZoomPanHandle { + const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); + const layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); + layer.append(...clone.childNodes); + clone.append(layer); + // 칸 비율 = viewBox 비율 → 커서 고정 · 팬 속도 정확(공용 조각이 칸 크기로 환산) + clone.style.setProperty("--ratio", String(width / height)); + return attachSvgZoomPan({ + svg: clone, + layer, + widthPx: width, + heightPx: height, + pad: CROSS_PAD, + maxScale: 16, + // 1배에서도 팬 — 그림 둘레 한 장씩 열어 둠 + content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, + wheelZoom: true, + initial, + onChange, + }); +} + /* ── 모달 ───────────────────────────────────────────────────────────── */ interface OpenDetail { @@ -151,29 +189,41 @@ export function openCrossDetail(section: CrossSection): void { tableBox.className = "b06-detail__table"; body.append(cad, note, tableBox); - // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) + // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) let picked: string | null = null; const showPicked = (): void => { + const svg = cad.querySelector("svg"); + const present = svg ? presentKeys(svg, state.section.design) : new Set(); + if (picked && !present.has(picked)) picked = null; tableBox.querySelectorAll("[data-key]").forEach((el) => { el.classList.toggle("is-picked", el.dataset.key === picked); + el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? "")); }); - const svg = cad.querySelector("svg"); if (svg) highlightDetail(svg, state.section.design, picked); }; tableBox.addEventListener("click", (event) => { - const key = (event.target as HTMLElement).closest("[data-key]")?.dataset.key; - if (!key) return; + const el = (event.target as HTMLElement).closest("[data-key]"); + const key = el?.dataset.key; + if (!key || el.hasAttribute("data-empty")) return; picked = picked === key ? null : key; showPicked(); }); + // 모달 배율 — 다시 떠도(재복제) 이어 감 + let zoom: ZoomPanState | undefined; + const state: OpenDetail = { stationId: section.station_id, section, paint() { const card = cardOf(state.section); const svg = card?.querySelector("svg.b06-section__chart"); - cad.replaceChildren(svg ? svg.cloneNode(true) : document.createTextNode("그림 없음")); + if (!svg) cad.replaceChildren(document.createTextNode("그림 없음")); + else { + const clone = svg.cloneNode(true) as SVGSVGElement; + const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next)); + cad.replaceChildren(clone, buildZoomControls(handle)); + } showPicked(); }, loadTable() { diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts index 6912c05d2..01f3415c9 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts @@ -5,7 +5,7 @@ * 칸 키 → 그림 요소: 면적 띠(깍기 · 쌓기) · 사토장 선 · 중심 십자(높이 넷) · 보라 판정 선 구역 * (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려 * `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리. - * 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌). + * 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖. * ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름). * ========================================================================== */ @@ -43,26 +43,35 @@ export function lineZone(design: CrossDesign, offsetM: number): LineZone { return ditch ? "ditch" : "cut_slope"; } -/** 그림 복제 안 강조 — 묻어 온 `.is-active` 를 지우고 `key` 대상만 켬(null = 모두 끔). */ -export function highlightDetail(svg: Element, design: CrossDesign | undefined, key: string | null) { - svg.querySelectorAll(".is-active").forEach((el) => el.classList.remove("is-active")); - const target = key ? DETAIL_TARGETS[key] : undefined; - if (!target) return; - if ("selector" in target) { - svg.querySelectorAll(target.selector).forEach((el) => el.classList.add("is-active")); - return; - } +/** `key` 의 강조 대상 요소 — 그 카드에 없으면 빈 목록(164-2 `data-empty`). */ +function targetsOf(svg: Element, design: CrossDesign | undefined, key: string): Element[] { + const target = DETAIL_TARGETS[key]; + if (!target) return []; + if ("selector" in target) return [...svg.querySelectorAll(target.selector)]; const center = svg.querySelector("g.b06-chart__center line"); const ppm = Number(svg.getAttribute("data-px-per-m")); - if (!design || !center || !(ppm > 0)) return; + if (!design || !center || !(ppm > 0)) return []; const x0 = Number(center.getAttribute("x1")); - svg.querySelectorAll(".b06-chart__design-hit").forEach((hit) => { + return [...svg.querySelectorAll(".b06-chart__design-hit")].filter((hit) => { const xs = (hit.getAttribute("points") ?? "") .trim() .split(/\s+/) .map((pair) => Number(pair.split(",")[0])); const mid = (xs[0] + xs[xs.length - 1]) / 2; // x = 왼쪽 여백 + (최대 offset − offset) × ppm → offset = (x(0) − x) ÷ ppm - if (lineZone(design, (x0 - mid) / ppm) === target.zone) hit.classList.add("is-active"); + return lineZone(design, (x0 - mid) / ppm) === target.zone; }); } + +/** 그림 복제 안 강조 — 묻어 온 `.is-active` 를 지우고 `key` 대상만 켬(null = 모두 끔). */ +export function highlightDetail(svg: Element, design: CrossDesign | undefined, key: string | null) { + svg.querySelectorAll(".is-active").forEach((el) => el.classList.remove("is-active")); + if (key) targetsOf(svg, design, key).forEach((el) => el.classList.add("is-active")); +} + +/** 그 카드에 강조 대상이 있는 키(164-2) — 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음)은 빠짐. */ +export function presentKeys(svg: Element, design: CrossDesign | undefined): Set { + return new Set( + Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0), + ); +} diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index 53adc3245..4dd3b21d7 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -50,13 +50,21 @@ overflow: hidden; flex: 1; min-height: 0; + /* [+ − ⤢] 우상단(164-1) · 그림은 가운데 */ + position: relative; + container-type: size; + display: flex; + align-items: center; + justify-content: center; } -/* viewBox 그대로 — 비율 유지하며 칸 가득 */ +/* 칸 비율 = viewBox 비율(--ratio · 164-1) — 칸 가득 · 여백 없이 커서 고정 줌이 정확 */ .b06-detail__cad > svg { display: block; - width: 100%; - height: 100%; + flex: none; + width: min(100cqw, 100cqh * var(--ratio, 2)); + height: auto; + aspect-ratio: var(--ratio, 2); } /* B07 횡단 수량 산출표 — 칸 배치 그대로 */ @@ -89,24 +97,52 @@ color: var(--color-text); } -/* 표 칸 누름 ↔ 그림 강조(150-4) — 짝 있는 칸만 손가락 · 고른 칸 강조 */ +/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 · 줄 붉게 */ .b06-detail__qtable [data-key] { cursor: pointer; } -.b06-detail__qtable [data-key]:hover, +.b06-detail__qtable [data-key]:hover { + background: color-mix(in srgb, var(--color-danger) 8%, transparent); +} + .b06-detail__qtable .is-picked { - background: color-mix(in srgb, var(--color-primary) 18%, transparent); + background: color-mix(in srgb, var(--color-danger) 18%, transparent); + box-shadow: inset 0 0 0 1px var(--color-danger); + color: var(--color-danger); +} + +.b06-detail__qtable tr:has(.is-picked) { + background: color-mix(in srgb, var(--color-danger) 5%, transparent); +} + +/* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */ +.b06-detail__qtable [data-key][data-empty] { + cursor: default; + background: none; + color: var(--color-text-muted); +} + +/* 그림 강조 한 벌 = 붉은 계통(164-2 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */ +.b06-detail__cad .b06-chart__area.is-active > polygon { + fill: color-mix(in srgb, var(--color-danger) 35%, transparent); + stroke: var(--color-danger); + stroke-width: 2; +} + +.b06-detail__cad .b06-chart__design-hit.is-active { + stroke: color-mix(in srgb, var(--color-danger) 70%, transparent); + stroke-width: 9; } -/* 그림 복제에만 있는 강조 둘 — 중심 십자(높이 넷) · 사토장 선 */ .b06-detail__cad .b06-chart__center.is-active line { - stroke: var(--color-warning); + stroke: var(--color-danger); stroke-width: 3; } .b06-detail__cad .b06-chart__spoil-fill.is-active { - stroke-width: 4; + stroke: var(--color-danger); + stroke-width: 3; } /* 판정 선은 복제에서 누를 일 없음 — 손가락 지움 */ diff --git a/resources/tester/helper_150_detail_pick.cjs b/resources/tester/helper_150_detail_pick.cjs index 3a7def913..b6ed3c7af 100644 --- a/resources/tester/helper_150_detail_pick.cjs +++ b/resources/tester/helper_150_detail_pick.cjs @@ -131,4 +131,12 @@ for (const key of [ pick.highlightDetail(svg, design, key); out.active[String(key)] = activeOf(svg); } +// 164-2 — 그 카드에 대상이 있는 키(암석 띠 · 사토장 없는 카드 = 그 칸 빠짐) +out.present = [...pick.presentKeys(fakeSvg(20, 300), design)].sort(); +const bare = fakeSvg(20, 300); +const kept = bare.els.filter( + (el) => !el.set.has("b06-chart__area--cut_rock") && !el.set.has("b06-chart__spoil-fill"), +); +bare.els.splice(0, bare.els.length, ...kept); +out.presentBare = [...pick.presentKeys(bare, design)].sort(); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs new file mode 100644 index 000000000..ada95dc88 --- /dev/null +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -0,0 +1,117 @@ +/* PLAN 164-1 단위검증 헬퍼 — [상세] 그림 줌 · 팬(`B06_Section_UI_Cross_Detail.ts` `attachDetailZoom`). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 가짜 svg 에 휠 · 가운데 단추 · 더블클릭을 쏨. + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_164_detail_zoom.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 B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +// 카드 여백은 원본에서 읽음(화면과 같은 값) +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +const STUBS = { + "@ui/ui_template_modal": { openModal: () => {} }, + "../A00_Common/b_page_state": {}, + "./B06_Section_Api_Fetch": {}, + "./B06_Section_UI_Section_Common": { CROSS_PAD, L: (key) => key }, + "./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (request.endsWith(".css")) return {}; + if (STUBS[request]) return STUBS[request]; + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +function el(tag) { + const attrs = {}; + const listeners = {}; + const self = { + tag, + parent: null, + childNodes: [], + attrs, + style: { setProperty: (name, value) => (attrs[`style:${name}`] = value) }, + classList: { add: () => {}, remove: () => {} }, + setAttribute: (name, value) => (attrs[name] = String(value)), + getAttribute: (name) => attrs[name] ?? null, + // 진짜 DOM 처럼 옛 부모에서 옮겨 옴 + append: (...nodes) => + nodes.forEach((node) => { + if (node.parent) node.parent.childNodes.splice(node.parent.childNodes.indexOf(node), 1); + node.parent = self; + self.childNodes.push(node); + }), + addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), + fire: (type, event) => { + const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }; + (listeners[type] ?? []).forEach((fn) => fn(full)); + return full; + }, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + // 칸 = viewBox 의 2 배(비율 같음) + getBoundingClientRect: () => ({ left: 0, top: 0, width: 1200, height: 600 }), + }; + return self; +} +globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: el }; + +const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts")); + +const svg = el("svg"); +svg.setAttribute("viewBox", "0 0 600 300"); +svg.append(el("rect"), el("g"), el("g")); +let saved; +const handle = attachDetailZoom(svg, undefined, (next) => (saved = next)); +const layer = svg.childNodes[0]; +const out = { + wrapped: svg.childNodes.length === 1 && layer.tag === "g" && layer.childNodes.length === 3, + ratio: svg.attrs["style:--ratio"], + before: layer.getAttribute("transform"), +}; +// 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정 +svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); +out.wheelIn = { ...saved }; +out.cursorFixed = saved.tx + saved.scale * 300; +svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 }); +out.wheelOut = saved.scale; +// 원배율에서 좌클릭 끌기 = 팬 아님 +handle.reset(); +svg.fire("pointerdown", { button: 0, clientX: 100, clientY: 100 }); +svg.fire("pointermove", { clientX: 300, clientY: 100 }); +svg.fire("pointerup", {}); +out.leftDrag = { ...saved }; +// 가운데 단추 끌기 = 팬(1배에서도 · 화면 200px = svg 100) +svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 }); +svg.fire("pointermove", { clientX: 300, clientY: 140 }); +svg.fire("pointerup", {}); +out.middlePan = { ...saved }; +out.panClickStopped = svg.fire("click", { + stopPropagation() { + this.stopped = true; + }, +}).stopped; +// 다시 떠도 배율 이어 감 +const again = el("svg"); +again.setAttribute("viewBox", "0 0 600 300"); +attachDetailZoom(again, saved, () => {}); +out.restored = again.childNodes[0].getAttribute("transform"); +// 더블클릭 원복 +svg.fire("dblclick", {}); +out.reset = layer.getAttribute("transform"); +out.max = (() => { + for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); + return saved.scale; +})(); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_150_detail_pick.py b/resources/tester/test_150_detail_pick.py index 1657f1a71..250ea5908 100644 --- a/resources/tester/test_150_detail_pick.py +++ b/resources/tester/test_150_detail_pick.py @@ -68,6 +68,12 @@ def test_칸마다_그림_강조(out): assert active["fence"] == [] +def test_대상_있는_키만_빈칸은_빠짐(out): + """164-2 — 그림에 대상이 없는 칸(암석 띠 · 사토장 없음)은 `data-empty`.""" + assert out["present"] == sorted(out["targets"]) + assert set(out["present"]) - set(out["presentBare"]) == {"cut_rock", "spoil_fill"} + + def test_상세_표_칸_키_누름_배선(): with open( os.path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail.ts"), encoding="utf-8" @@ -89,4 +95,4 @@ def test_상세_큰_창_공용_막_그림_글_표_순서(): assert "body.append(cad, note, tableBox)" in src assert "pointer-events: none" not in css and "b06-detail__backdrop" not in css assert ".ui-modal.b06-detail > .ui-modal__body {\n overflow: hidden;" in css - assert "flex: 1;\n min-height: 0;" in css and "height: 100%;" in css + assert "flex: 1;\n min-height: 0;" in css and "aspect-ratio: var(--ratio, 2);" in css diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py new file mode 100644 index 000000000..668369ae7 --- /dev/null +++ b/resources/tester/test_164_detail_zoom.py @@ -0,0 +1,103 @@ +"""PLAN 164 — B06 [상세] 그림 줌 · 팬(164-1) · 표 강조 붉은 한 벌(164-2). + +짝: `B06_Section/B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` · 칸 누름) · +`_UI_Style_Cross_Detail.css`(칸 비율 · 붉은 강조). 실제 TS 를 `helper_164_detail_zoom.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +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="프론트엔드 의존성 미설치", +) + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_164_detail_zoom.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_복제_자식을_g_하나로_감쌈_비율(out): + assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임 + assert out["ratio"] == "2" # viewBox 600 × 300 + assert out["before"] is None + + +def test_휠_당기면_확대_커서_고정(out): + assert out["wheelIn"]["scale"] == pytest.approx(1.15) + assert out["cursorFixed"] == pytest.approx(300) # 커서 아래 svg x 300 이 제자리 + assert out["wheelOut"] < out["wheelIn"]["scale"] + assert out["max"] == 16 + + +def test_가운데_단추_팬_좌클릭은_팬_아님(out): + assert out["leftDrag"] == {"scale": 1, "tx": 0, "ty": 0} + # 1배에서도 팬 · 화면 200·40 px = svg 100·20(칸 2 배) + assert out["middlePan"] == {"scale": 1, "tx": 100, "ty": 20} + assert out["panClickStopped"] is True + + +def test_다시_떠도_배율_유지_더블클릭_원복(out): + assert out["restored"] == "translate(100 20) scale(1)" + assert out["reset"] == "translate(0 0) scale(1)" + + +def test_모달_배선_단추_칸_비율(): + src = _read("B06_Section_UI_Cross_Detail.ts") + css = _read("B06_Section_UI_Style_Cross_Detail.css") + assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src + assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src + assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src + assert "position: relative;" in css and "container-type: size;" in css + assert "aspect-ratio: var(--ratio, 2);" in css + assert "width: min(100cqw, 100cqh * var(--ratio, 2));" in css + + +def test_강조_붉은_한_벌_고름_hover_다름_빈칸(): + css = _read("B06_Section_UI_Style_Cross_Detail.css") + for selector in ( + ".b06-detail__cad .b06-chart__area.is-active > polygon {\n fill: color-mix(in srgb, var(--color-danger)", + ".b06-detail__cad .b06-chart__design-hit.is-active {\n stroke: color-mix(in srgb, var(--color-danger)", + ".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);", + ".b06-detail__cad .b06-chart__spoil-fill.is-active {\n stroke: var(--color-danger);", + ".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%", + ".b06-detail__qtable tr:has(.is-picked) {", + ".b06-detail__qtable [data-key][data-empty] {\n cursor: default;", + ): + assert selector in css, selector + assert "royal-amethyst" not in css and "--color-warning);" not in css.split("/* 그림 강조")[1] + assert ( + ".b06-detail__qtable [data-key]:hover {\n background: color-mix(in srgb, var(--color-danger) 8%" + in css + ) + + +def test_빈칸_누름_없음_이름_칸_첫_키(): + src = _read("B06_Section_UI_Cross_Detail.ts") + assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src + assert 'if (!key || el.hasAttribute("data-empty")) return;' in src + assert 'cell("th", groupL, { row: 2 }, keyL)' in src + assert 'cell("th", groupM, { row: 2 }, keyM)' in src