@lightningjs/renderer
Version:
Lightning 3 Renderer
662 lines (541 loc) • 20.5 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 { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js';
import { CoreNodeRenderState } from './CoreNode.js';
import { Stage } from './Stage.js';
import { CoreRenderer } from './renderers/CoreRenderer.js';
import { mock } from 'vitest-mock-extended';
import type { TextRenderer } from './text-rendering/TextRenderer.js';
import { createBound } from './lib/utils.js';
describe('CoreTextNode', () => {
let stage: Stage;
let mockTextRenderer: TextRenderer;
const defaultTextProps: CoreTextNodeProps = {
// CoreNodeProps
alpha: 1,
autosize: false,
boundsMargin: null,
clipping: false,
color: 0xffffffff,
colorBl: 0xffffffff,
colorBottom: 0xffffffff,
colorBr: 0xffffffff,
colorLeft: 0xffffffff,
colorRight: 0xffffffff,
colorTl: 0xffffffff,
colorTop: 0xffffffff,
colorTr: 0xffffffff,
h: 0,
mount: 0,
mountX: 0,
mountY: 0,
parent: null,
pivot: 0,
pivotX: 0,
pivotY: 0,
rotation: 0,
rtt: false,
scale: 1,
scaleX: 1,
scaleY: 1,
shader: null,
src: '',
texture: null,
textureOptions: {},
w: 0,
x: 0,
y: 0,
zIndex: 0,
// TrProps
text: 'Test',
textAlign: 'left',
contain: 'none',
fontFamily: 'Arial',
fontStyle: 'normal',
fontSize: 16,
letterSpacing: 0,
lineHeight: 1,
maxHeight: 0,
maxLines: 0,
maxWidth: 0,
offsetY: 0,
overflowSuffix: '...',
verticalAlign: 'top',
wordBreak: 'break-word',
// CoreTextNodeProps specific
textRendererOverride: null,
forceLoad: false,
};
const clippingRect = {
x: 0,
y: 0,
w: 200,
h: 200,
valid: true,
};
beforeEach(() => {
stage = mock<Stage>({
strictBound: createBound(0, 0, 1920, 1080),
preloadBound: createBound(0, 0, 1920, 1080),
defaultTexture: {
state: 'loaded',
},
renderer: mock<CoreRenderer>() as CoreRenderer,
});
// Mock text renderer with basic functionality
mockTextRenderer = {
type: 'sdf',
font: {
isFontLoaded: vi.fn().mockReturnValue(true),
waitingForFont: vi.fn(),
stopWaitingForFont: vi.fn(),
},
renderText: vi.fn().mockReturnValue({
width: 100,
height: 20,
layout: { glyphs: [], width: 100, height: 20 },
}),
addQuads: vi.fn().mockReturnValue(new Float32Array(0)),
renderQuads: vi.fn(),
} as any;
});
describe('text property handling', () => {
it('should normalize empty string and set node as non-renderable', () => {
const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
node.text = '';
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
});
it('should normalize null to empty string and set node as non-renderable', () => {
const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
node.text = null as any;
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
});
it('should normalize undefined to empty string and set node as non-renderable', () => {
const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
node.text = undefined as any;
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
});
it('should convert non-string values to strings', () => {
const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
node.text = 123 as any;
expect(node.text).toBe('123');
});
it('should handle valid text and allow node to be renderable', () => {
const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
node.text = 'Valid text content';
expect(node.text).toBe('Valid text content');
// Note: renderable state is determined during update cycle
});
it('should clear cached layout data when text is set to empty', () => {
const props = { ...defaultTextProps, text: 'Initial text' };
const node = new CoreTextNode(stage, props, mockTextRenderer);
// Simulate some cached state
node.text = '';
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
});
it('should clear cached layout data when text is set to null', () => {
const props = { ...defaultTextProps, text: 'Initial text' };
const node = new CoreTextNode(stage, props, mockTextRenderer);
node.text = null as any;
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
});
it('should clear cached layout data when text is set to undefined', () => {
const props = { ...defaultTextProps, text: 'Initial text' };
const node = new CoreTextNode(stage, props, mockTextRenderer);
node.text = undefined as any;
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
});
it('should not call renderText during update when text is empty', () => {
const props = { ...defaultTextProps, text: '', forceLoad: true };
const node = new CoreTextNode(stage, props, mockTextRenderer);
node.update(16, clippingRect);
expect(mockTextRenderer.renderText).not.toHaveBeenCalled();
});
it('should not call renderText during update when text is null', () => {
const props = { ...defaultTextProps, text: null as any, forceLoad: true };
const node = new CoreTextNode(stage, props, mockTextRenderer);
node.update(16, clippingRect);
expect(mockTextRenderer.renderText).not.toHaveBeenCalled();
});
it('should transition from valid text to empty text correctly', () => {
const props = { ...defaultTextProps, text: 'Valid text' };
const node = new CoreTextNode(stage, props, mockTextRenderer);
// Set to valid text first
node.text = 'Some content';
expect(node.text).toBe('Some content');
// Then set to empty
node.text = '';
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
});
it('should transition from empty text to valid text correctly', () => {
const props = { ...defaultTextProps, text: '' };
const node = new CoreTextNode(stage, props, mockTextRenderer);
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
// Set to valid text
node.text = 'New content';
expect(node.text).toBe('New content');
});
it('should handle rapid text changes including null/undefined', () => {
const props = { ...defaultTextProps, text: 'Initial' };
const node = new CoreTextNode(stage, props, mockTextRenderer);
node.text = 'First';
expect(node.text).toBe('First');
node.text = null as any;
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
node.text = 'Second';
expect(node.text).toBe('Second');
node.text = undefined as any;
expect(node.text).toBe('');
expect(node.isRenderable).toBe(false);
node.text = 'Third';
expect(node.text).toBe('Third');
});
});
describe('updateIsRenderable with invalid text', () => {
it('should mark node as non-renderable when text is empty', () => {
const props = { ...defaultTextProps, text: '' };
const node = new CoreTextNode(stage, props, mockTextRenderer);
node.updateIsRenderable();
expect(node.isRenderable).toBe(false);
});
it('should mark node as non-renderable even if layout exists when text is empty', () => {
const props = { ...defaultTextProps, text: 'Valid' };
const node = new CoreTextNode(stage, props, mockTextRenderer);
// Change text to empty after potential layout generation
node.text = '';
node.updateIsRenderable();
expect(node.isRenderable).toBe(false);
});
});
describe('update cycle with invalid text', () => {
it('should skip layout generation when text is empty', () => {
const props = { ...defaultTextProps, text: '', forceLoad: true };
const node = new CoreTextNode(stage, props, mockTextRenderer);
node.update(16, clippingRect);
expect(mockTextRenderer.renderText).not.toHaveBeenCalled();
expect(node.isRenderable).toBe(false);
});
it('should skip layout generation when text becomes empty during update', () => {
const props = { ...defaultTextProps, text: 'Valid', forceLoad: true };
const node = new CoreTextNode(stage, props, mockTextRenderer);
node.text = '';
node.update(16, clippingRect);
expect(node.isRenderable).toBe(false);
});
});
function makeStageWithDeleteBuffer(deleteBuffer: ReturnType<typeof vi.fn>) {
return mock<Stage>({
strictBound: createBound(0, 0, 1920, 1080),
preloadBound: createBound(0, 0, 1920, 1080),
defaultTexture: { state: 'loaded' },
renderer: { deleteBuffer } as unknown as CoreRenderer,
});
}
function createSdfRenderInfo() {
return {
type: 'sdf' as const,
width: 100,
height: 20,
atlasTexture: {} as any,
layout: {
glyphCount: 1,
width: 100,
height: 20,
fontScale: 1,
lineHeight: 20,
fontFamily: 'Arial',
distanceRange: 4,
vertexBuffer: new Float32Array([0, 0, 0, 0, 10, 0, 1, 0]),
truncatedTextLines: 0,
},
hasRemainingText: false,
remainingLines: 0,
};
}
describe('updateRenderState – SDF buffer release on OutOfBounds', () => {
it('should call renderer.deleteBuffer and clear _sdfBuffer when transitioning to OutOfBounds', () => {
const deleteBuffer = vi.fn();
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
defaultTextProps,
mockTextRenderer,
);
const fakeBuffer = {};
(node as any)._sdfBuffer = fakeBuffer;
(node as any)._renderInfo = createSdfRenderInfo();
node.updateRenderState(CoreNodeRenderState.OutOfBounds);
expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
expect((node as any)._sdfBuffer).toBeNull();
});
it('should not call renderer.deleteBuffer when _sdfBuffer is already null', () => {
const deleteBuffer = vi.fn();
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
defaultTextProps,
mockTextRenderer,
);
(node as any)._renderInfo = createSdfRenderInfo();
node.updateRenderState(CoreNodeRenderState.OutOfBounds);
expect(deleteBuffer).not.toHaveBeenCalled();
});
it('should not release the buffer when transitioning to InBounds', () => {
const deleteBuffer = vi.fn();
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
defaultTextProps,
mockTextRenderer,
);
const fakeBuffer = {};
(node as any)._sdfBuffer = fakeBuffer;
(node as any)._renderInfo = createSdfRenderInfo();
node.updateRenderState(CoreNodeRenderState.InBounds);
expect(deleteBuffer).not.toHaveBeenCalled();
expect((node as any)._sdfBuffer).toBe(fakeBuffer);
});
it('should not release the buffer for a canvas-type text node', () => {
const deleteBuffer = vi.fn();
const canvasTextRenderer = {
...mockTextRenderer,
type: 'canvas' as const,
} as any;
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
defaultTextProps,
canvasTextRenderer,
);
(node as any)._renderInfo = {
type: 'canvas',
width: 100,
height: 20,
imageData: {} as ImageData,
hasRemainingText: false,
remainingLines: 0,
};
node.updateRenderState(CoreNodeRenderState.OutOfBounds);
expect(deleteBuffer).not.toHaveBeenCalled();
});
});
describe('SDF buffer release on layout regeneration', () => {
it('should call renderer.deleteBuffer before regenerating layout when font is already loaded', () => {
const deleteBuffer = vi.fn();
const props = { ...defaultTextProps, forceLoad: true };
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
props,
mockTextRenderer,
);
const fakeBuffer = {} as WebGLBuffer;
(node as any)._sdfBuffer = fakeBuffer;
node.update(16, clippingRect);
expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
expect((node as any)._sdfBuffer).toBeNull();
});
it('should call renderer.deleteBuffer again on each subsequent layout regeneration', () => {
const deleteBuffer = vi.fn();
const props = { ...defaultTextProps, forceLoad: true };
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
props,
mockTextRenderer,
);
node.update(16, clippingRect); // first layout – no buffer yet, no delete call
expect(deleteBuffer).not.toHaveBeenCalled();
// Trigger a second layout pass by invalidating the layout
node.fontSize = 24;
const secondBuffer = {} as WebGLBuffer;
(node as any)._sdfBuffer = secondBuffer;
node.update(16, clippingRect);
expect(deleteBuffer).toHaveBeenCalledWith(secondBuffer);
expect((node as any)._sdfBuffer).toBeNull();
});
it('should not call renderer.deleteBuffer when buffer is already null at regeneration time', () => {
const deleteBuffer = vi.fn();
const props = { ...defaultTextProps, forceLoad: true };
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
props,
mockTextRenderer,
);
// _sdfBuffer is null by default
node.update(16, clippingRect);
expect(deleteBuffer).not.toHaveBeenCalled();
});
});
describe('SDF buffer release when text becomes invalid', () => {
it('should call renderer.deleteBuffer when text is cleared during update', () => {
const deleteBuffer = vi.fn();
const props = { ...defaultTextProps, text: 'Hello', forceLoad: true };
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
props,
mockTextRenderer,
);
// Prime the node with a cached buffer
const fakeBuffer = {} as WebGLBuffer;
(node as any)._sdfBuffer = fakeBuffer;
(node as any)._layoutGenerated = true;
node.text = '';
node.update(16, clippingRect);
expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
expect((node as any)._sdfBuffer).toBeNull();
});
it('should not call renderer.deleteBuffer when text is invalid and buffer is already null', () => {
const deleteBuffer = vi.fn();
const props = { ...defaultTextProps, text: '', forceLoad: true };
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
props,
mockTextRenderer,
);
node.update(16, clippingRect);
expect(deleteBuffer).not.toHaveBeenCalled();
});
it('should also clear _renderInfo when text becomes invalid', () => {
const deleteBuffer = vi.fn();
const props = { ...defaultTextProps, text: 'Hello', forceLoad: true };
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
props,
mockTextRenderer,
);
(node as any)._renderInfo = createSdfRenderInfo();
node.text = '';
node.update(16, clippingRect);
expect((node as any)._renderInfo).toBeNull();
});
});
describe('SDF buffer release on destroy', () => {
it('should call renderer.deleteBuffer on destroy when a buffer is held', () => {
const deleteBuffer = vi.fn();
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
defaultTextProps,
mockTextRenderer,
);
const fakeBuffer = {} as WebGLBuffer;
(node as any)._sdfBuffer = fakeBuffer;
node.destroy();
expect(deleteBuffer).toHaveBeenCalledWith(fakeBuffer);
expect((node as any)._sdfBuffer).toBeNull();
});
it('should not call renderer.deleteBuffer on destroy when buffer is already null', () => {
const deleteBuffer = vi.fn();
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
defaultTextProps,
mockTextRenderer,
);
// _sdfBufferRef.current is null by default
node.destroy();
expect(deleteBuffer).not.toHaveBeenCalled();
});
it('should clear _renderInfo on destroy', () => {
const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
(node as any)._renderInfo = createSdfRenderInfo();
node.destroy();
expect((node as any)._renderInfo).toBeNull();
});
});
describe('SDF render path', () => {
it('reuses the uploaded SDF buffer across renderQuads calls', () => {
const createBuffer = vi.fn().mockReturnValue({ label: 'sdf-buffer' });
const arrayBufferData = vi.fn();
const glw = {
createBuffer,
arrayBufferData,
STATIC_DRAW: 0x88e4,
FLOAT: 0x1406,
};
const sdfStage = mock<Stage>({
strictBound: createBound(0, 0, 1920, 1080),
preloadBound: createBound(0, 0, 1920, 1080),
defaultTexture: { state: 'loaded' },
renderer: { glw } as unknown as CoreRenderer,
});
const node = new CoreTextNode(
sdfStage,
defaultTextProps,
mockTextRenderer,
);
const transform = new Float32Array([1, 0, 0, 1, 0, 0]);
(node as any).handleRenderResult(createSdfRenderInfo());
(node as any).globalTransform = {
getFloatArr: vi.fn().mockReturnValue(transform),
};
node.renderQuads(sdfStage.renderer);
node.renderQuads(sdfStage.renderer);
expect(createBuffer).toHaveBeenCalledTimes(1);
expect(arrayBufferData).toHaveBeenCalledTimes(1);
expect(mockTextRenderer.renderQuads).toHaveBeenCalledTimes(2);
expect((node as any)._sdfBuffer).toEqual({ label: 'sdf-buffer' });
expect(node.sdfShaderProps.transform).toBe(transform);
});
it('uses framebuffer-relative scissor coordinates for RTT draws', () => {
const bindRenderOp = vi.fn();
const useShader = vi.fn();
const setScissorTest = vi.fn();
const scissor = vi.fn();
const drawArrays = vi.fn();
const sdfStage = mock<Stage>({
strictBound: createBound(0, 0, 1920, 1080),
preloadBound: createBound(0, 0, 1920, 1080),
defaultTexture: { state: 'loaded' },
pixelRatio: 2,
platform: { canvas: { width: 1920, height: 1080 } } as any,
shManager: { useShader } as any,
});
const node = new CoreTextNode(
sdfStage,
defaultTextProps,
mockTextRenderer,
);
const shader = { program: { bindRenderOp } };
node.props.shader = shader as any;
node.numQuads = 2;
node.clippingRect = { x: 10, y: 20, w: 30, h: 40, valid: true };
node.parentHasRenderTexture = true;
node.rttParent = { framebufferDimensions: { w: 320, h: 180 } } as any;
node.props.h = 25;
node.draw({
glw: {
TRIANGLES: 4,
setScissorTest,
scissor,
drawArrays,
},
stage: sdfStage,
} as any);
expect(useShader).toHaveBeenCalledWith(shader.program);
expect(bindRenderOp).toHaveBeenCalledWith(node);
expect(setScissorTest).toHaveBeenCalledWith(true);
expect(scissor).toHaveBeenCalledWith(10, 155, 30, 40);
expect(drawArrays).toHaveBeenCalledWith(4, 0, 12);
});
});
});