Files
feuerwerk/tests/editor/song-edit.test.ts
T
adminandClaude Opus 5.5 5b96d0b329
game-maker / gate (push) Successful in 3m25s
game-maker / suite (push) Successful in 16m33s
Paste under the pointer and pull song items out left in music tabs
A round of fixes to the Pattern and Song tabs: where a paste lands,
what Shift does on a drag, how far the rows zoom, and how an item of
a song is sized at its ends.

- Shift does what Alt does on a drag: off the grid, on the tick. A
  Shift click on a note or an item still adds or unpicks it. A Shift
  press only decides on letting go: no drag toggles, a drag moves.
- Ctrl+V lands under the pointer. The earliest copy goes on the grid
  line before it, the highest note on its row or the topmost item on
  its track, held to the keyboard or the tracks there are. Off the
  grid or left of bar 1 it lands at the start of playing as before.
- The rows of the piano roll zoom to 64 px instead of 28, the tracks
  of a song to 320 px instead of 160.
- Fit in the Pattern tab zooms the rows as well, so the notes fill
  the height, no longer held to the default of 14 px.
- An item of a song can be pulled out by its left end past the start
  of what it plays, which then repeats. The offset goes round to the
  end of a round. A sound not loaded yet still stops at its start.
- Both ends of an item snap to the start of a round of what it plays
  as well as to the grid, whichever is nearer. The end of a round
  between two ticks is cut to the tick before. A start put within
  half a tick of a round goes right on it, so it plays from the very
  beginning.
- An offset past a whole round is not folded back to 0, so a pattern
  that grows later still plays from where the item said.
- A middle-drag pans past the first and the last row, as it pans
  left of bar 1: the highest note or the first track up to half the
  height down, the other end up to half the height up. The room past
  them is dark in the piano roll and empty in the song.

Tests:
- Unit: song-edit 14, piano-roll 19, song 14 tests. New cases for
  the paste spot, pulling out left, the round snap and Fit.
- e2e 64+65 on melektaus-atx: ALL PASSED - 54 checks (+4). New:
  Shift drag and Shift click, Ctrl+V under the pointer in both tabs,
  Fit zooming the rows, pulling a pattern out left and snapping a
  sound to the end of its second round. e2e 64 zooms the rows out
  again after Fit, so the keys it plays later are on screen.
- Gate on melektaus-atx: 251 files, 3912 tests, CHECK EXIT 0.
- Then the pan down the rows: e2e 64 on melektaus-atx ALL PASSED -
  30 checks. Its pan check now also pans the top row down, half the
  grid's height, and a click above it draws nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:14:28 +02:00

236 lines
10 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { newItem, type SongItem } from '../../src/core/music/song';
import { secondsOfTicks } from '../../src/core/music/time';
import {
clampMove,
draggedLength,
droppedLength,
duplicateOffset,
moveDelta,
moved,
placedCopies,
roundsOf,
snapEdge,
split,
trimDelta,
trimmed,
} from '../../src/editor/song/songEdit';
import {
clampTracks,
HEAD_BOXES,
headPartAt,
itemAt,
heldSongView,
itemsWithin,
loopSeams,
MAX_TRACK_HEIGHT,
MIN_TRACK_HEIGHT,
peakOver,
SLIDERS_MIN_HEIGHT,
sliderValue,
type SongView,
TRACK_HEIGHT,
trackAtY,
zoomTracks,
} from '../../src/editor/song/songView';
const BAR = 1920;
const TRACKS = ['t1', 't2', 't3'];
const rows = new Map(TRACKS.map((id, i) => [id, i]));
const tracks = TRACKS.map((id) => ({ id, name: id, volume: 0, pan: 0, mute: false, solo: false }));
function item(
id: string,
track: string,
tick: number,
length: number,
extra: Partial<SongItem> = {},
): SongItem {
return { ...newItem({ track, kind: 'pattern', asset: 'p', tick, length }), id, ...extra };
}
describe('moving and sizing items (#334)', () => {
const items = [item('a', 't1', BAR, BAR), item('b', 't2', 3 * BAR, BAR)];
const both = new Set(['a', 'b']);
it('moves the picked items on the grid, across tracks, never before 0 or off the tracks', () => {
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 700, 1, BAR, false)).toEqual({
ticks: 0,
tracks: 1,
});
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 1000, 2, BAR, false)).toEqual({
ticks: BAR,
tracks: 1,
});
expect(moveDelta(items, both, rows, 3, items[0]!, -5000, 0, BAR, false)).toEqual({
ticks: -BAR,
tracks: 0,
});
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 7, 0, BAR, true)).toEqual({
ticks: 7,
tracks: 0,
});
expect(clampMove(items, both, rows, 3, 0, -1)).toEqual({ ticks: 0, tracks: 0 });
expect(moved(items, both, TRACKS, 480, 1).map((i) => [i.tick, i.track])).toEqual([
[BAR + 480, 't2'],
[3 * BAR + 480, 't3'],
]);
});
it('lengthens by the right end to the grid, and never back past its own start', () => {
expect(draggedLength(items[0]!, 3 * BAR + 100, BAR, false)).toBe(2 * BAR);
expect(draggedLength(items[0]!, 0, BAR, false)).toBe(BAR);
expect(draggedLength(items[0]!, BAR + 5, BAR, true)).toBe(5);
});
it('cuts back by the left end what it plays with it, and pulls it out no further than its start', () => {
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0.25 });
const picked = new Set(['s']);
// 0.25 s at 120 BPM is 240 ticks: that is as far back as the left end goes.
expect(trimDelta([sound], picked, sound, 0, 120, false, 120)).toBe(-240);
expect(trimDelta([sound], picked, sound, 5 * BAR, 120, false, 120)).toBe(BAR - 1);
const [cut] = trimmed([sound], picked, 480, 120);
expect([cut!.tick, cut!.length, cut!.offset]).toEqual([BAR + 480, BAR - 480, 0.75]);
const pattern = item('p', 't1', BAR, BAR, { offset: 120 });
expect(trimDelta([pattern], new Set(['p']), pattern, 0, 60, false, 120)).toBe(-120);
expect(trimmed([pattern], new Set(['p']), -120, 120)[0]).toMatchObject({ tick: BAR - 120, offset: 0 });
});
it('pulls an item out left past what it plays, which then repeats from its end', () => {
// 0.5 s at 120 BPM is 480 ticks; a pattern a bar long.
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0 });
const pattern = item('p', 't1', BAR, BAR);
const period = (i: SongItem) => (i.kind === 'sound' ? 480 : BAR);
expect(trimDelta([sound], new Set(['s']), sound, 0, 120, false, 120, period)).toBe(-BAR);
expect(trimDelta([sound], new Set(['s']), sound, 0, 120, false, 120)).toBe(0);
const [s] = trimmed([sound], new Set(['s']), -240, 120, period);
expect([s!.tick, s!.length, s!.offset]).toEqual([BAR - 240, BAR + 240, 0.25]);
const [p] = trimmed([pattern], new Set(['p']), -480, 120, period);
expect([p!.tick, p!.length, p!.offset]).toEqual([BAR - 480, BAR + 480, BAR - 480]);
});
it('snaps an end to where what the item plays starts over, as well as to the grid', () => {
// A sound 700.4 ticks long: its rounds end between two ticks.
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0 });
const rounds = roundsOf(sound, 700.4, 120)!;
expect(rounds).toEqual({ base: BAR, period: 700.4 });
expect(snapEdge(BAR + 1390, BAR, rounds)).toBeCloseTo(BAR + 1400.8);
expect(snapEdge(BAR + 1900, BAR, rounds)).toBe(2 * BAR);
expect(snapEdge(BAR + 1390, BAR, null)).toBe(2 * BAR);
// The end is cut to the tick before, so nothing of the next round sounds.
expect(draggedLength(sound, BAR + 1390, BAR, false, rounds)).toBe(1400);
// The start put back on a round plays from the very beginning, not a hair into it.
const cut = item('c', 't1', BAR + 100, 2000, { kind: 'sound', offset: 100 / 960 });
const picked = new Set(['c']);
const by = trimDelta([cut], picked, cut, BAR + 705, 480, false, 120, () => 700.4);
expect(by).toBe(600);
expect(trimmed([cut], picked, by, 120, () => 700.4)[0]!.offset).toBe(secondsOfTicks(700.4, 120));
// An offset a whole pattern in stays so: the pattern may grow later.
const far = item('f', 't1', BAR, 2 * BAR);
expect(trimmed([far], new Set(['f']), BAR, 120, () => BAR)[0]!.offset).toBe(BAR);
});
it('splits the picked items the point falls inside, and gives the right-hand parts', () => {
const cut = split(items, new Set(['a', 'b']), BAR + 480, 120);
expect(cut.items.map((i) => [i.track, i.tick, i.length, i.offset])).toEqual([
['t1', BAR, 480, 0],
['t1', BAR + 480, BAR - 480, 480],
['t2', 3 * BAR, BAR, 0],
]);
expect(cut.right).toEqual([cut.items[1]!.id]);
});
it('puts a duplicate right behind the picked items, and a paste on their tracks', () => {
expect(duplicateOffset(items, both, BAR)).toBe(3 * BAR);
const copies = placedCopies(items, 480, ['t2', 't3']);
expect(copies.map((i) => [i.tick, i.track])).toEqual([
[480, 't2'],
[480 + 2 * BAR, 't2'],
]);
expect(copies.every((c) => c.id !== 'a' && c.id !== 'b')).toBe(true);
// Under the pointer: the topmost on its track, as far down as there are tracks.
expect(placedCopies(items, 0, TRACKS, 2).map((i) => i.track)).toEqual(['t2', 't3']);
expect(placedCopies(items, 0, TRACKS, 0).map((i) => i.track)).toEqual(['t1', 't2']);
});
it('makes a dropped pattern as long as it is, a sound as long as it plays at the tempo, else a bar', () => {
expect(droppedLength(null, 3 * BAR, 120, BAR)).toBe(3 * BAR);
expect(droppedLength(1.25, null, 120, BAR)).toBe(1200);
expect(droppedLength(null, null, 120, BAR)).toBe(BAR);
});
});
describe('where things are in the Song tab (#334)', () => {
const view: SongView = { panX: 0, pxPerTick: 0.1, panY: 0, trackHeight: TRACK_HEIGHT };
const items = [item('a', 't1', 0, BAR), item('b', 't2', BAR, BAR)];
it('finds an item and which end of it is under the pointer', () => {
expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'body' });
expect(itemAt(view, items, tracks, { x: 190, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'end' });
expect(itemAt(view, items, tracks, { x: 194, y: TRACK_HEIGHT * 1.5 })).toEqual({
id: 'b',
part: 'start',
});
expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT * 1.5 })).toBeNull();
expect(itemsWithin(view, items, tracks, { x: 150, y: 10 }, { x: 250, y: TRACK_HEIGHT + 10 })).toEqual([
'a',
'b',
]);
});
it('scrolls half the height past the first or the last track at most', () => {
expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(40);
expect(clampTracks({ ...view, panY: 900 }, 3, 400).panY).toBe(200);
// Tracks that fit with room to spare are not pushed up off the top.
expect(clampTracks({ ...view, panY: -40 }, 1, 400).panY).toBe(0);
expect(clampTracks({ ...view, panY: -5000 }, 10, 400).panY).toBe(400 - 200 - 10 * TRACK_HEIGHT);
});
it('tells the parts of a head apart and reads a slider from left to right', () => {
const { mute, solo, volume, pan } = HEAD_BOXES;
expect(headPartAt(mute.x + 4, mute.y + 4)).toBe('mute');
expect(headPartAt(solo.x + 4, solo.y + 4)).toBe('solo');
expect(headPartAt(volume.x + 10, volume.y + 4)).toBe('volume');
expect(headPartAt(pan.x + 10, pan.y + 4)).toBe('pan');
expect(headPartAt(20, 8)).toBe('name');
expect(sliderValue(volume.x, volume, -60, 6, 0.5)).toBe(-60);
expect(sliderValue(volume.x + volume.w, volume, -60, 6, 0.5)).toBe(6);
expect(sliderValue(pan.x + pan.w / 2, pan, -1, 1, 0.05)).toBe(0);
// A head too low for its sliders has none to hit.
expect(headPartAt(volume.x + 10, volume.y + 4, SLIDERS_MIN_HEIGHT - 1)).toBe('name');
expect(headPartAt(mute.x + 4, mute.y + 4, MIN_TRACK_HEIGHT)).toBe('mute');
});
it('zooms the tracks about the pointer, within their bounds, and fills in an old saved view', () => {
const zoomed = zoomTracks({ ...view, panY: -20 }, 2, 150);
expect(zoomed.trackHeight).toBe(2 * TRACK_HEIGHT);
expect(trackAtY(zoomed, 10, 150)).toBe(trackAtY({ ...view, panY: -20 }, 10, 150));
expect(zoomTracks(view, 100, 0).trackHeight).toBe(MAX_TRACK_HEIGHT);
expect(zoomTracks(view, 0.01, 0).trackHeight).toBe(MIN_TRACK_HEIGHT);
expect(clampTracks({ ...zoomed, panY: -9999 }, 10, 400).panY).toBe(400 - 200 - 10 * 2 * TRACK_HEIGHT);
expect(heldSongView({ panX: -5, pxPerTick: 0.3, panY: -10 })).toEqual({
panX: -5,
pxPerTick: 0.3,
panY: -10,
trackHeight: TRACK_HEIGHT,
});
});
it('marks where an item starts over inside it, from as far in as it starts', () => {
expect(loopSeams({ tick: BAR, length: 3 * BAR }, BAR, 0)).toEqual([2 * BAR, 3 * BAR]);
expect(loopSeams({ tick: BAR, length: 2 * BAR }, BAR, 480)).toEqual([2 * BAR - 480, 3 * BAR - 480]);
expect(loopSeams({ tick: 0, length: BAR }, BAR, 0)).toEqual([]);
expect(loopSeams({ tick: 0, length: 1000 }, 400.5, 0)).toEqual([400.5, 801]);
expect(loopSeams({ tick: 0, length: BAR }, 0, 0)).toEqual([]);
});
it('draws a waveform column as the loudest of every bucket it covers', () => {
const peaks = [0.1, 0.9, 0.2, 0.3];
expect(peakOver(peaks, 0, 0.25)).toBe(0.1);
expect(peakOver(peaks, 0.2, 0.3)).toBe(0.9);
expect(peakOver(peaks, 0.5, 1)).toBe(0.3);
expect(peakOver(peaks, 0.6, 0.61)).toBe(0.2);
expect(peakOver(peaks, 0.99, 1.2)).toBe(0.3);
});
});