Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions examples/tests/texture-placeholder-color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { INode } from '@lightningjs/renderer';
import type { ExampleSettings } from '../common/ExampleSettings.js';

import rockoPng from '../assets/rocko.png';

/**
* Visual test for `placeholderColor`: a node with a texture renders a solid
* color rect (through its shader, so rounded corners apply) until the texture
* loads, instead of rendering nothing.
*
* Deterministic states captured in the snapshot:
* 1. Rounded placeholder for a permanently failed src (placeholder remains).
* 2. RoundedWithBorder placeholder for a permanently failed src.
* 3. A loaded image with placeholderColor set — the image shows untinted,
* proving the placeholder does not leak into the loaded state.
* 4. Control: failed src without placeholderColor renders nothing.
*/

const MISSING_SRC = '/does-not-exist-placeholder-test.png';

function waitForEvent(
node: INode,
event: 'loaded' | 'failed',
timeoutMs: number,
): Promise<boolean> {
return new Promise((resolve) => {
const timeout = setTimeout(() => resolve(false), timeoutMs);
node.once(event, () => {
clearTimeout(timeout);
resolve(true);
});
});
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function automation(settings: ExampleSettings) {
await test(settings);
// The scene settled inside test() (loaded/failed events already awaited),
// so the 'idle' transition may have already fired — don't wait for it.
// Force a final frame and let it draw instead.
settings.renderer.rerender();
await delay(100);
await settings.snapshot();
}

export default async function test({ renderer, testRoot }: ExampleSettings) {
renderer.createTextNode({
fontFamily: 'Ubuntu',
text: 'placeholderColor',
fontSize: 30,
color: 0xffffffff,
x: 20,
y: 20,
parent: testRoot,
});

// Fail fast and permanently: maxRetryCount 0 = one attempt, no retries.
const missingTexture = renderer.createTexture('ImageTexture', {
src: MISSING_SRC,
maxRetryCount: 0,
});

// 1. Placeholder stays visible for a permanently failed texture (rounded).
const failedRounded = renderer.createNode({
x: 20,
y: 80,
w: 200,
h: 280,
texture: missingTexture,
placeholderColor: 0x336699ff,
shader: renderer.createShader('Rounded', { radius: [20] }),
parent: testRoot,
});

// 2. Same with a border shader.
const failedBordered = renderer.createNode({
x: 250,
y: 80,
w: 200,
h: 280,
texture: missingTexture,
placeholderColor: 0x993311ff,
shader: renderer.createShader('RoundedWithBorder', {
radius: [20],
'border-w': 8,
}),
parent: testRoot,
});

// 3. A successfully loaded image with a placeholder configured must show
// the image, untinted.
const loadedImage = renderer.createNode({
x: 480,
y: 80,
w: 181,
h: 218,
src: rockoPng,
placeholderColor: 0x336699ff,
shader: renderer.createShader('Rounded', { radius: [20] }),
parent: testRoot,
});

// 4. Control: a failed texture without a placeholder renders nothing.
renderer.createNode({
x: 710,
y: 80,
w: 200,
h: 280,
texture: missingTexture,
parent: testRoot,
});

const settled = await Promise.all([
waitForEvent(failedRounded, 'failed', 10000),
waitForEvent(failedBordered, 'failed', 10000),
waitForEvent(loadedImage, 'loaded', 10000),
]);

if (settled[0] === false || settled[1] === false || settled[2] === false) {
console.error('[texture-placeholder-color] scene did not settle', settled);
return false;
}

console.log('[texture-placeholder-color] scene settled');
return true;
}
200 changes: 200 additions & 0 deletions src/core/CoreNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { type TextureOptions } from './CoreTextureManager.js';
import { createBound } from './lib/utils.js';
import { ImageTexture } from './textures/ImageTexture.js';
import { Matrix3d } from './lib/Matrix3d.js';
import { EventEmitter } from '../common/EventEmitter.js';
import { premultiplyColorABGR } from '../utils.js';

describe('set color()', () => {
const defaultProps = (overrides?: Partial<CoreNodeProps>): CoreNodeProps => ({
Expand All @@ -23,6 +25,7 @@ describe('set color()', () => {
colorTl: 0,
colorTop: 0,
colorTr: 0,
placeholderColor: 0,
h: 0,
mount: 0,
mountX: 0,
Expand Down Expand Up @@ -1263,4 +1266,201 @@ describe('set color()', () => {
expect(shader.update).toHaveBeenCalledTimes(2);
});
});

describe('placeholderColor', () => {
// A texture fake on a real EventEmitter so CoreNode's loadTextureTask
// subscribes for real and we can drive the loaded/freed/failed handler
// chain by emitting, like the engine does.
function emittingTexture(state: string): ImageTexture & {
emit: (event: string, data?: unknown) => void;
} {
return Object.assign(new EventEmitter(), {
state,
preventCleanup: false,
retryCount: 0,
maxRetryCount: 1,
dimensions: { w: 100, h: 100 },
setRenderableOwner: vi.fn(),
}) as unknown as ImageTexture & {
emit: (event: string, data?: unknown) => void;
};
}

function visibleNode(): CoreNode {
const parent = new CoreNode(stage, defaultProps());
parent.globalTransform = Matrix3d.identity();
parent.worldAlpha = 1;

const node = new CoreNode(stage, defaultProps({ parent }));
node.alpha = 1;
node.x = 0;
node.y = 0;
node.w = 100;
node.h = 100;
return node;
}

// Flush the queueMicrotask(loadTextureTask) so listeners attach.
const flushMicrotasks = () => Promise.resolve();

it('renders the placeholder while the texture is loading', () => {
const node = visibleNode();
node.placeholderColor = 0x336699ff;
node.texture = emittingTexture('initial');

node.update(0, clippingRect);

expect(node.placeholderActive).toBe(true);
expect(node.isRenderable).toBe(true);
expect(node.renderTexture).toBe(stage.defaultTexture);

const expected = premultiplyColorABGR(0x336699ff, 1);
expect(node.premultipliedColorTl).toBe(expected);
expect(node.premultipliedColorTr).toBe(expected);
expect(node.premultipliedColorBl).toBe(expected);
expect(node.premultipliedColorBr).toBe(expected);
});

it('is inactive without a placeholderColor (loading renders nothing)', () => {
const node = visibleNode();
node.texture = emittingTexture('initial');

node.update(0, clippingRect);

expect(node.placeholderActive).toBe(false);
expect(node.isRenderable).toBe(false);
});

it('is inactive without a texture (color-only nodes are unaffected)', () => {
const node = visibleNode();
node.placeholderColor = 0x336699ff;

node.update(0, clippingRect);

expect(node.placeholderActive).toBe(false);
});

it('switches to the texture and regular colors once loaded', async () => {
const node = visibleNode();
node.color = 0xffffffff;
node.placeholderColor = 0x336699ff;
const texture = emittingTexture('initial');
node.texture = texture;
node.update(0, clippingRect);
expect(node.renderTexture).toBe(stage.defaultTexture);

await flushMicrotasks();
(texture as { state: string }).state = 'loaded';
texture.emit('loaded', { w: 100, h: 100 });
node.isQuadDirty = false;
node.update(1, clippingRect);

expect(node.placeholderActive).toBe(false);
expect(node.isRenderable).toBe(true);
expect(node.renderTexture).toBe(texture);
expect(node.premultipliedColorTl).toBe(
premultiplyColorABGR(0xffffffff, 1),
);
// The color switch must reach the GPU quad buffer
expect(node.isQuadDirty).toBe(true);
});

it('shows the placeholder again while a freed texture reloads', async () => {
const node = visibleNode();
node.placeholderColor = 0x336699ff;
const texture = emittingTexture('initial');
node.texture = texture;
node.update(0, clippingRect);

await flushMicrotasks();
(texture as { state: string }).state = 'loaded';
texture.emit('loaded', { w: 100, h: 100 });
node.update(1, clippingRect);
expect(node.placeholderActive).toBe(false);

(texture as { state: string }).state = 'freed';
texture.emit('freed');
node.update(2, clippingRect);

expect(node.placeholderActive).toBe(true);
expect(node.isRenderable).toBe(true);
expect(node.renderTexture).toBe(stage.defaultTexture);
expect(node.premultipliedColorTl).toBe(
premultiplyColorABGR(0x336699ff, 1),
);
});

it('keeps the placeholder when the texture permanently fails', async () => {
const node = visibleNode();
node.placeholderColor = 0x336699ff;
const texture = emittingTexture('initial');
node.texture = texture;
node.update(0, clippingRect);

await flushMicrotasks();
(texture as { state: string }).state = 'failed';
(texture as { retryCount: number }).retryCount = 2; // > maxRetryCount (1)
texture.emit('failed', new Error('404'));
node.update(1, clippingRect);

expect(node.placeholderActive).toBe(true);
expect(node.isRenderable).toBe(true);
expect(node.renderTexture).toBe(stage.defaultTexture);
});

it('a permanently failed texture without a placeholder stays non-renderable', async () => {
const node = visibleNode();
const texture = emittingTexture('initial');
node.texture = texture;
node.update(0, clippingRect);

await flushMicrotasks();
(texture as { state: string }).state = 'failed';
(texture as { retryCount: number }).retryCount = 2;
texture.emit('failed', new Error('404'));
node.update(1, clippingRect);

expect(node.isRenderable).toBe(false);
});

it('deactivates when placeholderColor is cleared while loading', () => {
const node = visibleNode();
node.placeholderColor = 0x336699ff;
node.texture = emittingTexture('initial');
node.update(0, clippingRect);
expect(node.isRenderable).toBe(true);

node.placeholderColor = 0;
node.update(1, clippingRect);

expect(node.placeholderActive).toBe(false);
expect(node.isRenderable).toBe(false);
});

it('updates the shown color when placeholderColor changes while active', () => {
const node = visibleNode();
node.placeholderColor = 0x336699ff;
node.texture = emittingTexture('initial');
node.update(0, clippingRect);

node.placeholderColor = 0x993311ff;
node.update(1, clippingRect);

expect(node.premultipliedColorTl).toBe(
premultiplyColorABGR(0x993311ff, 1),
);
});

it('does not activate when the assigned texture is already loaded', () => {
const node = visibleNode();
node.placeholderColor = 0x336699ff;
const texture = emittingTexture('loaded');
node.texture = texture;

node.update(0, clippingRect);

expect(node.placeholderActive).toBe(false);
expect(node.renderTexture).toBe(texture);
});
});
});
Loading
Loading