From 017080108817e3d6b365575bd06ac8d2ad656107 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 20:47:37 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfix(B05=C2=B7B06=20=ED=95=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80):=20=EA=B5=90=EC=A0=90=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B0=EB=A9=B4=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=97=B4=EB=A6=BC=20=E2=80=94=20=ED=8E=B4=EA=B8=B0=EB=8A=94=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B3=A0=EB=A6=84=EB=A7=8C=20(39?= =?UTF-8?q?8-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_user_pick.ts 새 파일 — 사용자 고름 표시 한 칸(진짜 누름 안 · 묶음 차례 없음) - design_shared onReveal 한 곳이 그 칸만 봄 — 폼(고름 · 우클릭 추가)은 그대로 펴기를 부름 - design_page_data pageBusy — 묶음 차례가 떠 있나 - 배수유역 다시 나눔 응답 · 방향키 · 끌기 · 묶음 다시 돎의 다시 칠하기 = 안 폄 - 시험 test_398_1_reveal_user_pick(헬퍼 helper_398_1_user_pick.cjs) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_page_data.ts | 3 + A00_Common/design/design_shared.ts | 3 +- A00_Common/design/design_user_pick.ts | 30 +++++++ resources/tester/helper_398_1_user_pick.cjs | 81 +++++++++++++++++++ .../tester/test_398_1_reveal_user_pick.py | 73 +++++++++++++++++ 5 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 A00_Common/design/design_user_pick.ts create mode 100644 resources/tester/helper_398_1_user_pick.cjs create mode 100644 resources/tester/test_398_1_reveal_user_pick.py diff --git a/A00_Common/design/design_page_data.ts b/A00_Common/design/design_page_data.ts index 752bfccf0..f674b95de 100644 --- a/A00_Common/design/design_page_data.ts +++ b/A00_Common/design/design_page_data.ts @@ -69,6 +69,9 @@ const notify = (projectId: string): void => { busyListeners.get(projectId)?.forEach((fn) => fn(now)); }; +/** 묶음 차례가 떠 있나 — 그 사이 폼이 다시 실려도 좌측 패널은 안 폄(`design_user_pick` · 398-1). */ +export const pageBusy = (projectId: string): boolean => (busy.get(projectId) ?? 0) > 0; + /** 묶음 차례 하나가 뜸 — 끝나면(밀림 · 실패 포함) 내림. */ export function trackPageBusy(projectId: string, run: Promise | null): void { if (!run) return; diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 2633f98b2..6e387f993 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -31,6 +31,7 @@ import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page"; import { plantedBase } from "./design_pipeline_base"; +import { isUserPick } from "./design_user_pick"; import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { applyStructureAreaRows, @@ -261,7 +262,7 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection { onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM), onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM), onListRelease: () => on().onListRelease?.(), - onReveal: () => shared.revealLeft(), + onReveal: () => isUserPick(shared.projectId) && shared.revealLeft(), // 사용자 고름만(398-1) }, { includeDetail: () => shared.view() === "cross", projectId: shared.projectId }, ); diff --git a/A00_Common/design/design_user_pick.ts b/A00_Common/design/design_user_pick.ts new file mode 100644 index 000000000..dbd062541 --- /dev/null +++ b/A00_Common/design/design_user_pick.ts @@ -0,0 +1,30 @@ +/* ============================================================================= + * A00_Common/design/design_user_pick.ts + * 「사용자 고름」 표시 한 칸(PLAN 398-1) — 좌측 패널 저절로 펴기(09-04 「바깥에서 고르면 좌측 패널도」)는 + * 사용자가 직접 누른 고름에서만. + * + * 사용자 고름 = 지금 브라우저가 진짜 누름(포인터 · 마우스 · 우클릭)을 처리하는 중 — 눈금 · 마름모 · 3D · + * 종단 알약 · 배수유역도 · 횡단 카드 · 우클릭 추가가 다 그 누름 안에서 폼을 엶. + * 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음이 도는 동안 + * (교점 끌기 · 곡선 지우기 · 되돌리기가 띄운 차례의 관 · 구조물 따라가기 · 다시 칠하기). + * 폼이 고른 것을 다시 실을 때마다 부르는 쪽을 막지 않고 펴기 한 곳(`design_shared` onReveal)이 여기만 봄. + * ========================================================================== */ + +import { pageBusy } from "./design_page_data"; + +const PICK_EVENTS = new Set([ + "pointerdown", + "pointerup", + "mousedown", + "mouseup", + "click", + "dblclick", + "contextmenu", +]); + +/** 지금 사용자 누름 안이고 묶음이 안 도는가 — 좌측 패널을 펴도 되는가. */ +export function isUserPick(projectId: string | null): boolean { + const event = (globalThis as { event?: Event }).event; // 처리 중인 이벤트(그 마이크로태스크까지) + if (!event?.isTrusted || !PICK_EVENTS.has(event.type)) return false; + return !projectId || !pageBusy(projectId); +} diff --git a/resources/tester/helper_398_1_user_pick.cjs b/resources/tester/helper_398_1_user_pick.cjs new file mode 100644 index 000000000..d344e7b0a --- /dev/null +++ b/resources/tester/helper_398_1_user_pick.cjs @@ -0,0 +1,81 @@ +/* PLAN 398-1 단위검증 헬퍼 — 진짜 `design_user_pick.ts` + 진짜 `design_page_data.ts`(묶음 차례 · 「계산 중」)로 + * 좌측 패널 펴기 한 곳(`design_shared` onReveal 과 같은 식)을 길마다 셈. 짝 = `test_398_1_reveal_user_pick.py`. + * 브라우저가 처리 중인 이벤트(`window.event`)는 `globalThis.event` 로 흉내. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const DESIGN = path.join(ROOT, "A00_Common", "design"); + +function load(file, stubs) { + const js = ts.transpileModule(fs.readFileSync(path.join(DESIGN, 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) => { + if (!stubs[name]) throw new Error(`stub 없음: ${name}`); + return stubs[name]; + }); + return box.exports; +} + +const data = load("design_page_data.ts", { + "./design_page_route": { setPageRouteId: () => undefined, pageRouteId: () => null }, + "../../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({}) }, + "../../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({}), + dropStaleStructureTypes: async () => false, + }, +}); +const pick = load("design_user_pick.ts", { "./design_page_data": data }); + +const P = "p1"; +let reveals = 0; +/** 폼이 고른 것을 실을 때마다 부르는 펴기(`design_shared` onReveal 그대로). */ +const onReveal = () => pick.isUserPick(P) && (reveals += 1); +/** 브라우저가 그 이벤트를 처리하는 동안(그 마이크로태스크까지) — 끝나면 이벤트 없음. */ +async function during(event, body) { + globalThis.event = event; + try { + await body(); + } finally { + globalThis.event = undefined; + } +} +const trusted = (type) => ({ type, isTrusted: true }); +const count = async (event, body) => { + const before = reveals; + await during(event, body); + return reveals - before; +}; + +(async () => { + const out = {}; + // 사용자 고름 — 종단 알약 · 횡단 카드(click) · 3D 부재(pointerup) · 우클릭 추가(contextmenu) + out.click = await count(trusted("click"), () => onReveal()); + out.pointerup = await count(trusted("pointerup"), () => onReveal()); + out.contextmenu = await count(trusted("contextmenu"), () => onReveal()); + // 누름 밖 — 배수유역 다시 나눔 응답 · 횡단 다시 그림(서버 응답 · 타이머) + out.outside = await count(undefined, () => onReveal()); + // 방향키로 교점 옮김 · 끌기 도중(pointermove) + out.arrow = await count(trusted("keydown"), () => onReveal()); + out.drag = await count(trusted("pointermove"), () => onReveal()); + // 스크립트가 낸 가짜 누름 + out.synthetic = await count({ type: "click", isTrusted: false }, () => onReveal()); + // 교점 끌기 놓음 · 곡선 지우기 · 되돌리기 단추 — 묶음 차례가 떠 그 마이크로태스크에서 관 · 구조물 따라가며 다시 칠함 + let finish; + out.pipelineRepaint = await count(trusted("pointerup"), async () => { + data.trackPageBusy(P, new Promise((resolve) => (finish = resolve))); + await Promise.resolve(); + onReveal(); // follow → 폼 다시 실음 + }); + out.busyPick = await count(trusted("click"), () => onReveal()); // 차례가 도는 사이 고름은 안 폄 + finish(); + await new Promise((resolve) => setTimeout(resolve, 0)); + out.afterRun = await count(trusted("click"), () => onReveal()); // 차례가 끝나면 다시 폄 + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/test_398_1_reveal_user_pick.py b/resources/tester/test_398_1_reveal_user_pick.py new file mode 100644 index 000000000..0e489612d --- /dev/null +++ b/resources/tester/test_398_1_reveal_user_pick.py @@ -0,0 +1,73 @@ +"""PLAN 398-1 sub7 — 교점 옮기면 좌측 패널 열림(요구 248). + +못박는 것 + - 좌측 패널 저절로 펴기는 사용자 고름만 — 진짜 누름(click · pointerup · contextmenu) 안 = 1. + - 다시 칠하기 · 누가거리 따라가기 · 되돌리기 · 묶음 다시 돎 = 0 — 누름 밖(배수유역 다시 나눔 응답) · + 방향키 · 끌기 도중 · 가짜 누름 · 묶음 차례가 떠 있는 동안(교점 놓음 · 곡선 지우기 · 되돌리기). + - 뿌리 한 곳 — 폼(`selectPipeByChainage` · `selectById` · `addAt`)은 그대로 펴기를 부르고 `design_shared` + onReveal 하나가 `isUserPick` 으로 거름. + +짝: `helper_398_1_user_pick.cjs`(진짜 `design_user_pick` · `design_page_data`). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_398_1_user_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("way", ["click", "pointerup", "contextmenu", "afterRun"]) +def test_사용자_고름은_폄(r, way): + assert r[way] == 1 + + +@pytest.mark.parametrize( + "way", ["outside", "arrow", "drag", "synthetic", "pipelineRepaint", "busyPick"] +) +def test_다시_칠하기는_안_폄(r, way): + assert r[way] == 0 + + +def test_뿌리_한_곳(): + shared = _read("A00_Common/design/design_shared.ts") + assert "onReveal: () => isUserPick(shared.projectId) && shared.revealLeft()" in shared + panel = _read("B05_Profile/B05_Profile_UI_Structures_Panel.ts") + assert panel.count("callbacks.onReveal?.()") == 3 # 고름 둘 · 우클릭 추가 — 부르는 쪽은 안 막음 + found = subprocess.run( # noqa: S603, S607 — 고정 실행 파일 + ["git", "grep", "-l", "isUserPick(", "--", "*.ts"], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + ) + users = set(found.stdout.split()) | { + "A00_Common/design/design_user_pick.ts", + "A00_Common/design/design_shared.ts", + } # 새 파일(추적 전) + assert sorted(users) == [ + "A00_Common/design/design_shared.ts", + "A00_Common/design/design_user_pick.ts", + ]