Files
feuerwerk/tests/editor/shader-preview.test.ts
T
adminandClaude Opus 5 73a71473f1
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m43s
The shader tab previews the effect on the last picked image (#168)
A shader could only be judged by placing it on a sprite and running
the scene. The shader tab now shows its effect next to the graph, on
the image the user picked last, without a picker to maintain.

- The preview follows the last selection that has an image: a placed
  sprite with its overrides, size and tint, a sprite asset, a texture
  or a frame. Clicking the shader, a node, a behavior or a scene keeps
  the picture.
- Without such a selection, or when the picked image is deleted, a
  built-in 64 px test image is shown. Its colour runs with u and v, a
  checkerboard shows where a UV lands and the border fades for alpha.
- Each property gets a slider. The sliders belong to the preview and
  never write to the project. They start at the sprite's values when
  it carries this shader, otherwise at the default.
- The preview has its own clock. Hold and Restart are only enabled
  when a Time node is in the graph.
- The graph | splitter | panel layout of the particles tab moves into
  PreviewSplit, and both tabs use it.
- A nine-sliced frame is previewed unsliced. This is documented.

Findings:

- The slider row stayed squeezed to a few pixels when the properties
  changed while the tab was hidden. The layout was triggered one level
  too high. The slider check in block 28n holds this.
- The graph canvas is narrower now, 385 px at a 1280 px window, the
  same as in the particles tab. Block 28b clicked at a fixed x+420 and
  now uses the measured width.

Tests:

- ./check.sh: 132 files, 1952 tests (was 131 / 1941), exit 0
- npm run build: exit 0
- GM_STEPS=28,31 freshly seeded: ALL PASSED, 105 checks
- 11 new unit tests in tests/editor/shader-preview.test.ts
- 7 new e2e checks in block 28n
- Counter-probes: start values ignoring the sprite fail 1 unit test,
  a preview ignoring the selection fails 2 e2e checks
- Check total 1209 in README is computed (1202 + 7), CI measures it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:55:02 +02:00

163 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import { RemoveSceneObjectsCommand } from '../../src/core/commands/sceneObjectCommands';
import { findAsset } from '../../src/core/model/Project';
import type { FlatDef } from '../../src/core/properties/PropertySchema';
import {
pictureSubjectOf,
previewPicture,
sliderRange,
startValues,
TEST_IMAGE_SIZE,
testImagePixels,
} from '../../src/editor/shader/previewPicture';
import type { Selection } from '../../src/editor/state';
import { makeSpriteScene, place } from '../helpers';
const FILE = `${'cd'.repeat(32)}.png`;
/** A scene with a sprite "Box", a 64×32 texture "Sheet" with a 16×8 frame "Idle", and one placed Box. */
function setup() {
const fixture = makeSpriteScene();
const texture = new CreateTextureCommand('Sheet', { file: FILE, width: 64, height: 32 });
fixture.stack.execute(texture);
const frame = new AddFrameCommand(texture.asset.id, { x: 16, y: 8, w: 16, h: 8 }, 'Idle');
fixture.stack.execute(frame);
const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 });
const stored = () => fixture.sceneOf().objects.find((o) => o.id === objectId)!;
return { ...fixture, textureId: texture.asset.id, frameId: frame.frameId, objectId, stored };
}
function numberRow(key: string, extra: Partial<FlatDef<'number'>> = {}): FlatDef<'number'> {
return { key, label: key, kind: 'number', default: 1, min: 0, max: 2, ...extra };
}
describe('the picture the Shader tab previews (#168)', () => {
it('is named by a selected sprite, placed or not, a texture or a frame', () => {
const f = setup();
const object: Selection = {
kind: 'object',
holderId: f.sceneId,
objectId: f.objectId,
objectIds: [f.objectId],
};
expect(pictureSubjectOf(f.project, object)).toEqual({
kind: 'object',
holderId: f.sceneId,
objectId: f.objectId,
});
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.spriteId })).toEqual({
kind: 'asset',
assetId: f.spriteId,
});
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.textureId })).not.toBeNull();
expect(pictureSubjectOf(f.project, { kind: 'frame', assetId: f.textureId, frameId: f.frameId })).toEqual({
kind: 'frame',
frameId: f.frameId,
});
});
it('is not named by anything without a picture, so the preview keeps the one before', () => {
const f = setup();
const behavior = new CreateAssetCommand('behavior', 'Mover');
f.stack.execute(behavior);
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: behavior.asset.id })).toBeNull();
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.sceneId })).toBeNull();
expect(pictureSubjectOf(f.project, null)).toBeNull();
const node: Selection = {
kind: 'node',
assetId: behavior.asset.id,
nodeId: 'n',
nodeIds: ['n'],
annotationIds: [],
};
expect(pictureSubjectOf(f.project, node)).toBeNull();
});
it('shows a frame at its own size, a whole texture at the sheet size', () => {
const f = setup();
const frame = previewPicture(f.project, { kind: 'frame', frameId: f.frameId })!;
expect(frame).toMatchObject({ name: 'Sheet / Idle', w: 16, h: 8, tint: 0xffffff, props: {} });
expect(frame.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 });
const sheet = previewPicture(f.project, { kind: 'asset', assetId: f.textureId })!;
expect(sheet).toMatchObject({ name: 'Sheet', w: 64, h: 32 });
expect(sheet.texture?.rect).toBeNull();
});
it('shows a placed sprite as the scene does: its overrides, size and colour', () => {
const f = setup();
Object.assign(f.stored().overrides, { texture: f.frameId, width: 48, height: 24, color: 0xff8040 });
const picture = previewPicture(f.project, { kind: 'object', holderId: f.sceneId, objectId: f.objectId })!;
expect(picture).toMatchObject({ name: 'Box', w: 48, h: 24, tint: 0xff8040 });
expect(picture.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 });
// The asset itself is untouched by the object's overrides.
const asset = previewPicture(f.project, { kind: 'asset', assetId: f.spriteId })!;
expect(asset).toMatchObject({ name: 'Box', w: 100, h: 100, texture: null });
});
it('follows an edit, and is gone with its object – the tab then shows the test image', () => {
const f = setup();
const subject = { kind: 'object', holderId: f.sceneId, objectId: f.objectId } as const;
f.stored().overrides.width = 20;
expect(previewPicture(f.project, subject)?.w).toBe(20);
f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [f.objectId]));
expect(previewPicture(f.project, subject)).toBeNull();
f.stack.undo();
expect(previewPicture(f.project, subject)?.w).toBe(20);
expect(findAsset(f.project, f.spriteId)).toBeDefined();
});
});
describe('the preview sliders (#168)', () => {
const defs = [numberRow('glow'), numberRow('speed', { default: 0.5 })];
it('start at the values of a sprite wearing this shader', () => {
expect(startValues(defs, { shader: 'neon', glow: 1.75, speed: 0.25 }, 'neon')).toEqual({
glow: 1.75,
speed: 0.25,
});
// A row the sprite does not carry yet starts at its default.
expect(startValues(defs, { shader: 'neon', glow: 0.2 }, 'neon')).toEqual({ glow: 0.2, speed: 0.5 });
});
it('start at the defaults when the picture wears another shader or none', () => {
expect(startValues(defs, { shader: 'other', glow: 1.75 }, 'neon')).toEqual({ glow: 1, speed: 0.5 });
expect(startValues(defs, {}, 'neon')).toEqual({ glow: 1, speed: 0.5 });
});
it('reach the declared range, or twice the default without a max', () => {
expect(sliderRange(numberRow('a', { min: -1, max: 3 }))).toEqual({ min: -1, max: 3 });
expect(sliderRange(numberRow('b', { max: undefined, default: 1.5 }))).toEqual({ min: 0, max: 3 });
expect(sliderRange(numberRow('c', { min: 2, max: undefined, default: 2 }))).toEqual({ min: 2, max: 3 });
});
});
describe('the built-in test image (#168)', () => {
const pixels = testImagePixels();
const at = (x: number, y: number) =>
Array.from(pixels.slice((y * TEST_IMAGE_SIZE + x) * 4, (y * TEST_IMAGE_SIZE + x) * 4 + 4));
it('runs red from left to right and green from top to bottom', () => {
expect(pixels.length).toBe(TEST_IMAGE_SIZE * TEST_IMAGE_SIZE * 4);
const [r1, g1, b1] = at(16, 16);
const [r2, g2, b2] = at(48, 48);
expect(r2).toBeGreaterThan(r1!);
expect(g2).toBeGreaterThan(g1!);
expect(b2).toBeLessThan(b1!);
});
it('has a checker, so a moved UV shows', () => {
// Two neighbours across a cell border: same place in the gradient, a different shade.
expect(at(24, 20)[0]! / at(23, 20)[0]!).toBeCloseTo(0.55, 1);
});
it('fades out at the edge and is opaque inside', () => {
expect(at(0, 32)[3]).toBe(16);
expect(at(32, TEST_IMAGE_SIZE - 1)[3]).toBe(16);
expect(at(4, 32)[3]).toBe(143);
expect(at(8, 32)[3]).toBe(255);
expect(at(32, 32)[3]).toBe(255);
});
});