@lightningjs/renderer
Version:
Lightning 3 Renderer
637 lines (505 loc) • 20 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 type { TextRenderer } from './text-rendering/TextRenderer.js';
import { makeMockStage, makeTextProps } from '../../test/mockStage.js';
describe('CoreTextNode', () => {
let stage: Stage;
let mockTextRenderer: TextRenderer;
const defaultTextProps: CoreTextNodeProps = makeTextProps();
const clippingRect = {
x: 0,
y: 0,
w: 200,
h: 200,
valid: true,
clipRadius: 0,
};
beforeEach(() => {
stage = makeMockStage();
// 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 makeMockStage({
renderer: { deleteBuffer } as unknown as CoreRenderer,
});
}
function createSdfRenderInfo() {
return {
type: 'sdf' as const,
width: 100,
height: 20,
atlasTexture: {} as any,
layout: {
glyphs: new Float32Array([0, 0, 0, 0, 10, 0, 1, 0]),
glyphCount: 1,
totalQuadCount: 1,
richText: false,
width: 100,
height: 20,
fontScale: 1,
lineHeight: 20,
fontFamily: 'Arial',
distanceRange: 4,
},
hasRemainingText: false,
remainingLines: 0,
};
}
describe('updateRenderState – SDF cache release on OutOfBounds', () => {
function seedCache(node: CoreTextNode) {
(node as any)._sdfCache = {
vertices: new Float32Array([0, 0]),
glyphCount: 1,
color: 0xffffffff,
alpha: 1,
transform: new Float32Array(6),
layoutRef: null,
};
}
it('should clear the SDF cache when transitioning to OutOfBounds', () => {
const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
seedCache(node);
(node as any)._renderInfo = createSdfRenderInfo();
node.updateRenderState(CoreNodeRenderState.OutOfBounds);
expect((node as any)._sdfCache).toBeNull();
});
it('should not call renderer.deleteBuffer (buffers are renderer-owned)', () => {
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 cache when transitioning to InBounds', () => {
const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
seedCache(node);
(node as any)._renderInfo = createSdfRenderInfo();
node.updateRenderState(CoreNodeRenderState.InBounds);
expect((node as any)._sdfCache).not.toBeNull();
});
it('should not release the cache for a canvas-type text node', () => {
const canvasTextRenderer = {
...mockTextRenderer,
type: 'canvas' as const,
} as any;
const node = new CoreTextNode(
stage,
defaultTextProps,
canvasTextRenderer,
);
seedCache(node);
(node as any)._renderInfo = {
type: 'canvas',
width: 100,
height: 20,
imageData: {} as ImageData,
hasRemainingText: false,
remainingLines: 0,
};
node.updateRenderState(CoreNodeRenderState.OutOfBounds);
expect((node as any)._sdfCache).not.toBeNull();
});
});
describe('SDF cache release on layout regeneration', () => {
it('should drop the cache before regenerating layout when font is already loaded', () => {
const props = { ...defaultTextProps, forceLoad: true };
const node = new CoreTextNode(stage, props, mockTextRenderer);
(node as any)._sdfCache = {
vertices: new Float32Array([0, 0]),
glyphCount: 1,
color: 0xffffffff,
alpha: 1,
transform: new Float32Array(6),
layoutRef: null,
};
node.update(16, clippingRect);
// The stale cached vertices are dropped; a fresh empty cache is created
// when the new layout renders.
const cache = (node as any)._sdfCache;
expect(cache).not.toBeNull();
expect(cache.vertices).toBeNull();
expect(cache.layoutRef).toBeNull();
});
it('should not call renderer.deleteBuffer (buffers are renderer-owned)', () => {
const deleteBuffer = vi.fn();
const props = { ...defaultTextProps, forceLoad: true };
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
props,
mockTextRenderer,
);
(node as any)._sdfCache = {
vertices: new Float32Array([0, 0]),
glyphCount: 1,
color: 0xffffffff,
alpha: 1,
transform: new Float32Array(6),
layoutRef: null,
};
node.update(16, clippingRect);
expect(deleteBuffer).not.toHaveBeenCalled();
});
});
describe('SDF cache release when text becomes invalid', () => {
it('should drop the cache when text is cleared during update', () => {
const props = { ...defaultTextProps, text: 'Hello', forceLoad: true };
const node = new CoreTextNode(stage, props, mockTextRenderer);
// Prime the node with a cached layout and vertex cache
(node as any)._sdfCache = {
vertices: new Float32Array([0, 0]),
glyphCount: 1,
color: 0xffffffff,
alpha: 1,
transform: new Float32Array(6),
layoutRef: null,
};
(node as any)._layoutGenerated = true;
node.text = '';
node.update(16, clippingRect);
expect((node as any)._sdfCache).toBeNull();
});
it('should not call renderer.deleteBuffer when text is invalid and cache 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 cache release on destroy', () => {
it('should clear the SDF cache on destroy when a cache is held', () => {
const deleteBuffer = vi.fn();
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
defaultTextProps,
mockTextRenderer,
);
(node as any)._sdfCache = {
vertices: new Float32Array([0, 0]),
glyphCount: 1,
color: 0xffffffff,
alpha: 1,
transform: new Float32Array(6),
layoutRef: null,
};
node.destroy();
expect(deleteBuffer).not.toHaveBeenCalled();
expect((node as any)._sdfCache).toBeNull();
});
it('should not call renderer.deleteBuffer on destroy when cache is already null', () => {
const deleteBuffer = vi.fn();
const node = new CoreTextNode(
makeStageWithDeleteBuffer(deleteBuffer),
defaultTextProps,
mockTextRenderer,
);
// _sdfCache 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('submits glyphs to the renderer via textRenderer.renderQuads with cache props', () => {
const node = new CoreTextNode(stage, 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(stage.renderer);
node.renderQuads(stage.renderer);
// Batched path: no per-node WebGL buffer is ever created.
expect(mockTextRenderer.renderQuads).toHaveBeenCalledTimes(2);
const layout = (node as any)._renderInfo.layout;
const renderProps = (mockTextRenderer.renderQuads as any).mock
.calls[0][3];
expect((mockTextRenderer.renderQuads as any).mock.calls[0][1]).toBe(
layout,
);
expect((mockTextRenderer.renderQuads as any).mock.calls[0][2]).toBeNull();
expect(renderProps.globalTransform).toBe(transform);
expect(renderProps.worldAlpha).toBe(node.worldAlpha);
expect(renderProps.color).toBe(node.props.color);
expect(renderProps.clippingRect).toBe(node.clippingRect);
expect(renderProps.sdfCache).not.toBeNull();
expect(renderProps.sdfCache).toBe((node as any)._sdfCache);
});
it('does not submit when _renderInfo is null', () => {
const node = new CoreTextNode(stage, defaultTextProps, mockTextRenderer);
node.renderQuads(stage.renderer);
expect(mockTextRenderer.renderQuads).not.toHaveBeenCalled();
});
it('defers to super for canvas render info', () => {
const canvasTextRenderer = {
...mockTextRenderer,
type: 'canvas' as const,
} as any;
const node = new CoreTextNode(
stage,
defaultTextProps,
canvasTextRenderer,
);
(node as any)._renderInfo = {
type: 'canvas',
width: 100,
height: 20,
imageData: {} as ImageData,
hasRemainingText: false,
remainingLines: 0,
};
node.texture = stage.defaultTexture as any;
(node as any).props.texture = stage.defaultTexture;
const addQuad = vi.fn();
(stage.renderer as any).addQuad = addQuad;
node.renderQuads(stage.renderer);
expect(mockTextRenderer.renderQuads).not.toHaveBeenCalled();
expect(addQuad).toHaveBeenCalled();
});
});
// -------------------------------------------------------------------------
// richText property
// -------------------------------------------------------------------------
describe('richText property', () => {
let node: CoreTextNode;
beforeEach(() => {
node = new CoreTextNode(stage, { ...defaultTextProps }, mockTextRenderer);
// Start each test with a clean update state
node.update(0, stage as any);
});
it('defaults to false', () => {
const fresh = new CoreTextNode(
stage,
{ ...defaultTextProps },
mockTextRenderer,
);
expect(fresh.richText).toBe(false);
});
it('getter returns the stored value after being set to true', () => {
node.richText = true;
expect(node.richText).toBe(true);
});
it('getter returns false after being set back to false', () => {
node.richText = true;
node.richText = false;
expect(node.richText).toBe(false);
});
it('setter marks layout as stale when value changes to true', () => {
node['_layoutGenerated'] = true;
node.richText = true;
expect(node['_layoutGenerated']).toBe(false);
});
it('setter marks layout as stale when value changes back to false', () => {
node.richText = true;
node['_layoutGenerated'] = true;
node.richText = false;
expect(node['_layoutGenerated']).toBe(false);
});
it('setter does not mark layout as stale when value is unchanged (false → false)', () => {
node['_layoutGenerated'] = true;
node.richText = false;
expect(node['_layoutGenerated']).toBe(true);
});
it('setter does not mark layout as stale when value is unchanged (true → true)', () => {
node.richText = true;
node['_layoutGenerated'] = true;
node.richText = true;
expect(node['_layoutGenerated']).toBe(true);
});
});
});