Files
feuerwerk/tests/editor/song-edit.test.ts
T
adminandClaude Opus 5.5 d0b76ec355 Fit a song both ways and zoom its time in further (#362)
Fit in the Song tab only set the time, from the very left edge, and
kept the height the tracks had. The time also stopped zooming at the
bound of the Pattern tab, too soon to lay a take down closely.

- fitSong in songView.ts frames the song with 9 % of the lanes free on
  every side. The tracks share out the height in between, held to
  28 to 320 px. A song opened without a kept view fits the same way.
- The time zooms in to 8 px a tick, eight times the bound of the
  Pattern tab. zoomTime takes the bound as a parameter, so the Pattern
  tab keeps its own.

Tests:
- Unit song-edit: 15 green (1 new), the counter-check without the
  margin is red.
- e2e 65: ALL PASSED, 56 checks (1 new). The new check presses Fit
  after the tracks are added and finds room around bar and tracks.
  Adding a track presses Fit first now: a new song opens with its one
  track 320 px tall, so the track added below it lay off the edge.
- Gate: 272 files, 4213 tests green.

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

263 lines
12 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,
FIT_MARGIN,
fitSong,
HEAD_BOXES,
headPartAt,
itemAt,
heldSongView,
itemsWithin,
loopSeams,
MAX_TRACK_HEIGHT,
MIN_TRACK_HEIGHT,
peakOver,
SLIDERS_MIN_HEIGHT,
sliderValue,
SONG_MAX_PX_PER_TICK,
type SongView,
TRACK_HEIGHT,
trackAtY,
zoomTracks,
} from '../../src/editor/song/songView';
import { MAX_PX_PER_TICK, tickToX, zoomTime } from '../../src/editor/music/timeView';
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, fx: '' }));
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);
// Past its own end the left end stops on the last grid line before it.
expect(trimDelta([sound], picked, sound, 5 * BAR, 120, false, 120)).toBe(BAR - 120);
expect(trimDelta([sound], picked, sound, 5 * BAR, 120, true, 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));
// A start dragged right up to a round that is its own end does not snap there, it would leave a
// tick: it takes the grid line before.
const two = item('t', 't1', BAR, 1400, { kind: 'sound', offset: 0 });
expect(trimDelta([two], new Set(['t']), two, BAR + 1390, 480, false, 120, () => 700)).toBe(960);
// 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('fits the song across and its tracks down with room around, and zooms in further than a pattern', () => {
// 4 bars and 4 tracks in 1000 x 500: 90 pixels free left and right, 45 above and below.
const fitted = fitSong(4 * BAR, 4, 1000, 500);
expect(fitted.panX).toBeCloseTo(1000 * FIT_MARGIN);
expect(fitted.panY).toBeCloseTo(500 * FIT_MARGIN);
expect(tickToX(fitted, 4 * BAR)).toBeCloseTo(1000 * (1 - FIT_MARGIN));
expect(fitted.panY + 4 * fitted.trackHeight).toBeCloseTo(500 * (1 - FIT_MARGIN));
// A track keeps the heights it may have, however many or few there are.
expect(fitSong(BAR, 1, 1000, 2000).trackHeight).toBe(MAX_TRACK_HEIGHT);
expect(fitSong(BAR, 60, 1000, 500).trackHeight).toBe(MIN_TRACK_HEIGHT);
// The wheel goes past where a pattern stops, up to the song's own bound.
const deep = zoomTime({ panX: 0, pxPerTick: 1 }, 100, 0, SONG_MAX_PX_PER_TICK);
expect(SONG_MAX_PX_PER_TICK).toBeGreaterThan(MAX_PX_PER_TICK);
expect(deep.pxPerTick).toBe(SONG_MAX_PX_PER_TICK);
expect(fitSong(1, 1, 1000, 500).pxPerTick).toBe(SONG_MAX_PX_PER_TICK);
});
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);
});
});