@lightningjs/renderer
Version:
Lightning 3 Renderer
302 lines (254 loc) • 8.66 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 } from 'vitest';
import { AnimationManager } from './AnimationManager.js';
import type { CoreNode } from '../CoreNode.js';
import type { IAnimationController } from '../../common/IAnimationController.js';
import type { Stage } from '../Stage.js';
/**
* Create a minimal mock stage that satisfies AnimationManager's needs.
*/
function createMockStage(): Stage {
return { activeAnimationCount: 0 } as unknown as Stage;
}
/**
* Create a minimal mock node that satisfies CoreAnimation's needs.
* CoreAnimation accesses node[key] for start values, node.shader, and node.destroyed.
*/
function createMockNode(overrides: Record<string, unknown> = {}): CoreNode {
return {
x: 0,
y: 0,
w: 100,
h: 100,
alpha: 1,
rotation: 0,
scale: 1,
color: 0xffffffff,
destroyed: false,
shader: null,
...overrides,
} as unknown as CoreNode;
}
describe('AnimationManager', () => {
describe('object pooling', () => {
it('should create fresh objects when pool is empty', () => {
const manager = new AnimationManager(createMockStage());
const node = createMockNode();
const controller1 = manager.createAnimation(
node,
{ x: 100 },
{ duration: 1000 },
);
const controller2 = manager.createAnimation(
node,
{ y: 200 },
{ duration: 1000 },
);
// Should be different controller instances
expect(controller1).not.toBe(controller2);
});
it('should reuse objects from the pool after stop()', () => {
const manager = new AnimationManager(createMockStage());
const node = createMockNode();
// Create and start an animation
const controller1 = manager.createAnimation(
node,
{ x: 100 },
{ duration: 1000 },
);
controller1.start();
// Stop it -- should release to pool
controller1.stop();
// Pool should now have objects
// Create another animation -- should reuse from pool
const controller2 = manager.createAnimation(
node,
{ y: 200 },
{ duration: 1000 },
);
// The controller instance should be reused (same object reference)
expect(controller2).toBe(controller1);
});
it('should properly reinitialize recycled objects', () => {
const manager = new AnimationManager(createMockStage());
const node = createMockNode();
// Create, start, and stop
const controller1 = manager.createAnimation(
node,
{ x: 100 },
{ duration: 500 },
);
controller1.start();
controller1.stop();
// Reuse from pool with different props
const controller2 = manager.createAnimation(
node,
{ y: 200 },
{ duration: 1000 },
);
// Should be stopped state (reinitialized)
expect(controller2.state).toBe('stopped');
// Should be startable
controller2.start();
expect(controller2.state).toBe('scheduled');
});
it('should clear event listeners on recycled objects', () => {
const manager = new AnimationManager(createMockStage());
const node = createMockNode();
// Create and add a user listener
const controller1 = manager.createAnimation(
node,
{ x: 100 },
{ duration: 1000 },
);
const stoppedSpy = vi.fn();
controller1.on('stopped', stoppedSpy);
controller1.start();
controller1.stop();
// The stopped listener should have fired once
expect(stoppedSpy).toHaveBeenCalledTimes(1);
// Reuse from pool
const controller2 = manager.createAnimation(
node,
{ y: 200 },
{ duration: 1000 },
);
// Old listener should not fire on the recycled controller
const newStoppedSpy = vi.fn();
controller2.on('stopped', newStoppedSpy);
controller2.start();
controller2.stop();
// Only the new spy should have fired, not the old one
expect(stoppedSpy).toHaveBeenCalledTimes(1); // still 1 from before
expect(newStoppedSpy).toHaveBeenCalledTimes(1);
});
it('should release to pool on natural animation finish', () => {
const manager = new AnimationManager(createMockStage());
const node = createMockNode();
const controller = manager.createAnimation(
node,
{ x: 100 },
{ duration: 0 },
);
controller.start();
// Duration is 0 so animation finishes immediately on first update
manager.update(0);
// Create another -- should reuse from pool
const controller2 = manager.createAnimation(
node,
{ y: 200 },
{ duration: 1000 },
);
expect(controller2).toBe(controller);
});
it('should release to pool on node destruction', () => {
const manager = new AnimationManager(createMockStage());
const node = createMockNode();
const controller = manager.createAnimation(
node,
{ x: 100 },
{ duration: 1000 },
);
controller.start();
// Simulate node destruction
(node as unknown as Record<string, unknown>).destroyed = true;
manager.update(16);
// Create another -- should reuse from pool
const controller2 = manager.createAnimation(
node,
{ y: 200 },
{ duration: 1000 },
);
expect(controller2).toBe(controller);
});
it('should NOT release looping animations to pool on finish', () => {
const manager = new AnimationManager(createMockStage());
const node = createMockNode();
const controller = manager.createAnimation(
node,
{ x: 100 },
{ duration: 100, loop: true },
);
controller.start();
// Advance past the duration
manager.update(200);
// Create another -- should NOT reuse the looping controller
const controller2 = manager.createAnimation(
node,
{ y: 200 },
{ duration: 1000 },
);
expect(controller2).not.toBe(controller);
});
it('should handle multiple pool cycles', () => {
const manager = new AnimationManager(createMockStage());
const node = createMockNode();
// Cycle 1
const c1 = manager.createAnimation(node, { x: 100 }, { duration: 1000 });
c1.start();
c1.stop();
// Cycle 2 -- reuses c1
const c2 = manager.createAnimation(node, { y: 200 }, { duration: 1000 });
expect(c2).toBe(c1);
c2.start();
c2.stop();
// Cycle 3 -- reuses again
const c3 = manager.createAnimation(
node,
{ alpha: 0 },
{ duration: 1000 },
);
expect(c3).toBe(c1);
});
it('should grow pool independently for concurrent animations', () => {
const manager = new AnimationManager(createMockStage());
const node = createMockNode();
// Create 3 concurrent animations
const controllers: IAnimationController[] = [];
for (let i = 0; i < 3; i++) {
const c = manager.createAnimation(
node,
{ x: i * 100 },
{ duration: 1000 },
);
c.start();
controllers.push(c);
}
// All should be distinct
expect(controllers[0]).not.toBe(controllers[1]);
expect(controllers[1]).not.toBe(controllers[2]);
// Stop all -- pool should now have 3
for (const c of controllers) {
c.stop();
}
// Create 3 more -- should all reuse from pool
const reused: IAnimationController[] = [];
for (let i = 0; i < 3; i++) {
reused.push(
manager.createAnimation(node, { y: i * 100 }, { duration: 1000 }),
);
}
// Each should be one of the original controllers (pool is LIFO)
for (const r of reused) {
expect(controllers).toContain(r);
}
});
});
});