fix(B05·B06 한 페이지): 교점 옮기면 좌측 패널 열림 — 펴기는 사용자 고름만 (398-1)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-17 20:47:37 +09:00
co-authored by Claude Opus 5.5
parent 5face40cf0
commit 0170801088
5 changed files with 189 additions and 1 deletions
+3
View File
@@ -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<unknown> | null): void {
if (!run) return;
+2 -1
View File
@@ -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 },
);
+30
View File
@@ -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);
}
@@ -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);
});
@@ -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",
]