/** @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) { const manager = document.createElement('div'); manager.className = 'game-resource-book-manager'; manager.innerHTML = `
`; 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-titlebar', )!; const rects = new Map(); const setRect = (element: Element, rect: MotionRect) => { rects.set(element, rect); }; for (const element of [manager, world, cardHost, cardBox, titleHost]) { 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, 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(); }); 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 的内联 transform 钉在屏幕上。 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('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(); }); });