Files
feuerwerk/tests/core/composition-sprites.test.ts
T
adminandClaude Opus 5.5 2e4c0a09ea
feuerwerk / gate (push) Successful in 4m58s
feuerwerk / suite (push) Failing after 19m37s
Open clips and frames from the asset panel by double click (#430)
A double click on a clip row did not open its tab. The first click of
the double click had already selected the clip for the inspector, so
the second click selected the same thing again and the editor saw no
change. Frames of textures and pixel art had no rows at all.

- A double click now asks for the tab with openSelection, even when
  the selection is already right.
- Textures with frames and pixel art with more than one frame get a
  chevron and one row per frame, like sounds and videos. Which rows an
  asset has is decided in one place, src/core/model/assetParts.ts,
  used by the panel and the inspector link.
- A texture frame shows in the inspector on click and opens the
  texture with the frame selected on double click.
- A pixel art frame selects the pixel art on click, so an open tab
  paints on that frame. A double click opens the tab on the frame.
- A frame can be dragged onto a composition track like a video clip.
  The item carries the sprite of the whole image, the frame becomes
  the texture of its object, and it is 3 s long. The agent can do the
  same with add_composition_item.
- A texture label that names a frame now points at the frame row.

Found on the way: the e2e check from #425 only double clicked the
texture label, where the first click is a ping, so the bug never
showed there.

Tests:
- Unit: asset-parts (new, 6), composition-sprites and references
  extended, related tests 303/303, agent tests 21/21
- 5 mutations, all red in the matching test
- e2e 69 (atx): ALL PASSED, 26 checks, 69t new
- Gate (atx): CHECK EXIT 0, 325 files, 5017 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:30:29 +02:00

311 lines
13 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.
/**
* What a composition keeps to itself (#419): a texture or video laid on a track wears a sprite the
* composition owns, one per picture. It goes when nothing uses it, comes back with a pasted cut,
* turns into a project asset once placed in a scene and is copied for another composition.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateTextureCommand,
CreateVideoCommand,
DeleteAssetCommand,
DuplicateAssetCommand,
MakeProjectAssetCommand,
} from '../../src/core/commands/assetCommands';
import { AddClipCommand } from '../../src/core/commands/clipCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import type { Command } from '../../src/core/commands/Command';
import { pasteCompositionItems, removeFromComposition } from '../../src/core/commands/compositionCommands';
import {
addPictureItem,
droppedPicture,
KeepCompositionSpritesCommand,
wornPicture,
} from '../../src/core/commands/compositionSprites';
import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands';
import { copyObjects } from '../../src/core/model/clipboard';
import { isCompositionSprite, itemVideo } from '../../src/core/model/composition';
import { droppedLength } from '../../src/core/model/compositionEdit';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import { assetTitle, listedAssets } from '../../src/core/music/embedded';
import { VIDEO_END_KEY, VIDEO_START_KEY } from '../../src/core/properties/videos';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { makeProject } from '../helpers';
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
function makeFilm() {
const f = makeProject();
const run = (command: Command) => f.stack.execute(new KeepCompositionSpritesCommand(command));
const scene = new CreateAssetCommand('scene', 'Level');
const film = new CreateAssetCommand('composition', 'Film');
const video = new CreateVideoCommand('Intro', FILE, {
width: 320,
height: 180,
duration: 5,
fps: 30,
audio: false,
});
for (const c of [scene, film, video]) run(c);
const comp = (id = film.asset.id) => findAssetOf(f.project, id, 'composition')!;
const sprites = () => f.project.assets.filter(isCompositionSprite);
/** The video laid on the first track of `compId` at `start`, run through the stack as the timeline does. */
const drop = (start: number, compId = film.asset.id, dropped = video.asset.id) => {
const { command, objectId } = addPictureItem(f.project, compId, droppedPicture(f.project, dropped)!, {
track: comp(compId).tracks[0]!.id,
start,
length: 150,
offset: 0,
fitMedia: true,
});
run(command);
return objectId;
};
const wornBy = (holder: { objects: { id: string; assetId: string }[] }, id: string) =>
holder.objects.find((o) => o.id === id)!.assetId;
return {
...f,
run,
sceneId: scene.asset.id,
filmId: film.asset.id,
videoId: video.asset.id,
comp,
sprites,
drop,
wornBy,
};
}
describe('a picture laid on a track', () => {
it('wears a sprite the composition keeps, at the picture size, left out of the lists', () => {
const film = makeFilm();
const id = film.drop(0);
const [sprite] = film.sprites();
expect(sprite).toMatchObject({
owner: film.filmId,
name: 'Intro',
props: { texture: film.videoId, width: 320, height: 180 },
});
expect(film.wornBy(film.comp(), id)).toBe(sprite!.id);
expect(film.comp().items).toMatchObject([{ id, start: 0, length: 150, fitMedia: true }]);
expect(listedAssets(film.project).some((a) => a.id === sprite!.id)).toBe(false);
expect(assetTitle(film.project, sprite!)).toBe('Film › Intro');
});
it('is a texture, a drawn picture or a video, and a video lasts as long as it plays', () => {
const film = makeFilm();
const video = findAsset(film.project, film.videoId)!;
expect(wornPicture(video)).toBe(film.videoId);
expect(wornPicture(findAsset(film.project, film.sceneId)!)).toBeNull();
expect(droppedLength(film.project, film.comp(), video.id)).toBe(300);
});
it('wears a sprite that "Make project asset" lists like any other, and it stays when the item goes', () => {
const film = makeFilm();
const id = film.drop(0);
const sprite = film.wornBy(film.comp(), id);
film.run(new MakeProjectAssetCommand(sprite));
expect(findAsset(film.project, sprite)).toMatchObject({ name: 'Intro 2' });
expect(listedAssets(film.project).some((a) => a.id === sprite)).toBe(true);
film.run(removeFromComposition(film.project, film.filmId, [id], []));
expect(findAsset(film.project, sprite)).toBeDefined();
});
it('shares the sprite with every item of the same picture, and the first undo takes only the item', () => {
const film = makeFilm();
const first = film.drop(0);
const second = film.drop(200);
expect(film.sprites()).toHaveLength(1);
expect(film.wornBy(film.comp(), second)).toBe(film.wornBy(film.comp(), first));
film.stack.undo();
expect(film.sprites()).toHaveLength(1);
film.stack.undo();
expect(film.sprites()).toHaveLength(0);
expect(film.comp().objects).toHaveLength(0);
});
it('takes its sprite along when its last item goes, and an undo brings both back', () => {
const film = makeFilm();
const a = film.drop(0);
const b = film.drop(200);
film.run(removeFromComposition(film.project, film.filmId, [a], []));
expect(film.sprites()).toHaveLength(1);
film.run(removeFromComposition(film.project, film.filmId, [b], []));
expect(film.sprites()).toHaveLength(0);
film.stack.undo();
expect(film.sprites()).toHaveLength(1);
expect(film.wornBy(film.comp(), b)).toBe(film.sprites()[0]!.id);
});
});
describe('a clip of a video laid on a track (#425)', () => {
const withClip = () => {
const film = makeFilm();
const add = new AddClipCommand(film.videoId, { [VIDEO_START_KEY]: 1, [VIDEO_END_KEY]: 3 });
film.run(add);
return { ...film, clipId: add.clipId };
};
const objectOf = (film: ReturnType<typeof withClip>, id: string) =>
film.comp().objects.find((o) => o.id === id)!;
it('wears the sprite of its video with the clip as the texture, as long as the clip', () => {
const film = withClip();
expect(droppedPicture(film.project, film.clipId)).toEqual({
picture: film.videoId,
part: film.clipId,
name: 'Intro',
});
expect(droppedLength(film.project, film.comp(), film.clipId)).toBe(120);
const id = film.drop(0, film.filmId, film.clipId);
const [sprite] = film.sprites();
expect(sprite!.props).toMatchObject({ texture: film.videoId, width: 320, height: 180 });
expect(objectOf(film, id)).toMatchObject({ assetId: sprite!.id, overrides: { texture: film.clipId } });
const item = film.comp().items.find((i) => i.id === id)!;
expect(itemVideo(film.project, film.comp(), item)?.clip?.id).toBe(film.clipId);
});
it('shares the sprite with the whole video, and one undo takes a first item with its sprite', () => {
const film = withClip();
const whole = film.drop(0);
const cut = film.drop(200, film.filmId, film.clipId);
expect(film.sprites()).toHaveLength(1);
expect(film.wornBy(film.comp(), cut)).toBe(film.wornBy(film.comp(), whole));
expect(objectOf(film, whole).overrides).toEqual({});
film.stack.undo();
expect(film.comp().objects.map((o) => o.id)).toEqual([whole]);
film.stack.undo();
film.drop(0, film.filmId, film.clipId);
film.stack.undo();
expect(film.sprites()).toHaveLength(0);
expect(film.comp().objects).toHaveLength(0);
});
it('is no picture for a sound or a scene', () => {
const film = withClip();
expect(droppedPicture(film.project, film.sceneId)).toBeNull();
expect(droppedPicture(film.project, 'nothing')).toBeNull();
});
});
describe('a frame of a picture laid on a track (#430)', () => {
const withFrames = () => {
const film = makeFilm();
const texture = new CreateTextureCommand('Sheet', {
file: `${'ab'.repeat(32)}.png`,
width: 64,
height: 32,
});
const art = new CreateAssetCommand('pixelart', 'Art');
film.run(texture);
film.run(art);
const frame = new AddFrameCommand(texture.asset.id, { x: 16, y: 0, w: 16, h: 16 }, 'Run');
film.run(frame);
const pixel = findAssetOf(film.project, art.asset.id, 'pixelart')!;
pixel.frames.push({ id: 'second', cels: {} });
return { ...film, textureId: texture.asset.id, frameId: frame.frameId, art: pixel };
};
it('wears the sprite of its texture with the frame as the texture, 3 s long', () => {
const film = withFrames();
expect(droppedPicture(film.project, film.frameId)).toEqual({
picture: film.textureId,
part: film.frameId,
name: 'Sheet',
});
expect(droppedLength(film.project, film.comp(), film.frameId)).toBe(180);
const whole = film.drop(0, film.filmId, film.textureId);
const cut = film.drop(200, film.filmId, film.frameId);
expect(film.sprites()).toHaveLength(1);
expect(film.wornBy(film.comp(), cut)).toBe(film.wornBy(film.comp(), whole));
expect(film.comp().objects.find((o) => o.id === cut)!.overrides).toEqual({ texture: film.frameId });
});
it('of pixel art wears the sprite of its first frame, and the first frame needs no override', () => {
const film = withFrames();
const first = film.art.frames[0]!.id;
expect(droppedPicture(film.project, 'second')).toEqual({ picture: first, part: 'second', name: 'Art' });
expect(droppedPicture(film.project, first)).toEqual({ picture: first, part: null, name: 'Art' });
});
});
describe('a copied item', () => {
it('pasted after a cut wears its sprite again', () => {
const film = makeFilm();
const id = film.drop(0);
const sprite = film.sprites()[0]!;
const clip = copyObjects(film.project, film.comp(), [id])!;
film.run(removeFromComposition(film.project, film.filmId, [id], []));
expect(film.sprites()).toHaveLength(0);
const pasted = pasteCompositionItems(film.project, film.filmId, clip, 50)!;
film.run(pasted.command);
expect(film.sprites()).toEqual([sprite]);
expect(film.wornBy(film.comp(), pasted.itemIds[0]!)).toBe(sprite.id);
film.stack.undo();
expect(film.sprites()).toHaveLength(0);
});
it('pasted into a scene makes the sprite a project asset, named apart from the video', () => {
const film = makeFilm();
const id = film.drop(0);
const clip = copyObjects(film.project, film.comp(), [id])!;
const paste = new PasteObjectsCommand(film.sceneId, clip, {}, { x: 0, y: 0 });
film.run(paste);
const sprite = findAsset(film.project, film.wornBy(film.comp(), id))!;
expect(sprite.owner).toBeUndefined();
expect(sprite.name).toBe('Intro 2');
expect(film.wornBy(findAssetOf(film.project, film.sceneId, 'scene')!, paste.rootIds[0]!)).toBe(sprite.id);
film.stack.undo();
expect(findAsset(film.project, sprite.id)).toMatchObject({ owner: film.filmId, name: 'Intro' });
});
it('pasted into another composition gets a copy that one keeps', () => {
const film = makeFilm();
const other = new CreateAssetCommand('composition', 'Other');
film.run(other);
const id = film.drop(0);
const clip = copyObjects(film.project, film.comp(), [id])!;
const pasted = pasteCompositionItems(film.project, other.asset.id, clip, 0)!;
film.run(pasted.command);
const mine = film.wornBy(film.comp(), id);
const theirs = film.wornBy(film.comp(other.asset.id), pasted.itemIds[0]!);
expect(theirs).not.toBe(mine);
expect(findAsset(film.project, mine)!.owner).toBe(film.filmId);
expect(findAsset(film.project, theirs)).toMatchObject({
owner: other.asset.id,
props: { texture: film.videoId },
});
film.stack.undo();
expect(film.sprites().map((s) => s.id)).toEqual([mine]);
});
});
describe('the composition', () => {
it('duplicated, keeps sprites of its own', () => {
const film = makeFilm();
film.drop(0);
const duplicate = new DuplicateAssetCommand(film.filmId);
film.run(duplicate);
const copy = film.comp(duplicate.copyId);
const worn = copy.objects[0]!.assetId;
expect(worn).not.toBe(film.comp().objects[0]!.assetId);
expect(findAsset(film.project, worn)!.owner).toBe(duplicate.copyId);
});
it('deleted, takes its sprites along', () => {
const film = makeFilm();
film.drop(0);
film.run(new DeleteAssetCommand(film.filmId));
expect(film.sprites()).toHaveLength(0);
});
it('saved and loaded, still keeps its sprites; one whose composition is gone is a project asset', () => {
const film = makeFilm();
film.drop(0);
const sprite = film.sprites()[0]!;
const raw = serializeProject(film.project) as { assets: { id: string; owner?: string }[] };
expect(findAsset(deserializeProject(structuredClone(raw)), sprite.id)!.owner).toBe(film.filmId);
raw.assets = raw.assets.filter((a) => a.id !== film.filmId);
expect(findAsset(deserializeProject(raw), sprite.id)!.owner).toBeUndefined();
});
});