|
@@ -0,0 +1,326 @@
|
|
|
|
|
+package project
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "strconv"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+
|
|
|
|
|
+ "git.monoexp.com/bajdev/mpckit/internal/xpj"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// loadChanged reads the device's DemoFile, applies change to its data, writes
|
|
|
|
|
+// it to a temporary folder and loads it.
|
|
|
|
|
+func loadChanged(t *testing.T, change func(d *xpj.Node)) error {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ f, err := xpj.ReadFile(filepath.Join(testdata, "device", "DemoFile.xpj"))
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+ d, err := f.Data()
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+ change(d)
|
|
|
|
|
+ path := filepath.Join(t.TempDir(), "Changed.xpj")
|
|
|
|
|
+ must(t, f.WriteFile(path))
|
|
|
|
|
+ _, _, err = Load(path)
|
|
|
|
|
+ return err
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func set(t *testing.T, d *xpj.Node, path string, v *xpj.Node) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ must(t, d.Set(path, v))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func remove(t *testing.T, d *xpj.Node, path, key string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ n, err := d.Get(path)
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+ if !n.DeleteKey(key) {
|
|
|
|
|
+ t.Fatalf("%s has no key %q", path, key)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestLoadRejectsMalformed(t *testing.T) {
|
|
|
|
|
+ for _, tc := range []struct {
|
|
|
|
|
+ name string
|
|
|
|
|
+ change func(t *testing.T, d *xpj.Node)
|
|
|
|
|
+ want string // in the error
|
|
|
|
|
+ }{
|
|
|
|
|
+ {"parent folder", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "samples[0].path", xpj.NewString("../x.wav"))
|
|
|
|
|
+ }, `samples[0].path: file name "../x.wav" contains a folder separator`},
|
|
|
|
|
+ {"subfolder", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "samples[1].path", xpj.NewString("a/b.wav"))
|
|
|
|
|
+ }, `samples[1].path: file name "a/b.wav" contains a folder separator`},
|
|
|
|
|
+ {"backslash", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "samples[1].path", xpj.NewString(`..\b.wav`))
|
|
|
|
|
+ }, `samples[1].path: file name "..\\b.wav" contains a folder separator`},
|
|
|
|
|
+ {"dot dot", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "samples[2].path", xpj.NewString(".."))
|
|
|
|
|
+ }, `samples[2].path: ".." is not a file name`},
|
|
|
|
|
+ {"empty", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "samples[0].path", xpj.NewString(""))
|
|
|
|
|
+ }, `samples[0].path: file name is empty`},
|
|
|
|
|
+ {"control character", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "samples[0].path", xpj.NewString("a\x00.wav"))
|
|
|
|
|
+ }, `samples[0].path: file name "a\x00.wav" contains a control character`},
|
|
|
|
|
+ {"track pool", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "tracks[0].samples[0].path", xpj.NewString("../x.wav"))
|
|
|
|
|
+ }, `tracks[0].samples[0].path: file name "../x.wav"`},
|
|
|
|
|
+ {"missing path", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ remove(t, d, "samples[3]", "path")
|
|
|
|
|
+ }, "samples[3].path is missing or not text"},
|
|
|
|
|
+ {"path not text", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "samples[3].path", xpj.NewInt(3))
|
|
|
|
|
+ }, "samples[3].path is missing or not text"},
|
|
|
|
|
+ {"layer outside", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "tracks[0].program.drum.instruments[17].layersv[2].sampleFile", xpj.NewString("../x.wav"))
|
|
|
|
|
+ }, `pad B2 layer 3 sampleFile: file name "../x.wav" contains a folder separator`},
|
|
|
|
|
+ {"layer not text", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "tracks[0].program.drum.instruments[0].layersv[0].sampleFile", xpj.NewNull())
|
|
|
|
|
+ }, "pad A1 layer 1 sampleFile is not text"},
|
|
|
|
|
+ {"missing bpm", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ remove(t, d, "sequences[0].value", "bpm")
|
|
|
|
|
+ }, "sequences[0]: bpm is missing or not a number"},
|
|
|
|
|
+ {"missing key", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ remove(t, d, "sequences[1]", "key")
|
|
|
|
|
+ }, "sequences[1]: key is missing or not a whole number"},
|
|
|
|
|
+ {"missing value", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "sequences[1].value", xpj.NewArray())
|
|
|
|
|
+ }, "sequences[1]: value is missing or not an object"},
|
|
|
|
|
+ {"missing length", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ remove(t, d, "sequences[0].value", "lengthBars")
|
|
|
|
|
+ }, "sequences[0]: lengthBars is missing or not a whole number"},
|
|
|
|
|
+ {"name not text", func(t *testing.T, d *xpj.Node) {
|
|
|
|
|
+ set(t, d, "sequences[0].value.name", xpj.NewInt(1))
|
|
|
|
|
+ }, "sequences[0]: name is missing or not text"},
|
|
|
|
|
+ } {
|
|
|
|
|
+ t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
+ err := loadChanged(t, func(d *xpj.Node) { tc.change(t, d) })
|
|
|
|
|
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
|
|
|
+ t.Fatalf("got error %v, want one containing %q", err, tc.want)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestLoadAcceptsMissingLayerFile(t *testing.T) {
|
|
|
|
|
+ err := loadChanged(t, func(d *xpj.Node) {
|
|
|
|
|
+ remove(t, d, "tracks[0].program.drum.instruments[0].layersv[0]", "sampleFile")
|
|
|
|
|
+ })
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestLoadMissingVersion(t *testing.T) {
|
|
|
|
|
+ f, err := xpj.ReadFile(filepath.Join(testdata, "device", "DemoFile.xpj"))
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+ d, err := f.Data()
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+ d.DeleteKey("version")
|
|
|
|
|
+ path := filepath.Join(t.TempDir(), "NoVersion.xpj")
|
|
|
|
|
+ must(t, f.WriteFile(path))
|
|
|
|
|
+ _, warnings, err := Load(path)
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+ if len(warnings) != 1 || !strings.Contains(warnings[0], "version is missing") {
|
|
|
|
|
+ t.Fatalf("warnings = %q", warnings)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestPlainFileName(t *testing.T) {
|
|
|
|
|
+ for _, ok := range []string{"kick.wav", "80s-Kik-Dreamer2 SA.wav", "a..b.wav", ".hidden.wav", "Ünïcode.wav"} {
|
|
|
|
|
+ if err := PlainFileName(ok); err != nil {
|
|
|
|
|
+ t.Errorf("PlainFileName(%q) = %v", ok, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, bad := range []string{"", ".", "..", "../x.wav", "a/b.wav", `a\b.wav`, "/x.wav", "x\n.wav", "x\x7f.wav"} {
|
|
|
|
|
+ if err := PlainFileName(bad); err == nil {
|
|
|
|
|
+ t.Errorf("PlainFileName(%q) = nil", bad)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// FuzzLoad loads arbitrary files; none may make the read-only calls panic.
|
|
|
|
|
+func FuzzLoad(f *testing.F) {
|
|
|
|
|
+ seeds, err := filepath.Glob(filepath.Join(testdata, "device", "*.xpj"))
|
|
|
|
|
+ if err != nil || len(seeds) == 0 {
|
|
|
|
|
+ f.Fatalf("no seed files: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, s := range seeds {
|
|
|
|
|
+ b, err := os.ReadFile(s)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ f.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ f.Add(b)
|
|
|
|
|
+ }
|
|
|
|
|
+ f.Fuzz(func(t *testing.T, b []byte) {
|
|
|
|
|
+ path := filepath.Join(t.TempDir(), "Fuzz.xpj")
|
|
|
|
|
+ if err := os.WriteFile(path, b, 0o644); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ p, _, err := Load(path)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ for slot := range Pads {
|
|
|
|
|
+ _, _ = p.Pad(slot)
|
|
|
|
|
+ }
|
|
|
|
|
+ p.Sequences()
|
|
|
|
|
+ p.PoolFiles()
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestBrokenFieldsDoNotPanic changes, one at a time, each field that project
|
|
|
|
|
+// reads in DemoFile: replaced by null, text, a negative or large number, an
|
|
|
|
|
+// empty list or object, or deleted. Whatever wrap makes of the result, reads
|
|
|
|
|
+// and edits must not panic. Only the first item of each list is changed, so
|
|
|
|
|
+// the test stays fast; a full sweep (all fields to depth 5, 12,726 cases)
|
|
|
|
|
+// found nothing either and takes about 10 minutes.
|
|
|
|
|
+func TestBrokenFieldsDoNotPanic(t *testing.T) {
|
|
|
|
|
+ if testing.Short() {
|
|
|
|
|
+ t.Skip("slow")
|
|
|
|
|
+ }
|
|
|
|
|
+ base, err := xpj.ReadFile(filepath.Join(testdata, "device", "DemoFile.xpj"))
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+ d, err := base.Data()
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+
|
|
|
|
|
+ events := "sequences[0].value.trackClipMaps[0][0].value.eventList.events"
|
|
|
|
|
+ roots := []struct {
|
|
|
|
|
+ path string
|
|
|
|
|
+ depth int
|
|
|
|
|
+ }{
|
|
|
|
|
+ {"version", 0}, {"currentSequence", 0}, {"tracks[0].name", 0},
|
|
|
|
|
+ {"samples", 3}, {"tracks[0].samples", 2},
|
|
|
|
|
+ {"tracks[0].program.drum.instruments", 1},
|
|
|
|
|
+ {"tracks[0].program.drum.instruments[0]", 1},
|
|
|
|
|
+ {"tracks[0].program.drum.instruments[0].layersv", 2},
|
|
|
|
|
+ {"tracks[0].program.drum.instruments[0].layersv[0].sliceInfo", 1},
|
|
|
|
|
+ {"sequences", 3},
|
|
|
|
|
+ {"sequences[0].value.trackClipMaps", 4},
|
|
|
|
|
+ {events, 3},
|
|
|
|
|
+ }
|
|
|
|
|
+ // The first event is automation; add the first note event too.
|
|
|
|
|
+ evs, err := d.Get(events)
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+ for i, ev := range evs.Items {
|
|
|
|
|
+ if num(ev.Lookup("type")) == eventNote {
|
|
|
|
|
+ roots = append(roots, struct {
|
|
|
|
|
+ path string
|
|
|
|
|
+ depth int
|
|
|
|
|
+ }{fmt.Sprintf("%s[%d]", events, i), 1}, struct {
|
|
|
|
|
+ path string
|
|
|
|
|
+ depth int
|
|
|
|
|
+ }{fmt.Sprintf("%s[%d].note.note", events, i), 0})
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ var paths []string
|
|
|
|
|
+ for _, r := range roots {
|
|
|
|
|
+ n, err := d.Get(r.path)
|
|
|
|
|
+ must(t, err)
|
|
|
|
|
+ fieldPaths(n, r.path, r.depth, &paths)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ changes := []func(parent *xpj.Node, key string, i int){
|
|
|
|
|
+ func(p *xpj.Node, k string, i int) { replace(p, k, i, xpj.NewNull()) },
|
|
|
|
|
+ func(p *xpj.Node, k string, i int) { replace(p, k, i, xpj.NewString("../x.wav")) },
|
|
|
|
|
+ func(p *xpj.Node, k string, i int) { replace(p, k, i, xpj.NewInt(-1)) },
|
|
|
|
|
+ func(p *xpj.Node, k string, i int) { replace(p, k, i, xpj.NewInt(1000)) },
|
|
|
|
|
+ func(p *xpj.Node, k string, i int) { replace(p, k, i, xpj.NewArray()) },
|
|
|
|
|
+ func(p *xpj.Node, k string, i int) { replace(p, k, i, xpj.NewObject()) },
|
|
|
|
|
+ func(p *xpj.Node, k string, i int) {
|
|
|
|
|
+ if k != "" {
|
|
|
|
|
+ p.DeleteKey(k)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ _ = p.Remove(i)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ names := []string{"null", "text", "-1", "1000", "list", "object", "deleted"}
|
|
|
|
|
+ for _, path := range paths {
|
|
|
|
|
+ parentPath, key, i := splitPath(path)
|
|
|
|
|
+ for c, change := range changes {
|
|
|
|
|
+ f := base.Clone()
|
|
|
|
|
+ fd, _ := f.Data()
|
|
|
|
|
+ parent, err := fd.Get(parentPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ continue // a key holding '.' or '[' cannot be addressed by path
|
|
|
|
|
+ }
|
|
|
|
|
+ change(parent, key, i)
|
|
|
|
|
+ func() {
|
|
|
|
|
+ defer func() {
|
|
|
|
|
+ if r := recover(); r != nil {
|
|
|
|
|
+ t.Errorf("%s %s: panic: %v", path, names[c], r)
|
|
|
|
|
+ }
|
|
|
|
|
+ }()
|
|
|
|
|
+ p, err := wrap(f, "Broken", "")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ exercise(p)
|
|
|
|
|
+ }()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ t.Logf("%d fields, %d cases", len(paths), len(paths)*len(changes))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// fieldPaths lists path and the paths below n to depth levels, taking only
|
|
|
|
|
+// the first item of each list.
|
|
|
|
|
+func fieldPaths(n *xpj.Node, path string, depth int, out *[]string) {
|
|
|
|
|
+ *out = append(*out, path)
|
|
|
|
|
+ if depth == 0 {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(n.Items) > 0 {
|
|
|
|
|
+ fieldPaths(n.Items[0], path+"[0]", depth-1, out)
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, m := range n.Members {
|
|
|
|
|
+ fieldPaths(m.Value, path+"."+m.Key, depth-1, out)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// splitPath splits a path into its parent and last step: a key, or an index
|
|
|
|
|
+// when key is "".
|
|
|
|
|
+func splitPath(path string) (parent, key string, i int) {
|
|
|
|
|
+ if strings.HasSuffix(path, "]") {
|
|
|
|
|
+ open := strings.LastIndexByte(path, '[')
|
|
|
|
|
+ i, _ = strconv.Atoi(path[open+1 : len(path)-1])
|
|
|
|
|
+ return path[:open], "", i
|
|
|
|
|
+ }
|
|
|
|
|
+ dot := strings.LastIndexByte(path, '.')
|
|
|
|
|
+ if dot < 0 {
|
|
|
|
|
+ return "", path, 0
|
|
|
|
|
+ }
|
|
|
|
|
+ return path[:dot], path[dot+1:], 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func replace(parent *xpj.Node, key string, i int, v *xpj.Node) {
|
|
|
|
|
+ if key != "" {
|
|
|
|
|
+ _ = parent.SetKey(key, v)
|
|
|
|
|
+ } else if i < len(parent.Items) {
|
|
|
|
|
+ parent.Items[i] = v
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// exercise runs every read and most edits on p.
|
|
|
|
|
+func exercise(p *Project) {
|
|
|
|
|
+ for slot := range Pads {
|
|
|
|
|
+ _, _ = p.Pad(slot)
|
|
|
|
|
+ p.PadHasNotes(slot)
|
|
|
|
|
+ }
|
|
|
|
|
+ p.PoolFiles()
|
|
|
|
|
+ for slot := range 2 {
|
|
|
|
|
+ _ = p.SetTune(slot, 1, 1)
|
|
|
|
|
+ _ = p.SetMuteGroup(slot, 1)
|
|
|
|
|
+ _ = p.CopyPad(slot, slot+20)
|
|
|
|
|
+ _ = p.MovePad(slot+20, slot+40)
|
|
|
|
|
+ _ = p.SwapPads(slot, slot+1)
|
|
|
|
|
+ _ = p.ClearPad(slot + 1)
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, s := range p.Sequences() {
|
|
|
|
|
+ _ = p.SetSequence(s.Slot, "Changed", 100, 4)
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = p.AddSequence(50)
|
|
|
|
|
+ _ = p.RemoveSequence(50)
|
|
|
|
|
+ _ = p.RemoveSequence(0)
|
|
|
|
|
+}
|