|
@@ -0,0 +1,367 @@
|
|
|
|
|
+package xpj
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "bytes"
|
|
|
|
|
+ "compress/gzip"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "math"
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+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)
|
|
|
|
|
+ }
|
|
|
|
|
+ raw, err := io.ReadAll(zr)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return raw
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// firstDiff describes where two byte slices first differ.
|
|
|
|
|
+func firstDiff(a, b []byte) string {
|
|
|
|
|
+ n := min(len(a), len(b))
|
|
|
|
|
+ for i := 0; i < n; i++ {
|
|
|
|
|
+ if a[i] != b[i] {
|
|
|
|
|
+ lo := max(0, i-40)
|
|
|
|
|
+ return "at byte " + itoa(i) + ":\n got " + quote(a[lo:min(len(a), i+40)]) + "\n want " + quote(b[lo:min(len(b), i+40)])
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return "lengths " + itoa(len(a)) + " and " + itoa(len(b))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func itoa(i int) string { return NewInt(int64(i)).Text }
|
|
|
|
|
+func quote(b []byte) string {
|
|
|
|
|
+ return strings.ReplaceAll(string(b), "\n", `\n`)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// The M1 milestone: every device-saved project re-writes byte-identical.
|
|
|
|
|
+func TestRoundTripDeviceProjects(t *testing.T) {
|
|
|
|
|
+ for _, name := range []string{"DemoBlank", "Demo02", "DemoFile"} {
|
|
|
|
|
+ t.Run(name, func(t *testing.T) {
|
|
|
|
|
+ roundTrip(t, filepath.Join(testdata, "device", name+".xpj"))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Files from the reference writer that the device accepted round-trip too.
|
|
|
|
|
+func TestRoundTripAcceptedProjects(t *testing.T) {
|
|
|
|
|
+ paths, _ := filepath.Glob(filepath.Join(testdata, "accepted", "*.xpj"))
|
|
|
|
|
+ more, _ := filepath.Glob(filepath.Join(testdata, "wav-formats", "*.xpj"))
|
|
|
|
|
+ paths = append(paths, more...)
|
|
|
|
|
+ if len(paths) < 5 {
|
|
|
|
|
+ t.Fatalf("found only %d accepted projects", len(paths))
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, p := range paths {
|
|
|
|
|
+ t.Run(filepath.Base(p), func(t *testing.T) { roundTrip(t, p) })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func roundTrip(t *testing.T, path string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ want := decompress(t, path)
|
|
|
|
|
+ f, err := ReadFile(path)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ got, err := f.Encode()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !bytes.Equal(got, want) {
|
|
|
|
|
+ t.Fatalf("re-encoded file differs %s", firstDiff(got, want))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Through gzip and back, as an export would.
|
|
|
|
|
+ var z bytes.Buffer
|
|
|
|
|
+ if err := f.Write(&z); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ f2, err := Read(&z)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ got2, _ := f2.Encode()
|
|
|
|
|
+ if !bytes.Equal(got2, want) {
|
|
|
|
|
+ t.Fatalf("gzip round trip differs %s", firstDiff(got2, want))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestDeviceProjectShape(t *testing.T) {
|
|
|
|
|
+ f, err := ReadFile(filepath.Join(testdata, "device", "DemoBlank.xpj"))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.Join(f.Header, "|") != strings.Join(TestedHeader, "|") {
|
|
|
|
|
+ t.Errorf("header = %q", f.Header)
|
|
|
|
|
+ }
|
|
|
|
|
+ d, err := f.Data()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(d.Members) != 66 {
|
|
|
|
|
+ t.Errorf("data has %d keys, want 66", len(d.Members))
|
|
|
|
|
+ }
|
|
|
|
|
+ if v, _ := d.Lookup("version").Int(); v != 28 {
|
|
|
|
|
+ t.Errorf("data.version = %d, want 28", v)
|
|
|
|
|
+ }
|
|
|
|
|
+ pads, err := d.Get("tracks[0].program.drum.instruments")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(pads.Items) != 128 {
|
|
|
|
|
+ t.Errorf("%d pads, want 128", len(pads.Items))
|
|
|
|
|
+ }
|
|
|
|
|
+ seq, err := d.Get("sequences[0].value")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if seq.Lookup("name").Text != "Sequence 01" {
|
|
|
|
|
+ t.Errorf("sequence name = %q", seq.Lookup("name").Text)
|
|
|
|
|
+ }
|
|
|
|
|
+ if bpm, _ := seq.Lookup("bpm").Float(); bpm != 128 {
|
|
|
|
|
+ t.Errorf("bpm = %v", bpm)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestGzipHeader(t *testing.T) {
|
|
|
|
|
+ f := &File{Header: TestedHeader, Root: NewObject()}
|
|
|
|
|
+ var b bytes.Buffer
|
|
|
|
|
+ if err := f.Write(&b); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ // ID, CM=deflate, FLG=0, MTIME=0, XFL=0, OS=3: the same ten bytes the device writes.
|
|
|
|
|
+ want := []byte{0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3}
|
|
|
|
|
+ if got := b.Bytes()[:10]; !bytes.Equal(got, want) {
|
|
|
|
|
+ t.Errorf("gzip header = % x, want % x", got, want)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestLayout(t *testing.T) {
|
|
|
|
|
+ in := `{"a": [1, 2.50, {}], "b": {"x": [], "y": "s<&>/"}, "c": null, "d": true, "e": -1e-05}`
|
|
|
|
|
+ n, err := ParseJSON([]byte(in))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ got, err := n.MarshalJSON()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ want := "{\n\"a\": [\n1,\n2.50,\n{}\n],\n\"b\": {\n\"x\": [],\n\"y\": \"s<&>/\"\n},\n\"c\": null,\n\"d\": true,\n\"e\": -1e-05\n}"
|
|
|
|
|
+ if string(got) != want {
|
|
|
|
|
+ t.Errorf("got\n%s\nwant\n%s", got, want)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestKeyOrderAndDuplicates(t *testing.T) {
|
|
|
|
|
+ n, err := ParseJSON([]byte(`{"z": 1, "a": 2, "z": 3}`))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ got, _ := n.MarshalJSON()
|
|
|
|
|
+ if want := "{\n\"z\": 1,\n\"a\": 2,\n\"z\": 3\n}"; string(got) != want {
|
|
|
|
|
+ t.Errorf("got %q", got)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestStringEscapes(t *testing.T) {
|
|
|
|
|
+ s := "q\" b\\ n\n t\t c\x01 é ✓"
|
|
|
|
|
+ n := NewString(s)
|
|
|
|
|
+ got, _ := n.MarshalJSON()
|
|
|
|
|
+ if want := `"q\" b\\ n\n t\t c\u0001 é ✓"`; string(got) != want {
|
|
|
|
|
+ t.Errorf("got %s want %s", got, want)
|
|
|
|
|
+ }
|
|
|
|
|
+ back, err := ParseJSON(got)
|
|
|
|
|
+ if err != nil || back.Text != s {
|
|
|
|
|
+ t.Errorf("read back %q, %v", back.Text, err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestParseErrors(t *testing.T) {
|
|
|
|
|
+ for _, in := range []string{``, `{`, `{"a": }`, `[1,]`, `{} {}`, `{"a": 1} x`, `nul`} {
|
|
|
|
|
+ if _, err := ParseJSON([]byte(in)); err == nil {
|
|
|
|
|
+ t.Errorf("ParseJSON(%q) succeeded", in)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := Decode([]byte("ACVS\n1\n2\n3\n")); err == nil {
|
|
|
|
|
+ t.Error("short header accepted")
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := Decode([]byte("XXXX\n1\n2\n3\n4\n{}")); err == nil {
|
|
|
|
|
+ t.Error("bad magic accepted")
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := Decode([]byte("ACVS\n1\n2\n3\n4\n[]")); err == nil {
|
|
|
|
|
+ t.Error("array body accepted")
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := Read(strings.NewReader("not gzip")); err == nil {
|
|
|
|
|
+ t.Error("non-gzip accepted")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestInvalidNumberText(t *testing.T) {
|
|
|
|
|
+ for _, s := range []string{"", "1.", ".5", "01", "+1", "1e", "true", `"1"`, "NaN", "1 "} {
|
|
|
|
|
+ n := &Node{Kind: Number, Text: s}
|
|
|
|
|
+ if _, err := n.MarshalJSON(); err == nil {
|
|
|
|
|
+ t.Errorf("number text %q accepted", s)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestFormatFloat(t *testing.T) {
|
|
|
|
|
+ // Expected values are Python's repr, which the reference writer used.
|
|
|
|
|
+ cases := map[float64]string{
|
|
|
|
|
+ 96: "96.0",
|
|
|
|
|
+ 20: "20.0",
|
|
|
|
|
+ 0: "0.0",
|
|
|
|
|
+ math.Copysign(0, -1): "-0.0",
|
|
|
|
|
+ 12.399999618530273: "12.399999618530273",
|
|
|
|
|
+ 1e-05: "1e-05",
|
|
|
|
|
+ 0.0001: "0.0001",
|
|
|
|
|
+ 1e16: "1e+16",
|
|
|
|
|
+ 1.5e16: "1.5e+16",
|
|
|
|
|
+ 123456789012345.6: "123456789012345.6",
|
|
|
|
|
+ 1e15: "1000000000000000.0",
|
|
|
|
|
+ -2.5: "-2.5",
|
|
|
|
|
+ 0.7079457640647888: "0.7079457640647888",
|
|
|
|
|
+ 5e-324: "5e-324",
|
|
|
|
|
+ 1.7976931348623157e308: "1.7976931348623157e+308",
|
|
|
|
|
+ 0.1: "0.1",
|
|
|
|
|
+ 100: "100.0",
|
|
|
|
|
+ 9999999999999998: "9999999999999998.0",
|
|
|
|
|
+ }
|
|
|
|
|
+ for f, want := range cases {
|
|
|
|
|
+ if got := FormatFloat(f); got != want {
|
|
|
|
|
+ t.Errorf("FormatFloat(%v) = %q, want %q", f, got, want)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // Tuning +12 semitones 40 cents, as the device writes it.
|
|
|
|
|
+ if got := NewFloat32(12 + 40.0/100).Text; got != "12.399999618530273" {
|
|
|
|
|
+ t.Errorf("NewFloat32(12.4) = %s", got)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := NewInt(-1).Text; got != "-1" {
|
|
|
|
|
+ t.Errorf("NewInt(-1) = %s", got)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestPaths(t *testing.T) {
|
|
|
|
|
+ n, err := ParseJSON([]byte(`{"a": [{"b": [10, 20]}, {"c": "x"}], "d": {"e": 1.0}}`))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ get := func(p string) *Node {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ v, err := n.Get(p)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return v
|
|
|
|
|
+ }
|
|
|
|
|
+ if get("a[0].b[1]").Text != "20" || get("a[1].c").Text != "x" || get("d.e").Text != "1.0" {
|
|
|
|
|
+ t.Error("Get returned wrong nodes")
|
|
|
|
|
+ }
|
|
|
|
|
+ if get("") != n {
|
|
|
|
|
+ t.Error("empty path is not the root")
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, p := range []string{"a[2]", "a[0].x", "d[0]", "d.e.f", "a[", "a[-1]", "a[x]", ".a", "a..b", "a.", "a]", "a.[0]"} {
|
|
|
|
|
+ if _, err := n.Get(p); err == nil {
|
|
|
|
|
+ t.Errorf("Get(%q) succeeded", p)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if err := n.Set("a[0].b[0]", NewInt(4)); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := n.Set("d.e", NewFloat(96)); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := n.Set("d.new", NewString("n")); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := n.Set("a[5]", NewNull()); err == nil {
|
|
|
|
|
+ t.Error("Set past array end succeeded")
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := n.Set("", NewNull()); err == nil {
|
|
|
|
|
+ t.Error("Set root succeeded")
|
|
|
|
|
+ }
|
|
|
|
|
+ got, _ := n.MarshalJSON()
|
|
|
|
|
+ want := "{\n\"a\": [\n{\n\"b\": [\n4,\n20\n]\n},\n{\n\"c\": \"x\"\n}\n],\n\"d\": {\n\"e\": 96.0,\n\"new\": \"n\"\n}\n}"
|
|
|
|
|
+ if string(got) != want {
|
|
|
|
|
+ t.Errorf("got\n%s", got)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestArrayEditsAndClone(t *testing.T) {
|
|
|
|
|
+ n, _ := ParseJSON([]byte(`{"s": [{"k": 0}]}`))
|
|
|
|
|
+ arr, _ := n.Get("s")
|
|
|
|
|
+ c := arr.Items[0].Clone()
|
|
|
|
|
+ if err := c.SetKey("k", NewInt(1)); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if arr.Items[0].Lookup("k").Text != "0" {
|
|
|
|
|
+ t.Fatal("clone shares data with its source")
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := arr.Insert(0, c); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := arr.Append(NewInt(9)); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := arr.Insert(5, NewNull()); err == nil {
|
|
|
|
|
+ t.Error("insert out of range succeeded")
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := arr.Remove(1); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := arr.Remove(2); err == nil {
|
|
|
|
|
+ t.Error("remove out of range succeeded")
|
|
|
|
|
+ }
|
|
|
|
|
+ got, _ := n.MarshalJSON()
|
|
|
|
|
+ if want := "{\n\"s\": [\n{\n\"k\": 1\n},\n9\n]\n}"; string(got) != want {
|
|
|
|
|
+ t.Errorf("got\n%s", got)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ obj, _ := n.Get("s[0]")
|
|
|
|
|
+ if !obj.DeleteKey("k") || obj.DeleteKey("k") {
|
|
|
|
|
+ t.Error("DeleteKey")
|
|
|
|
|
+ }
|
|
|
|
|
+ if got, _ := obj.MarshalJSON(); string(got) != "{}" {
|
|
|
|
|
+ t.Errorf("emptied object = %s", got)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ f := &File{Header: TestedHeader, Root: n}
|
|
|
|
|
+ fc := f.Clone()
|
|
|
|
|
+ fc.Header[0] = "X"
|
|
|
|
|
+ _ = fc.Root.SetKey("s", NewNull())
|
|
|
|
|
+ if f.Header[0] != "ACVS" || n.Lookup("s").Kind != Array {
|
|
|
|
|
+ t.Error("File.Clone shares data with its source")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestNumberAccessors(t *testing.T) {
|
|
|
|
|
+ for text, want := range map[string]int64{"4": 4, "-1": -1, "8212": 8212, "20.0": 20} {
|
|
|
|
|
+ if got, err := (&Node{Kind: Number, Text: text}).Int(); err != nil || got != want {
|
|
|
|
|
+ t.Errorf("Int(%s) = %d, %v", text, got, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := (&Node{Kind: Number, Text: "1.5"}).Int(); err == nil {
|
|
|
|
|
+ t.Error("Int(1.5) succeeded")
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := NewString("1").Int(); err == nil {
|
|
|
|
|
+ t.Error("Int on a string succeeded")
|
|
|
|
|
+ }
|
|
|
|
|
+ if f, err := NewFloat32(0.1).Float(); err != nil || f != float64(float32(0.1)) {
|
|
|
|
|
+ t.Errorf("Float = %v, %v", f, err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|