| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- 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()
- 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)
- }
- if len(st.Pads) != 128 || st.Pads[0].Label != "A01" || st.Pads[17].Label != "B02" || st.Pads[127].Label != "H16" {
- t.Fatalf("pads: %d", len(st.Pads))
- }
- for _, p := range st.Pads {
- if p.File != "" || p.HasNotes || p.Audio != "" {
- t.Fatalf("pad %s not empty", p.Label)
- }
- }
- if st.SequenceCount != 1 || st.Sequences[0] != (Sequence{Slot: 0, Label: "A01", Name: "Sequence 01", BPM: 128, Bars: 2}) {
- t.Errorf("sequences %+v", st.Sequences)
- }
- if st.Limits.MaxMuteGroup != 16 || st.Limits.MinBPM != 20 || st.Limits.MaxBPM != 999 || st.Limits.MinCoarse != -24 {
- t.Errorf("limits %+v", st.Limits)
- }
- if st2 := s.New(); st2.Name != DefaultName || len(st2.Pads) != 128 {
- t.Error("New")
- }
- }
- func TestStateJSON(t *testing.T) {
- 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)
- }
- }
- }
- func TestAbout(t *testing.T) {
- 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)
- }
- }
- }
|