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>
164 lines
7.1 KiB
TypeScript
164 lines
7.1 KiB
TypeScript
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);
|
||
});
|
||
});
|