import { describe, expect, it } from 'vitest'; import type { PatternNote } from '../../src/core/music/pattern'; import { barLabelStep, clampTimePan, fitTime, 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); }); }); 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, ]); }); });