@lightningjs/renderer
Version:
Lightning 3 Renderer
314 lines (264 loc) • 10.6 kB
text/typescript
/*
* If not stated otherwise in this file or this component's LICENSE file the
* following copyright and licenses apply:
*
* Copyright 2023 Comcast Cable Communications Management, LLC.
*
* Licensed under the Apache License, Version 2.0 (the License);
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { CoreAnimationController } from './CoreAnimationController.js';
import { AnimationManager } from './AnimationManager.js';
import { CoreAnimation } from './CoreAnimation.js';
import { mock } from 'vitest-mock-extended';
import { EventEmitter } from '../../common/EventEmitter.js';
/**
* Minimal mock for CoreAnimation that extends EventEmitter so event
* wiring in registerAnimation / unregisterAnimation works correctly.
*/
function createMockAnimation(
overrides: Partial<CoreAnimation> = {},
): CoreAnimation {
const emitter = new EventEmitter();
const animation = Object.assign(emitter, {
id: 1,
duration: 1000,
delay: 0,
easing: 'linear',
loop: false,
repeat: 0,
stopMethod: false as const,
progress: 0,
propValuesMap: { props: null, shaderProps: null },
reset: vi.fn(),
restore: vi.fn(),
reverse: vi.fn(),
update: vi.fn(),
...overrides,
}) as unknown as CoreAnimation;
return animation;
}
describe('CoreAnimationController', () => {
let manager: AnimationManager;
let animation: CoreAnimation;
function createController(
mgr?: AnimationManager,
anim?: CoreAnimation,
): CoreAnimationController {
const controller = new CoreAnimationController();
controller.init(mgr ?? manager, anim ?? animation);
return controller;
}
beforeEach(() => {
manager = mock<AnimationManager>();
animation = createMockAnimation();
});
describe('constructor', () => {
it('should initialize in stopped state', () => {
const controller = createController();
expect(controller.state).toBe('stopped');
});
});
describe('lazy promise creation', () => {
it('should not allocate a promise on construction', () => {
const controller = createController();
expect(controller.stoppedPromise).toBeNull();
});
it('should not allocate a promise on start()', () => {
const controller = createController();
controller.start();
expect(controller.stoppedPromise).toBeNull();
expect(controller.stoppedResolve).toBeNull();
});
it('should return a resolved promise from waitUntilStopped() when already stopped', async () => {
const controller = createController();
// state is 'stopped' by default
const promise = controller.waitUntilStopped();
expect(promise).toBeInstanceOf(Promise);
// Should not cache the promise since we are already stopped
expect(controller.stoppedPromise).toBeNull();
await expect(promise).resolves.toBeUndefined();
});
it('should lazily create a promise when waitUntilStopped() is called on a running animation', () => {
const controller = createController();
controller.start();
expect(controller.stoppedPromise).toBeNull();
const promise = controller.waitUntilStopped();
expect(promise).toBeInstanceOf(Promise);
expect(controller.stoppedPromise).toBe(promise);
expect(controller.stoppedResolve).not.toBeNull();
});
it('should return the same promise on repeated waitUntilStopped() calls', () => {
const controller = createController();
controller.start();
const promise1 = controller.waitUntilStopped();
const promise2 = controller.waitUntilStopped();
expect(promise1).toBe(promise2);
});
it('should reset the cached promise on a new start() cycle', () => {
const controller = createController();
controller.start();
const promise1 = controller.waitUntilStopped();
controller.stop();
controller.start();
expect(controller.stoppedPromise).toBeNull();
const promise2 = controller.waitUntilStopped();
expect(promise2).not.toBe(promise1);
});
});
describe('start()', () => {
it('should transition to scheduled state', () => {
const controller = createController();
controller.start();
expect(controller.state).toBe('scheduled');
});
it('should register the animation with the manager', () => {
const controller = createController();
controller.start();
expect(manager.registerAnimation).toHaveBeenCalledWith(animation);
});
it('should not re-register if already running', () => {
const controller = createController();
controller.start();
// Simulate the animation emitting 'animating' to move to 'running'
animation.emit('animating', {});
expect(controller.state).toBe('running');
controller.start();
// Should only have been registered once
expect(manager.registerAnimation).toHaveBeenCalledTimes(1);
});
});
describe('stop()', () => {
it('should resolve the promise when stopped manually', async () => {
const controller = createController();
controller.start();
const promise = controller.waitUntilStopped();
controller.stop();
expect(controller.state).toBe('stopped');
await expect(promise).resolves.toBeUndefined();
});
it('should emit stopped event when promise exists', () => {
const controller = createController();
const stoppedHandler = vi.fn();
controller.on('stopped', stoppedHandler);
controller.start();
controller.waitUntilStopped(); // create the promise + resolve
controller.stop();
expect(stoppedHandler).toHaveBeenCalled();
});
it('should work without waitUntilStopped() being called (fire-and-forget)', () => {
const controller = createController();
const stoppedHandler = vi.fn();
controller.on('stopped', stoppedHandler);
controller.start();
// No waitUntilStopped() call -- fire and forget
expect(controller.stoppedResolve).toBeNull();
// stop should not throw and should still emit 'stopped'
controller.stop();
expect(controller.state).toBe('stopped');
expect(stoppedHandler).toHaveBeenCalledTimes(1);
});
it('should not emit stopped when already in stopped state', () => {
const controller = createController();
const stoppedHandler = vi.fn();
controller.on('stopped', stoppedHandler);
// Controller starts in 'stopped' state, calling stop() should be a no-op
controller.stop();
expect(stoppedHandler).not.toHaveBeenCalled();
});
it('should reset animation when stop is called with reset=true', () => {
const controller = createController();
controller.start();
controller.stop(true);
expect(animation.reset).toHaveBeenCalled();
});
});
describe('pause()', () => {
it('should transition to paused state', () => {
const controller = createController();
controller.start();
controller.pause();
expect(controller.state).toBe('paused');
});
it('should unregister the animation from the manager', () => {
const controller = createController();
controller.start();
controller.pause();
expect(manager.unregisterAnimation).toHaveBeenCalledWith(animation);
});
});
describe('animation lifecycle events', () => {
it('should transition to running when animation emits animating', () => {
const controller = createController();
controller.start();
animation.emit('animating', {});
expect(controller.state).toBe('running');
});
it('should resolve promise when animation finishes naturally', async () => {
const controller = createController();
controller.start();
const promise = controller.waitUntilStopped();
animation.emit('finished', {});
expect(controller.state).toBe('stopped');
await expect(promise).resolves.toBeUndefined();
});
it('should emit stopped event when animation finishes naturally', () => {
const controller = createController();
const stoppedHandler = vi.fn();
controller.on('stopped', stoppedHandler);
controller.start();
animation.emit('finished', {});
expect(stoppedHandler).toHaveBeenCalled();
});
it('should handle finish without waitUntilStopped (fire-and-forget)', () => {
const controller = createController();
controller.start();
// No waitUntilStopped() call
// Should not throw when the animation finishes
animation.emit('finished', {});
expect(controller.state).toBe('stopped');
expect(controller.stoppedResolve).toBeNull();
});
it('should handle stopMethod reverse by re-registering finished listener', () => {
const animationWithReverse = createMockAnimation({
stopMethod: 'reverse' as const,
});
const controller = createController(manager, animationWithReverse);
controller.start();
animationWithReverse.emit('finished', {});
// Should NOT be stopped -- reverse re-registers
expect(controller.state).not.toBe('stopped');
expect(animationWithReverse.reverse).toHaveBeenCalled();
});
it('should not resolve promise when looping', () => {
const loopingAnimation = createMockAnimation({
loop: true,
});
const controller = createController(manager, loopingAnimation);
controller.start();
// Call waitUntilStopped to trigger lazy promise creation
controller.waitUntilStopped();
loopingAnimation.emit('finished', {});
// Should still be running, not stopped
expect(controller.state).not.toBe('stopped');
// Promise should still be pending (stoppedResolve not cleared)
expect(controller.stoppedResolve).not.toBeNull();
});
it('should transition to stopped on destroy', () => {
const controller = createController();
controller.start();
animation.emit('destroyed', {});
expect(controller.state).toBe('stopped');
});
});
});