@lightningjs/renderer
Version:
Lightning 3 Renderer
523 lines (440 loc) • 15.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 } from 'vitest';
import { CoreNode, UpdateType } from './CoreNode.js';
import { mock } from 'vitest-mock-extended';
import { ImageTexture } from './textures/ImageTexture.js';
import { makeMockStage, makeNodeProps } from '../../test/mockStage.js';
describe('CoreNode', () => {
const defaultProps = makeNodeProps({
alpha: 0,
color: 0,
colorBl: 0,
colorBottom: 0,
colorBr: 0,
colorLeft: 0,
colorRight: 0,
colorTl: 0,
colorTop: 0,
colorTr: 0,
scale: 0,
scaleX: 0,
scaleY: 0,
});
const clippingRect = {
x: 0,
y: 0,
w: 200,
h: 200,
valid: false,
clipRadius: 0,
};
const stage = makeMockStage({}, { width: 200, height: 200 });
describe('set color()', () => {
it('should set all color subcomponents.', () => {
const node = new CoreNode(stage, defaultProps);
node.colorBl = 0x99aabbff;
node.colorBr = 0xaabbccff;
node.colorTl = 0xbbcceeff;
node.colorTr = 0xcceeffff;
node.color = 0xffffffff;
node.color = 0xffffffff;
expect(node.color).toBe(0xffffffff);
expect(node.colorBl).toBe(0xffffffff);
expect(node.colorBr).toBe(0xffffffff);
expect(node.colorTl).toBe(0xffffffff);
expect(node.colorTr).toBe(0xffffffff);
expect(node.colorLeft).toBe(0xffffffff);
expect(node.colorRight).toBe(0xffffffff);
expect(node.colorTop).toBe(0xffffffff);
expect(node.colorBottom).toBe(0xffffffff);
});
it('should set update type.', () => {
const node = new CoreNode(stage, defaultProps);
node.updateType = 0;
node.color = 0xffffffff;
node.color = 0xffffffff;
expect(node.updateType).toBe(UpdateType.PremultipliedColors);
});
});
describe('isRenderable checks', () => {
it('should return false if node is not renderable', () => {
const node = new CoreNode(stage, defaultProps);
expect(node.isRenderable).toBe(false);
});
it('visible node that is a color texture', () => {
const node = new CoreNode(stage, defaultProps);
node.alpha = 1;
node.x = 0;
node.y = 0;
node.w = 100;
node.h = 100;
node.color = 0xffffffff;
node.update(0, clippingRect);
expect(node.isRenderable).toBe(true);
});
it('visible node that is a texture', () => {
const node = new CoreNode(stage, defaultProps);
node.alpha = 1;
node.x = 0;
node.y = 0;
node.w = 100;
node.h = 100;
node.texture = mock<ImageTexture>({
state: 'initial',
});
node.update(0, clippingRect);
expect(node.isRenderable).toBe(false);
node.texture.state = 'loaded';
node.setUpdateType(UpdateType.IsRenderable);
node.update(1, clippingRect);
expect(node.isRenderable).toBe(true);
});
it('a node with a texture with alpha 0 should not be renderable', () => {
const node = new CoreNode(stage, defaultProps);
node.alpha = 0;
node.x = 0;
node.y = 0;
node.w = 100;
node.h = 100;
node.texture = mock<ImageTexture>({
state: 'loaded',
});
node.update(0, clippingRect);
expect(node.isRenderable).toBe(false);
});
it('a node with a texture that is OutOfBounds should not be renderable', () => {
const node = new CoreNode(stage, defaultProps);
node.alpha = 1;
node.x = 300;
node.y = 300;
node.w = 100;
node.h = 100;
node.texture = mock<ImageTexture>({
state: 'loaded',
});
node.update(0, clippingRect);
expect(node.isRenderable).toBe(false);
});
it('a node with a freed texture should not be renderable', () => {
const node = new CoreNode(stage, defaultProps);
node.alpha = 1;
node.x = 0;
node.y = 0;
node.w = 100;
node.h = 100;
node.texture = mock<ImageTexture>({
state: 'freed',
});
node.update(0, clippingRect);
expect(node.isRenderable).toBe(false);
});
it('should emit renderable event when isRenderable status changes', () => {
const node = new CoreNode(stage, defaultProps);
const eventCallback = vi.fn();
// Listen for the renderableChanged event
node.on('renderable', eventCallback);
// Set up node as a color texture that should be renderable
node.alpha = 1;
node.x = 0;
node.y = 0;
node.w = 100;
node.h = 100;
node.color = 0xffffffff;
// Initial state should be false
expect(node.isRenderable).toBe(false);
expect(eventCallback).not.toHaveBeenCalled();
// Update should make it renderable (false -> true)
node.update(0, clippingRect);
expect(node.isRenderable).toBe(true);
expect(eventCallback).toHaveBeenCalledWith(node, {
type: 'renderable',
isRenderable: true,
});
// Reset the mock
eventCallback.mockClear();
// Make node invisible (alpha = 0) to make it not renderable (true -> false)
node.alpha = 0;
node.update(1, clippingRect);
expect(node.isRenderable).toBe(false);
expect(eventCallback).toHaveBeenCalledWith(node, {
type: 'renderable',
isRenderable: false,
});
// Reset the mock again
eventCallback.mockClear();
// Setting same value shouldn't trigger event
node.alpha = 0;
node.update(2, clippingRect);
expect(node.isRenderable).toBe(false);
expect(eventCallback).not.toHaveBeenCalled();
});
});
describe('clipRadius', () => {
it('defaults to 0', () => {
const node = new CoreNode(stage, defaultProps);
expect(node.clipRadius).toBe(0);
});
it('setter stores value in props', () => {
const node = new CoreNode(stage, defaultProps);
node.clipRadius = 20;
expect(node.clipRadius).toBe(20);
});
it('setter is a no-op when value is unchanged', () => {
const node = new CoreNode(stage, defaultProps);
node.clipRadius = 10;
node.updateType = 0;
node.clipRadius = 10;
expect(node.updateType).toBe(0);
});
it('setter schedules UpdateType.Clipping when value changes', () => {
const node = new CoreNode(stage, defaultProps);
node.updateType = 0;
node.clipRadius = 15;
expect(node.updateType & UpdateType.Clipping).not.toBe(0);
});
it('calculateClippingRect stores clipRadius on clippingRect when clipping is active', () => {
const node = new CoreNode(stage, {
...defaultProps,
clipping: true,
clipRadius: 12,
scaleX: 1,
scaleY: 1,
w: 100,
h: 100,
alpha: 1,
});
node.update(0, clippingRect);
expect(node.clippingRect.valid).toBe(true);
expect(node.clippingRect.clipRadius).toBe(12);
});
it('calculateClippingRect sets clipRadius to 0 when clipping is false', () => {
const node = new CoreNode(stage, {
...defaultProps,
clipping: false,
clipRadius: 12,
w: 100,
h: 100,
alpha: 1,
});
node.update(0, clippingRect);
expect(node.clippingRect.valid).toBe(false);
expect(node.clippingRect.clipRadius).toBe(0);
});
it('calculateClippingRect child inherits parent clipRadius when it has no own clipping', () => {
const parent = new CoreNode(stage, {
...defaultProps,
clipping: true,
clipRadius: 8,
scaleX: 1,
scaleY: 1,
w: 200,
h: 200,
alpha: 1,
});
const child = new CoreNode(stage, {
...defaultProps,
clipping: false,
scaleX: 1,
scaleY: 1,
w: 50,
h: 50,
alpha: 1,
parent,
});
parent.update(0, clippingRect);
expect(child.clippingRect.valid).toBe(true);
expect(child.clippingRect.clipRadius).toBe(8);
});
it('calculateClippingRect child clipping overrides parent clipRadius', () => {
const parent = new CoreNode(stage, {
...defaultProps,
clipping: true,
clipRadius: 8,
scaleX: 1,
scaleY: 1,
w: 200,
h: 200,
alpha: 1,
});
const child = new CoreNode(stage, {
...defaultProps,
clipping: true,
clipRadius: 20,
scaleX: 1,
scaleY: 1,
w: 100,
h: 100,
alpha: 1,
parent,
});
parent.update(0, clippingRect);
expect(child.clippingRect.valid).toBe(true);
expect(child.clippingRect.clipRadius).toBe(20);
});
it('calculateClippingRect disables clipping when node is rotated even if clipRadius > 0', () => {
const node = new CoreNode(stage, {
...defaultProps,
clipping: true,
clipRadius: 10,
rotation: Math.PI / 4,
scaleX: 1,
scaleY: 1,
w: 100,
h: 100,
alpha: 1,
});
node.update(0, clippingRect);
expect(node.clippingRect.valid).toBe(false);
expect(node.clippingRect.clipRadius).toBe(0);
});
});
describe('autosize system', () => {
it('should initialize with autosize disabled', () => {
const node = new CoreNode(stage, defaultProps);
expect(node.autosize).toBe(false);
});
it('should enable texture autosize when texture is present', () => {
const node = new CoreNode(stage, defaultProps);
const mockTexture = mock<ImageTexture>();
mockTexture.state = 'loading';
node.texture = mockTexture;
node.autosize = true;
// Should not create autosize manager for texture mode
expect((node as any).autosizer).toBeTruthy();
});
it('should enable children autosize when no texture but has children', () => {
const parent = new CoreNode(stage, defaultProps);
const child = new CoreNode(stage, defaultProps);
parent.autosize = true;
child.parent = parent;
// Should create autosize manager for children mode
expect((parent as any).autosizer).toBeTruthy();
});
it('should prioritize texture autosize over children autosize', () => {
const parent = new CoreNode(stage, defaultProps);
const child = new CoreNode(stage, defaultProps);
const mockTexture = mock<ImageTexture>();
mockTexture.state = 'loading';
child.parent = parent;
parent.texture = mockTexture;
parent.autosize = true;
expect(parent.autosize).toBe(true);
// Should NOT create autosize manager when texture is present
expect((parent as any).autosizer).toBeTruthy();
});
it('should switch from children to texture autosize when texture is added', () => {
const parent = new CoreNode(stage, defaultProps);
const child = new CoreNode(stage, defaultProps);
child.parent = parent;
parent.autosize = true;
expect((parent as any).autosizer).toBeTruthy();
// Add texture - should switch to texture autosize
const mockTexture = mock<ImageTexture>();
mockTexture.state = 'loading';
parent.texture = mockTexture;
expect((parent as any).autosizer).toBeTruthy();
});
it('should switch from texture to children autosize when texture is removed', () => {
const parent = new CoreNode(stage, defaultProps);
const child = new CoreNode(stage, defaultProps);
const mockTexture = mock<ImageTexture>();
mockTexture.state = 'loading';
child.parent = parent;
parent.texture = mockTexture;
parent.autosize = true;
expect((parent as any).autosizer).toBeTruthy();
// Remove texture - should switch to children autosize
parent.texture = null;
expect((parent as any).autosizer).toBeTruthy();
});
it('should cleanup autosize manager when disabled', () => {
const parent = new CoreNode(stage, defaultProps);
const child = new CoreNode(stage, defaultProps);
child.parent = parent;
parent.autosize = true;
expect((parent as any).autosizer).toBeTruthy();
parent.autosize = false;
expect((parent as any).autosizer).toBeFalsy();
});
it('should establish autosize chain when child is added to autosize parent', () => {
const parent = new CoreNode(stage, defaultProps);
const child = new CoreNode(stage, defaultProps);
// Enable autosize BEFORE adding child
parent.autosize = true;
child.parent = parent;
expect((child as any).parentAutosizer).toBe(parent.autosizer);
expect((parent as any).autosizer.childMap.size).toBe(1);
});
it('should remove from autosize chain when child is removed', () => {
const parent = new CoreNode(stage, defaultProps);
const child = new CoreNode(stage, defaultProps);
// Enable autosize BEFORE adding child
parent.autosize = true;
child.parent = parent;
expect((parent as any).autosizer.childMap.size).toBe(1);
child.parent = null;
expect((child as any).parentAutosizer).toBeNull();
});
});
describe('isQuadDirty marking (dirty quad buffer)', () => {
it('starts clean', () => {
const node = new CoreNode(stage, defaultProps);
expect(node.isQuadDirty).toBe(false);
expect(node.quadBufferIndex).toBe(-1);
});
it('marks isQuadDirty when a visual flag (PremultipliedColors) is processed', () => {
const node = new CoreNode(stage, defaultProps);
node.isQuadDirty = false;
node.color = 0xffffffff;
node.update(0, clippingRect);
expect(node.isQuadDirty).toBe(true);
});
it('marks isQuadDirty when a transform flag (Global) is processed', () => {
const node = new CoreNode(stage, defaultProps);
node.isQuadDirty = false;
node.x = 10;
node.update(0, clippingRect);
expect(node.isQuadDirty).toBe(true);
});
it('does not mark isQuadDirty for bookkeeping-only flags', () => {
const node = new CoreNode(stage, defaultProps);
node.isQuadDirty = false;
node.updateType = UpdateType.Children;
node.update(0, clippingRect);
expect(node.isQuadDirty).toBe(false);
});
it('does not clear an existing dirty mark for bookkeeping-only flags', () => {
const node = new CoreNode(stage, defaultProps);
node.isQuadDirty = true;
node.updateType = UpdateType.Children;
node.update(0, clippingRect);
// Only the renderer clears isQuadDirty after a GPU upload.
expect(node.isQuadDirty).toBe(true);
});
it('marks isQuadDirty immediately in the texture setter', () => {
const node = new CoreNode(stage, defaultProps);
node.isQuadDirty = false;
node.texture = mock<ImageTexture>({
state: 'loaded',
});
expect(node.isQuadDirty).toBe(true);
});
});
});