Files
Genarrative/apps/ai-game-creator-shell/tests/resourceBookController.test.ts
T
suzmii ff0bc5a3e7 资源工作台钉住的标题栏移出画布缩放层:整行文字不再发虚
- 钉在视口上的栏目标题栏(栏目名 / 计数徽标 / 资源总览入口)改挂场景根,成为带 scale() 的 .game-resource-book-scene-world 的兄弟节点,不再用 scale(1 / s) 抵消 world;总览态的栏目标题栏仍留在 world 里与缩略卡对齐
- index.tsx 抽出 ResourceBookScene.renderTitlebar(placement.pinned) 一处产出两种宿主,删除只服务抵消变换的 titlebarTransform 与 safeViewport/safeMainViewport 中间量
- resourceBookController 按节点自己的坐标系换算:world 内用 world 缩放,world 外(钉住标题栏)用 1;新增 nodeWorldTransform,readWorld 补 element 输出,nodeWorld 贯穿 cleanRect / FLIP 反向 transform / worldScale
- 更新 resourceBookFlipValues 与 readOrigin 的口径注释:translate 单位是宿主自身坐标系,不再假定所有宿主都在 world 里
- appSurface 用例改钉新不变量:钉住的标题栏内联 transform 必须为空、不在 world 内、父节点为场景根,卡片必须仍在 world 内;并说明标题栏宿主不再跨视图同一节点、转场仍由控制器按 key 记录的 First 屏幕矩形驱动
- resourceBookController 用例新增「屏幕坐标系的宿主不被 world 缩放除」,并保留 world 内卡片仍按缩放除的对照
- 技术方案 2026-09-05 段落同步新结构;pitfalls 增记取证(截图墨色峰值 98.6 vs 同色正文 91.7、点阵周期反推 dpr=1)、本机复现尝试与结论、三处变异验证和真机判据
2026-09-12 17:09:42 +08:00

658 lines
26 KiB
TypeScript

/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, type Mock, vi } from 'vitest';
import {
createResourceBookTransitionController,
RESOURCE_BOOK_MOTION_DURATION,
RESOURCE_BOOK_MOTION_MIN_DURATION,
resourceBookFlipTransform,
resourceBookValidMotionRect,
} from '../src/view/project-development/resourceBookController';
type MotionRect = {
left: number;
top: number;
width: number;
height: number;
};
function toDomRect(rect: MotionRect): DOMRect {
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
x: rect.left,
y: rect.top,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
toJSON: () => ({}),
} as DOMRect;
}
type Flight = {
host: Element;
frames: Keyframe[];
options: KeyframeAnimationOptions;
cancel: Mock;
finish: () => void;
reject: () => void;
};
const originalAnimate = Object.getOwnPropertyDescriptor(
Element.prototype,
'animate',
);
function installAnimateMock() {
const flights: Flight[] = [];
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
writable: true,
value(
this: Element,
frames: Keyframe[],
options: KeyframeAnimationOptions,
) {
let finish!: () => void;
let reject!: () => void;
const finished = new Promise<Animation>((resolve, fail) => {
finish = () => resolve({} as Animation);
reject = () => fail(new Error('canceled'));
});
const cancel = vi.fn(reject);
flights.push({ host: this, frames, options, cancel, finish, reject });
return {
finished,
cancel,
effect: { getComputedTiming: () => ({}) },
} as unknown as Animation;
},
});
return flights;
}
function fixture(worldScale = 1, options: { pinnedTitlebar?: boolean } = {}) {
const manager = document.createElement('div');
manager.className = 'game-resource-book-manager';
manager.innerHTML = `<div class="game-resource-book-scene">
<div class="game-resource-book-scene-world">
<div class="game-resource-book-scene-titlebar" data-resource-book-category="document"></div>
<div class="game-resource-book-scene-card" data-resource-book-category="document">
<div class="game-resource-card" data-resource-card-id="one"></div>
</div>
</div>
${
options.pinnedTitlebar
? '<div class="game-resource-book-scene-titlebar is-active" data-resource-book-category="unclassified"></div>'
: ''
}
</div>`;
document.body.append(manager);
const world = manager.querySelector<HTMLElement>(
'.game-resource-book-scene-world',
)!;
const cardHost = manager.querySelector<HTMLElement>(
'.game-resource-book-scene-card',
)!;
const cardBox = manager.querySelector<HTMLElement>('.game-resource-card')!;
const titleHost = manager.querySelector<HTMLElement>(
'.game-resource-book-scene-world .game-resource-book-scene-titlebar',
)!;
const pinnedTitleHost = manager.querySelector<HTMLElement>(
'.game-resource-book-scene > .game-resource-book-scene-titlebar',
);
const rects = new Map<Element, MotionRect>();
const setRect = (element: Element, rect: MotionRect) => {
rects.set(element, rect);
};
for (const element of [
manager,
world,
cardHost,
cardBox,
titleHost,
...(pinnedTitleHost ? [pinnedTitleHost] : []),
]) {
vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() =>
toDomRect(rects.get(element) ?? { left: 0, top: 0, width: 0, height: 0 }),
);
}
setRect(manager, { left: 0, top: 0, width: 800, height: 600 });
setRect(world, { left: 0, top: 0, width: 800, height: 600 });
setRect(titleHost, { left: 0, top: 0, width: 280, height: 42 });
const realGetComputedStyle = window.getComputedStyle.bind(window);
vi.spyOn(window, 'getComputedStyle').mockImplementation(((
element: Element,
pseudo?: string | null,
) => {
const style = realGetComputedStyle(element, pseudo);
if (element !== world) return style;
return new Proxy(style, {
get: (target, property) =>
property === 'transform'
? `matrix(${worldScale}, 0, 0, ${worldScale}, 0, 0)`
: Reflect.get(target, property),
});
}) as typeof window.getComputedStyle);
const signatures = new Map<string, string>([
['card:one', 'child:10,20:180,128'],
['title:document', 'idle:0,0,280'],
]);
return {
manager,
world,
cardHost,
cardBox,
titleHost,
pinnedTitleHost,
setRect,
signatures,
};
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
if (originalAnimate)
Object.defineProperty(Element.prototype, 'animate', originalAnimate);
else delete (Element.prototype as Partial<Element>).animate;
document.body.replaceChildren();
});
describe('resource book FLIP geometry', () => {
it('uses the classic FLIP transform when the origin is the last box', () => {
expect(
resourceBookFlipTransform(
{ left: 30, top: 40, width: 50, height: 20 },
{ left: 10, top: 10, width: 100, height: 80 },
),
).toBe('translate(20px, 30px) scale(0.5, 0.25)');
});
it('accounts for a host origin that is not the last box', () => {
// 卡片包装节点是 0x0,缩放围绕它的原点,位置要按原点偏移换算。
expect(
resourceBookFlipTransform(
{ left: 30, top: 40, width: 50, height: 20 },
{ left: 10, top: 10, width: 100, height: 80 },
{ left: 0, top: 0 },
),
).toBe('translate(25px, 37.5px) scale(0.5, 0.25)');
});
it('divides screen translation by the world scale', () => {
expect(
resourceBookFlipTransform(
{ left: 300, top: 250, width: 90, height: 64 },
{ left: 200, top: 200, width: 180, height: 128 },
{ left: 100, top: 100 },
2,
),
).toBe('translate(75px, 50px) scale(0.5, 0.5)');
});
it('rejects degenerate rectangles', () => {
expect(
resourceBookValidMotionRect({ left: 0, top: 0, width: 0, height: 10 }),
).toBe(false);
expect(
resourceBookValidMotionRect({ left: 0, top: 0, width: 10, height: 10 }),
).toBe(true);
});
});
describe('resource book real element FLIP', () => {
it('animates the same card node instead of cloning it', async () => {
const flights = installAnimateMock();
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
setRect(cardHost, { left: 200, top: 150, width: 0, height: 0 });
const done = vi.fn();
controller.play(manager, token, done, signatures);
const cardFlights = flights.filter((flight) => flight.host === cardHost);
expect(cardFlights).toHaveLength(1);
expect(cardFlights[0]!.frames[0]!.transform).toBe(
'translate(-100px, -50px) scale(0.511, 0.5)',
);
expect(cardFlights[0]!.frames[1]!.transform).toBe('none');
expect(cardFlights[0]!.options.duration).toBe(
RESOURCE_BOOK_MOTION_DURATION,
);
// 真实元素:没有克隆节点,也没有快照层。
expect(manager.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0);
expect(manager.querySelectorAll('.game-resource-card')).toHaveLength(1);
expect(controller.isAnimating()).toBe(true);
expect(done).not.toHaveBeenCalled();
cardFlights[0]!.finish();
await vi.waitFor(() => expect(done).toHaveBeenCalledTimes(1));
expect(controller.isAnimating()).toBe(false);
controller.dispose();
});
it('composes the FLIP transform with the titlebar base transform', () => {
const flights = installAnimateMock();
const { manager, titleHost, cardBox, setRect, signatures } = fixture();
const base = 'translate(-10px, -20px) scale(0.5)';
vi.mocked(titleHost.getBoundingClientRect).mockImplementation(() =>
titleHost.style.transform
? toDomRect({ left: -10, top: -20, width: 140, height: 21 })
: toDomRect({ left: 0, top: 0, width: 280, height: 42 }),
);
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
titleHost.style.transform = base;
controller.play(manager, token, () => undefined, signatures);
const flight = flights.find((entry) => entry.host === titleHost)!;
expect(flight.frames[0]!.transform).toBe(
`translate(20px, 40px) scale(2, 2) ${base}`,
);
expect(flight.frames[1]!.transform).toBe(base);
controller.dispose();
});
it('pins a leaving node in place and fades it out', () => {
const flights = installAnimateMock();
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
// 提交后该栏目不再可见(内联 opacity 0),world 也变了导致屏幕位置改变。
cardHost.style.opacity = '0';
setRect(cardBox, { left: 700, top: 500, width: 184, height: 128 });
setRect(cardHost, { left: 700, top: 500, width: 0, height: 0 });
controller.play(manager, token, () => undefined, signatures);
const flight = flights.find((entry) => entry.host === cardHost)!;
expect(flight.frames[0]!.transform).toBe(flight.frames[1]!.transform);
expect(flight.frames[0]!.opacity).toBe(1);
expect(flight.frames[1]!.opacity).toBe(0);
controller.dispose();
});
it('does not move a titlebar that was invisible when the transition started', () => {
const flights = installAnimateMock();
const { manager, cardHost, cardBox, titleHost, setRect, signatures } =
fixture();
setRect(cardHost, { left: 0, top: 0, width: 0, height: 0 });
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
// 子画布静止态:非活动标题栏不可见,屏幕位置来自子画布 world,可能远在屏幕外。
titleHost.style.opacity = '0';
setRect(titleHost, { left: -2_000, top: -1_500, width: 280, height: 42 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
// 返回总览:标题栏回到总览矩形并可见,卡片收回摞上。
titleHost.style.opacity = '1';
setRect(titleHost, { left: 0, top: 0, width: 280, height: 42 });
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
controller.play(manager, token, () => undefined, signatures);
const titleFlight = flights.find((flight) => flight.host === titleHost);
// 允许淡入,但几何首末必须相等:不能从屏幕外飞进来。
if (titleFlight) {
expect(titleFlight.frames[0]!.transform).toBe(
titleFlight.frames[1]!.transform,
);
}
const cardFlight = flights.find((flight) => flight.host === cardHost)!;
expect(cardFlight.frames[0]!.transform).not.toBe(
cardFlight.frames[1]!.transform,
);
controller.dispose();
});
it('still animates the active titlebar back to the overview stack', () => {
const flights = installAnimateMock();
const { manager, cardBox, titleHost, setRect, signatures } = fixture();
// 子画布静止态:标题栏的屏幕矩形与 world 局部矩形不一致(world 有平移/缩放)。
vi.mocked(titleHost.getBoundingClientRect).mockImplementation(() =>
titleHost.style.transform
? toDomRect({ left: 0, top: 0, width: 560, height: 84 })
: toDomRect({ left: 500, top: 300, width: 280, height: 42 }),
);
titleHost.style.transform = 'translate(-300px, -200px) scale(2)';
titleHost.style.opacity = '1';
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
titleHost.style.transform = '';
controller.play(manager, token, () => undefined, signatures);
const flight = flights.find((entry) => entry.host === titleHost)!;
expect(flight.frames[0]!.transform).not.toBe(flight.frames[1]!.transform);
controller.dispose();
});
it('flips a pinned titlebar in screen space, not in world space', () => {
const flights = installAnimateMock();
// 钉在视口上的标题栏是缩放层 world 的兄弟节点(见 ResourceBookScene):它不在 world 坐标系里,
// 反向平移不能再除以 world 缩放,否则动画终点会被缩放除错。
const { manager, cardBox, pinnedTitleHost, setRect, signatures } = fixture(
2,
{
pinnedTitlebar: true,
},
);
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
setRect(pinnedTitleHost!, { left: 40, top: 30, width: 560, height: 84 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
// 收起回总览:钉住的那一条换成总览矩形(同位、更窄更矮)。
setRect(pinnedTitleHost!, { left: 0, top: 0, width: 800, height: 42 });
controller.play(manager, token, () => undefined, signatures);
const flight = flights.find((entry) => entry.host === pinnedTitleHost)!;
// world 缩放为 2:世界层里的节点会被除以 2;屏幕坐标系里的宿主必须原样保留 (40, 30)。
expect(flight.frames[0]!.transform).toBe(
'translate(40px, 30px) scale(0.7, 2)',
);
expect(flight.frames[1]!.transform).toBe('none');
controller.dispose();
});
it('still divides a world-space card translation by the world scale', () => {
const flights = installAnimateMock();
const { manager, cardHost, cardBox, setRect, signatures } = fixture(2);
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
setRect(cardHost, { left: 200, top: 150, width: 0, height: 0 });
controller.play(manager, token, () => undefined, signatures);
const flight = flights.find((entry) => entry.host === cardHost)!;
expect(flight.frames[0]!.transform).toBe(
'translate(-50px, -25px) scale(0.511, 0.5)',
);
controller.dispose();
});
it('fades a titlebar out in place while entering a category', () => {
const flights = installAnimateMock();
const { manager, cardBox, titleHost, setRect, signatures } = fixture();
titleHost.style.opacity = '1';
setRect(titleHost, { left: 0, top: 0, width: 280, height: 42 });
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
// 进入栏目:标题栏不可见,屏幕位置来自子画布 world。
titleHost.style.opacity = '0';
setRect(titleHost, { left: -2_000, top: -1_500, width: 280, height: 42 });
controller.play(manager, token, () => undefined, signatures);
const flight = flights.find((entry) => entry.host === titleHost)!;
expect(flight.frames[0]!.transform).toBe(flight.frames[1]!.transform);
expect(flight.frames[0]!.opacity).toBe(1);
expect(flight.frames[1]!.opacity).toBe(0);
controller.dispose();
});
it('fades in a card that mounts during the transition', () => {
const flights = installAnimateMock();
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
controller.play(manager, token, () => undefined, signatures);
const flight = flights.find((entry) => entry.host === cardHost)!;
expect(flight.frames[0]!.opacity).toBe(0);
expect(flight.frames[1]!.opacity).toBe(1);
expect(flight.frames[0]!.transform).toBe('none');
expect(flight.frames[1]!.transform).toBe('none');
controller.dispose();
});
it('settles immediately without Web Animations support', () => {
const { manager, signatures } = fixture();
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(manager, controller.begin(manager), done, signatures);
expect(done).toHaveBeenCalledTimes(1);
expect(controller.isAnimating()).toBe(false);
controller.dispose();
});
it('settles immediately for reduced motion', () => {
installAnimateMock();
vi.stubGlobal('matchMedia', () => ({ matches: true }));
const { manager, signatures } = fixture();
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(manager, controller.begin(manager), done, signatures);
expect(done).toHaveBeenCalledTimes(1);
controller.dispose();
});
it('cancels running animations on settle and reports completion once', () => {
const flights = installAnimateMock();
const { manager, cardBox, setRect, signatures } = fixture();
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
const controller = createResourceBookTransitionController();
const done = vi.fn();
const token = controller.begin(manager);
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
controller.play(manager, token, done, signatures);
expect(controller.isAnimating()).toBe(true);
controller.settle();
expect(done).toHaveBeenCalledTimes(1);
expect(controller.isAnimating()).toBe(false);
expect(
flights.every((flight) => flight.cancel.mock.calls.length >= 1),
).toBe(true);
controller.dispose();
});
});
describe('resource book layout rebase', () => {
it('rebases a running transition from the current pixels', () => {
const flights = installAnimateMock();
const now = vi.spyOn(performance, 'now');
now.mockReturnValue(1_000);
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
setRect(cardHost, { left: 200, top: 150, width: 0, height: 0 });
controller.play(manager, token, () => undefined, signatures);
expect(flights).toHaveLength(1);
// 预览图尺寸晚到:动画跑到一半时布局再变一次。取消旧动画后几何才变成新布局。
now.mockReturnValue(1_300);
const visual: MotionRect = { left: 150, top: 130, width: 140, height: 100 };
const clean: MotionRect = { left: 210, top: 170, width: 220, height: 160 };
setRect(cardBox, visual);
flights[0]!.cancel.mockImplementation(() => {
setRect(cardBox, clean);
flights[0]!.reject();
});
signatures.set('card:one', 'child:10,20:220,160');
controller.sync(manager, signatures);
expect(flights).toHaveLength(2);
const rebase = flights[1]!;
expect(rebase.frames[0]!.transform).toBe(
resourceBookFlipTransform(visual, clean, { left: 200, top: 150 }),
);
expect(rebase.frames[1]!.transform).toBe('none');
expect(rebase.options.duration).toBe(120);
controller.dispose();
});
it('clamps the rebased duration to the minimum', () => {
const flights = installAnimateMock();
const now = vi.spyOn(performance, 'now');
now.mockReturnValue(1_000);
const { manager, cardBox, setRect, signatures } = fixture();
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
controller.play(manager, token, () => undefined, signatures);
now.mockReturnValue(1_000 + RESOURCE_BOOK_MOTION_DURATION + 50);
setRect(cardBox, { left: 150, top: 130, width: 140, height: 100 });
flights[0]!.cancel.mockImplementation(() => {
setRect(cardBox, { left: 210, top: 170, width: 220, height: 160 });
flights[0]!.reject();
});
signatures.set('card:one', 'child:10,20:220,160');
controller.sync(manager, signatures);
expect(flights[1]!.options.duration).toBe(
RESOURCE_BOOK_MOTION_MIN_DURATION,
);
controller.dispose();
});
it('animates an idle layout change with the full duration', () => {
const flights = installAnimateMock();
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
setRect(cardHost, { left: 0, top: 0, width: 0, height: 0 });
const previous: MotionRect = {
left: 200,
top: 150,
width: 180,
height: 128,
};
setRect(cardBox, previous);
const controller = createResourceBookTransitionController();
controller.sync(manager, signatures);
expect(flights).toHaveLength(0);
// 预览图尺寸在动画结束之后才到:位置/尺寸变化必须是动画,而不是瞬移。
const next: MotionRect = { left: 260, top: 150, width: 220, height: 160 };
setRect(cardBox, next);
signatures.set('card:one', 'child:60,20:220,160');
controller.sync(manager, signatures);
expect(flights).toHaveLength(1);
expect(flights[0]!.frames[0]!.transform).toBe(
resourceBookFlipTransform(previous, next, { left: 0, top: 0 }),
);
expect(flights[0]!.frames[1]!.transform).toBe('none');
expect(flights[0]!.options.duration).toBe(RESOURCE_BOOK_MOTION_DURATION);
controller.dispose();
});
it('ignores world-only changes such as pan and zoom', () => {
const flights = installAnimateMock();
const { manager, cardBox, setRect, signatures } = fixture(2);
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
const controller = createResourceBookTransitionController();
controller.sync(manager, signatures);
setRect(cardBox, { left: 260, top: 190, width: 180, height: 128 });
controller.sync(manager, signatures);
expect(flights).toHaveLength(0);
controller.dispose();
});
it('keeps the layout baseline valid across a pan', () => {
const flights = installAnimateMock();
const { manager, world, cardHost, cardBox, setRect, signatures } =
fixture();
setRect(cardHost, { left: 0, top: 0, width: 0, height: 0 });
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
const controller = createResourceBookTransitionController();
controller.sync(manager, signatures);
// 平移主画布:world 原点移动,卡片跟着移动,布局签名不变。
setRect(world, { left: 40, top: 30, width: 800, height: 600 });
setRect(cardHost, { left: 40, top: 30, width: 0, height: 0 });
setRect(cardBox, { left: 240, top: 180, width: 180, height: 128 });
controller.sync(manager, signatures);
expect(flights).toHaveLength(0);
// 平移之后预览图尺寸才到:起点必须是卡片"此刻"的屏幕位置,不是平移前的。
const next: MotionRect = { left: 300, top: 180, width: 220, height: 160 };
setRect(cardBox, next);
signatures.set('card:one', 'child:60,20:220,160');
controller.sync(manager, signatures);
expect(flights).toHaveLength(1);
expect(flights[0]!.frames[0]!.transform).toBe(
resourceBookFlipTransform(
{ left: 240, top: 180, width: 180, height: 128 },
next,
{ left: 40, top: 30 },
),
);
controller.dispose();
});
it('freezes the dragged card', () => {
const flights = installAnimateMock();
const { manager, cardBox, setRect, signatures } = fixture();
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
const controller = createResourceBookTransitionController();
controller.sync(manager, signatures);
setRect(cardBox, { left: 320, top: 260, width: 180, height: 128 });
signatures.set('card:one', 'child:120,110:180,128');
controller.sync(manager, signatures, new Set(['card:one']));
expect(flights).toHaveLength(0);
controller.dispose();
});
it('does not animate when the manager size changes', () => {
const flights = installAnimateMock();
const { manager, cardBox, setRect, signatures } = fixture();
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
const controller = createResourceBookTransitionController();
controller.sync(manager, signatures);
setRect(manager, { left: 0, top: 0, width: 640, height: 480 });
setRect(cardBox, { left: 160, top: 120, width: 180, height: 128 });
signatures.set('card:one', 'child:20,10:180,128');
controller.sync(manager, signatures);
expect(flights).toHaveLength(0);
controller.dispose();
});
it('reports remaining motion for fading exit nodes', () => {
installAnimateMock();
const now = vi.spyOn(performance, 'now');
now.mockReturnValue(500);
const { manager, cardBox, setRect, signatures } = fixture();
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
const controller = createResourceBookTransitionController();
const token = controller.begin(manager);
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
controller.play(manager, token, () => undefined, signatures);
expect(controller.motionRemainingMs()).toBe(RESOURCE_BOOK_MOTION_DURATION);
now.mockReturnValue(800);
expect(controller.motionRemainingMs()).toBe(
RESOURCE_BOOK_MOTION_DURATION - 300,
);
controller.dispose();
});
});