import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createRoot } from '../vdom.ts' import { animateLayout } from './animate-layout-mixin.ts' import { invariant } from '../invariant.ts' interface MockAnimation { keyframes: Keyframe[] options: KeyframeAnimationOptions playState: AnimationPlayState finished: Promise cancel: () => void } let originalAnimate: typeof Element.prototype.animate let originalRaf: typeof globalThis.requestAnimationFrame let mockAnimations: MockAnimation[] = [] function createMockAnimation( keyframes: Keyframe[], options: KeyframeAnimationOptions, ): MockAnimation { let resolveFinished!: () => void let finished = new Promise((_resolve) => { resolveFinished = () => _resolve({} as Animation) }) return { keyframes, options, playState: 'running', finished, cancel() { this.playState = 'idle' resolveFinished() }, } } function mockBoundingRect( el: Element, rect: { left: number; top: number; right: number; bottom: number }, ) { el.getBoundingClientRect = () => ({ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.right - rect.left, height: rect.bottom - rect.top, x: rect.left, y: rect.top, toJSON() { return this }, }) as DOMRect } function mockBoundingRectSequence( el: Element, rects: Array<{ left: number; top: number; right: number; bottom: number }>, ) { let index = 0 el.getBoundingClientRect = () => { let next = rects[Math.min(index, rects.length - 1)] index++ return { left: next.left, top: next.top, right: next.right, bottom: next.bottom, width: next.right - next.left, height: next.bottom - next.top, x: next.left, y: next.top, toJSON() { return this }, } as DOMRect } } describe('animateLayout mixin', () => { beforeEach(() => { mockAnimations = [] originalAnimate = Element.prototype.animate originalRaf = globalThis.requestAnimationFrame globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => { queueMicrotask(() => callback(performance.now() + 1000)) return 0 } Element.prototype.animate = function (keyframes, options) { let animation = createMockAnimation( keyframes as Keyframe[], options as KeyframeAnimationOptions, ) as unknown as Animation mockAnimations.push(animation as unknown as MockAnimation) return animation } }) afterEach(() => { Element.prototype.animate = originalAnimate globalThis.requestAnimationFrame = originalRaf document.body.innerHTML = '' }) it('animates when layout geometry changes', () => { let container = document.createElement('div') let root = createRoot(container) root.render(
) root.flush() let node = container.querySelector('div') invariant(node) mockBoundingRect(node, { left: 0, top: 0, right: 100, bottom: 100 }) root.render(
) root.flush() mockAnimations = [] mockBoundingRectSequence(node, [ { left: 0, top: 0, right: 100, bottom: 100 }, { left: 40, top: 10, right: 140, bottom: 110 }, ]) root.render(
) root.flush() expect(mockAnimations).toHaveLength(1) let animation = mockAnimations[0] expect(animation.options.duration).toBe(350) expect(animation.options.easing).toBe('linear') }) it('can animate translation without scale projection', () => { let container = document.createElement('div') let root = createRoot(container) root.render(
) root.flush() let node = container.querySelector('div') invariant(node) mockBoundingRect(node, { left: 0, top: 0, right: 100, bottom: 100 }) root.render(
) root.flush() mockAnimations = [] mockBoundingRectSequence(node, [ { left: 0, top: 0, right: 100, bottom: 100 }, { left: 20, top: 30, right: 160, bottom: 170 }, ]) root.render(
) root.flush() expect(mockAnimations).toHaveLength(1) let animation = mockAnimations[0] expect(String(animation.keyframes[0].transform)).toContain('translate3d(') expect(String(animation.keyframes[0].transform)).not.toContain('scale(') }) it('does not invent translation for size-only changes when size projection is disabled', () => { let container = document.createElement('div') let root = createRoot(container) root.render(
) root.flush() let node = container.querySelector('div') invariant(node) mockBoundingRect(node, { left: 0, top: 0, right: 100, bottom: 40 }) root.render(
) root.flush() mockAnimations = [] mockBoundingRectSequence(node, [ { left: 0, top: 0, right: 100, bottom: 40 }, { left: 0, top: 0, right: 100, bottom: 120 }, ]) root.render(
) root.flush() expect(mockAnimations).toHaveLength(0) }) it('does not animate when geometry does not change', () => { let container = document.createElement('div') let root = createRoot(container) root.render(
) root.flush() let node = container.querySelector('div') invariant(node) mockBoundingRect(node, { left: 5, top: 5, right: 105, bottom: 105 }) root.render(
) root.flush() mockAnimations = [] mockBoundingRectSequence(node, [ { left: 5, top: 5, right: 105, bottom: 105 }, { left: 5, top: 5, right: 105, bottom: 105 }, ]) root.render(
) root.flush() expect(mockAnimations).toHaveLength(0) }) it('cancels an in-flight layout animation when interrupted', () => { let container = document.createElement('div') let root = createRoot(container) root.render(
) root.flush() let node = container.querySelector('div') invariant(node) mockBoundingRect(node, { left: 0, top: 0, right: 100, bottom: 100 }) root.render(
) root.flush() mockAnimations = [] mockBoundingRectSequence(node, [ { left: 0, top: 0, right: 100, bottom: 100 }, { left: 30, top: 0, right: 130, bottom: 100 }, ]) root.render(
) root.flush() let firstAnimation = mockAnimations[0] expect(firstAnimation.playState).toBe('running') mockBoundingRectSequence(node, [ { left: 30, top: 0, right: 130, bottom: 100 }, { left: 60, top: 0, right: 160, bottom: 100 }, ]) root.render(
) root.flush() expect(firstAnimation.playState).toBe('idle') expect(mockAnimations).toHaveLength(2) }) it('cancels active animation on remove', () => { let container = document.createElement('div') let root = createRoot(container) root.render(
) root.flush() let node = container.querySelector('div') invariant(node) mockBoundingRect(node, { left: 0, top: 0, right: 100, bottom: 100 }) root.render(
) root.flush() mockAnimations = [] mockBoundingRectSequence(node, [ { left: 0, top: 0, right: 100, bottom: 100 }, { left: 30, top: 0, right: 130, bottom: 100 }, ]) root.render(
) root.flush() let active = mockAnimations[0] expect(active.playState).toBe('running') root.render(null) root.flush() expect(active.playState).toBe('idle') }) })