import { assertEquals, assertNotEquals, assertThrows } from "@std/assert"; import { encodeCBOR } from "@char/cbor"; import { voiceTrack } from "./harmony.ts"; import { type ChordTrack, newChordTrack, newNoteTrack, newProject, NOTE_VEL, placeChord, type Project, projectKey, } from "./model.ts"; import { fromStored, projectFromFile, projectToFile, type SavedProject, type StoredChord, type StoredProject, toStored, } from "./persist.ts"; function sketch(): Project { const p = newProject(); p.grid = 1 / 3; p.loopRegion = { start: 2 / 3, end: 8 }; const chords = newChordTrack(); for (const [start, dur, symbol] of [ [0, 4, "C"], [4, 4 / 3, "bVI"], [16 / 3, 8 / 3, "iv"], [8, 4, "V7"], ] as const) { placeChord(chords, { id: symbol, start, dur, symbol }); } chords.chords[2].notes = [41, 56, 60, 65]; // iv, hand-voiced chords.bass = false; const melody = newNoteTrack(); melody.gain = -4.5; melody.notes.push( { id: "n", start: 0, dur: 1, pitch: 72, vel: NOTE_VEL }, { id: "m", start: 1 / 3, dur: 1 / 6, pitch: 74, vel: 101 / 127 }, ); p.tracks = [chords, melody]; return p; } const chordsOf = (p: Project) => (p.tracks[0] as ChordTrack).chords; const storedChords = (s: StoredProject) => s.tracks[0].kind === "chords" ? s.tracks[0].chords : []; const sounding = (p: Project) => voiceTrack(p.tracks[0] as ChordTrack, projectKey(p)).map(v => v.notes); // ids only tell things apart while editing, so they're made anew on loading const withoutIds = (p: Project) => JSON.parse(JSON.stringify(p, (k, v) => (k === "id" ? undefined : v))); Deno.test("saved projects keep every chord's voicing, and which are pinned", () => { const p = sketch(); const stored = toStored(p); assertEquals( storedChords(stored).map(c => c[4]), [false, false, true, false], ); assertEquals( storedChords(stored).map(c => c[3]), sounding(p), ); }); Deno.test("a project comes back as it was saved, triplets and all", () => { const p = sketch(); const loaded = fromStored(JSON.parse(JSON.stringify(toStored(p)))); assertEquals(withoutIds(loaded), withoutIds(p)); assertEquals(sounding(loaded), sounding(p)); }); Deno.test("a chord that would now be voiced differently comes back pinned as it was", () => { const stored = toStored(sketch()); // as if the voicing of ♭VI had changed since it was saved (storedChords(stored)[1] as StoredChord)[3] = [44, 72, 75, 80]; const p = fromStored(stored); assertEquals( chordsOf(p).map(c => c.notes !== undefined), [false, true, true, false], ); const { id: _, ...flat } = chordsOf(p)[1]; assertEquals(flat, { start: 4, dur: 4 / 3, symbol: "bVI", notes: [44, 72, 75, 80] }); // and it's what plays, now and after (voicings are cached) assertEquals(sounding(p)[1], [44, 72, 75, 80]); }); /** a project as it was saved before events were tuples */ function savedAsVersion2(): SavedProject { const c = voiceTrack( { ...newChordTrack(), chords: [{ id: "c", start: 0, dur: 4, symbol: "C" }] }, projectKey(newProject()), )[0].notes; return { version: 2, id: "old", name: "from before", modified: 1, bpm: 96, beatsPerBar: 4, key: { tonic: "C", mode: "ionian" }, chordLength: 4, grid: 0.5, loop: true, // and before tracks had a volume trim, or a bass to turn off tracks: [ { id: "t", name: "chords", kind: "chords", muted: false, view: { low: 38, high: 77 }, chords: [ { id: "c", start: 0, dur: 4, symbol: "C", notes: c, pinned: false }, { id: "p", start: 4, dur: 4, notes: [41, 56, 60, 65], pinned: true }, ], }, { id: "m", name: "melody", kind: "notes", muted: true, view: { low: 55, high: 88 }, notes: [{ id: "n", start: 1.5, dur: 0.5, pitch: 72, vel: 0.8 }], }, ], } as SavedProject; } Deno.test("projects saved with objects rather than tuples still load", () => { const p = fromStored(savedAsVersion2()); assertEquals(withoutIds(p), { name: "from before", modified: 1, bpm: 96, beatsPerBar: 4, key: { tonic: "C", mode: "ionian" }, chordLength: 4, grid: 0.5, loop: true, tracks: [ { name: "chords", kind: "chords", muted: false, gain: 0, bass: true, view: { low: 38, high: 77 }, chords: [ { start: 0, dur: 4, symbol: "C" }, { start: 4, dur: 4, notes: [41, 56, 60, 65] }, ], }, { name: "melody", kind: "notes", muted: true, gain: 0, view: { low: 55, high: 88 }, // the nearest midi velocity notes: [{ start: 1.5, dur: 0.5, pitch: 72, vel: 102 / 127 }], }, ], }); assertEquals(p.id, "old"); }); Deno.test("projects from before voicings were kept aren't loaded", () => { const legacy = { ...toStored(sketch()), version: 1 } as unknown as StoredProject; assertThrows(() => fromStored(legacy), Error, "version 1"); }); Deno.test("a project file loads as a new project, the same as the one it came from", () => { const p = { ...sketch(), name: "for a friend", bpm: 132 }; const loaded = projectFromFile(projectToFile(p)); // so loading it never takes the place of a project already here, even the one it came from assertNotEquals(loaded.id, p.id); assertEquals(withoutIds({ ...loaded, modified: 0 }), withoutIds({ ...p, modified: 0 })); assertEquals(sounding(loaded), sounding(p)); const bytes = (s: string) => new TextEncoder().encode(s); assertThrows( () => projectFromFile(bytes("not a project")), Error, "isn't a chordpad project", ); assertThrows( () => projectFromFile(encodeCBOR({ hello: 1 })), Error, "isn't a chordpad project", ); assertThrows( () => projectFromFile(encodeCBOR({ ...toStored(p), version: 9 })), Error, "version 9", ); }); Deno.test("project files from when they were json still open", () => { const file = new TextEncoder().encode(JSON.stringify(savedAsVersion2())); const opened = projectFromFile(file); assertEquals( withoutIds({ ...opened, modified: 0 }), withoutIds({ ...fromStored(savedAsVersion2()), modified: 0 }), ); });