From 468cee8fb7b492eb05b706928a2505a9386da2a7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 14:13:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=20=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD)?= =?UTF-8?q?:=20369-3=20=EC=A7=80=EB=8F=84=20=EC=9C=84=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=C2=B7=20=ED=9D=90=EB=A6=BC=20=ED=8C=90=20=EB=88=84=EB=A6=84?= =?UTF-8?q?=20=3D=20viewport=20=EC=A7=91=EA=B8=B0=20=C2=B7=20=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=ED=8C=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=BA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 흐림 판 [업데이트] 아래 관 표지가 있으면 setPointerCapture 로 단추가 click 못 받던 것 - 시험 test_369_3_update_press(가짜 표지 + pointer 사건 차례) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- .../B05_Profile_UI_Drainage_Interact.ts | 6 + .../tester/helper_369_3_update_press.cjs | 147 ++++++++++++++++++ resources/tester/test_369_3_update_press.py | 42 +++++ 3 files changed, 195 insertions(+) create mode 100644 resources/tester/helper_369_3_update_press.cjs create mode 100644 resources/tester/test_369_3_update_press.py diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index 22c3f6b8e..4f4bc3896 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -24,6 +24,9 @@ import type { createMapContextMenu } from "@ui/ui_template_context_menu"; /** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ const CLICK_SLOP_PX = 4; +/** 지도 집기 · 유역 고르기 · 팬에서 빼는 지도 위 요소. */ +const HTML_CONTROLS = + "button, input, select, textarea, a, label, [role='button'], .b05-drainage__veil"; export interface DrainageInteractParams { viewport: HTMLElement; @@ -92,6 +95,9 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { viewport.addEventListener("pointerdown", (event) => { if (contextMenu.contains(event.target)) return; contextMenu.close(); + // 지도 위 HTML 조작 요소(흐림 판 [업데이트] 등) 누름은 그 요소 몫 — 아래 관 표지를 집어 + // setPointerCapture 하면 단추가 pointerup · click 을 못 받는다(369-3). + if (event.target instanceof Element && event.target.closest(HTML_CONTROLS)) return; // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); const rect = viewport.getBoundingClientRect(); diff --git a/resources/tester/helper_369_3_update_press.cjs b/resources/tester/helper_369_3_update_press.cjs new file mode 100644 index 000000000..41ecd61ea --- /dev/null +++ b/resources/tester/helper_369_3_update_press.cjs @@ -0,0 +1,147 @@ +/* 369-3 [업데이트] 진짜 누름 헬퍼 — `B05_Profile_UI_Drainage_Interact.ts` 를 그 자리에서 + * 트랜스파일해 가짜 viewport · 단추 · 관 표지로 pointerdown → pointerup → click 차례를 돌림. + * 사로잡기(setPointerCapture)가 걸리면 pointerup · click 은 viewport 로 감(브라우저와 같음). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); + +class Element { + constructor(tag, parent = null) { + this.tag = tag; + this.parent = parent; + this.className = ""; + this.listeners = {}; + } + matches(selector) { + return selector.split(",").some((part) => { + const s = part.trim(); + return s.startsWith(".") ? this.className.split(" ").includes(s.slice(1)) : s === this.tag; + }); + } + closest(selector) { + for (let node = this; node; node = node.parent) if (node.matches(selector)) return node; + return null; + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + fire(type, event) { + (this.listeners[type] ?? []).forEach((fn) => fn(event)); + } +} +global.Element = Element; + +const stubs = { + "../A00_Common/touch_bridge": { wheelZoomsIn: () => true }, + "../B04_PreProcess/B04_PreProcess_UI_MapRender": { + computeMaxScale: () => 10, + lonLatToScreen: () => ({ x: 0, y: 0 }), + }, + "../B04_PreProcess/B04_PreProcess_UI_MapOverlays": { pointInRings: () => true }, + "./B05_Profile_UI_Drainage_ZoomRange": { clampWheelScale: (value) => value }, +}; +const file = path.join(B05, "B05_Profile_UI_Drainage_Interact.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)( + box.exports, + box, + (name) => stubs[name] ?? require(name), +); + +function setup() { + const viewport = new Element("div"); + viewport.className = "b05-drainage__viewport"; + viewport.style = { removeProperty() {} }; + viewport.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 300 }); + viewport.captured = null; + viewport.setPointerCapture = (id) => (viewport.captured = id); + const canvas = new Element("canvas", viewport); + const veil = new Element("div", viewport); + veil.className = "b05-drainage__veil"; + const update = new Element("button", veil); + update.className = "b05-drainage__analyze b05-drainage__update"; + const log = { live: 0, grabs: 0, picks: 0 }; + update.addEventListener("click", () => log.live++); // [업데이트] = drainage/live 한 번 + let dragging = false; + const state = { scale: 1, offset: { x: 0, y: 0 } }; + box.exports.bindDrainageInteractions({ + viewport, + contextMenu: { contains: () => false, close() {} }, + // 가짜 표지 — 단추 바로 아래(어디를 눌러도 집힘) + pipeEditor: { + handleDown: () => ((dragging = true), log.grabs++, true), + handleMove: () => dragging, + handleUp: () => ((dragging = false), false), + }, + currentView: () => ({ mapRect: { width: 400 }, width: 400 }), + getScale: () => state.scale, + setScale: (v) => (state.scale = v), + getMeta: () => null, + getMinScale: () => 0.1, + getOffset: () => state.offset, + setOffset: (x, y) => (state.offset = { x, y }), + scheduleDraw() {}, + getNormalizer: () => ({}), + getBasins: () => [ + { + index: 0, + polygon_lonlat: [ + [0, 0], + [1, 0], + [1, 1], + ], + area_m2: 1, + }, + ], + getSelectedBasin: () => null, + selectBasin: () => log.picks++, + }); + return { viewport, canvas, update, log, state }; +} + +/** 진짜 마우스 누름 — 사건은 target 에서 viewport 로 거품 · 사로잡히면 up · click 이 viewport 로. */ +function press(world, target, button = 0) { + const event = { target, button, pointerId: 1, pointerType: "mouse", clientX: 200, clientY: 150 }; + const bubble = (type, from) => { + for (let node = from; node; node = node.parent) + node.fire(type, { ...event, preventDefault() {} }); + }; + world.viewport.captured = null; + bubble("pointerdown", target); + const upTarget = world.viewport.captured !== null ? world.viewport : target; + bubble("pointerup", upTarget); + bubble("click", upTarget); +} + +const onButton = setup(); +press(onButton, onButton.update); +const onMap = setup(); +press(onMap, onMap.canvas); +const panMap = setup(); +const panEvent = { + target: panMap.canvas, + button: 1, + pointerId: 2, + pointerType: "mouse", + preventDefault() {}, +}; +panMap.viewport.fire("pointerdown", { ...panEvent, clientX: 0, clientY: 0 }); +panMap.viewport.fire("pointermove", { ...panEvent, clientX: 30, clientY: 40 }); + +process.stdout.write( + JSON.stringify({ + button: { + ...onButton.log, + captured: onButton.viewport.captured, + offset: onButton.state.offset, + }, + map: { ...onMap.log, captured: onMap.viewport.captured }, + pan: { captured: panMap.viewport.captured, offset: panMap.state.offset }, + }), +); diff --git a/resources/tester/test_369_3_update_press.py b/resources/tester/test_369_3_update_press.py new file mode 100644 index 000000000..28bc93455 --- /dev/null +++ b/resources/tester/test_369_3_update_press.py @@ -0,0 +1,42 @@ +"""369-3 배수유역 흐림 판 [업데이트] 진짜 누름 — 아래 관 표지가 있어도 단추가 click 을 받음. + +- 단추 위 pointerdown = viewport 집기 · 사로잡기 · 유역 고르기 안 함 → click = 계산 시작 1회. +- 지도(캔버스) 위 좌클릭 = 관 표지 집기 그대로 · 가운데 버튼 = 팬 그대로. +Node 헬퍼(`helper_369_3_update_press.cjs`)가 Interact.ts 를 그 자리에서 돌린다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_369_3_update_press.cjs")], + capture_output=True, + text=True, + timeout=60, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_단추_누름은_계산_시작_한_번(r): + assert r["button"] == { + "live": 1, + "grabs": 0, + "picks": 0, + "captured": None, + "offset": {"x": 0, "y": 0}, + } + + +def test_지도_누름은_관_표지_집기_가운데는_팬(r): + assert r["map"] == {"live": 0, "grabs": 1, "picks": 0, "captured": 1} + assert r["pan"] == {"captured": 2, "offset": {"x": 30, "y": 40}}