fix(B05): 종단 새 창 최대화 때 편집 · 다시 그리기 — 놓인 창 화면 틀 · 시계 공용 함수로 통일(230-2)

공용 common_util_view_window(viewOf · frameOf) 신설.
design_profile rebuild · 접기 · render, Heights 높이 맞춤, 표 · 유토곡선 크기 끌기, 유토곡선 세로창 미끄러짐,
끌기 뒤 그리기, ▲▼ · 방향키 길게 누르기, 배수유역도, 구조물 말풍선 — 메인 창 전역 틀 · 시계 대신 요소가 놓인 창 것.
새 시험 test_230_2_popout_frames(메인 창 틀 멈춤 흉내 · 새 창 패널 코드 훑기).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 17:52:01 +09:00
co-authored by Claude Opus 5.5
parent edc98bad57
commit 3ec5779169
16 changed files with 429 additions and 41 deletions
@@ -79,9 +79,13 @@ globalThis.ResizeObserver = class {
disconnect() {}
};
const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"));
const viewWindow = load(path.join(ROOT, "common_util", "common_util_view_window.ts"));
const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), {
"../common_util/common_util_view_window": viewWindow,
});
const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), {
"../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod,
"@util/common_util_view_window": viewWindow,
"@util/common_util_chart_ywindow": { needsFullRedraw: () => true },
});
@@ -0,0 +1,227 @@
/* 종단 새 창 최대화 — 같은 꼴 나머지(계획서 230-2) 단위검증 헬퍼.
* 메인 창은 「가려짐」(화면 틀 · 시계 멈춤) · 종단 요소는 새 창 소속. 실제 TS 의 해당 함수 본문을
* 잘라 그 자리에서 트랜스파일해 돌리고 JSON 으로 냄 — 다시 그리기 · 높이 맞춤 · 표 · 유토곡선 ·
* 끌기 뒤 그리기 · 길게 누르기 · 배수유역도가 새 창 틀 · 시계로 도는지. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8");
const run = (code, scope) => {
const js = ts.transpileModule(code, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const names = Object.keys(scope);
return new Function(...names, js)(...names.map((n) => scope[n]));
};
/** `start` 부터 짝 맞는 닫는 중괄호까지 잘라 냄 — 본문 여는 괄호가 반환 타입 뒤면 `open` 으로 짚음. */
function block(src, start, open = "{") {
const at = src.indexOf(start);
if (at < 0) throw new Error(`본문 없음: ${start}`);
let depth = 0;
for (let i = src.indexOf(open, at) + open.length - 1; i < src.length; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}" && --depth === 0) return src.slice(at, i + 1);
}
throw new Error(`닫힘 없음: ${start}`);
}
// ---- 가짜 창 — frozen 이면 화면 틀 · 시계가 안 돎 ----
function fakeWindow(name, frozen) {
const win = { name, frozen, now: 0, timers: [], seq: 0, frames: 0, listeners: [] };
win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq);
win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id));
win.setInterval = (fn, ms) => {
const id = ++win.seq;
const again = (at) => win.timers.push({ id, at, fn: () => (fn(), again(win.now + ms)) });
again(win.now + ms);
return id;
};
win.clearInterval = win.clearTimeout;
win.requestAnimationFrame = (fn) => win.setTimeout(() => (win.frames++, fn(win.now)), 16);
win.cancelAnimationFrame = win.clearTimeout;
win.addEventListener = (type, fn) => win.listeners.push([type, fn]);
win.removeEventListener = (type, fn) =>
(win.listeners = win.listeners.filter(([t, f]) => t !== type || f !== fn));
win.tick = (ms) => {
if (win.frozen) return;
const end = win.now + ms;
for (;;) {
const next = win.timers.filter((t) => t.at <= end).sort((a, b) => a.at - b.at)[0];
if (!next) break;
win.now = next.at;
win.clearTimeout(next.id);
next.fn();
}
win.now = end;
};
return win;
}
const main = fakeWindow("main", true);
const popup = fakeWindow("popup", false);
globalThis.window = main;
globalThis.requestAnimationFrame = main.requestAnimationFrame; // 옛 코드가 쓰던 전역 = 메인 창
globalThis.cancelAnimationFrame = main.cancelAnimationFrame;
const inPopup = () => ({ ownerDocument: { defaultView: popup }, style: { setProperty() {} } });
const vw = {};
run(
read("common_util", "common_util_view_window.ts").replace(/export /g, "") +
"\nObject.assign(vw, { viewOf, frameOf });",
{ vw },
);
const { viewOf, frameOf } = vw;
const out = {};
const mainBusy = () => main.timers.length;
// ① 공용 함수 — 새 창 요소는 새 창 · 안 붙은 요소는 메인 창
out.viewOf = {
popup: viewOf(inPopup()).name,
detached: viewOf({ ownerDocument: { defaultView: null } }).name,
none: viewOf(null).name,
};
// ② 계획고 편집 뒤 다시 그리기(design_profile rebuild) — 두 번 불러도 한 번만 그림
{
const s = { draws: 0, previews: 0 };
const src = block(
read("A00_Common", "design", "design_profile.ts"),
"function rebuild(): void {",
);
run(`let redrawPending = false; ${src}\nrebuild(); rebuild(); s.rebuild = true;`, {
s,
base: null,
alignment: null,
buildAlignment: () => null,
store: { edits: () => ({}) },
scheduleCrossPreview: () => s.previews++,
draw: () => s.draws++,
root: inPopup(),
frameOf,
});
popup.tick(50);
out.rebuild = { draws: s.draws, previews: s.previews, mainTimers: mainBusy() };
}
// ③ 높이 맞춤(Heights scheduleLightSync)
{
const s = { syncs: 0 };
const src = block(
read("B05_Profile", "B05_Profile_UI_Profile_Heights.ts"),
"function scheduleLightSync(): void {",
);
run(`let lightSyncPending = false; ${src}\nscheduleLightSync(); scheduleLightSync();`, {
deps: { body: inPopup() },
syncHeightsLight: () => s.syncs++,
frameOf,
});
popup.tick(50);
out.heights = { syncs: s.syncs, mainTimers: mainBusy() };
}
// ④ 표 · 유토곡선 크기 끌기(TableOverlay · MassHaul onResize)
for (const [key, file] of [
["table", "B05_Profile_UI_Profile_TableOverlay.ts"],
["massHaul", "B05_Profile_UI_Profile_MassHaul.ts"],
]) {
const s = { changed: 0, handles: 0 };
const src = block(read("B05_Profile", file), "onResize: () => {").replace(
/^onResize: /,
"const onResize = ",
);
run(`let resizeRedrawPending = false; ${src}\nonResize(); onResize();`, {
syncHandlePosition: () => s.handles++,
onChanged: () => s.changed++,
overlay: inPopup(),
frameOf,
});
popup.tick(50);
out[key] = { changed: s.changed, handles: s.handles, mainTimers: mainBusy() };
}
// ⑤ 유토곡선 세로창 미끄러짐(scheduleMassHaulSettle)
{
const s = { redraws: 0 };
const src = block(
read("common_util", "common_util_mass_haul_view.ts"),
"export function scheduleMassHaulSettle(",
).replace(/^export /, "");
const state = { held: null, settled: false, frame: 0 };
run(
`${src}\nscheduleMassHaulSettle(state, () => s.redraws++, node); scheduleMassHaulSettle(state, () => s.redraws++, node);`,
{
s,
state,
node: inPopup(),
viewOf,
},
);
popup.tick(50);
out.settle = {
redraws: s.redraws,
frame: state.frame,
view: state.view?.name,
mainTimers: mainBusy(),
};
}
// ⑥ 끌기 뒤 다시 그리기(Profile_Drag scheduleDraw · 120ms 모음)
{
const s = { draws: 0 };
const src = block(
read("B05_Profile", "B05_Profile_UI_Profile_Drag.ts"),
"const scheduleDraw = (): void => {",
);
run(`let resizeTimer = 0; let timerView = window; ${src}\nscheduleDraw(); scheduleDraw();`, {
window: main,
root: inPopup(),
ctx: { draw: () => s.draws++ },
viewOf,
});
popup.tick(200);
out.drag = { draws: s.draws, mainTimers: mainBusy() };
}
// ⑦ ▲▼ 길게 누르기(Profile_Edit createHoldRepeater) — 0.5초 뒤 초당 10회
{
const s = { acts: 0 };
const src = block(
read("B05_Profile", "B05_Profile_UI_Profile_Edit.ts"),
"function createHoldRepeater(",
"} {",
);
run(`${src}\nconst r = createHoldRepeater(node); r.start(() => s.acts++); s.r = r;`, {
s,
window: main,
node: inPopup(),
viewOf,
HOLD_DELAY_MS: 500,
HOLD_INTERVAL_MS: 100,
});
popup.tick(1000);
const acts = s.acts;
s.r.stop();
popup.tick(1000);
out.hold = { acts, afterStop: s.acts, mainTimers: mainBusy(), popupTimers: popup.timers.length };
}
// ⑧ 배수유역도(Drainage_Panel scheduleDraw)
{
const s = { draws: 0 };
const src = block(
read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts"),
"function scheduleDraw(): void {",
);
run(`let frameHandle = 0; let frameView = window; ${src}\nscheduleDraw(); scheduleDraw();`, {
window: main,
root: inPopup(),
draw: () => s.draws++,
viewOf,
});
popup.tick(50);
out.drainage = { draws: s.draws, mainTimers: mainBusy() };
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
"""종단 새 창 최대화 — 같은 꼴 나머지(계획서 230-2) 회귀검증.
230-1 과 같은 까닭 — 메인 창 전역 화면 틀 · 시계는 새 창 최대화로 메인 창이 가려지면 멈추거나
늦게 돎. 새 창으로 가는 종단 패널(종단도 · 표 · 유토곡선 · 배수유역도)의 틀 · 시계는 공용
`viewOf` · `frameOf`(common_util_view_window) 로 그 요소가 놓인 창에서 받음.
`helper_230_2_popout_frames.cjs` 가 메인 창을 「가려짐」(틀 · 시계 멈춤)으로 두고 실제 함수 본문을 돌림.
옛 코드로는 아래 다시 그리기가 모두 0 번.
"""
import json
import os
import re
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
# 새 창으로 가는 종단 패널의 화면 코드
POPOUT_FILES = [
"A00_Common/design/design_profile.ts",
"A00_Common/design/design_profile_viewport.ts",
"B05_Profile/B05_Profile_UI_Profile_Wheel.ts",
"B05_Profile/B05_Profile_UI_Profile_Heights.ts",
"B05_Profile/B05_Profile_UI_Profile_MassHaul.ts",
"B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts",
"B05_Profile/B05_Profile_UI_Profile_Drag.ts",
"B05_Profile/B05_Profile_UI_Profile_Edit.ts",
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts",
"B05_Profile/B05_Profile_UI_Drainage_Panel.ts",
"B05_Profile/B05_Profile_UI_Structures_Marks.ts",
"common_util/common_util_mass_haul_view.ts",
]
# 메인 창 전역 틀 · 시계 — `x.requestAnimationFrame` 처럼 창을 짚은 것은 제외
MAIN_WINDOW_CLOCK = re.compile(
r"(?<![.\w])(?:window\.)?(requestAnimationFrame|cancelAnimationFrame|setTimeout|setInterval)\("
)
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_230_2_popout_frames.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_공용_함수_놓인_창(r):
assert r["viewOf"] == {"popup": "popup", "detached": "main", "none": "main"}
def test_계획고_편집_뒤_다시_그림(r):
assert r["rebuild"] == {"draws": 1, "previews": 2, "mainTimers": 0} # 두 번 불러도 한 번
def test_높이_맞춤(r):
assert r["heights"] == {"syncs": 1, "mainTimers": 0}
def test_표_유토곡선_크기_끌기_다시_그림(r):
assert r["table"] == {"changed": 1, "handles": 2, "mainTimers": 0}
assert r["massHaul"] == {"changed": 1, "handles": 2, "mainTimers": 0}
def test_유토곡선_세로창_미끄러짐(r):
assert r["settle"] == {"redraws": 1, "frame": 0, "view": "popup", "mainTimers": 0}
def test_끌기_뒤_다시_그림(r):
assert r["drag"] == {"draws": 1, "mainTimers": 0}
def test_길게_누르기_새_창_시계(r):
# 0.5초 뒤부터 0.1초마다 — 1초 동안 5번 · 멈춘 뒤 더 안 돎
assert r["hold"] == {"acts": 5, "afterStop": 5, "mainTimers": 0, "popupTimers": 0}
def test_배수유역도_다시_그림(r):
assert r["drainage"] == {"draws": 1, "mainTimers": 0}
def test_새_창_패널_코드에_메인_창_틀_시계_없음():
found = []
for rel in POPOUT_FILES:
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
for no, line in enumerate(f, 1):
code = line.split("//")[0]
if code.lstrip().startswith("*"):
continue
if MAIN_WINDOW_CLOCK.search(code):
found.append(f"{rel}:{no}: {line.strip()}")
assert found == []
def test_design_profile_700줄_안():
with open(os.path.join(ROOT, "A00_Common/design/design_profile.ts"), encoding="utf-8") as f:
assert sum(1 for _ in f) <= 700