/** @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((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 = `
${ options.pinnedTitlebar ? '
' : '' }
`; document.body.append(manager); const world = manager.querySelector( '.game-resource-book-scene-world', )!; const cardHost = manager.querySelector( '.game-resource-book-scene-card', )!; const cardBox = manager.querySelector('.game-resource-card')!; const titleHost = manager.querySelector( '.game-resource-book-scene-world .game-resource-book-scene-titlebar', )!; const pinnedTitleHost = manager.querySelector( '.game-resource-book-scene > .game-resource-book-scene-titlebar', ); const rects = new Map(); 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([ ['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).animate; document.body.replaceChildren(); }); type StackCardSpec = { id: string; /** 总览里的类型摞列号(与 `resourceBookOverviewCardLayout` 的 `stackColumn` 同口径)。 */ column: number; /** 摞内下标:越大越深、越靠后。 */ index: number; rect: MotionRect; }; /** * 多卡宿主:把场景世界换成"一根栏目摞"。 * * 宿主是 0x0 的定位包装,它的原点就是卡片自己的落点(经典 FLIP 起点);真假两态都复用它, * 差别只在传入的卡片清单与矩形——总览一摞只铺 3 张,进栏目后这一摞铺满。 */ function mountStackCards(world: HTMLElement, cards: StackCardSpec[]) { world.replaceChildren(); const hosts = new Map(); const boxes = new Map(); for (const card of cards) { const host = document.createElement('div'); host.className = 'game-resource-book-scene-card'; host.dataset.resourceBookCategory = 'unclassified'; host.dataset.resourceBookStackColumn = String(card.column); host.dataset.resourceBookStackIndex = String(card.index); const box = document.createElement('div'); box.className = 'game-resource-card'; box.dataset.resourceCardId = card.id; host.append(box); world.append(host); vi.spyOn(host, 'getBoundingClientRect').mockImplementation(() => toDomRect({ left: card.rect.left, top: card.rect.top, width: 0, height: 0, }), ); vi.spyOn(box, 'getBoundingClientRect').mockImplementation(() => toDomRect(card.rect), ); hosts.set(card.id, host); boxes.set(card.id, box); } return { hosts, boxes }; } /** 卡片宿主上必须正好有一段动画;返回它,避免逐处 `flights.find(...)!`。 */ function flightFor(flights: Flight[], host: Element | undefined) { const matches = flights.filter((flight) => flight.host === host); expect(matches).toHaveLength(1); return matches[0]!; } describe('resource book pile anchors', () => { /** * 用户口径:进栏目时**每一张卡**都要从那摞里位移出来,而不是只有总览里已经渲染的那几张。 * * 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张(其余数量写在"N 项"里),所以 * 大部分卡片在总览侧没有节点、拿不到 First 帧;它们必须从自己那一摞起飞(合成 First), * 而不是原地淡入。 */ it('flies every card of the opened category out of its own overview pile', () => { const flights = installAnimateMock(); const { manager, world, signatures } = fixture(); // 总览:第 0 摞铺满 3 张(下标 2 最深,就是"那一摞"的落点),第 1 摞只有 1 张。 mountStackCards(world, [ { id: 'art-0', column: 0, index: 0, rect: { left: 100, top: 100, width: 92, height: 64 }, }, { id: 'art-1', column: 0, index: 1, rect: { left: 100, top: 105, width: 92, height: 64 }, }, { id: 'art-2', column: 0, index: 2, rect: { left: 100, top: 110, width: 92, height: 64 }, }, { id: 'art-3', column: 1, index: 0, rect: { left: 202, top: 110, width: 92, height: 64 }, }, ]); const controller = createResourceBookTransitionController(); const token = controller.begin(manager); // 提交后:这一摞铺满全部卡片,art-4 / art-5 / art-6 在总览侧从来没有节点。 const { hosts } = mountStackCards(world, [ { id: 'art-0', column: 0, index: 0, rect: { left: 400, top: 500, width: 180, height: 128 }, }, { id: 'art-1', column: 0, index: 1, rect: { left: 400, top: 700, width: 180, height: 128 }, }, { id: 'art-2', column: 0, index: 2, rect: { left: 400, top: 900, width: 180, height: 128 }, }, { id: 'art-4', column: 0, index: 3, rect: { left: 700, top: 500, width: 180, height: 128 }, }, { id: 'art-5', column: 0, index: 4, rect: { left: 700, top: 700, width: 180, height: 128 }, }, { id: 'art-3', column: 1, index: 0, rect: { left: 1_000, top: 500, width: 180, height: 128 }, }, { id: 'art-6', column: 1, index: 1, rect: { left: 1_000, top: 700, width: 180, height: 128 }, }, ]); controller.play(manager, token, () => undefined, signatures); // 名单:这一栏目进的每一张卡都要有位移(起点 ≠ 终点),不是只有总览那几张。 for (const [id, host] of hosts) { const flight = flightFor(flights, host); expect(flight.frames[0]!.transform, id).not.toBe( flight.frames[1]!.transform, ); expect(flight.frames[1]!.transform, id).toBe('none'); } // 起点是"自己那一摞"最深那张卡的矩形。 expect(flightFor(flights, hosts.get('art-4')).frames[0]!.transform).toBe( 'translate(-600px, -390px) scale(0.511, 0.5)', ); // 第 1 摞的卡片从第 1 摞起飞,不会被贴到第 0 摞的落点上。 expect(flightFor(flights, hosts.get('art-6')).frames[0]!.transform).toBe( 'translate(-798px, -590px) scale(0.511, 0.5)', ); // 总览里已经渲染的那几张仍从它自己的 First 帧起飞。 expect(flightFor(flights, hosts.get('art-0')).frames[0]!.transform).toBe( 'translate(-300px, -400px) scale(0.511, 0.5)', ); // 从摞里出来是"看得见地飞",不叠一层原地淡入。 expect(flightFor(flights, hosts.get('art-4')).frames[0]!.opacity).toBe(1); expect(flightFor(flights, hosts.get('art-4')).frames[1]!.opacity).toBe(1); controller.dispose(); }); /** * 卡片已经飞出那一摞、正在动的时候,预览图尺寸才到(布局再变一次)。 * * 重基必须从"此刻像素"续上:堆锚点在整段转场里都还活着,一旦重基也去认领它, * 卡片会被拉回摞上再飞一次——用户看到的是"飞一半又跳回卡片堆"。 */ it('rebases a flying card from the current pixels instead of the pile anchor', () => { const flights = installAnimateMock(); const now = vi.spyOn(performance, 'now'); now.mockReturnValue(1_000); const { manager, world, signatures } = fixture(); mountStackCards(world, [ { id: 'art-0', column: 0, index: 0, rect: { left: 100, top: 100, width: 92, height: 64 }, }, ]); const controller = createResourceBookTransitionController(); const token = controller.begin(manager); const { hosts, boxes } = mountStackCards(world, [ { id: 'art-0', column: 0, index: 0, rect: { left: 400, top: 500, width: 180, height: 128 }, }, { id: 'art-4', column: 0, index: 1, rect: { left: 700, top: 500, width: 180, height: 128 }, }, ]); signatures.set('card:art-0', 'child:400,500:180,128'); signatures.set('card:art-4', 'child:700,500:180,128'); controller.play(manager, token, () => undefined, signatures); const flying = flightFor(flights, hosts.get('art-4')); expect(flying.frames[0]!.transform).not.toBe('none'); now.mockReturnValue(1_300); const visual: MotionRect = { left: 650, top: 470, width: 140, height: 100 }; const clean: MotionRect = { left: 720, top: 520, width: 220, height: 160 }; vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation( () => toDomRect(visual), ); flying.cancel.mockImplementation(() => { vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation( () => toDomRect(clean), ); flying.reject(); }); signatures.set('card:art-4', 'child:720,520:220,160'); controller.sync(manager, signatures); const rebased = flights.filter( (flight) => flight.host === hosts.get('art-4'), ); expect(rebased).toHaveLength(2); expect(rebased[1]!.frames[0]!.transform).toBe( resourceBookFlipTransform(visual, clean, { left: 700, top: 500 }), ); expect(rebased[1]!.frames[1]!.transform).toBe('none'); controller.dispose(); }); it('does not fly a card mounted after the transition out of a stale pile', async () => { const flights = installAnimateMock(); const { manager, world, signatures } = fixture(); mountStackCards(world, [ { id: 'art-0', column: 0, index: 0, rect: { left: 100, top: 100, width: 92, height: 64 }, }, ]); const controller = createResourceBookTransitionController(); const token = controller.begin(manager); mountStackCards(world, [ { id: 'art-0', column: 0, index: 0, rect: { left: 400, top: 500, width: 180, height: 128 }, }, { id: 'art-4', column: 0, index: 1, rect: { left: 700, top: 500, width: 180, height: 128 }, }, ]); controller.play(manager, token, () => undefined, signatures); for (const flight of flights) flight.finish(); await vi.waitFor(() => expect(controller.isAnimating()).toBe(false)); // 转场收尾后堆锚点必须失效:否则此后挂载的卡片会从一段过期位置飞出来。 const { hosts: lateHosts } = mountStackCards(world, [ { id: 'art-0', column: 0, index: 0, rect: { left: 400, top: 500, width: 180, height: 128 }, }, { id: 'art-9', column: 0, index: 1, rect: { left: 900, top: 900, width: 180, height: 128 }, }, ]); controller.sync(manager, signatures); expect( flights.filter((flight) => flight.host === lateHosts.get('art-9')), ).toHaveLength(0); controller.dispose(); }); }); 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(); }); });