|
|
@@ -1,13 +1,53 @@
|
|
|
package app
|
|
|
|
|
|
import (
|
|
|
+ "bytes"
|
|
|
+ "compress/gzip"
|
|
|
+ "encoding/binary"
|
|
|
"encoding/json"
|
|
|
+ "io"
|
|
|
+ "math"
|
|
|
+ "net/http"
|
|
|
+ "net/http/httptest"
|
|
|
+ "os"
|
|
|
+ "path/filepath"
|
|
|
"strings"
|
|
|
"testing"
|
|
|
+
|
|
|
+ "git.monoexp.com/bajdev/mpckit/internal/project"
|
|
|
)
|
|
|
|
|
|
+const testdata = "../../testdata"
|
|
|
+
|
|
|
+// fakeDialogs returns preset answers; "" is a cancelled dialog.
|
|
|
+type fakeDialogs struct {
|
|
|
+ wav, folder, zip string
|
|
|
+ zipDefault string
|
|
|
+}
|
|
|
+
|
|
|
+func (f *fakeDialogs) OpenWAV() (string, error) { return f.wav, nil }
|
|
|
+func (f *fakeDialogs) ChooseFolder() (string, error) { return f.folder, nil }
|
|
|
+func (f *fakeDialogs) SaveZip(name string) (string, error) {
|
|
|
+ f.zipDefault = name
|
|
|
+ return f.zip, nil
|
|
|
+}
|
|
|
+
|
|
|
+func newService() (*Service, *fakeDialogs) {
|
|
|
+ d := &fakeDialogs{}
|
|
|
+ return NewService(d), d
|
|
|
+}
|
|
|
+
|
|
|
+func must(t *testing.T, err error) {
|
|
|
+ t.Helper()
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+var kickDir = filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
|
|
|
+
|
|
|
func TestNewState(t *testing.T) {
|
|
|
- s := NewService()
|
|
|
+ s, _ := newService()
|
|
|
st := s.State()
|
|
|
if st.Name != DefaultName || st.Dirty || st.SampleCount != 0 {
|
|
|
t.Errorf("state %q dirty %v samples %d", st.Name, st.Dirty, st.SampleCount)
|
|
|
@@ -16,7 +56,7 @@ func TestNewState(t *testing.T) {
|
|
|
t.Fatalf("pads: %d", len(st.Pads))
|
|
|
}
|
|
|
for _, p := range st.Pads {
|
|
|
- if p.File != "" || p.HasNotes {
|
|
|
+ if p.File != "" || p.HasNotes || p.Audio != "" {
|
|
|
t.Fatalf("pad %s not empty", p.Label)
|
|
|
}
|
|
|
}
|
|
|
@@ -32,11 +72,10 @@ func TestNewState(t *testing.T) {
|
|
|
}
|
|
|
|
|
|
func TestStateJSON(t *testing.T) {
|
|
|
- b, err := json.Marshal(NewService().State())
|
|
|
- if err != nil {
|
|
|
- t.Fatal(err)
|
|
|
- }
|
|
|
- for _, key := range []string{`"name":"NewKit"`, `"pads":[{"slot":0,"label":"A01"`, `"sequences":[{"slot":0`, `"limits":{`, `"muteGroup":0`} {
|
|
|
+ s, _ := newService()
|
|
|
+ b, err := json.Marshal(s.State())
|
|
|
+ must(t, err)
|
|
|
+ for _, key := range []string{`"name":"NewKit"`, `"pads":[{"slot":0,"label":"A01"`, `"sequences":[{"slot":0`, `"limits":{`, `"muteGroup":0`, `"audio":""`} {
|
|
|
if !strings.Contains(string(b), key) {
|
|
|
t.Errorf("JSON lacks %s", key)
|
|
|
}
|
|
|
@@ -44,8 +83,222 @@ func TestStateJSON(t *testing.T) {
|
|
|
}
|
|
|
|
|
|
func TestAbout(t *testing.T) {
|
|
|
- a := NewService().About()
|
|
|
+ s, _ := newService()
|
|
|
+ a := s.About()
|
|
|
if a.Version != Version || !strings.Contains(a.License, "2026 bajdev") || !strings.HasPrefix(a.Repo, "https://") {
|
|
|
t.Errorf("%+v", a)
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+func TestPadEdits(t *testing.T) {
|
|
|
+ s, d := newService()
|
|
|
+ kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
|
|
|
+
|
|
|
+ // Cancelled dialog: nothing changes, no error.
|
|
|
+ st, err := s.AssignSampleDialog(0)
|
|
|
+ must(t, err)
|
|
|
+ if st.Pads[0].File != "" || st.Dirty {
|
|
|
+ t.Error("cancelled dialog changed the project")
|
|
|
+ }
|
|
|
+
|
|
|
+ d.wav = kick
|
|
|
+ st, err = s.AssignSampleDialog(0)
|
|
|
+ must(t, err)
|
|
|
+ p := st.Pads[0]
|
|
|
+ if p.File != "Linn 9000 kick.wav" || p.Name != "Linn 9000 kick" || p.End != 8212 || p.Start != 0 || !strings.HasPrefix(p.Audio, "/audio/0?k=") || !st.Dirty || st.SampleCount != 1 {
|
|
|
+ t.Errorf("after assign: %+v", p)
|
|
|
+ }
|
|
|
+
|
|
|
+ st, err = s.AssignSamplePath(16, kick)
|
|
|
+ must(t, err)
|
|
|
+ if st.Pads[16].File != "Linn 9000 kick.wav" || st.SampleCount != 1 {
|
|
|
+ t.Error("drop of the same file did not share the pool entry")
|
|
|
+ }
|
|
|
+ if st.Pads[16].Audio == st.Pads[0].Audio || !strings.HasSuffix(st.Pads[16].Audio, st.Pads[0].Audio[strings.Index(st.Pads[0].Audio, "?"):]) {
|
|
|
+ t.Errorf("audio URLs %q %q: want same key, different slot", st.Pads[0].Audio, st.Pads[16].Audio)
|
|
|
+ }
|
|
|
+
|
|
|
+ st, err = s.SetTune(16, 5, -20)
|
|
|
+ must(t, err)
|
|
|
+ if st.Pads[16].Coarse != 5 || st.Pads[16].Fine != -20 {
|
|
|
+ t.Error("tune")
|
|
|
+ }
|
|
|
+ st, err = s.SetMuteGroup(16, 3)
|
|
|
+ must(t, err)
|
|
|
+ if st.Pads[16].MuteGroup != 3 {
|
|
|
+ t.Error("mute group")
|
|
|
+ }
|
|
|
+ st, err = s.SetMuteGroup(16, 17)
|
|
|
+ if err == nil || st.Pads[16].MuteGroup != 3 {
|
|
|
+ t.Error("mute group 17 accepted, or state lost on error")
|
|
|
+ }
|
|
|
+
|
|
|
+ st, err = s.ClearPad(0)
|
|
|
+ must(t, err)
|
|
|
+ if st.Pads[0].File != "" || st.SampleCount != 1 {
|
|
|
+ t.Error("clear")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Errors still return the current state.
|
|
|
+ st, err = s.AssignSamplePath(1, filepath.Join(testdata, "nope.wav"))
|
|
|
+ if err == nil || st.Pads[16].File == "" {
|
|
|
+ t.Errorf("bad path: %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestSetName(t *testing.T) {
|
|
|
+ s, _ := newService()
|
|
|
+ st, err := s.SetName(" My Kit ")
|
|
|
+ must(t, err)
|
|
|
+ if st.Name != "My Kit" || !st.Dirty {
|
|
|
+ t.Errorf("name %q", st.Name)
|
|
|
+ }
|
|
|
+ st, err = s.SetName("bad/name")
|
|
|
+ if err == nil || st.Name != "My Kit" {
|
|
|
+ t.Errorf("bad name: %v, name now %q", err, st.Name)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func gunzip(t *testing.T, path string) []byte {
|
|
|
+ t.Helper()
|
|
|
+ f, err := os.Open(path)
|
|
|
+ must(t, err)
|
|
|
+ defer f.Close()
|
|
|
+ zr, err := gzip.NewReader(f)
|
|
|
+ must(t, err)
|
|
|
+ b, err := io.ReadAll(zr)
|
|
|
+ must(t, err)
|
|
|
+ return b
|
|
|
+}
|
|
|
+
|
|
|
+// The M5 done-when in miniature: the UI's calls build the same file as the
|
|
|
+// prototype and the CLI (MpckitT1 without its sequences).
|
|
|
+func TestExport(t *testing.T) {
|
|
|
+ s, d := newService()
|
|
|
+ _, err := s.SetName("Kit")
|
|
|
+ must(t, err)
|
|
|
+ _, err = s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
|
|
|
+ must(t, err)
|
|
|
+
|
|
|
+ d.folder = t.TempDir()
|
|
|
+ dir, err := s.ChooseExportFolder()
|
|
|
+ must(t, err)
|
|
|
+ res, err := s.ExportFolder(dir, false)
|
|
|
+ must(t, err)
|
|
|
+ if res.Path != filepath.Join(dir, "Kit.xpj") || len(res.Exists) != 0 {
|
|
|
+ t.Fatalf("export: %+v", res)
|
|
|
+ }
|
|
|
+ if s.State().Dirty {
|
|
|
+ t.Error("still dirty after export")
|
|
|
+ }
|
|
|
+ if _, err := os.Stat(filepath.Join(dir, "Kit"+project.DataSuffix, "Linn 9000 kick.wav")); err != nil {
|
|
|
+ t.Error(err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Second time: reported, not written, until overwrite.
|
|
|
+ res, err = s.ExportFolder(dir, false)
|
|
|
+ must(t, err)
|
|
|
+ if res.Path != "" || len(res.Exists) != 2 {
|
|
|
+ t.Errorf("re-export: %+v", res)
|
|
|
+ }
|
|
|
+ res, err = s.ExportFolder(dir, true)
|
|
|
+ must(t, err)
|
|
|
+ if res.Path == "" {
|
|
|
+ t.Error("overwrite export failed")
|
|
|
+ }
|
|
|
+
|
|
|
+ d.zip = filepath.Join(dir, "kit-export") // no extension: .zip is added
|
|
|
+ zpath, err := s.ChooseZipPath()
|
|
|
+ must(t, err)
|
|
|
+ if d.zipDefault != "Kit.zip" {
|
|
|
+ t.Errorf("zip dialog default %q", d.zipDefault)
|
|
|
+ }
|
|
|
+ res, err = s.ExportZip(zpath, false)
|
|
|
+ must(t, err)
|
|
|
+ if res.Path != filepath.Join(dir, "kit-export.zip") {
|
|
|
+ t.Errorf("zip: %+v", res)
|
|
|
+ }
|
|
|
+ if res, _ = s.ExportZip(zpath, false); len(res.Exists) != 1 {
|
|
|
+ t.Errorf("zip re-export: %+v", res)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Missing folder: a real error.
|
|
|
+ if _, err := s.ExportFolder(filepath.Join(dir, "missing", "deeper"), false); err == nil {
|
|
|
+ t.Error("export into a missing folder succeeded")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestExportMatchesCLIRecipe(t *testing.T) {
|
|
|
+ // Same edits as MpckitT1's pads, through the service.
|
|
|
+ s, _ := newService()
|
|
|
+ _, err := s.SetName("MpckitT1")
|
|
|
+ must(t, err)
|
|
|
+ _, err = s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
|
|
|
+ must(t, err)
|
|
|
+ _, err = s.AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav"))
|
|
|
+ must(t, err)
|
|
|
+ _, err = s.SetTune(16, 5, 0)
|
|
|
+ must(t, err)
|
|
|
+ dir := t.TempDir()
|
|
|
+ res, err := s.ExportFolder(dir, false)
|
|
|
+ must(t, err)
|
|
|
+
|
|
|
+ p, err := project.New("MpckitT1")
|
|
|
+ must(t, err)
|
|
|
+ must(t, p.AssignSample(0, filepath.Join(kickDir, "Linn 9000 kick.wav")))
|
|
|
+ must(t, p.AssignSample(16, filepath.Join(kickDir, "Linn 9000 kick001.wav")))
|
|
|
+ must(t, p.SetTune(16, 5, 0))
|
|
|
+ want, err := p.File().Encode()
|
|
|
+ must(t, err)
|
|
|
+ if !bytes.Equal(gunzip(t, res.Path), want) {
|
|
|
+ t.Error("service export differs from the model's")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestAudio(t *testing.T) {
|
|
|
+ s, _ := newService()
|
|
|
+ kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
|
|
|
+ st, err := s.AssignSamplePath(3, kick)
|
|
|
+ must(t, err)
|
|
|
+
|
|
|
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("asset")) })
|
|
|
+ h := s.AudioMiddleware(next)
|
|
|
+ get := func(url string) *httptest.ResponseRecorder {
|
|
|
+ rec := httptest.NewRecorder()
|
|
|
+ h.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
|
|
|
+ return rec
|
|
|
+ }
|
|
|
+
|
|
|
+ if rec := get("/index.html"); rec.Body.String() != "asset" {
|
|
|
+ t.Error("other paths are not passed on")
|
|
|
+ }
|
|
|
+ rec := get(st.Pads[3].Audio)
|
|
|
+ want, _ := os.ReadFile(kick)
|
|
|
+ if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), want) {
|
|
|
+ t.Errorf("wav: %d, %d bytes", rec.Code, rec.Body.Len())
|
|
|
+ }
|
|
|
+
|
|
|
+ rec = get("/audio/3?pcm=1")
|
|
|
+ b := rec.Body.Bytes()
|
|
|
+ if rec.Code != 200 || len(b) < 12 {
|
|
|
+ t.Fatalf("pcm: %d", rec.Code)
|
|
|
+ }
|
|
|
+ rate, ch, frames := binary.LittleEndian.Uint32(b), binary.LittleEndian.Uint32(b[4:]), binary.LittleEndian.Uint32(b[8:])
|
|
|
+ if rate != 44100 || ch != 1 || frames != 8213 || len(b) != 12+4*8213 {
|
|
|
+ t.Errorf("pcm header %d Hz, %d ch, %d frames, %d bytes", rate, ch, frames, len(b))
|
|
|
+ }
|
|
|
+ peak := float32(0)
|
|
|
+ for i := 12; i < len(b); i += 4 {
|
|
|
+ v := math.Float32frombits(binary.LittleEndian.Uint32(b[i:]))
|
|
|
+ peak = max(peak, v, -v)
|
|
|
+ }
|
|
|
+ if peak < 0.1 || peak > 1 {
|
|
|
+ t.Errorf("pcm peak %v", peak)
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, url := range []string{"/audio/4", "/audio/x", "/audio/128", "/audio/-1"} {
|
|
|
+ if rec := get(url); rec.Code != 404 {
|
|
|
+ t.Errorf("%s: %d, want 404", url, rec.Code)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|