|
|
@@ -0,0 +1,563 @@
|
|
|
+package project
|
|
|
+
|
|
|
+import (
|
|
|
+ "bytes"
|
|
|
+ "compress/gzip"
|
|
|
+ "encoding/binary"
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+ "os"
|
|
|
+ "path/filepath"
|
|
|
+ "strconv"
|
|
|
+ "strings"
|
|
|
+ "testing"
|
|
|
+
|
|
|
+ "git.monoexp.com/bajdev/mpckit/internal/xpj"
|
|
|
+)
|
|
|
+
|
|
|
+const testdata = "../../testdata"
|
|
|
+
|
|
|
+func decompress(t *testing.T, path string) []byte {
|
|
|
+ t.Helper()
|
|
|
+ fh, err := os.Open(path)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ defer fh.Close()
|
|
|
+ zr, err := gzip.NewReader(fh)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ b, err := io.ReadAll(zr)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ return b
|
|
|
+}
|
|
|
+
|
|
|
+func encode(t *testing.T, p *Project) []byte {
|
|
|
+ t.Helper()
|
|
|
+ b, err := p.File().Encode()
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ return b
|
|
|
+}
|
|
|
+
|
|
|
+func must(t *testing.T, err error) {
|
|
|
+ t.Helper()
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func newProject(t *testing.T) *Project {
|
|
|
+ t.Helper()
|
|
|
+ p, err := New("Test")
|
|
|
+ must(t, err)
|
|
|
+ return p
|
|
|
+}
|
|
|
+
|
|
|
+// leaves flattens a tree into path -> literal text, for comparing files.
|
|
|
+func leaves(n *xpj.Node, path string, out map[string]string) map[string]string {
|
|
|
+ if out == nil {
|
|
|
+ out = map[string]string{}
|
|
|
+ }
|
|
|
+ switch n.Kind {
|
|
|
+ case xpj.Object:
|
|
|
+ for _, m := range n.Members {
|
|
|
+ leaves(m.Value, path+"."+m.Key, out)
|
|
|
+ }
|
|
|
+ case xpj.Array:
|
|
|
+ for i, it := range n.Items {
|
|
|
+ leaves(it, fmt.Sprintf("%s[%d]", path, i), out)
|
|
|
+ }
|
|
|
+ if len(n.Items) == 0 {
|
|
|
+ out[path] = "[]"
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ b, _ := n.MarshalJSON()
|
|
|
+ out[path] = string(b)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+func diffPaths(a, b *xpj.Node) []string {
|
|
|
+ la, lb := leaves(a, "", nil), leaves(b, "", nil)
|
|
|
+ var diff []string
|
|
|
+ for k, v := range la {
|
|
|
+ if lb[k] != v {
|
|
|
+ diff = append(diff, k)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for k := range lb {
|
|
|
+ if _, ok := la[k]; !ok {
|
|
|
+ diff = append(diff, k)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return diff
|
|
|
+}
|
|
|
+
|
|
|
+// The prototype's edits for MpckitT1 and T3 (build plan appendix), made
|
|
|
+// through the model. The output must equal the files the device accepted.
|
|
|
+func buildAccepted(t *testing.T, name string, shared bool) *Project {
|
|
|
+ t.Helper()
|
|
|
+ p, err := New(name)
|
|
|
+ must(t, err)
|
|
|
+ dir := filepath.Join(testdata, "accepted", name+DataSuffix)
|
|
|
+ must(t, p.AssignSample(0, filepath.Join(dir, "Linn 9000 kick.wav")))
|
|
|
+ second := "Linn 9000 kick001.wav"
|
|
|
+ if shared {
|
|
|
+ second = "Linn 9000 kick.wav"
|
|
|
+ }
|
|
|
+ must(t, p.AssignSample(16, filepath.Join(dir, second)))
|
|
|
+ must(t, p.SetTune(16, 5, 0))
|
|
|
+ must(t, p.SetSequence(0, "Verse", 96, 8))
|
|
|
+ must(t, p.AddSequence(1))
|
|
|
+ must(t, p.SetSequence(1, "Chorus", 96, 4))
|
|
|
+ return p
|
|
|
+}
|
|
|
+
|
|
|
+func TestGoldenMpckitT1(t *testing.T) {
|
|
|
+ p := buildAccepted(t, "MpckitT1", false)
|
|
|
+ want := decompress(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj"))
|
|
|
+ if got := encode(t, p); !bytes.Equal(got, want) {
|
|
|
+ f, _ := xpj.Decode(want)
|
|
|
+ t.Fatalf("differs from MpckitT1.xpj at %v", diffPaths(p.File().Root, f.Root))
|
|
|
+ }
|
|
|
+ if p.SampleCount() != 2 {
|
|
|
+ t.Errorf("%d samples, want 2", p.SampleCount())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestGoldenMpckitT3(t *testing.T) {
|
|
|
+ p := buildAccepted(t, "MpckitT3", true)
|
|
|
+ want := decompress(t, filepath.Join(testdata, "accepted", "MpckitT3.xpj"))
|
|
|
+ if got := encode(t, p); !bytes.Equal(got, want) {
|
|
|
+ f, _ := xpj.Decode(want)
|
|
|
+ t.Fatalf("differs from MpckitT3.xpj at %v", diffPaths(p.File().Root, f.Root))
|
|
|
+ }
|
|
|
+ if p.SampleCount() != 1 {
|
|
|
+ t.Errorf("%d samples, want 1 shared", p.SampleCount())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Rebuild Demo02 from the blank template and compare with the device's file.
|
|
|
+func TestRebuildDemo02(t *testing.T) {
|
|
|
+ dir := filepath.Join(testdata, "device", "Demo02"+DataSuffix)
|
|
|
+ p := newProject(t)
|
|
|
+ must(t, p.AssignSample(0, filepath.Join(dir, "Linn 9000 kick.wav")))
|
|
|
+ must(t, p.AssignSample(4, filepath.Join(dir, "Linn 9000 kick001.wav")))
|
|
|
+ must(t, p.SetTune(4, 12, 40))
|
|
|
+ // Start/end trim is a 2.0 feature; its fields are verified, so set them
|
|
|
+ // directly to complete the rebuild.
|
|
|
+ l, err := layer(p.pad(4))
|
|
|
+ must(t, err)
|
|
|
+ must(t, l.Set("sliceInfo.Start", xpj.NewInt(328)))
|
|
|
+ must(t, l.Set("sliceInfo.LoopStart", xpj.NewInt(328)))
|
|
|
+ must(t, l.Set("sliceInfo.End", xpj.NewInt(3777)))
|
|
|
+ must(t, p.SetSequence(0, "Sequence 01", 105, 2))
|
|
|
+ must(t, p.AddSequence(1))
|
|
|
+ must(t, p.SetSequence(1, "Sequence 02", 105, 4))
|
|
|
+
|
|
|
+ want, err := xpj.ReadFile(filepath.Join(testdata, "device", "Demo02.xpj"))
|
|
|
+ must(t, err)
|
|
|
+ got, wantLeaves := leaves(p.File().Root, "", nil), leaves(want.Root, "", nil)
|
|
|
+ var unexpected []string
|
|
|
+ for _, path := range diffPaths(p.File().Root, want.Root) {
|
|
|
+ switch {
|
|
|
+ case sameFloat(got[path], wantLeaves[path]):
|
|
|
+ // The device prints some floats with 17 digits where we print
|
|
|
+ // the shortest form: pitch 12.399999618530274 vs ...273 is the
|
|
|
+ // same float64.
|
|
|
+ case path == ".data.tracks[0].colour",
|
|
|
+ path == ".data.assignableEnvelopeFollowerAssignments.currentControlIndex",
|
|
|
+ strings.Contains(path, ".eventList.events"):
|
|
|
+ // Known noise and recorded notes (build plan M2).
|
|
|
+ case path == ".data.tracks[0].program.drum.instruments[4].layersv[0].sliceInfo.LoopCrossfadeLength":
|
|
|
+ // The device wrote a different value on the pad it trimmed; we
|
|
|
+ // write -1 as for every assigned sample (format doc section 3).
|
|
|
+ default:
|
|
|
+ unexpected = append(unexpected, path)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if len(unexpected) > 0 {
|
|
|
+ t.Errorf("unexpected differences from Demo02: %v", unexpected)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func sameFloat(a, b string) bool {
|
|
|
+ fa, errA := strconv.ParseFloat(a, 64)
|
|
|
+ fb, errB := strconv.ParseFloat(b, 64)
|
|
|
+ return errA == nil && errB == nil && fa == fb
|
|
|
+}
|
|
|
+
|
|
|
+// writeWAV writes a short 16-bit mono WAV of frames frames.
|
|
|
+func writeWAV(t *testing.T, path string, frames int) string {
|
|
|
+ t.Helper()
|
|
|
+ data := make([]byte, frames*2)
|
|
|
+ var b bytes.Buffer
|
|
|
+ le := binary.LittleEndian
|
|
|
+ b.WriteString("RIFF")
|
|
|
+ binary.Write(&b, le, uint32(4+8+16+8+len(data)))
|
|
|
+ b.WriteString("WAVEfmt ")
|
|
|
+ for _, v := range []any{uint32(16), uint16(1), uint16(1), uint32(44100), uint32(88200), uint16(2), uint16(16)} {
|
|
|
+ binary.Write(&b, le, v)
|
|
|
+ }
|
|
|
+ b.WriteString("data")
|
|
|
+ binary.Write(&b, le, uint32(len(data)))
|
|
|
+ b.Write(data)
|
|
|
+ must(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
|
|
+ must(t, os.WriteFile(path, b.Bytes(), 0o644))
|
|
|
+ return path
|
|
|
+}
|
|
|
+
|
|
|
+func padOf(t *testing.T, p *Project, slot int) Pad {
|
|
|
+ t.Helper()
|
|
|
+ v, err := p.Pad(slot)
|
|
|
+ must(t, err)
|
|
|
+ return v
|
|
|
+}
|
|
|
+
|
|
|
+func TestAssignAndPoolReuse(t *testing.T) {
|
|
|
+ dir := t.TempDir()
|
|
|
+ kick := writeWAV(t, filepath.Join(dir, "kick.wav"), 100)
|
|
|
+ p := newProject(t)
|
|
|
+ must(t, p.AssignSample(0, kick))
|
|
|
+ must(t, p.AssignSample(1, kick))
|
|
|
+ if p.SampleCount() != 1 {
|
|
|
+ t.Fatalf("same source on two pads: %d pool entries, want 1", p.SampleCount())
|
|
|
+ }
|
|
|
+ a := padOf(t, p, 1)
|
|
|
+ if a.File != "kick.wav" || a.SampleName != "kick" || a.End != 99 {
|
|
|
+ t.Errorf("pad A2 = %+v", a)
|
|
|
+ }
|
|
|
+ if !p.Dirty {
|
|
|
+ t.Error("not dirty after assign")
|
|
|
+ }
|
|
|
+ // Reassigning the same source to its only pad keeps the entry.
|
|
|
+ must(t, p.ClearPad(1))
|
|
|
+ must(t, p.AssignSample(0, kick))
|
|
|
+ if p.SampleCount() != 1 || padOf(t, p, 0).File != "kick.wav" {
|
|
|
+ t.Errorf("reassign same source: %d entries, pad %+v", p.SampleCount(), padOf(t, p, 0))
|
|
|
+ }
|
|
|
+ if got := p.Sources["kick.wav"]; got != kick {
|
|
|
+ t.Errorf("source = %q, want %q", got, kick)
|
|
|
+ }
|
|
|
+ // Replacing drops the old entry when no pad uses it.
|
|
|
+ snare := writeWAV(t, filepath.Join(dir, "snare.wav"), 50)
|
|
|
+ must(t, p.AssignSample(0, snare))
|
|
|
+ if p.SampleCount() != 1 || p.inPool("kick.wav") || p.Sources["kick.wav"] != "" {
|
|
|
+ t.Errorf("replace did not drop kick: %d entries", p.SampleCount())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestNameCollision(t *testing.T) {
|
|
|
+ dir := t.TempDir()
|
|
|
+ p := newProject(t)
|
|
|
+ must(t, p.AssignSample(0, writeWAV(t, filepath.Join(dir, "a", "kick.wav"), 10)))
|
|
|
+ must(t, p.AssignSample(1, writeWAV(t, filepath.Join(dir, "b", "kick.wav"), 20)))
|
|
|
+ must(t, p.AssignSample(2, writeWAV(t, filepath.Join(dir, "c", "Kick.WAV"), 30)))
|
|
|
+ want := []string{"kick.wav", "kick001.wav", "Kick002.WAV"}
|
|
|
+ for i, w := range want {
|
|
|
+ if got := padOf(t, p, i); got.File != w || got.SampleName != strings.TrimSuffix(w, filepath.Ext(w)) {
|
|
|
+ t.Errorf("pad %d = %q/%q, want %q", i, got.SampleName, got.File, w)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Both pools hold the same entries; the track pool in reverse order.
|
|
|
+ var pool, track []string
|
|
|
+ for _, e := range p.pool().Items {
|
|
|
+ pool = append(pool, e.Lookup("path").Text)
|
|
|
+ }
|
|
|
+ for _, e := range p.trackPool().Items {
|
|
|
+ track = append(track, e.Lookup("path").Text)
|
|
|
+ }
|
|
|
+ if strings.Join(pool, ",") != "kick.wav,kick001.wav,Kick002.WAV" || strings.Join(track, ",") != "Kick002.WAV,kick001.wav,kick.wav" {
|
|
|
+ t.Errorf("pool %v, track pool %v", pool, track)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestSampleLimit(t *testing.T) {
|
|
|
+ dir := t.TempDir()
|
|
|
+ p := newProject(t)
|
|
|
+ // With one sample per pad the pool can only fill up when it also holds
|
|
|
+ // entries no pad uses, as a loaded project may. Simulate one.
|
|
|
+ must(t, p.addPoolEntry("unused.wav"))
|
|
|
+ for i := 0; i < MaxSamples-1; i++ {
|
|
|
+ must(t, p.AssignSample(i, writeWAV(t, filepath.Join(dir, fmt.Sprintf("s%03d.wav", i)), 4)))
|
|
|
+ }
|
|
|
+ if p.SampleCount() != MaxSamples {
|
|
|
+ t.Fatalf("%d samples, want %d", p.SampleCount(), MaxSamples)
|
|
|
+ }
|
|
|
+ err := p.AssignSample(127, writeWAV(t, filepath.Join(dir, "x.wav"), 4))
|
|
|
+ if err == nil || !strings.Contains(err.Error(), "128") {
|
|
|
+ t.Fatalf("129th sample: %v", err)
|
|
|
+ }
|
|
|
+ if !padOf(t, p, 127).Empty() || p.SampleCount() != MaxSamples {
|
|
|
+ t.Error("refused assign changed the project")
|
|
|
+ }
|
|
|
+ // Reusing a file already in the pool needs no new entry.
|
|
|
+ must(t, p.AssignSample(127, filepath.Join(dir, "s000.wav")))
|
|
|
+ // Replacing a pad's only sample frees its entry first.
|
|
|
+ must(t, p.AssignSample(1, filepath.Join(dir, "x.wav")))
|
|
|
+ if p.SampleCount() != MaxSamples || padOf(t, p, 1).File != "x.wav" {
|
|
|
+ t.Errorf("replace at the limit: %d samples, pad %+v", p.SampleCount(), padOf(t, p, 1))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestAssignRejectsBadFiles(t *testing.T) {
|
|
|
+ dir := t.TempDir()
|
|
|
+ p := newProject(t)
|
|
|
+ bad := filepath.Join(dir, "bad.wav")
|
|
|
+ must(t, os.WriteFile(bad, []byte("not a wav"), 0o644))
|
|
|
+ if err := p.AssignSample(0, bad); err == nil {
|
|
|
+ t.Error("non-WAV accepted")
|
|
|
+ }
|
|
|
+ if err := p.AssignSample(0, filepath.Join(dir, "missing.wav")); err == nil {
|
|
|
+ t.Error("missing file accepted")
|
|
|
+ }
|
|
|
+ if err := p.AssignSample(128, writeWAV(t, filepath.Join(dir, "ok.wav"), 4)); err == nil {
|
|
|
+ t.Error("slot 128 accepted")
|
|
|
+ }
|
|
|
+ if p.Dirty || p.SampleCount() != 0 {
|
|
|
+ t.Error("failed assigns changed the project")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestClearCopyMoveSwap(t *testing.T) {
|
|
|
+ dir := t.TempDir()
|
|
|
+ p := newProject(t)
|
|
|
+ kick := writeWAV(t, filepath.Join(dir, "kick.wav"), 10)
|
|
|
+ snare := writeWAV(t, filepath.Join(dir, "snare.wav"), 20)
|
|
|
+ must(t, p.AssignSample(0, kick))
|
|
|
+ must(t, p.AssignSample(1, snare))
|
|
|
+ must(t, p.SetTune(0, 3, -20))
|
|
|
+ must(t, p.SetMuteGroup(0, 2))
|
|
|
+
|
|
|
+ // Copy across banks keeps tuning and mute group and shares the entry.
|
|
|
+ must(t, p.CopyPad(0, 40))
|
|
|
+ c := padOf(t, p, 40)
|
|
|
+ if c.File != "kick.wav" || c.Coarse != 3 || c.Fine != -20 || c.MuteGroup != 2 || p.SampleCount() != 2 {
|
|
|
+ t.Errorf("copy: %+v, %d samples", c, p.SampleCount())
|
|
|
+ }
|
|
|
+ // The copy is independent of its source.
|
|
|
+ must(t, p.SetTune(40, 0, 0))
|
|
|
+ if padOf(t, p, 0).Coarse != 3 {
|
|
|
+ t.Error("copy shares data with its source")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Move onto a filled pad replaces it; the snare's entry goes.
|
|
|
+ must(t, p.MovePad(0, 1))
|
|
|
+ if !padOf(t, p, 0).Empty() || padOf(t, p, 1).File != "kick.wav" || p.inPool("snare.wav") {
|
|
|
+ t.Errorf("move: A1 %+v, A2 %+v", padOf(t, p, 0), padOf(t, p, 1))
|
|
|
+ }
|
|
|
+ blank, _ := template.pad.MarshalJSON()
|
|
|
+ if got, _ := p.pad(0).MarshalJSON(); !bytes.Equal(got, blank) {
|
|
|
+ t.Error("moved-from pad is not the blank pad")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Swap.
|
|
|
+ must(t, p.AssignSample(5, snare))
|
|
|
+ must(t, p.SwapPads(1, 5))
|
|
|
+ if padOf(t, p, 1).File != "snare.wav" || padOf(t, p, 5).File != "kick.wav" {
|
|
|
+ t.Error("swap")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Clearing the last user drops the entry; clearing a shared one keeps it.
|
|
|
+ must(t, p.ClearPad(5))
|
|
|
+ if !p.inPool("kick.wav") {
|
|
|
+ t.Error("cleared a shared entry (kick is still on pad 40)")
|
|
|
+ }
|
|
|
+ must(t, p.ClearPad(40))
|
|
|
+ if p.inPool("kick.wav") || p.SampleCount() != 1 {
|
|
|
+ t.Errorf("kick entry kept after its last pad was cleared (%d samples)", p.SampleCount())
|
|
|
+ }
|
|
|
+
|
|
|
+ // No-ops and range checks.
|
|
|
+ p.Dirty = false
|
|
|
+ must(t, p.ClearPad(100))
|
|
|
+ must(t, p.CopyPad(1, 1))
|
|
|
+ must(t, p.MovePad(1, 1))
|
|
|
+ must(t, p.SwapPads(1, 1))
|
|
|
+ if p.Dirty {
|
|
|
+ t.Error("no-op marked the project dirty")
|
|
|
+ }
|
|
|
+ for _, err := range []error{p.CopyPad(-1, 0), p.MovePad(0, 128), p.SwapPads(200, 0), p.ClearPad(128), p.SetTune(128, 0, 0), p.SetMuteGroup(-1, 0)} {
|
|
|
+ if err == nil {
|
|
|
+ t.Error("out-of-range slot accepted")
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestTuneAndMuteGroup(t *testing.T) {
|
|
|
+ p := newProject(t)
|
|
|
+ must(t, p.SetTune(0, 12, 40))
|
|
|
+ l, _ := layer(p.pad(0))
|
|
|
+ if got := l.Lookup("pitch").Text; got != "12.399999618530273" {
|
|
|
+ t.Errorf("pitch = %s", got)
|
|
|
+ }
|
|
|
+ must(t, p.SetTune(0, 99, -500))
|
|
|
+ if v := padOf(t, p, 0); v.Coarse != 24 || v.Fine != -90 {
|
|
|
+ t.Errorf("clamped tune = %d %d", v.Coarse, v.Fine)
|
|
|
+ }
|
|
|
+ if got := l.Lookup("pitch").Text; got != "23.100000381469727" {
|
|
|
+ t.Errorf("pitch = %s", got)
|
|
|
+ }
|
|
|
+ must(t, p.SetMuteGroup(3, 1))
|
|
|
+ if padOf(t, p, 3).MuteGroup != 1 {
|
|
|
+ t.Error("mute group")
|
|
|
+ }
|
|
|
+ if err := p.SetMuteGroup(3, MaxMuteGroup+1); err == nil {
|
|
|
+ t.Error("mute group out of range accepted")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestSequences(t *testing.T) {
|
|
|
+ p := newProject(t)
|
|
|
+ must(t, p.AddSequence(4))
|
|
|
+ s := p.Sequences()
|
|
|
+ if len(s) != 2 || s[1] != (Sequence{Slot: 4, Name: "Sequence 05", BPM: DefaultBPM, Bars: 2}) {
|
|
|
+ t.Fatalf("after add: %+v", s)
|
|
|
+ }
|
|
|
+ if err := p.AddSequence(4); err == nil {
|
|
|
+ t.Error("duplicate slot accepted")
|
|
|
+ }
|
|
|
+ if err := p.AddSequence(MaxSequences); err == nil {
|
|
|
+ t.Error("slot 128 accepted")
|
|
|
+ }
|
|
|
+
|
|
|
+ must(t, p.SetSequence(4, "Drop", 140.5, 16))
|
|
|
+ must(t, p.AddSequence(127))
|
|
|
+ s = p.Sequences()
|
|
|
+ if s[1].Name != "Drop" || s[1].BPM != 140.5 || s[1].Bars != 16 || s[2].Name != "Sequence 128" || s[2].BPM != 140.5 {
|
|
|
+ t.Errorf("after edit: %+v", s)
|
|
|
+ }
|
|
|
+ // Newest first in the file, as the device writes.
|
|
|
+ if num(p.sequences().Items[0].Lookup("key")) != 127 {
|
|
|
+ t.Error("new sequence not inserted first")
|
|
|
+ }
|
|
|
+
|
|
|
+ // All 26 length fields.
|
|
|
+ v := p.sequences().Items[p.findSequence(4)].Lookup("value")
|
|
|
+ n := 0
|
|
|
+ for path, val := range leaves(v, "", nil) {
|
|
|
+ if strings.HasSuffix(path, "lengthBars") || strings.HasSuffix(path, "loopEndBar") {
|
|
|
+ n++
|
|
|
+ if val != "16" {
|
|
|
+ t.Errorf("%s = %s", path, val)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if strings.HasSuffix(path, ".lengthPulses") || strings.HasSuffix(path, "loopEndPulses") || strings.HasSuffix(path, ".endPulses") {
|
|
|
+ n++
|
|
|
+ if val != "61440" {
|
|
|
+ t.Errorf("%s = %s", path, val)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if n != 26 {
|
|
|
+ t.Errorf("%d length fields, want 26", n)
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, bad := range []struct {
|
|
|
+ name string
|
|
|
+ bpm float64
|
|
|
+ bars int
|
|
|
+ }{{"", 120, 2}, {"a/b", 120, 2}, {"ok", 0, 2}, {"ok", -1, 2}, {"ok", 120, 0}, {"ok", 120, MaxBars + 1}} {
|
|
|
+ if err := p.SetSequence(4, bad.name, bad.bpm, bad.bars); err == nil {
|
|
|
+ t.Errorf("SetSequence(%q, %v, %d) accepted", bad.name, bad.bpm, bad.bars)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if err := p.SetSequence(9, "x", 120, 2); err == nil {
|
|
|
+ t.Error("missing sequence accepted")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Remove: current sequence moves to the lowest remaining slot.
|
|
|
+ must(t, p.data.SetKey("currentSequence", xpj.NewInt(4)))
|
|
|
+ must(t, p.RemoveSequence(4))
|
|
|
+ if cur := num(p.data.Lookup("currentSequence")); cur != 0 {
|
|
|
+ t.Errorf("currentSequence = %d, want 0", cur)
|
|
|
+ }
|
|
|
+ must(t, p.RemoveSequence(0))
|
|
|
+ if cur := num(p.data.Lookup("currentSequence")); cur != 127 {
|
|
|
+ t.Errorf("currentSequence = %d, want 127", cur)
|
|
|
+ }
|
|
|
+ if err := p.RemoveSequence(127); err == nil {
|
|
|
+ t.Error("removed the last sequence")
|
|
|
+ }
|
|
|
+ if err := p.RemoveSequence(3); err == nil {
|
|
|
+ t.Error("removed a missing sequence")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestLoadDemoFile(t *testing.T) {
|
|
|
+ p, warnings, err := Load(filepath.Join(testdata, "device", "DemoFile.xpj"))
|
|
|
+ must(t, err)
|
|
|
+ if len(warnings) != 0 {
|
|
|
+ t.Errorf("warnings: %v", warnings)
|
|
|
+ }
|
|
|
+ if p.Name != "DemoFile" || p.Dirty || p.SampleCount() != 6 {
|
|
|
+ t.Errorf("name %q dirty %v samples %d", p.Name, p.Dirty, p.SampleCount())
|
|
|
+ }
|
|
|
+ for file, src := range p.Sources {
|
|
|
+ if _, err := os.Stat(src); err != nil {
|
|
|
+ t.Errorf("source for %s: %v", file, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // The sequences have notes on A1-A4 (36-39); the other pads have none.
|
|
|
+ for slot := 0; slot < Pads; slot++ {
|
|
|
+ if got, want := p.PadHasNotes(slot), slot < 4; got != want {
|
|
|
+ t.Errorf("PadHasNotes(%s) = %v", SlotName(slot), got)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ s := p.Sequences()
|
|
|
+ if len(s) != 2 || s[0].Name != "Intro" || !s[0].HasNotes || s[1].Name != "Sequence 02" || !s[1].HasNotes {
|
|
|
+ t.Errorf("sequences %+v", s)
|
|
|
+ }
|
|
|
+ if v := padOf(t, p, 2); v.MuteGroup != 1 || v.File != "80s-Hat-Dreamer1 SA.wav" {
|
|
|
+ t.Errorf("A3 = %+v", v)
|
|
|
+ }
|
|
|
+ // Loaded and unchanged: writes back byte-identical.
|
|
|
+ want := decompress(t, filepath.Join(testdata, "device", "DemoFile.xpj"))
|
|
|
+ if !bytes.Equal(encode(t, p), want) {
|
|
|
+ t.Error("loaded project does not write back unchanged")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Assigning a file already in the project folder reuses its entry.
|
|
|
+ must(t, p.AssignSample(50, filepath.Join(testdata, "device", "DemoFile"+DataSuffix, "Bass-Pure C.wav")))
|
|
|
+ if p.SampleCount() != 6 {
|
|
|
+ t.Errorf("%d samples after reusing a loaded file", p.SampleCount())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestBlankPadsAndTemplate(t *testing.T) {
|
|
|
+ p := newProject(t)
|
|
|
+ if !bytes.Equal(encode(t, p), decompress(t, filepath.Join(testdata, "device", "DemoBlank.xpj"))) {
|
|
|
+ t.Error("New() is not DemoBlank (is internal/project/template out of date?)")
|
|
|
+ }
|
|
|
+ for slot := 0; slot < Pads; slot++ {
|
|
|
+ if !padOf(t, p, slot).Empty() || p.PadHasNotes(slot) {
|
|
|
+ t.Fatalf("pad %d not empty", slot)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestNames(t *testing.T) {
|
|
|
+ p := newProject(t)
|
|
|
+ must(t, p.SetName("My Kit 2"))
|
|
|
+ if p.Name != "My Kit 2" || !p.Dirty {
|
|
|
+ t.Error("SetName")
|
|
|
+ }
|
|
|
+ for _, bad := range []string{"", " ", "a:b", "a?", "end.", "end ", "CON", "com1.txt", "tab\there"} {
|
|
|
+ if err := ValidName(bad); err == nil {
|
|
|
+ t.Errorf("ValidName(%q) accepted", bad)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if _, err := New("bad|name"); err == nil {
|
|
|
+ t.Error("New with a bad name accepted")
|
|
|
+ }
|
|
|
+ for slot, want := range map[int]string{0: "A1", 4: "A5", 16: "B1", 17: "B2", 127: "H16"} {
|
|
|
+ if got := SlotName(slot); got != want {
|
|
|
+ t.Errorf("SlotName(%d) = %s, want %s", slot, got, want)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|