Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user