Files
feuerwerk/tests/editor/piano-roll.test.ts
T
adminandClaude Opus 5.5 aa097bd076 Zoom with the wheel and set the loop on its own strip in music tabs
The ruler of the Pattern and Song tabs did two things with one drag,
and the wheel did three things with Alt and Shift. Both now do one
thing each, and the Song tab gets a few edits it was missing.

- The ruler has a loop strip along its top. A drag there sets the
  loop, a press or drag below it moves only where playing starts.
- The wheel only zooms: the time over the grid, lanes and ruler, the
  rows over the keys, the tracks over their heads. Alt and Shift no
  longer change it. A middle-drag still pans.
- Tracks can be zoomed from 28 to 160 px. Below 46 px a head shows
  only its name, M and S. An old saved view gets the old height.
- A click on nothing in the lanes picks the song itself.
- A right-click on an item opens a menu with Delete instead of
  deleting it. The inspector has no delete buttons any more.
- A sound item goes up to +12 dB, a track up to +12 dB. A pattern
  item stays at 0 dB, since a note is held to unity.
- An end of an item under the pointer lights up and shows a resize
  cursor. A long item marks every point where it starts over.
- The Pattern tab has an Audition switch in its header, off at first.
  Only with it on does a note sound when drawn, grabbed or moved.
  The keys down the left sound either way.
- A looped sound item draws every round alike. Each round is drawn
  from its own start, and each column shows the loudest part of the
  sound it covers instead of one sampled bucket.
- A song is at least one bar long instead of four. An empty song
  fits that bar across, and Play song ends after the last item.
- The ghost notes held back in #334 got their own issue, #339.

Finding: suite 542 for f97961c failed 5 game checks in e2e 64 and 65
while my runs on the same Windows machine overlapped it. Modules
60-65 alone without load passed, so this push's suite has to show it.

Tests:
- Unit: song-edit 11, piano-roll 17, song 14, sound-library 67.
  Four of them are new, one in song changed.
- e2e 64+65 on melektaus-atx: ALL PASSED - 46 checks (+3).
- e2e 60-65 on melektaus-atx: ALL PASSED - 86 checks.
- Gate on melektaus-atx: 251 files, 3907 tests, CHECK EXIT 0.
- Audition, after the gate: e2e 64 alone ALL PASSED - 25 checks
  (+1), transitions and piano-roll 30 tests, typecheck and ESLint
  clean.
- Waveform and one bar, after the gate: song, song-edit, song-play
  and the node catalog 438 tests. e2e 65 alone ALL PASSED - 25
  checks (+1). A probe shot of a looped pop sound: all six rounds
  pixel for pixel alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:25:49 +02:00

215 lines
8.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { PatternNote } from '../../src/core/music/pattern';
import {
barLabelStep,
clampTimePan,
draggedLoop,
fitTime,
LOOP_STRIP,
rulerPartAt,
timeLines,
xToTick,
zoomTime,
} from '../../src/editor/music/timeView';
import {
clampShift,
draggedLength,
drawnNote,
duplicateOffset,
moveDelta,
placedCopies,
resized,
shifted,
withVelocity,
} from '../../src/editor/pattern/rollEdit';
import {
centreRows,
clampRows,
noteAt,
noteAtY,
noteTop,
notesWithin,
type RollView,
ROWS,
velocityAtY,
velocityBarAt,
zoomRows,
} from '../../src/editor/pattern/rollView';
const note = (id: string, tick: number, length: number, n = 60, velocity = 0.8): PatternNote => ({
id,
tick,
length,
note: n,
velocity,
});
/** 0.1 px per tick: a 1/16 is 12 px, a bar of 4/4 192. Rows of 10 px, C4 (60) at the top. */
const VIEW: RollView = { panX: 0, pxPerTick: 0.1, panY: -(127 - 60) * 10, rowHeight: 10 };
const COMMON = { beats: 4, unit: 4 };
describe('time view of the piano roll (#333)', () => {
it('zooms about the pointer and never shows time before 0', () => {
const zoomed = zoomTime({ panX: -100, pxPerTick: 0.1 }, 2, 300);
expect(xToTick(zoomed, 300)).toBeCloseTo(4000);
expect(clampTimePan({ panX: 40, pxPerTick: 0.1 }).panX).toBe(0);
expect(fitTime(7680, 768)).toEqual({ panX: 0, pxPerTick: 0.1 });
});
it('draws bars, beats and the grid, each tick at its strongest, the grid only with room', () => {
const lines = timeLines(VIEW, COMMON, 120, 200);
expect(lines.filter((l) => l.level === 0).map((l) => l.tick)).toEqual([0, 1920]);
expect(lines.filter((l) => l.level === 1).map((l) => l.tick)).toEqual([480, 960, 1440]);
expect(lines.filter((l) => l.level === 2)).toHaveLength(12);
// At a twentieth of that a 1/16 is 0.6 px: bars only.
expect(timeLines({ panX: 0, pxPerTick: 0.005 }, COMMON, 120, 200).every((l) => l.level === 0)).toBe(true);
});
it('keeps the beats of 6/8 between the lines of a triplet grid', () => {
const lines = timeLines({ panX: 0, pxPerTick: 0.1 }, { beats: 6, unit: 8 }, 160, 150);
expect(lines.filter((l) => l.level === 1).map((l) => l.tick)).toEqual([240, 480, 720, 960, 1200]);
expect(lines.filter((l) => l.level === 2).map((l) => l.tick)).toEqual([160, 320, 640, 800, 1120, 1280]);
});
it('numbers every bar while there is room, else every second, fourth …', () => {
expect(barLabelStep({ panX: 0, pxPerTick: 0.1 }, COMMON)).toBe(1);
expect(barLabelStep({ panX: 0, pxPerTick: 0.01 }, COMMON)).toBe(2);
expect(barLabelStep({ panX: 0, pxPerTick: 0.004 }, COMMON)).toBe(8);
});
it('sets the loop on the strip along the top of the ruler, and the start below it', () => {
expect(rulerPartAt(0)).toBe('loop');
expect(rulerPartAt(LOOP_STRIP - 1)).toBe('loop');
expect(rulerPartAt(LOOP_STRIP)).toBe('time');
const loop = { start: 1920, end: 3840 };
expect(draggedLoop(loop, null, 960, 480)).toEqual({ start: 480, end: 960 });
expect(draggedLoop(loop, null, 960, 960)).toBeNull();
expect(draggedLoop(loop, 'end', 3840, 5760)).toEqual({ start: 1920, end: 5760 });
expect(draggedLoop(loop, 'end', 3840, 0)).toEqual({ start: 1920, end: 1921 });
expect(draggedLoop(loop, 'start', 1920, 9999)).toEqual({ start: 3839, end: 3840 });
});
});
describe('rows of the piano roll (#333)', () => {
it('puts the highest note on top and finds the note under a point', () => {
expect(noteTop(VIEW, 60)).toBe(0);
expect(noteTop(VIEW, 59)).toBe(10);
expect(noteAtY(VIEW, 5)).toBe(60);
expect(noteAtY(VIEW, 15)).toBe(59);
expect(noteAtY({ ...VIEW, panY: 0 }, -1)).toBeNull();
expect(noteAtY({ ...VIEW, panY: 0 }, ROWS * 10)).toBeNull();
});
it('keeps the rows on screen and zooms them about the pointer', () => {
expect(clampRows({ ...VIEW, panY: 50 }, 300).panY).toBe(0);
expect(clampRows({ ...VIEW, panY: -5000 }, 300).panY).toBe(300 - ROWS * 10);
const zoomed = zoomRows(VIEW, 2, 25);
expect(zoomed.rowHeight).toBe(20);
expect(noteAtY(zoomed, 25)).toBe(58);
expect(zoomRows(VIEW, 100, 0).rowHeight).toBe(28);
});
it('centres the notes it is fitted to, lower rows where they do not fit', () => {
const fitted = centreRows(60, 64, 200);
expect(fitted.rowHeight).toBe(14);
const view = { ...VIEW, ...fitted };
expect(noteAtY(view, 100)).toBe(62);
expect(centreRows(24, 48, 200).rowHeight).toBeCloseTo(200 / 27);
expect(centreRows(0, 127, 200).rowHeight).toBe(6);
});
it('tells a note body from its right end, and takes the one drawn last', () => {
const notes = [note('a', 0, 480), note('b', 240, 480)];
expect(noteAt(VIEW, notes, { x: 10, y: 5 })).toEqual({ id: 'a', part: 'body' });
expect(noteAt(VIEW, notes, { x: 30, y: 5 })).toEqual({ id: 'b', part: 'body' });
expect(noteAt(VIEW, notes, { x: 70, y: 5 })).toEqual({ id: 'b', part: 'end' });
expect(noteAt(VIEW, notes, { x: 10, y: 15 })).toBeNull();
// A note of a tick is still 4 px to the pointer.
expect(noteAt(VIEW, [note('c', 1000, 1)], { x: 102, y: 5 })?.id).toBe('c');
});
it('picks the notes a band touches', () => {
const notes = [note('a', 0, 120, 60), note('b', 480, 120, 59), note('c', 960, 120, 55)];
expect(notesWithin(VIEW, notes, { x: 5, y: 2 }, { x: 50, y: 18 })).toEqual(['a', 'b']);
expect(notesWithin(VIEW, notes, { x: 50, y: 60 }, { x: 0, y: 0 })).toEqual(['a', 'b']);
});
it('finds the velocity bar under the pointer, a picked one first', () => {
const notes = [note('a', 0, 120), note('b', 0, 120, 64), note('c', 480, 120)];
expect(velocityBarAt(VIEW, notes, new Set(), 2)).toBe('b');
expect(velocityBarAt(VIEW, notes, new Set(['a']), 2)).toBe('a');
expect(velocityBarAt(VIEW, notes, new Set(), 30)).toBeNull();
expect(velocityAtY(10, 10, 64)).toBe(1);
expect(velocityAtY(42, 10, 64)).toBe(0.5);
expect(velocityAtY(100, 10, 64)).toBe(0);
});
});
describe('editing in the piano roll (#333)', () => {
it('draws on the grid line before the pointer, or on the tick with Alt', () => {
expect(drawnNote(250, 62, 120, false, 120, 0.8)).toMatchObject({
tick: 240,
length: 120,
note: 62,
velocity: 0.8,
});
expect(drawnNote(250.4, 62, 120, true, 960, 0.5)).toMatchObject({
tick: 250,
length: 960,
velocity: 0.5,
});
expect(drawnNote(250, 62, 120, false, 120, 1).id).not.toBe(drawnNote(250, 62, 120, false, 120, 1).id);
});
it('moves the grabbed note onto the grid, the others along, never before 0 or off the keys', () => {
const notes = [note('a', 240, 120, 60), note('b', 480, 120, 120), note('c', 0, 120, 30)];
const picked = new Set(['a', 'b']);
expect(moveDelta(notes, picked, notes[0]!, 370, 62, 120, false)).toEqual({ ticks: 120, notes: 2 });
expect(moveDelta(notes, picked, notes[0]!, 370, 62, 120, true)).toEqual({ ticks: 130, notes: 2 });
expect(moveDelta(notes, picked, notes[0]!, -500, 80, 120, false)).toEqual({ ticks: -240, notes: 7 });
expect(clampShift(notes, new Set(['c']), -10, -40)).toEqual({ ticks: 0, notes: -30 });
expect(shifted(notes, picked, 120, 2).map((n) => [n.tick, n.note])).toEqual([
[360, 62],
[600, 122],
[0, 30],
]);
});
it('drags a length to the nearest grid line, never back to its own start', () => {
const anchor = note('a', 240, 120);
expect(draggedLength(anchor, 470, 120, false)).toBe(240);
expect(draggedLength(anchor, 250, 120, false)).toBe(120);
expect(draggedLength(note('o', 250, 60), 255, 120, false)).toBe(110);
expect(draggedLength(anchor, 241.2, 120, true)).toBe(1);
expect(resized([anchor, note('b', 0, 60)], new Set(['a', 'b']), -100).map((n) => n.length)).toEqual([
20, 1,
]);
});
it('puts a duplicate right behind the picked notes, in whole grid steps', () => {
const notes = [note('a', 0, 120), note('b', 240, 60), note('c', 960, 120)];
expect(duplicateOffset(notes, new Set(['a', 'b']), 120)).toBe(360);
expect(duplicateOffset(notes, new Set(['b']), 120)).toBe(120);
expect(duplicateOffset(notes, new Set(), 120)).toBe(0);
});
it('pastes copies with ids of their own, the earliest at the start', () => {
const copies = placedCopies([note('a', 480, 120, 60), note('b', 720, 60, 64)], 1920);
expect(copies.map((n) => [n.tick, n.note])).toEqual([
[1920, 60],
[2160, 64],
]);
expect(copies.map((n) => n.id)).not.toContain('a');
});
it('sets the grabbed velocity and moves the other picked ones by as much', () => {
const notes = [note('a', 0, 120, 60, 0.5), note('b', 0, 120, 62, 0.9), note('c', 0, 120, 64, 0.2)];
expect(withVelocity(notes, new Set(['a', 'b']), notes[0]!, 0.7).map((n) => n.velocity)).toEqual([
0.7, 1, 0.2,
]);
// Unpicked, the grabbed note goes alone.
expect(withVelocity(notes, new Set(['b']), notes[0]!, 0.1).map((n) => n.velocity)).toEqual([
0.1, 0.9, 0.2,
]);
});
});