Files
feuerwerk/tests/core/song-markers.test.ts
adminandClaude Opus 5.5 56425fca6a
feuerwerk / gate (push) Successful in 5m22s
feuerwerk / suite (push) Failing after 19m54s
Let a song carry named markers on a strip in the song tab (#338)
Stage 1 of #338. Game sync needs named places in a song: an event when
the playhead passes one, and a switch that jumps to one. This stage
adds the markers themselves. Games do nothing with them yet.

- A song has markers, each with id, name and tick. A song without
  markers stores nothing for them. Two markers may share tick and
  name, since nodes will name them by id.
- Markers change neither the sound nor the bake fingerprint.
- The song tab draws a strip under the ruler. A marker has a green
  flag with its name and a faint line through the tracks. Tracks and
  lanes sit 18 px lower. The ruler of the pattern tab is unchanged.
- Double click on an empty place adds a marker on the grid, with Alt
  or Shift on the exact tick. Dragging moves it in one undo step.
- A click on a marker sets the start of playback there. A double click
  renames it in the name dialog of the hierarchy groups.
- The context menu offers Play from here, Rename and Delete marker,
  on an empty place Add marker. A tooltip shows the full name. The
  wheel over the strip zooms time like the ruler.
- New agent commands add_song_marker, change_song_marker and
  remove_song_marker. get_asset lists them under markers.

Tests:
- song-markers in core 6 and editor 4, 1 agent test, with the touched
  song, agent and MCP tests 175/175
- 9 counter-probes, all compile, all red
- e2e 65 on atx: ALL PASSED, 70 checks, 3 new in 65g3. Counter-probe
  where a dragged marker is not taken over: 65g3 red
- gate on atx: CHECK EXIT 0, 332 files, 5093 tests

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

104 lines
4.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { SetSongMarkersCommand } from '../../src/core/commands/songCommands';
import type { SongAsset } from '../../src/core/model/Asset';
import {
changedMarker,
heldSongMarkers,
newMarker,
nextMarkerName,
withoutMarker,
} from '../../src/core/music/markers';
import { makeProject, roundTrip } from '../helpers';
function makeSong() {
const f = makeProject();
const create = new CreateAssetCommand('song', 'Tune');
f.stack.execute(create);
const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
return { ...f, song };
}
describe('song markers (#338)', () => {
it('holds markers to whole ticks from 0, a name and ids of their own, in the order of their ticks', () => {
const held = heldSongMarkers([
{ id: 'b', name: ' Boss ', tick: 3840.4 },
{ id: 'a', name: 'Intro', tick: 0 },
{ id: 'a', name: '', tick: 1920 },
{ id: 'c', name: 'Before', tick: -1 },
{ id: 'd', name: 'Nowhere' },
'no marker',
null,
]);
expect(held.map((m) => [m.name, m.tick])).toEqual([
['Intro', 0],
['Marker 3', 1920],
['Boss', 3840],
]);
expect(new Set(held.map((m) => m.id)).size).toBe(3);
expect(held.find((m) => m.tick === 1920)!.id).not.toBe('a');
expect(heldSongMarkers(undefined)).toEqual([]);
});
it('keeps markers on one tick in the order they came', () => {
const held = heldSongMarkers([
{ id: 'x', name: 'Second', tick: 960 },
{ id: 'y', name: 'First', tick: 0 },
{ id: 'z', name: 'Third', tick: 960 },
]);
expect(held.map((m) => m.name)).toEqual(['First', 'Second', 'Third']);
});
it('names a new marker by the first number not taken', () => {
expect(nextMarkerName([])).toBe('Marker 1');
const markers = heldSongMarkers([
{ name: 'Marker 1', tick: 0 },
{ name: 'Marker 3', tick: 10 },
]);
expect(nextMarkerName(markers)).toBe('Marker 2');
const made = newMarker(markers, 479.6);
expect(made).toMatchObject({ name: 'Marker 2', tick: 480 });
expect(newMarker(markers, -5, ' Drop ')).toMatchObject({ name: 'Drop', tick: 0 });
});
it('changes or takes away one marker and leaves the others', () => {
const markers = heldSongMarkers([
{ id: 'a', name: 'A', tick: 0 },
{ id: 'b', name: 'B', tick: 960 },
]);
expect(changedMarker(markers, 'b', { tick: 1920 })).toEqual([markers[0], { ...markers[1], tick: 1920 }]);
expect(changedMarker(markers, 'a', { name: 'Start' })[0]).toEqual({ id: 'a', name: 'Start', tick: 0 });
expect(withoutMarker(markers, 'a')).toEqual([markers[1]]);
});
it('writes the markers as one step, leaves the key out without one and takes it back on undo', () => {
const f = makeSong();
expect(f.song().markers).toBeUndefined();
const one = [{ id: 'm', name: 'Drop', tick: 1920.2 }];
expect(SetSongMarkersCommand.changes(f.song(), one)).toBe(true);
f.stack.execute(new SetSongMarkersCommand(f.song().id, one, 'Add marker'));
expect(f.song().markers).toEqual([{ id: 'm', name: 'Drop', tick: 1920 }]);
expect(SetSongMarkersCommand.changes(f.song(), one)).toBe(false);
f.stack.execute(new SetSongMarkersCommand(f.song().id, [], 'Delete marker'));
expect('markers' in f.song()).toBe(false);
f.stack.undo();
expect(f.song().markers).toEqual([{ id: 'm', name: 'Drop', tick: 1920 }]);
f.stack.undo();
expect('markers' in f.song()).toBe(false);
});
it('keeps the markers through saving and loading', () => {
const f = makeSong();
const markers = [
{ id: 'a', name: 'Intro', tick: 0 },
{ id: 'b', name: 'Boss', tick: 7680 },
];
f.stack.execute(new SetSongMarkersCommand(f.song().id, markers));
const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset;
expect(back.markers).toEqual(markers);
const none = makeSong();
const plain = roundTrip(none.project).assets.find((a) => a.id === none.song().id) as SongAsset;
expect('markers' in plain).toBe(false);
});
});