Files
feuerwerk/tests/editor/shader-preview.test.ts
adminandClaude Opus 5.5 c5ba21533d
game-maker / gate (push) Successful in 2m37s
game-maker / suite (push) Successful in 13m10s
Pick a link on its line and delete it from there (#289)
A link could only be removed through the menu of its port, and the
line itself took no input. A transition of a state machine could only
be deleted in the list of the inspector. Now the line is the handle,
in a behavior's graph and between states alike.

- A click on a line selects that link and draws a halo behind it. A
  press counts within 6 screen pixels of the line, and where two cross
  the one drawn last wins.
- Ctrl-click or Shift-click adds a link or takes it away. The rubber
  band takes every line it crosses, into the same selection as the
  nodes and boxes it caught.
- Delete or Backspace removes what is picked as one undo step. A
  right-click on a line offers Hide link and Delete link.
- The inspector shows a picked link: the node and port at each end and
  the type it carries, with Hide link and Delete link below. For
  several it says how many.
- The arrow of a transition is picked the same way, on its name too.
  Delete or a right-click with Delete transition removes it.
- The menu of a port is unchanged, Disconnect included, as answered in
  the issue.
- editor/graph/edgeHit.ts holds the hit test as pure functions, and
  editor/inspector/edgeSection.ts builds the section. The selection
  has a new kind edge, and node and annotation carry edgeIds.

Decided here:

- A hidden link has no line and is never picked this way, also while
  it is shown on loan. Its mark at the port stays its handle.
- The menu of a line speaks about the whole selection, like the menu
  of a box. With nodes or boxes held alongside it reads Delete N
  items.
- Hiding picked links lets go of them, since no line is left.
- A link wins over the box it crosses, and a mark wins over a link.
- A transition has no Hide entry.
- With nodes or boxes in the selection the inspector keeps showing
  the node or box, not the link.

Tests:
- Gate: 201 files, 2982 tests (2967 + 15), exit 0
- e2e, full run: 1635 checks passed (1629 + 6), exit 0
- Smokes: 24 / 19 / 21, exit 0
- The full run was done for exactly this state.
- 15 unit tests in tests/editor/edge-selection.test.ts
- Six e2e checks with a real mouse, block 6z-xi in module 06 and 26e7
  in module 26
- 12 unit counter-probes all fail tests, run on stein2
- Three browser counter-probes fail, the build went through each
  time: no hit on the line, no halo, a band that skips lines
- Screenshots of a picked link and a picked transition looked at
  before the last build, none after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:51:15 +02:00

164 lines
7.1 KiB
TypeScript
Raw Permalink 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: [],
edgeIds: [],
};
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);
});
});