diff --git a/src/store/reducers/ui.test.js b/src/store/reducers/ui.test.js new file mode 100644 index 0000000..170fbbe --- /dev/null +++ b/src/store/reducers/ui.test.js @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { uiReducers } from "./ui"; + +function createUiState() { + return { + ui: { + windows: { + browser: { open: false, z: 1, x: 10, y: 10, width: 200, height: 200, isMaximized: false }, + playlist: { open: false, z: 2, x: 20, y: 20, width: 300, height: 300, isMaximized: false }, + }, + nextZ: 2, + browserTab: "packs", + theme: "default", + fxEditorTarget: null, + patternClipboard: [], + }, + project: { + patterns: [{ id: "pat-1" }], + }, + }; +} + +describe("uiReducers", () => { + describe("openWindow", () => { + it("opens a closed window and bumps z-index", () => { + const state = createUiState(); + uiReducers.openWindow(state, { payload: "browser" }); + expect(state.ui.windows.browser.open).toBe(true); + expect(state.ui.windows.browser.z).toBe(3); + expect(state.ui.nextZ).toBe(3); + }); + + it("ignores unknown window ids", () => { + const state = createUiState(); + uiReducers.openWindow(state, { payload: "unknown" }); + expect(state.ui.nextZ).toBe(2); + }); + }); + + describe("closeWindow", () => { + it("closes an open window", () => { + const state = createUiState(); + state.ui.windows.browser.open = true; + uiReducers.closeWindow(state, { payload: "browser" }); + expect(state.ui.windows.browser.open).toBe(false); + }); + }); + + describe("bringWindowToFront", () => { + it("bumps z-index of existing window", () => { + const state = createUiState(); + uiReducers.bringWindowToFront(state, { payload: "browser" }); + expect(state.ui.windows.browser.z).toBe(3); + }); + + it("ignores unknown window ids", () => { + const state = createUiState(); + uiReducers.bringWindowToFront(state, { payload: "unknown" }); + expect(state.ui.nextZ).toBe(2); + }); + }); + + describe("setWindowRect", () => { + it("updates window position and size", () => { + const state = createUiState(); + uiReducers.setWindowRect(state, { payload: { id: "browser", x: 50, y: 60, width: 400, height: 300 } }); + expect(state.ui.windows.browser.x).toBe(50); + expect(state.ui.windows.browser.y).toBe(60); + expect(state.ui.windows.browser.width).toBe(400); + expect(state.ui.windows.browser.height).toBe(300); + }); + }); + + describe("toggleWindowMaximize", () => { + it("maximizes window and stores restore rect", () => { + const state = createUiState(); + uiReducers.toggleWindowMaximize(state, { payload: { id: "browser", viewport: { width: 1920, height: 1080 } } }); + expect(state.ui.windows.browser.isMaximized).toBe(true); + expect(state.ui.windows.browser.restoreRect).toEqual({ x: 10, y: 10, width: 200, height: 200 }); + expect(state.ui.windows.browser.width).toBe(1920); + }); + + it("restores window from maximized state", () => { + const state = createUiState(); + uiReducers.toggleWindowMaximize(state, { payload: { id: "browser", viewport: { width: 1920, height: 1080 } } }); + uiReducers.toggleWindowMaximize(state, { payload: { id: "browser" } }); + expect(state.ui.windows.browser.isMaximized).toBe(false); + expect(state.ui.windows.browser.width).toBe(200); + }); + }); + + describe("setBrowserTab", () => { + it("sets the active browser tab", () => { + const state = createUiState(); + uiReducers.setBrowserTab(state, { payload: "plugins" }); + expect(state.ui.browserTab).toBe("plugins"); + }); + }); + + describe("setTheme", () => { + it("sets a valid theme", () => { + const state = createUiState(); + uiReducers.setTheme(state, { payload: "tealslate" }); + expect(state.ui.theme).toBe("tealslate"); + }); + + it("falls back to default for invalid theme", () => { + const state = createUiState(); + uiReducers.setTheme(state, { payload: "invalid" }); + expect(state.ui.theme).toBe("default"); + }); + }); + + describe("setPatternClipboard", () => { + it("stores valid pattern ids", () => { + const state = createUiState(); + uiReducers.setPatternClipboard(state, { payload: { patternIds: ["pat-1"] } }); + expect(state.ui.patternClipboardIds).toEqual(["pat-1"]); + }); + + it("ignores unknown pattern ids", () => { + const state = createUiState(); + uiReducers.setPatternClipboard(state, { payload: { patternIds: ["unknown"] } }); + expect(state.ui.patternClipboardIds).toEqual([]); + }); + }); +}); diff --git a/src/store/utils.test.js b/src/store/utils.test.js new file mode 100644 index 0000000..58c2743 --- /dev/null +++ b/src/store/utils.test.js @@ -0,0 +1,351 @@ +import { describe, it, expect } from "vitest"; +import { + clamp, + sanitizeUiTheme, + getDefaultEqBandType, + sanitizeEqBandType, + clampEqBandGainDb, + clampEqFrequencyHz, + clampEqQ, + makeGraphicEqParams, + getSafeGraphicEqParams, + clampReverb01, + clampReverbInRange, + makeReverbParams, + getSafeReverbParams, + sanitizeMaximizerMode, + clampMaximizerThresholdDb, + clampMaximizerCeilingDb, + clampMaximizerCharacter, + makeMaximizerParams, + getSafeMaximizerParams, + makeInsertSpectrum, + makeInsertWaveform, + makeMaximizerStereoMeter, + getFxSlotDefaultName, + normalizeFxSlot, + ensureInsertFxSlots, + makeFxSlots, + makeSampleSettings, + sanitizeLoadedSampleSettings, + nearlyEqual, + makeStepRow, + makePlaylistTracks, + makePatternStepGrid, + normalizeBarValue, + getSafePatternColor, + makeEmptyPattern, + getNextPatternNumber, + clonePatternForCopy, + isObjectLike, + cloneSerializable, +} from "./utils"; + +describe("store/utils", () => { + describe("clamp", () => { + it("clamps value between min and max", () => { + expect(clamp(5, 0, 10)).toBe(5); + expect(clamp(-5, 0, 10)).toBe(0); + expect(clamp(15, 0, 10)).toBe(10); + }); + }); + + describe("sanitizeUiTheme", () => { + it("returns valid theme", () => { + expect(sanitizeUiTheme("tealslate")).toBe("tealslate"); + expect(sanitizeUiTheme("studio95")).toBe("studio95"); + }); + + it("falls back to default for invalid theme", () => { + expect(sanitizeUiTheme("unknown")).toBe("default"); + expect(sanitizeUiTheme("")).toBe("default"); + expect(sanitizeUiTheme(null)).toBe("default"); + }); + }); + + describe("EQ helpers", () => { + it("getDefaultEqBandType returns correct types", () => { + expect(getDefaultEqBandType(0)).toBe("lowshelf"); + expect(getDefaultEqBandType(6)).toBe("highshelf"); + expect(getDefaultEqBandType(3)).toBe("peaking"); + }); + + it("sanitizeEqBandType accepts valid types", () => { + expect(sanitizeEqBandType("peaking", "lowshelf")).toBe("peaking"); + expect(sanitizeEqBandType("invalid", "lowshelf")).toBe("lowshelf"); + expect(sanitizeEqBandType("invalid", "bad")).toBe("peaking"); + }); + + it("clampEqBandGainDb clamps to [-18, 18]", () => { + expect(clampEqBandGainDb(10)).toBe(10); + expect(clampEqBandGainDb(25)).toBe(18); + expect(clampEqBandGainDb(-25)).toBe(-18); + }); + + it("clampEqFrequencyHz clamps to [20, 20000]", () => { + expect(clampEqFrequencyHz(1000)).toBe(1000); + expect(clampEqFrequencyHz(10)).toBe(20); + expect(clampEqFrequencyHz(30000)).toBe(20000); + }); + + it("clampEqQ clamps to [0.25, 8]", () => { + expect(clampEqQ(1.5)).toBe(1.5); + expect(clampEqQ(0.1)).toBe(0.25); + expect(clampEqQ(10)).toBe(8); + }); + + it("makeGraphicEqParams returns 7 points", () => { + const params = makeGraphicEqParams(); + expect(params.points).toHaveLength(7); + expect(params.points[0]).toMatchObject({ + frequencyHz: 50, + gainDb: 0, + bandType: "lowshelf", + }); + }); + + it("getSafeGraphicEqParams migrates legacy bands", () => { + const legacy = { bands: [3, -2, 0, 0, 0, 0, 0] }; + const result = getSafeGraphicEqParams(legacy); + expect(result.points[0].gainDb).toBe(3); + expect(result.points[1].gainDb).toBe(-2); + }); + }); + + describe("Reverb helpers", () => { + it("clampReverb01 clamps to [0, 1]", () => { + expect(clampReverb01(0.5, 0)).toBe(0.5); + expect(clampReverb01(-0.2, 0)).toBe(0); + expect(clampReverb01(1.5, 0)).toBe(1); + expect(clampReverb01(NaN, 0.5)).toBe(0.5); + }); + + it("clampReverbInRange clamps to given range", () => { + expect(clampReverbInRange(5, 0, 10, 0)).toBe(5); + expect(clampReverbInRange(15, 0, 10, 0)).toBe(10); + expect(clampReverbInRange(NaN, 0, 10, 3)).toBe(3); + }); + + it("makeReverbParams returns defaults", () => { + const params = makeReverbParams(); + expect(params.decayTime).toBe(2.8); + expect(params.dryWet).toBe(0.34); + expect(params.freeze).toBe(false); + }); + + it("getSafeReverbParams clamps all fields", () => { + const raw = { decayTime: 50, size: -1, dryWet: 2 }; + const safe = getSafeReverbParams(raw); + expect(safe.decayTime).toBe(20); + expect(safe.size).toBe(0); + expect(safe.dryWet).toBe(1); + }); + }); + + describe("Maximizer helpers", () => { + it("sanitizeMaximizerMode falls back to irc-ii", () => { + expect(sanitizeMaximizerMode("irc-iii")).toBe("irc-iii"); + expect(sanitizeMaximizerMode("bad")).toBe("irc-ii"); + }); + + it("clampMaximizerThresholdDb clamps to [-24, 0]", () => { + expect(clampMaximizerThresholdDb(-12)).toBe(-12); + expect(clampMaximizerThresholdDb(-30)).toBe(-24); + expect(clampMaximizerThresholdDb(5)).toBe(0); + }); + + it("clampMaximizerCeilingDb clamps to [-18, 0]", () => { + expect(clampMaximizerCeilingDb(-3)).toBe(-3); + expect(clampMaximizerCeilingDb(-30)).toBe(-18); + }); + + it("clampMaximizerCharacter clamps to [0, 1]", () => { + expect(clampMaximizerCharacter(0.5)).toBe(0.5); + expect(clampMaximizerCharacter(2)).toBe(1); + }); + + it("makeMaximizerParams returns defaults", () => { + const params = makeMaximizerParams(); + expect(params.mode).toBe("irc-ii"); + expect(params.truePeakEnabled).toBe(true); + }); + + it("getSafeMaximizerParams migrates legacy defaults", () => { + const legacy = { + thresholdDb: -6, + ceilingDb: -0.1, + character: 0.58, + mode: "irc-ii", + truePeakEnabled: true, + }; + const safe = getSafeMaximizerParams(legacy); + expect(safe.thresholdDb).toBe(0); + expect(safe.ceilingDb).toBe(-1); + expect(safe.character).toBe(0.5); + }); + }); + + describe("Insert / FX helpers", () => { + it("makeInsertSpectrum returns array of zeros", () => { + const spec = makeInsertSpectrum(); + expect(spec).toHaveLength(112); + expect(spec.every((v) => v === 0)).toBe(true); + }); + + it("makeInsertWaveform returns array of zeros", () => { + const wf = makeInsertWaveform(); + expect(wf).toHaveLength(220); + }); + + it("makeMaximizerStereoMeter returns default meters", () => { + const meter = makeMaximizerStereoMeter(); + expect(meter.leftVolumeDb).toBe(-96); + expect(meter.rightReductionDb).toBe(0); + }); + + it("getFxSlotDefaultName formats correctly", () => { + expect(getFxSlotDefaultName(0)).toBe("Slot 1"); + expect(getFxSlotDefaultName(9)).toBe("Slot 10"); + }); + + it("normalizeFxSlot normalizes empty slot", () => { + const slot = normalizeFxSlot({}, 0); + expect(slot.effectType).toBe("none"); + expect(slot.enabled).toBe(false); + }); + + it("normalizeFxSlot normalizes graphic-eq", () => { + const slot = normalizeFxSlot({ effectType: "graphic-eq", enabled: true }, 0); + expect(slot.effectType).toBe("graphic-eq"); + expect(slot.enabled).toBe(true); + expect(slot.params.points).toHaveLength(7); + }); + + it("ensureInsertFxSlots ensures 10 slots", () => { + const insert = {}; + ensureInsertFxSlots(insert); + expect(insert.fxSlots).toHaveLength(10); + expect(insert.fxSlots[0].id).toBe("slot-1"); + }); + + it("makeFxSlots returns 10 disabled slots", () => { + const slots = makeFxSlots(); + expect(slots).toHaveLength(10); + expect(slots[5].enabled).toBe(false); + }); + }); + + describe("Sample settings helpers", () => { + it("makeSampleSettings returns defaults", () => { + const settings = makeSampleSettings(); + expect(settings.stretchMode).toBe("resample"); + expect(settings.pitchCents).toBe(0); + }); + + it("sanitizeLoadedSampleSettings fixes invalid stretch mode", () => { + const raw = { stretchMode: "invalid", stretchTimeMode: "invalid" }; + const safe = sanitizeLoadedSampleSettings(raw); + expect(safe.stretchMode).toBe("resample"); + expect(safe.stretchTimeMode).toBe("none"); + }); + + it("sanitizeLoadedSampleSettings preserves valid modes", () => { + const raw = { stretchMode: "stretch", stretchTimeMode: "set-bpm" }; + const safe = sanitizeLoadedSampleSettings(raw); + expect(safe.stretchMode).toBe("stretch"); + expect(safe.stretchTimeMode).toBe("set-bpm"); + }); + }); + + describe("Pattern / project helpers", () => { + it("nearlyEqual compares with epsilon", () => { + expect(nearlyEqual(1.0, 1.00005)).toBe(true); + expect(nearlyEqual(1.0, 1.001)).toBe(false); + }); + + it("makeStepRow creates correct row", () => { + const row = makeStepRow(8, [0, 3, 7]); + expect(row).toEqual([true, false, false, true, false, false, false, true]); + }); + + it("makePlaylistTracks returns numbered tracks", () => { + const tracks = makePlaylistTracks(3); + expect(tracks).toHaveLength(3); + expect(tracks[0].name).toBe("Track 1"); + }); + + it("makePatternStepGrid builds empty grid", () => { + const channels = [{ id: "ch-1" }, { id: "ch-2" }]; + const grid = makePatternStepGrid(channels, 16); + expect(Object.keys(grid)).toHaveLength(2); + expect(grid["ch-1"]).toHaveLength(16); + }); + + it("normalizeBarValue rounds to 1/16", () => { + expect(normalizeBarValue(1.125, 0, 512)).toBe(1.125); + expect(normalizeBarValue(1.13, 0, 512)).toBe(1.125); + }); + + it("getSafePatternColor validates hex", () => { + expect(getSafePatternColor("#ff0000")).toBe("#ff0000"); + expect(getSafePatternColor("bad")).toBe("#4bef9f"); + }); + + it("makeEmptyPattern creates pattern with stepGrid", () => { + const pattern = makeEmptyPattern({ + id: "p1", + name: "Test", + lengthSteps: 16, + channels: [{ id: "ch-1" }], + }); + expect(pattern.lengthSteps).toBe(16); + expect(pattern.stepGrid["ch-1"]).toHaveLength(16); + }); + + it("getNextPatternNumber extracts from names and ids", () => { + const patterns = [ + { name: "Pattern 3", id: "pat-1" }, + { name: "Melody", id: "pat-5" }, + ]; + expect(getNextPatternNumber(patterns)).toBe(6); + }); + + it("clonePatternForCopy clones safely", () => { + const source = { + id: "p1", + name: "Original", + color: "#ff0000", + lengthSteps: 16, + stepGrid: { "ch-1": [true, false] }, + pianoPreview: {}, + }; + const clone = clonePatternForCopy(source, "p2", "Copy"); + expect(clone.id).toBe("p2"); + expect(clone.name).toBe("Copy"); + expect(clone.stepGrid["ch-1"]).toHaveLength(16); + }); + }); + + describe("Serialization helpers", () => { + it("isObjectLike detects plain objects", () => { + expect(isObjectLike({})).toBe(true); + expect(isObjectLike([])).toBe(false); + expect(isObjectLike(null)).toBe(false); + expect(isObjectLike("string")).toBe(false); + }); + + it("cloneSerializable deep clones", () => { + const obj = { a: 1, b: { c: 2 } }; + const clone = cloneSerializable(obj); + expect(clone).toEqual(obj); + expect(clone).not.toBe(obj); + expect(clone.b).not.toBe(obj.b); + }); + + it("cloneSerializable returns null on circular ref", () => { + const obj = {}; + obj.self = obj; + expect(cloneSerializable(obj)).toBeNull(); + }); + }); +}); diff --git a/src/utils/midiExport.test.js b/src/utils/midiExport.test.js new file mode 100644 index 0000000..54d2579 --- /dev/null +++ b/src/utils/midiExport.test.js @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { createMidiFileData } from "./midiExport"; + +function toHex(bytes) { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); +} + +describe("midiExport", () => { + describe("createMidiFileData", () => { + it("returns a Uint8Array", () => { + const data = createMidiFileData([], 120); + expect(data).toBeInstanceOf(Uint8Array); + }); + + it("contains valid MIDI header", () => { + const data = createMidiFileData([], 120); + // MThd + expect(data[0]).toBe(0x4d); + expect(data[1]).toBe(0x54); + expect(data[2]).toBe(0x68); + expect(data[3]).toBe(0x64); + // header length = 6 + expect(data[4]).toBe(0x00); + expect(data[5]).toBe(0x00); + expect(data[6]).toBe(0x00); + expect(data[7]).toBe(0x06); + // format 0, 1 track + expect(data[8]).toBe(0x00); + expect(data[9]).toBe(0x00); + expect(data[10]).toBe(0x00); + expect(data[11]).toBe(0x01); + // 480 ticks per quarter + expect(data[12]).toBe(0x01); + expect(data[13]).toBe(0xe0); + }); + + it("contains valid track chunk", () => { + const data = createMidiFileData([], 120); + const trackOffset = 14; // after header + // MTrk + expect(data[trackOffset]).toBe(0x4d); + expect(data[trackOffset + 1]).toBe(0x54); + expect(data[trackOffset + 2]).toBe(0x72); + expect(data[trackOffset + 3]).toBe(0x6b); + }); + + it("embeds tempo meta event for given BPM", () => { + const data = createMidiFileData([], 120); + const trackOffset = 14; + const trackLen = + (data[trackOffset + 4] << 24) | + (data[trackOffset + 5] << 16) | + (data[trackOffset + 6] << 8) | + data[trackOffset + 7]; + expect(trackLen).toBeGreaterThan(0); + + // After track length (4 bytes), first event should be tempo meta + const eventStart = trackOffset + 8; + expect(data[eventStart]).toBe(0x00); // delta 0 + expect(data[eventStart + 1]).toBe(0xff); // meta + expect(data[eventStart + 2]).toBe(0x51); // tempo + expect(data[eventStart + 3]).toBe(0x03); // 3 bytes + // 120 BPM = 500000 microseconds per quarter = 0x07 0xa1 0x20 + expect(data[eventStart + 4]).toBe(0x07); + expect(data[eventStart + 5]).toBe(0xa1); + expect(data[eventStart + 6]).toBe(0x20); + }); + + it("writes note on and note off events", () => { + const notes = [{ pitch: 60, velocity: 100, start: 0, length: 1 }]; + const data = createMidiFileData(notes, 120); + const hex = toHex(data); + + // 0x90 = note on channel 1, 0x80 = note off channel 1 + expect(hex).toContain("90 3c 64"); // pitch 60 (0x3c), velocity 100 (0x64) + expect(hex).toContain("80 3c 00"); // note off, pitch 60, velocity 0 + }); + + it("clamps pitch to [0, 127]", () => { + const notes = [{ pitch: 200, velocity: 100, start: 0, length: 1 }]; + const data = createMidiFileData(notes, 120); + const hex = toHex(data); + expect(hex).toContain("90 7f 64"); // pitch clamped to 127 (0x7f) + }); + + it("clamps velocity to [1, 127]", () => { + const notes = [{ pitch: 60, velocity: 0.5, start: 0, length: 1 }]; + const data = createMidiFileData(notes, 120); + const hex = toHex(data); + expect(hex).toContain("90 3c 01"); // velocity clamped to 1 + }); + + it("clamps BPM to [20, 300]", () => { + const dataSlow = createMidiFileData([], 10); + const dataFast = createMidiFileData([], 500); + + // 20 BPM = 3000000 us/q = 0x2d c6 c0 + const trackOffset = 14 + 8; // header + track header + delta + expect(dataSlow[trackOffset + 4]).toBe(0x2d); + expect(dataSlow[trackOffset + 5]).toBe(0xc6); + expect(dataSlow[trackOffset + 6]).toBe(0xc0); + + // 300 BPM = 200000 us/q = 0x03 0x0d 0x40 + expect(dataFast[trackOffset + 4]).toBe(0x03); + expect(dataFast[trackOffset + 5]).toBe(0x0d); + expect(dataFast[trackOffset + 6]).toBe(0x40); + }); + + it("handles multiple notes sorted by tick", () => { + const notes = [ + { pitch: 60, velocity: 100, start: 2, length: 1 }, + { pitch: 64, velocity: 100, start: 0, length: 1 }, + ]; + const data = createMidiFileData(notes, 120); + const hex = toHex(data); + + // Note 64 should appear before note 60 in the binary + const idx64 = hex.indexOf("90 40 64"); + const idx60 = hex.indexOf("90 3c 64"); + expect(idx64).toBeGreaterThan(0); + expect(idx60).toBeGreaterThan(idx64); + }); + + it("ignores invalid notes array", () => { + const data = createMidiFileData(null, 120); + expect(data).toBeInstanceOf(Uint8Array); + expect(data.length).toBeGreaterThan(0); + }); + }); +});