fix(ui): 돋보기 스크롤 따라감 — 다시 켤 때 원을 먼저 보여 스크롤 옮김이 먹게 · 켠 채 스크롤은 rAF 한 번에 칸 · 페이지 위치 맞춤 · 0.5 초 뒤 다시 뜸 (221-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
2026-10-10 16:29:34 +09:00
co-authored by Claude Opus 5.5
parent a5f5cac739
commit 8379836748
3 changed files with 137 additions and 15 deletions
+56 -2
View File
@@ -1,4 +1,4 @@
/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기) 단위검증 헬퍼 —
/* 돋보기(`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");
@@ -37,6 +37,8 @@ function fakeWindow(doc) {
(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)) {
@@ -374,7 +376,7 @@ class El {
this.style = {};
this.dataset = {};
this.attrs = {};
this.scrollTop = opts.scrollTop || 0;
this.st = opts.scrollTop || 0;
this.scrollLeft = 0;
this.width = opts.width || 0;
this.height = opts.height || 0;
@@ -391,6 +393,14 @@ class El {
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) };
}
@@ -470,6 +480,8 @@ function makePage() {
});
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;
@@ -581,4 +593,46 @@ function fakeDocument(app) {
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));
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""돋보기 스크롤 따라감(계획서 221-2) 회귀검증 — 스크롤해 둔 뒤 켜도 원 = 지금 화면 ·
켠 채 스크롤하면 다음 화면 틀(rAF 한 번)에 위치만 맞춤 · 0.5 초 뒤 다시 뜸 · 끄면 예약 없음.
218-1 헬퍼(`helper_218_1_loupe.cjs`)의 스크롤 장면을 판정함(숨긴 원 안에선 스크롤이 안 먹는 실제 동작 흉내).
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def s():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_218_1_loupe.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)["scroll"]
def test_스크롤한_뒤_켬(s):
assert s["zero"] == 0
assert s["before"] == 260 # 다시 켤 때도 지금 스크롤 그대로
def test_켠_채_스크롤_위치만_맞춤(s):
assert s["rafs"] == 1 # 스크롤 두 번 → 한 틀에 한 번
assert s["during"] == {"top": 400, "same": True, "pageTop": "-50px"} # 칸 · 문서 스크롤
def test_켠_채_스크롤_0_5초_뒤_다시_뜸(s):
assert s["recaptured"] == {"same": False, "top": 400}
assert s["timersAfterHide"] == 0