Files
Aislo/resources/tester/helper_218_1_loupe.cjs
T
eomsangdonandClaude Opus 5.5 db8462c6d4 feat(B07 CAD): 337-2 마-1 오토캐드 마우스 — 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · 그립 누르고-옮기고-누르기 · 그립 숫자 · 보기 전용 문 · 직교 강제 · Shift 임시 직교 · 휠 기본 밂 · 휠 두 번 범위
- 고르기: 누름 = 더함 · Shift = 뺌 · Ctrl = 넣고 빼기 · 상자 = 앞 고름 ∪ 상자 · Shift 상자 = 뺌(H4 · H5) · 명령 안 고르기 단계도 누를 때 첫 점 · 스침 강조(ic/selecting.ts)
- 그립: 쉴 때만 집음(N7) · 누르고 떼면 대기 → 다음 누름에 놓음 · 끌어 놓기(H3) · 거리 숫자(N8) · 확정 · 읽기 전용 도면은 그립 · Delete 막음(H2)
- 직교: F8 = 기준점에서 가로 · 세로 강제 · Shift 누른 동안 뒤집음 · 객체 스냅 점 · 좌표 · 각도 잠금은 안 덮음 · 클릭 사건 snapped 채움(ic/ortho.ts · H8) · 가이드는 F3 와 뗌 · 가이드 잡는 거리 = 화면 픽셀(H9)
- 휠: 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 모든 화면 · ☰ 설정 뒤집기 그대로) · 가운데 단추 두 번 = 범위 줌 · ZOOM · ⛶ 앞 화면 기억(ic/zoom-prompt.ts)
- 시험: mouse-acad 새로 · 휠 방향 시험 손질(input-wheel · input-prefs-cad · input_prefs · app_settings · 164 · 220_1 · 221_1 · 244_8 기대값 뒤집음 · 266 pull 박음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 23:41:46 +09:00

641 lines
22 KiB
JavaScript

/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기 · 221-2 스크롤) 단위검증 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function load(file) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const req = (p) =>
p.endsWith(".css")
? {}
: p.endsWith("key_shortcut") // 공용 키 판정(214-2)도 그 자리에서 트랜스파일
? load(path.join(ROOT, "A00_Common", "key_shortcut.ts"))
: p.endsWith("input_prefs") // 휠 방향(337 · Node = 기본 밂)
? load(path.join(ROOT, "A00_Common", "input_prefs.ts"))
: require(p);
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const m = load(path.join(ROOT, "ui_template", "ui_template_loupe.ts"));
// ---- 가짜 시계 · 가짜 창 ----
function fakeWindow(doc) {
const win = {
innerWidth: 1000,
innerHeight: 800,
document: doc,
now: 0,
timers: [],
seq: 0,
calls: [],
};
win.addEventListener = (type, fn, capture) => win.calls.push({ type, fn, capture });
win.removeEventListener = (type, fn) =>
(win.calls = win.calls.filter((c) => c.type !== type || c.fn !== fn));
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.requestAnimationFrame = (fn) => win.setTimeout(fn, 16); // 한 화면 틀
win.cancelAnimationFrame = win.clearTimeout;
win.tick = (ms) => {
win.now += ms;
for (const t of win.timers.filter((t) => t.at <= win.now)) {
win.clearTimeout(t.id);
t.fn();
}
};
win.fire = (type, props = {}) => {
const ev = {
type,
button: 0,
buttons: 0,
clientX: 0,
clientY: 0,
code: "",
target: { tagName: "DIV" },
...props,
};
ev.prevented = false;
ev.stopped = false;
ev.preventDefault = () => (ev.prevented = true);
ev.stopImmediatePropagation = () => (ev.stopped = true);
win.calls.filter((c) => c.type === type).forEach((c) => c.fn(ev));
return ev;
};
return win;
}
function fakeView() {
const log = [];
return {
log,
show: (x, y) => log.push(["show", x, y]),
move: (x, y) => log.push(["move", x, y]),
setZoom: (z) => log.push(["zoom", z]),
hide: () => log.push(["hide"]),
};
}
const last = (v) => (v.log.length ? v.log[v.log.length - 1][0] : null);
// ---- 켜는 법 ----
function setup(on = () => true) {
const win = fakeWindow(null);
const view = fakeView();
m.initLoupe(win, on, view);
return { win, view };
}
const r = {};
r.consts = {
d: m.LOUPE_DIAMETER,
zoom: m.LOUPE_ZOOM,
zoomMin: m.LOUPE_ZOOM_MIN,
zoomMax: m.LOUPE_ZOOM_MAX,
zoomStep: m.LOUPE_ZOOM_STEP,
hold: m.LOUPE_HOLD_MS,
refresh: m.LOUPE_REFRESH_MS,
};
{
const { win } = setup();
r.listeners = win.calls.map((c) => [c.type, c.capture]);
}
{
// 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배 · 확대 = 밂(deltaY < 0 · 337 기본)
const { win, view } = setup();
const wheel = (deltaY) => win.fire("wheel", { deltaY });
const zoomLog = () => view.log.filter((l) => l[0] === "zoom").map((l) => l[1]);
const offEv = wheel(-100);
const offState = { prevented: offEv.prevented, stopped: offEv.stopped, log: view.log.length };
win.fire("keydown", { code: "KeyZ" });
const up = wheel(-100);
const upState = { prevented: up.prevented, stopped: up.stopped, zoom: zoomLog().at(-1) };
wheel(100);
wheel(100);
const down = zoomLog().at(-1);
wheel(0); // 가로 굴림 — 배율 그대로 · 스크롤은 막음
const flat = zoomLog().at(-1);
for (let i = 0; i < 30; i++) wheel(-50);
const max = zoomLog().at(-1);
for (let i = 0; i < 30; i++) wheel(120);
const min = zoomLog().at(-1);
win.fire("keyup", { code: "KeyZ" });
const n = zoomLog().length;
const afterOff = wheel(-100);
const afterOffZoomed = zoomLog().length !== n;
win.fire("keydown", { code: "KeyZ" });
wheel(-100);
r.wheel = {
off: offState,
up: upState,
down,
flat,
max,
min,
afterOff: { prevented: afterOff.prevented, stopped: afterOff.stopped, zoomed: afterOffZoomed },
reopen: zoomLog().at(-1), // 다시 켜면 2배에서 한 칸
pure: [
m.nextLoupeZoom(2, -1),
m.nextLoupeZoom(2, 1),
m.nextLoupeZoom(6, -1),
m.nextLoupeZoom(1.5, 1),
],
};
// 길게 누름으로 켠 동안도 같음
const b = setup();
b.win.fire("mousedown", { button: 2, buttons: 2 });
b.win.tick(400);
const press = b.win.fire("wheel", { deltaY: -1 });
r.wheel.press = { prevented: press.prevented, zoom: b.view.log.at(-1) };
}
{
// 길게 누름 — 0.4 초 넘어야 켬 · 떼면 끔 · 뒤따르는 메뉴는 먹음
const { win, view } = setup();
win.fire("mousedown", { button: 2, buttons: 2, clientX: 300, clientY: 200 });
win.tick(399);
const before = view.log.length;
win.tick(1);
const on = last(view);
win.fire("mousemove", { buttons: 2, clientX: 310, clientY: 205 });
const moved = view.log[view.log.length - 1];
win.fire("mouseup", { button: 2 });
const off = last(view);
const menu = win.fire("contextmenu");
const nextMenu = win.fire("contextmenu");
r.long = {
before,
on,
show: view.log[0],
moved,
off,
menuPrevented: menu.prevented,
menuStopped: menu.stopped,
nextStopped: nextMenu.stopped,
};
}
{
// 짧게 누름 — 안 켬 · 메뉴 그대로
const { win, view } = setup();
win.fire("mousedown", { button: 2, buttons: 2 });
win.tick(200);
win.fire("mouseup", { button: 2 });
win.tick(1000);
const menu = win.fire("contextmenu");
r.short = { log: view.log.length, menuPrevented: menu.prevented, menuStopped: menu.stopped };
}
{
// 왼쪽 단추 · 창 밖에서 뗌
const { win, view } = setup();
win.fire("mousedown", { button: 0, buttons: 1 });
win.tick(1000);
const left = view.log.length;
win.fire("mousedown", { button: 2, buttons: 2 });
win.fire("mousemove", { buttons: 0 });
win.tick(1000);
const lostBeforeHold = view.log.length;
win.fire("mousedown", { button: 2, buttons: 2 });
win.tick(500);
win.fire("mousemove", { buttons: 0 });
r.mouseMisc = { left, lostBeforeHold, lostAfterHold: last(view) };
}
{
// Z 키
const { win, view } = setup();
const z = (props) => win.fire("keydown", { code: "KeyZ", ...props });
z({ key: "ㅋ" });
const on = last(view);
win.fire("keydown", { code: "KeyZ", repeat: true });
const repeatLen = view.log.length;
win.fire("keyup", { code: "KeyZ" });
const off = last(view);
const len = view.log.length;
z({ ctrlKey: true });
z({ altKey: true });
z({ metaKey: true });
win.fire("keydown", { code: "KeyX" });
const noCombo = view.log.length === len;
const typing = {};
for (const [k, target] of Object.entries({
input: { tagName: "INPUT", type: "text" },
inputNoType: { tagName: "INPUT", type: "" },
number: { tagName: "INPUT", type: "number" },
textarea: { tagName: "TEXTAREA" },
select: { tagName: "SELECT" },
editable: { tagName: "SPAN", isContentEditable: true },
checkbox: { tagName: "INPUT", type: "checkbox" },
button: { tagName: "BUTTON" },
})) {
const n = view.log.length;
z({ target });
typing[k] = view.log.length > n;
win.fire("keyup", { code: "KeyZ" });
}
z({ shiftKey: true });
const shift = last(view);
win.fire("blur", { target: { tagName: "INPUT", type: "text" } });
const fieldBlur = last(view);
win.fire("blur", { target: win });
r.key = { on, repeatLen, off, noCombo, typing, shift, fieldBlur, blur: last(view) };
}
{
// B05~B09 밖이면 안 켬
const { win, view } = setup(() => false);
win.fire("keydown", { code: "KeyZ" });
win.fire("mousedown", { button: 2, buttons: 2 });
win.tick(1000);
win.fire("mouseup", { button: 2 });
const menu = win.fire("contextmenu");
r.off = { log: view.log.length, menuStopped: menu.stopped };
}
r.routes = {};
for (const route of [
"a01-home",
"a06-login",
"a07-register",
"b01-account",
"b02-proj-register",
"b03-file-input",
"b04-preprocess",
"b05-profile",
"b06-section",
"b07-design-detail",
"b08-quantity",
"b09-estimation",
"b10-payment",
"b11-status",
"b11-loading",
"m01-master-data",
"m02-master-template",
])
r.routes[route] = m.LOUPE_ROUTE.test(route);
r.origin = {
mid: m.loupeOrigin(500, 400, 1000, 800),
topLeft: m.loupeOrigin(10, 10, 1000, 800),
bottomRight: m.loupeOrigin(995, 795, 1000, 800),
};
{
// 221-1 CAD 영역 — 위에선 안 켬 · 켜진 채 들어가면 숨김 · 나오면 보임 · 휠은 CAD 로
const cad = { tagName: "IFRAME", closest: (sel) => (sel === m.LOUPE_SKIP ? {} : null) };
const plain = { tagName: "DIV", closest: () => null };
const kinds = (v) => v.log.map((l) => l[0]);
r.cad = {
isCad: [m.isCadTarget(cad), m.isCadTarget(plain), m.isCadTarget(null), m.isCadTarget({})],
skip: m.LOUPE_SKIP,
};
{
// Z 를 CAD 위에서 누름 — 안 켬 · 나오면 보임 · 떼면 끔
const { win, view } = setup();
win.fire("mouseover", { target: cad });
win.fire("keydown", { code: "KeyZ", target: cad });
const onCad = view.log.length;
const wheel = win.fire("wheel", { deltaY: -100, target: cad });
win.fire("mouseover", { target: plain, clientX: 50, clientY: 60 });
const out = view.log[0];
win.fire("keyup", { code: "KeyZ" });
r.cad.keyOnCad = { onCad, wheelPrevented: wheel.prevented, out, off: last(view) };
}
{
// 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임
const { win, view } = setup();
win.fire("keydown", { code: "KeyZ" });
win.fire("wheel", { deltaY: -100 }); // 2.25 — 밂 = 확대(337 기본)
win.fire("mouseover", { target: cad });
const hidden = last(view);
const n = view.log.length;
const wheel = win.fire("wheel", { deltaY: -100, target: cad });
win.fire("mousemove", { target: cad, clientX: 5, clientY: 5 });
const quiet = view.log.length === n; // 숨긴 동안 move · 배율 안 부름
win.fire("mouseover", { target: plain, clientX: 70, clientY: 80 });
const back = view.log.slice(n);
win.fire("mousemove", { target: cad, clientX: 6, clientY: 6 });
const moveHide = last(view);
win.fire("mousemove", { target: plain, clientX: 7, clientY: 7 });
r.cad.enter = {
hidden,
wheel: { prevented: wheel.prevented, stopped: wheel.stopped },
quiet,
back,
moveHide,
moveBack: kinds(view).slice(-3),
};
}
{
// 우클릭 길게 — CAD 위면 안 켜고 메뉴 그대로
const { win, view } = setup();
win.fire("mousedown", { button: 2, buttons: 2, target: cad });
win.tick(1000);
win.fire("mouseup", { button: 2, target: cad });
const menu = win.fire("contextmenu", { target: cad });
r.cad.press = { log: view.log.length, menuPrevented: menu.prevented };
}
{
// 초점이 CAD iframe 으로 넘어감(cad_host) — 끄지 않고 숨김 · Z 떼기는 그쪽 창에서
const { win, view } = setup();
const frameCalls = [];
const frame = {
...cad,
contentWindow: { addEventListener: (type, fn, cap) => frameCalls.push({ type, fn, cap }) },
};
win.document = { activeElement: frame };
win.fire("keydown", { code: "KeyZ" });
win.fire("blur", { target: win });
const blurHidden = kinds(view);
win.document = { activeElement: plain };
const up = frameCalls.find((c) => c.type === "keyup");
up.fn({ code: "KeyZ" });
win.fire("mouseover", { target: plain });
const afterRelease = kinds(view);
// 우클릭 누른 채 들어감 — 나오면 다시 보임
const b = setup();
b.win.fire("mousedown", { button: 2, buttons: 2 });
b.win.tick(400);
b.win.document = { activeElement: frame };
b.win.fire("blur", { target: b.win });
b.win.document = { activeElement: plain };
b.win.fire("mousemove", { buttons: 2, target: plain });
const pressBack = kinds(b.view);
b.win.fire("mouseup", { button: 2 });
r.cad.blur = {
blurHidden,
frameKeyup: up && up.cap,
afterRelease,
pressBack,
pressOff: last(b.view),
};
}
}
// ---- 가짜 요소 ----
const SVG_NS = "http://www.w3.org/2000/svg";
class El {
constructor(tagName, opts = {}) {
this.tagName = tagName;
this.namespaceURI = opts.ns || "http://www.w3.org/1999/xhtml";
this.rect = opts.rect || { left: 0, top: 0, right: 100, bottom: 100, width: 100, height: 100 };
this.children = [];
this.style = {};
this.dataset = {};
this.attrs = {};
this.st = opts.scrollTop || 0;
this.scrollLeft = 0;
this.width = opts.width || 0;
this.height = opts.height || 0;
this.value = opts.value || "";
this.checked = !!opts.checked;
this.type = opts.type || "";
this.className = "";
this.hidden = false;
this.contentDocument = opts.contentDocument || null;
this.drawn = [];
this.parent = null;
this.mark = opts.mark || "";
}
getBoundingClientRect() {
return this.rect;
}
// 실제 DOM 처럼 숨긴(hidden) 조상 아래에선 스크롤을 적어도 안 먹음
get scrollTop() {
return this.st;
}
set scrollTop(v) {
for (let e = this; e; e = e.parent) if (e.hidden) return;
this.st = v;
}
get classList() {
return { contains: (k) => this.className.split(" ").includes(k) };
}
setAttribute(k, v) {
this.attrs[k] = v;
}
append(...nodes) {
for (const n of nodes) ((n.parent = this), this.children.push(n));
}
replaceChildren() {
this.children = [];
}
replaceWith(n) {
const i = this.parent.children.indexOf(this);
n.parent = this.parent;
this.parent.children[i] = n;
}
cloneNode(deep) {
const c = new El(this.tagName, {
ns: this.namespaceURI,
rect: this.rect,
width: this.width,
height: this.height,
type: this.type,
mark: this.mark,
});
c.cloned = true; // 값 · 스크롤 · 그림은 복제에 안 따라옴(실제 DOM 과 같게)
if (deep) c.append(...this.children.map((k) => k.cloneNode(true)));
return c;
}
getContext() {
return { drawImage: (src, x, y, w, h) => this.drawn.push([src.mark, x, y, w, h]) };
}
querySelectorAll(sel) {
const out = [];
const visit = (e) => (
e.tagName.toLowerCase() === sel && out.push(e),
e.children.forEach(visit)
);
this.children.forEach(visit);
return out;
}
}
const far = { left: 0, top: 2000, right: 100, bottom: 2100, width: 100, height: 100 };
function makePage() {
const app = new El("DIV", {
rect: { left: 0, top: 0, right: 1000, bottom: 800, width: 1000, height: 800 },
});
const scroller = new El("DIV", { scrollTop: 120, mark: "scroller" });
const canvas = new El("CANVAS", { width: 50, height: 40, mark: "canvas2d" });
const blank = new El("CANVAS", { mark: "blank" });
const input = new El("INPUT", { value: "12.5", type: "text" });
const check = new El("INPUT", { checked: true, type: "checkbox" });
const offscreen = new El("SECTION", { rect: far, mark: "off" });
offscreen.append(new El("P"), new El("P"));
const table = new El("TABLE");
const row = new El("TR", { rect: far, mark: "row" });
row.append(new El("TD", { rect: far }));
table.append(row);
const svg = new El("svg", { ns: SVG_NS });
svg.append(new El("path", { ns: SVG_NS }));
const frameDoc = new El("HTML");
frameDoc.append(
new El("CANVAS", {
width: 300,
height: 200,
mark: "cad",
rect: { left: 5, top: 6, width: 300, height: 200 },
}),
);
const frame = new El("IFRAME", {
contentDocument: frameDoc,
rect: { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 },
});
const cadBox = new El("DIV", {
rect: { left: 0, top: 300, right: 400, bottom: 600, width: 400, height: 300 },
});
cadBox.className = "cad-host";
cadBox.append(new El("IFRAME"), new El("A"));
scroller.scrollHeight = 900; // 스크롤될 수 있는 칸
scroller.clientHeight = 300;
scroller.append(canvas, blank, input, check);
app.append(scroller, offscreen, table, svg, frame, cadBox);
return app;
}
function fakeDocument(app) {
const doc = { body: new El("BODY") };
doc.createElement = (tag) => new El(tag.toUpperCase());
doc.getElementById = (id) => (id === "app" ? app : null);
return doc;
}
{
const app = makePage();
const doc = fakeDocument(app);
const win = fakeWindow(doc);
const observers = [];
win.MutationObserver = class {
constructor(fn) {
this.fn = fn;
this.watching = null;
observers.push(this);
}
observe(target, opts) {
this.watching = target;
this.opts = opts;
}
disconnect() {
this.watching = null;
}
};
const view = m.createLoupeView(win);
view.show(500, 400);
const root = doc.body.children[0];
const label = doc.body.children[1];
const stage = root.children[0];
const page = stage.children[0];
const copy = page.children[0];
const [scroller, offscreen, table, svg, frame, cadBox] = copy.children;
const [canvas, blank, input, check] = scroller.children;
r.capture = {
rootClass: root.className,
rootSize: [root.style.width, root.style.height],
inert: root.inert,
ariaHidden: root.attrs["aria-hidden"],
hidden: root.hidden,
rootTransform: root.style.transform,
stageTransform: stage.style.transform,
pageBox: [page.style.left, page.style.top, page.style.width, page.style.height],
isClone: copy.cloned === true && copy !== app,
canvasDrawn: canvas.drawn.map((d) => d[0]),
blankDrawn: blank.drawn.length,
inputValue: input.value,
checkChecked: check.checked,
scrollTop: scroller.scrollTop,
offscreenChildren: offscreen.children.length,
offscreenSize: [offscreen.style.width, offscreen.style.height],
rowKept: table.children[0].children.length,
svgKept: svg.children.length,
frameTag: frame.tagName,
frameDrawn: frame.drawn,
frameSize: [frame.style.width, frame.style.height],
frameEmpty: frame.children.length,
cadBoxEmpty: cadBox.children.length,
cadBoxSize: [cadBox.style.width, cadBox.style.height],
watching: observers[0].watching === app,
observeOpts: observers[0].opts,
scrollListen: win.calls.some((c) => c.type === "scroll" && c.capture),
};
// 220-1 배율 글 · 배율 바꿈
r.label = {
cls: label.className,
ariaHidden: label.attrs["aria-hidden"],
text: label.textContent,
at: label.style.transform,
hidden: label.hidden,
};
view.setZoom(3);
r.label.zoom3 = [label.textContent, stage.style.transform, root.style.transform];
view.move(500, 790);
r.label.bottom = label.style.transform;
view.setZoom(2);
view.move(500, 400);
// 화면이 바뀜 — 0.5 초 뒤 한 번만 다시 뜸
const firstCopy = copy;
observers[0].fn();
observers[0].fn();
win.tick(499);
const sameBefore = stage.children[0].children[0] === firstCopy;
win.tick(1);
const recaptured = stage.children[0].children[0] !== firstCopy && stage.children.length === 1;
view.move(10, 10);
const nearEdge = root.style.transform;
view.hide();
r.hide = {
sameBefore,
recaptured,
nearEdge,
stageChildren: stage.children.length,
hidden: root.hidden,
disconnected: observers[0].watching === null,
scrollListen: win.calls.some((c) => c.type === "scroll"),
};
observers[0].fn();
win.tick(1000);
r.hide.noRecaptureAfterHide = stage.children.length;
r.hide.labelHidden = label.hidden;
view.setZoom(4);
view.hide();
view.show(500, 400);
r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform];
r.hide.reuseRoot = doc.body.children.length;
}
{
// 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서)
const app = makePage();
const scroller = app.children[0];
const doc = fakeDocument(app);
const win = fakeWindow(doc);
win.MutationObserver = class {
observe() {}
disconnect() {}
};
const view = m.createLoupeView(win);
const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0];
const page = () => doc.body.children[0].children[0].children[0];
view.show(500, 400);
view.hide();
scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘
view.show(500, 400);
const zero = cloneScroller().scrollTop;
view.hide();
scroller.scrollTop = 260; // 꺼진 동안 스크롤해 둠 → 다시 켬(숨긴 원을 다시 씀)
view.show(500, 400);
const before = cloneScroller().scrollTop;
// 켠 채 스크롤 — 다음 화면 틀에 위치만 · 같은 복제
const first = cloneScroller();
scroller.scrollTop = 400;
app.rect = { ...app.rect, top: -50, bottom: 750 }; // 문서도 50 내려감
const scroll = () => win.calls.filter((c) => c.type === "scroll").forEach((c) => c.fn({}));
scroll();
scroll(); // 한 틀에 한 번으로 묶음
const rafs = win.timers.filter((t) => t.at === win.now + 16).length;
win.tick(16);
const during = {
top: cloneScroller().scrollTop,
same: cloneScroller() === first,
pageTop: page().style.top,
};
win.tick(484); // 0.5 초 뒤 다시 뜸 — 비워 둔 칸을 채움 · 스크롤은 그대로
const recaptured = { same: cloneScroller() === first, top: cloneScroller().scrollTop };
view.hide();
const n = win.timers.length;
r.scroll = { zero, before, rafs, during, recaptured, timersAfterHide: n };
}
console.log(JSON.stringify(r));