Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-10 00:15:10 +09:00
7 changed files with 361 additions and 32 deletions
+59 -9
View File
@@ -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<string>();
if (picked && !present.has(picked)) picked = null;
tableBox.querySelectorAll<HTMLElement>("[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<HTMLElement>("[data-key]")?.dataset.key;
if (!key) return;
const el = (event.target as HTMLElement).closest<HTMLElement>("[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() {
+22 -13
View File
@@ -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<string> {
return new Set(
Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0),
);
}
@@ -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;
}
/* 판정 선은 복제에서 누를 일 없음 — 손가락 지움 */
@@ -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));
+117
View File
@@ -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));
+7 -1
View File
@@ -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
+103
View File
@@ -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