Files
feuerwerk/tools/e2e/69-composition-tab.mjs
T
adminandClaude Opus 5.5 e435c54afb Edit a composition's tracks, items and clips in its own tab (#417)
A composition could be placed and played, but not filled. Its tab
puts the picture at the playhead above a timeline of tracks, so a cut
or a cutscene is laid out by dragging assets onto tracks.

- The timeline takes the song tab's geometry with frames for ticks:
  ruler in seconds, scrubbing, play and pause, + Track. Zoom and pan
  glide through viewTween like the other tabs.
- A placeable asset dropped on a track becomes an item, 3 s long or
  as long as its sprite's video. Dropped on an item it lands behind.
- Items move without overlapping. The right edge stops at the next
  item. The left edge trims into a video and not before its start.
- An animation dropped on a track becomes a narrower clip bar above
  the item it plays on. The asset panel now starts a drag for
  animations too.
- The picture is a scene view over compositionHolder. A click picks
  an item or its child, a drag moves the real object. It takes no
  assets, since an object there would have no item.
- The hierarchy lists the items alive at the playhead. Reparenting is
  off there, items stay at the top level.
- The inspector shows a TIMING block on an item (start, length,
  offset for a video, Fit to media) and edits tracks and clips.
- Delete, the context menu and Remove item take item and object in
  one undo step.
- fitMedia: an override that gives an item another video sets its
  length in the same undo step, capped at the next item. Dragging an
  edge or typing a length turns it off.
- A composition inside a composition is refused.

Tests:
- Unit composition-edit (new): 19 tests. Four counter-checks each
  turned their test red. The "back to 3 s" test got a step in
  between after one stayed green.
- e2e 69 (new): 11 checks, 9 own. The hierarchy check reads the rows
  shown, after a counter-check on the model's rows stayed green.
- Gate on melektaus-atx: CHECK EXIT 0, 302 files, 4662 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:47:22 +02:00

190 lines
7.7 KiB
JavaScript
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.
/**
* The Composition tab (#417). A sprite dragged in from the Assets panel becomes an item, 3 s long
* and picked, so the inspector shows its TIMING; a second one dropped onto the first lands right
* after it. A dragged end stops at the next item. An animation dragged in becomes a clip that moves
* the item it overlaps in the picture. The hierarchy lists what is alive at the playhead, Delete
* takes an item with its object, and Play runs the playhead to the end.
*
* Everything it adds carries a `ct69` id and goes again at the end.
*/
const CUT = 'ct69-cut';
const BOX = 'ct69-box';
const SLIDE = 'ct69-slide';
export async function compositionTab(t) {
const { check, sleep, json, evaluate, mutate, mouse, drag, press, showTab } = t;
/** Drags a row of the Assets panel to a point step by step: the drop only exists while the button is down. */
const dragAsset = async (assetId, to) => {
// A press on the row of the drag before, this soon, would read as a double-click that opens it.
await sleep(450);
const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`);
await mouse('mouseMoved', from.x, from.y);
await mouse('mousePressed', from.x, from.y);
for (let i = 1; i <= 8; i++) {
await mouse(
'mouseMoved',
from.x + ((to.x - from.x) * i) / 8,
from.y + ((to.y - from.y) * i) / 8,
'left',
{
buttons: 1,
},
);
await sleep(16);
}
const drop = await json('__gm.composition.drop()');
await mouse('mouseReleased', to.x, to.y);
await sleep(200);
return drop;
};
const model = () =>
json(`(() => {
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
return { objects: c.objects.map((o) => o.id), items: c.items, clips: c.clips };
})()`);
await press('Escape');
await showTab('scene');
// Ten frames a second keeps the numbers small: an item of 3 s is 30 frames, the slide 5.
await mutate(`
const p = __gm.state.project;
p.assets.push(
{ id: '${CUT}', type: 'composition', name: 'Cut 69', props: { fps: 10, loop: false }, objects: [], groups: [],
tracks: [{ id: 'ct69-t1', name: 'Front' }, { id: 'ct69-t2', name: 'Back' }], items: [], clips: [] },
{ id: '${BOX}', type: 'sprite', name: 'Box 69', props: { width: 100, height: 100, color: 0xff0000, texture: '' } },
{ id: '${SLIDE}', type: 'animation', name: 'Slide 69', props: { fps: 10, playback: 'once' }, root: '${BOX}',
tracks: [{ id: 'ct69-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }] },
);`);
await evaluate(`__gm.state.openAsset('${CUT}')`);
await sleep(300);
const opened = await json(`{ tab: __gm.tab(), current: __gm.composition.current() }`);
check(
'opening a composition brings up its tab',
opened.tab === 'composition' && opened.current === CUT,
JSON.stringify(opened),
);
// 69a. A sprite dropped on the first track at frame 5 is an item there, 3 s long, picked.
const first = await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 5)`));
let m = await model();
const a = m.items[0];
const picked = await json('__gm.state.selection');
check(
'a sprite dragged onto a track becomes an item where it is let go, 3 s long and picked',
first?.what === 'item' &&
first.rejection === '' &&
m.items.length === 1 &&
a.start === 5 &&
a.length === 30 &&
a.fitMedia === true &&
JSON.stringify(m.objects) === JSON.stringify([a.id]) &&
picked?.kind === 'object' &&
picked.holderId === CUT &&
picked.objectId === a.id,
JSON.stringify({ first, m, picked }),
);
const timing = await json(`__gm.section('TIMING')`);
check('the inspector shows the picked item’s TIMING', timing !== null, JSON.stringify(timing));
// 69b. A second one let go over the first lands right after it.
await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 20)`));
m = await model();
const b = m.items.find((i) => i.id !== a.id);
check(
'one dropped onto an item lands right after it',
m.items.length === 2 && b?.start === 35 && b.length === 30,
JSON.stringify(m.items),
);
// 69c. The first item's end dragged past the second stops where the second begins.
const box = await json(`__gm.composition.box('${a.id}')`);
const far = await json(`__gm.composition.laneAt('ct69-t1', 60)`);
await drag(box.x + box.w - 2, box.y + box.h / 2, far.x, box.y + box.h / 2);
await sleep(150);
m = await model();
const resized = m.items.find((i) => i.id === a.id);
check(
'a dragged end stops where the next item begins, and the length is the item’s own then',
resized?.length === 30 && resized.fitMedia === undefined,
JSON.stringify(resized),
);
// 69d. The animation dropped at frame 5 is a clip one pass long that moves the item in the picture.
const clipDrop = await dragAsset(SLIDE, await json(`__gm.composition.laneAt('ct69-t1', 5)`));
m = await model();
const x = async (frame) => {
await evaluate(`__gm.composition.scrub(${frame})`);
await sleep(60);
return (await json('__gm.composition.shown()')).find((o) => o.id === a.id)?.x ?? null;
};
const xs = [await x(5), await x(8), await x(20)];
check(
'an animation dragged onto a track becomes a clip, and moves the item it overlaps in the picture',
clipDrop?.what === 'clip' &&
m.clips.length === 1 &&
m.clips[0].start === 5 &&
m.clips[0].animation === SLIDE &&
xs[0] === 0 &&
xs[1] === 60 &&
xs[2] === 100,
JSON.stringify({ clipDrop, clips: m.clips, xs }),
);
// 69e. The hierarchy lists what is alive at the playhead – read off the rows it shows.
const rowsAt = async (frame) => {
await evaluate(`__gm.composition.scrub(${frame})`);
await sleep(80);
const shown = await json(`[${m.items.map((i) => `__gm.hierarchyRow('${i.id}')`).join(',')}]`);
return m.items.filter((_, n) => shown[n] !== null).map((i) => i.id);
};
const rows = [await rowsAt(2), await rowsAt(10), await rowsAt(40)];
check(
'the hierarchy lists the items alive at the playhead',
rows[0].length === 0 &&
JSON.stringify(rows[1]) === JSON.stringify([a.id]) &&
JSON.stringify(rows[2]) === JSON.stringify([b.id]),
JSON.stringify(rows),
);
// 69f. Delete takes the picked item with its object; undo brings both back.
await evaluate(`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${b.id}' })`);
await press('Delete');
await sleep(150);
const gone = await model();
await press('z', { ctrl: true });
await sleep(150);
const back = await model();
check(
'Delete takes an item with its object, undo brings both back',
!gone.items.some((i) => i.id === b.id) &&
!gone.objects.includes(b.id) &&
back.items.some((i) => i.id === b.id) &&
back.objects.includes(b.id),
JSON.stringify({ gone, back }),
);
// 69g. Play runs the playhead from the start to the end of the last item, and stops there.
await evaluate(`__gm.composition.scrub(0)`);
const playAt = await json('__gm.composition.playAt()');
await mouse('mouseMoved', playAt.x, playAt.y);
await mouse('mousePressed', playAt.x, playAt.y);
await mouse('mouseReleased', playAt.x, playAt.y);
await sleep(500);
const running = await json('__gm.composition.view()');
let ended = running;
for (let i = 0; i < 40 && ended.playing; i++) {
await sleep(150);
ended = await json('__gm.composition.view()');
}
check(
'Play runs the playhead to the end and stops there',
running.playing && running.playhead > 0 && !ended.playing && ended.playhead === 65,
JSON.stringify({ running, ended }),
);
await showTab('scene');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-'));`);
}