app_test.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. package app
  2. import (
  3. "bytes"
  4. "compress/gzip"
  5. "encoding/binary"
  6. "encoding/json"
  7. "io"
  8. "math"
  9. "net/http"
  10. "net/http/httptest"
  11. "os"
  12. "path/filepath"
  13. "strings"
  14. "testing"
  15. "git.monoexp.com/bajdev/mpckit/internal/project"
  16. )
  17. const testdata = "../../testdata"
  18. // fakeDialogs returns preset answers; "" is a cancelled dialog.
  19. type fakeDialogs struct {
  20. wav, folder, zip string
  21. zipDefault string
  22. }
  23. func (f *fakeDialogs) OpenWAV() (string, error) { return f.wav, nil }
  24. func (f *fakeDialogs) ChooseFolder() (string, error) { return f.folder, nil }
  25. func (f *fakeDialogs) SaveZip(name string) (string, error) {
  26. f.zipDefault = name
  27. return f.zip, nil
  28. }
  29. func newService() (*Service, *fakeDialogs) {
  30. d := &fakeDialogs{}
  31. return NewService(d), d
  32. }
  33. func must(t *testing.T, err error) {
  34. t.Helper()
  35. if err != nil {
  36. t.Fatal(err)
  37. }
  38. }
  39. var kickDir = filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
  40. func TestNewState(t *testing.T) {
  41. s, _ := newService()
  42. st := s.State()
  43. if st.Name != DefaultName || st.Dirty || st.SampleCount != 0 {
  44. t.Errorf("state %q dirty %v samples %d", st.Name, st.Dirty, st.SampleCount)
  45. }
  46. if len(st.Pads) != 128 || st.Pads[0].Label != "A01" || st.Pads[17].Label != "B02" || st.Pads[127].Label != "H16" {
  47. t.Fatalf("pads: %d", len(st.Pads))
  48. }
  49. for _, p := range st.Pads {
  50. if p.File != "" || p.HasNotes || p.Audio != "" {
  51. t.Fatalf("pad %s not empty", p.Label)
  52. }
  53. }
  54. if st.SequenceCount != 1 || st.Sequences[0] != (Sequence{Slot: 0, Label: "A01", Name: "Sequence 01", BPM: 128, Bars: 2}) {
  55. t.Errorf("sequences %+v", st.Sequences)
  56. }
  57. if st.Limits.MaxMuteGroup != 16 || st.Limits.MinBPM != 20 || st.Limits.MaxBPM != 999 || st.Limits.MinCoarse != -24 {
  58. t.Errorf("limits %+v", st.Limits)
  59. }
  60. if st2 := s.New(); st2.Name != DefaultName || len(st2.Pads) != 128 {
  61. t.Error("New")
  62. }
  63. }
  64. func TestStateJSON(t *testing.T) {
  65. s, _ := newService()
  66. b, err := json.Marshal(s.State())
  67. must(t, err)
  68. for _, key := range []string{`"name":"NewKit"`, `"pads":[{"slot":0,"label":"A01"`, `"sequences":[{"slot":0`, `"limits":{`, `"muteGroup":0`, `"audio":""`} {
  69. if !strings.Contains(string(b), key) {
  70. t.Errorf("JSON lacks %s", key)
  71. }
  72. }
  73. }
  74. func TestAbout(t *testing.T) {
  75. s, _ := newService()
  76. a := s.About()
  77. if a.Version != Version || !strings.Contains(a.License, "2026 bajdev") || !strings.HasPrefix(a.Repo, "https://") {
  78. t.Errorf("%+v", a)
  79. }
  80. }
  81. func TestPadEdits(t *testing.T) {
  82. s, d := newService()
  83. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  84. // Cancelled dialog: nothing changes, no error.
  85. st, err := s.AssignSampleDialog(0)
  86. must(t, err)
  87. if st.Pads[0].File != "" || st.Dirty {
  88. t.Error("cancelled dialog changed the project")
  89. }
  90. d.wav = kick
  91. st, err = s.AssignSampleDialog(0)
  92. must(t, err)
  93. p := st.Pads[0]
  94. 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 {
  95. t.Errorf("after assign: %+v", p)
  96. }
  97. st, err = s.AssignSamplePath(16, kick)
  98. must(t, err)
  99. if st.Pads[16].File != "Linn 9000 kick.wav" || st.SampleCount != 1 {
  100. t.Error("drop of the same file did not share the pool entry")
  101. }
  102. 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, "?"):]) {
  103. t.Errorf("audio URLs %q %q: want same key, different slot", st.Pads[0].Audio, st.Pads[16].Audio)
  104. }
  105. st, err = s.SetTune(16, 5, -20)
  106. must(t, err)
  107. if st.Pads[16].Coarse != 5 || st.Pads[16].Fine != -20 {
  108. t.Error("tune")
  109. }
  110. st, err = s.SetMuteGroup(16, 3)
  111. must(t, err)
  112. if st.Pads[16].MuteGroup != 3 {
  113. t.Error("mute group")
  114. }
  115. st, err = s.SetMuteGroup(16, 17)
  116. if err == nil || st.Pads[16].MuteGroup != 3 {
  117. t.Error("mute group 17 accepted, or state lost on error")
  118. }
  119. st, err = s.ClearPad(0)
  120. must(t, err)
  121. if st.Pads[0].File != "" || st.SampleCount != 1 {
  122. t.Error("clear")
  123. }
  124. // Errors still return the current state.
  125. st, err = s.AssignSamplePath(1, filepath.Join(testdata, "nope.wav"))
  126. if err == nil || st.Pads[16].File == "" {
  127. t.Errorf("bad path: %v", err)
  128. }
  129. }
  130. func TestSetName(t *testing.T) {
  131. s, _ := newService()
  132. st, err := s.SetName(" My Kit ")
  133. must(t, err)
  134. if st.Name != "My Kit" || !st.Dirty {
  135. t.Errorf("name %q", st.Name)
  136. }
  137. st, err = s.SetName("bad/name")
  138. if err == nil || st.Name != "My Kit" {
  139. t.Errorf("bad name: %v, name now %q", err, st.Name)
  140. }
  141. }
  142. func gunzip(t *testing.T, path string) []byte {
  143. t.Helper()
  144. f, err := os.Open(path)
  145. must(t, err)
  146. defer f.Close()
  147. zr, err := gzip.NewReader(f)
  148. must(t, err)
  149. b, err := io.ReadAll(zr)
  150. must(t, err)
  151. return b
  152. }
  153. // The M5 done-when in miniature: the UI's calls build the same file as the
  154. // prototype and the CLI (MpckitT1 without its sequences).
  155. func TestExport(t *testing.T) {
  156. s, d := newService()
  157. _, err := s.SetName("Kit")
  158. must(t, err)
  159. _, err = s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  160. must(t, err)
  161. d.folder = t.TempDir()
  162. dir, err := s.ChooseExportFolder()
  163. must(t, err)
  164. res, err := s.ExportFolder(dir, false)
  165. must(t, err)
  166. if res.Path != filepath.Join(dir, "Kit.xpj") || len(res.Exists) != 0 {
  167. t.Fatalf("export: %+v", res)
  168. }
  169. if s.State().Dirty {
  170. t.Error("still dirty after export")
  171. }
  172. if _, err := os.Stat(filepath.Join(dir, "Kit"+project.DataSuffix, "Linn 9000 kick.wav")); err != nil {
  173. t.Error(err)
  174. }
  175. // Second time: reported, not written, until overwrite.
  176. res, err = s.ExportFolder(dir, false)
  177. must(t, err)
  178. if res.Path != "" || len(res.Exists) != 2 {
  179. t.Errorf("re-export: %+v", res)
  180. }
  181. res, err = s.ExportFolder(dir, true)
  182. must(t, err)
  183. if res.Path == "" {
  184. t.Error("overwrite export failed")
  185. }
  186. d.zip = filepath.Join(dir, "kit-export") // no extension: .zip is added
  187. zpath, err := s.ChooseZipPath()
  188. must(t, err)
  189. if d.zipDefault != "Kit.zip" {
  190. t.Errorf("zip dialog default %q", d.zipDefault)
  191. }
  192. res, err = s.ExportZip(zpath, false)
  193. must(t, err)
  194. if res.Path != filepath.Join(dir, "kit-export.zip") {
  195. t.Errorf("zip: %+v", res)
  196. }
  197. if res, _ = s.ExportZip(zpath, false); len(res.Exists) != 1 {
  198. t.Errorf("zip re-export: %+v", res)
  199. }
  200. // Missing folder: a real error.
  201. if _, err := s.ExportFolder(filepath.Join(dir, "missing", "deeper"), false); err == nil {
  202. t.Error("export into a missing folder succeeded")
  203. }
  204. }
  205. func TestExportMatchesCLIRecipe(t *testing.T) {
  206. // Same edits as MpckitT1's pads, through the service.
  207. s, _ := newService()
  208. _, err := s.SetName("MpckitT1")
  209. must(t, err)
  210. _, err = s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  211. must(t, err)
  212. _, err = s.AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav"))
  213. must(t, err)
  214. _, err = s.SetTune(16, 5, 0)
  215. must(t, err)
  216. dir := t.TempDir()
  217. res, err := s.ExportFolder(dir, false)
  218. must(t, err)
  219. p, err := project.New("MpckitT1")
  220. must(t, err)
  221. must(t, p.AssignSample(0, filepath.Join(kickDir, "Linn 9000 kick.wav")))
  222. must(t, p.AssignSample(16, filepath.Join(kickDir, "Linn 9000 kick001.wav")))
  223. must(t, p.SetTune(16, 5, 0))
  224. want, err := p.File().Encode()
  225. must(t, err)
  226. if !bytes.Equal(gunzip(t, res.Path), want) {
  227. t.Error("service export differs from the model's")
  228. }
  229. }
  230. func TestAudio(t *testing.T) {
  231. s, _ := newService()
  232. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  233. st, err := s.AssignSamplePath(3, kick)
  234. must(t, err)
  235. next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("asset")) })
  236. h := s.AudioMiddleware(next)
  237. get := func(url string) *httptest.ResponseRecorder {
  238. rec := httptest.NewRecorder()
  239. h.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
  240. return rec
  241. }
  242. if rec := get("/index.html"); rec.Body.String() != "asset" {
  243. t.Error("other paths are not passed on")
  244. }
  245. rec := get(st.Pads[3].Audio)
  246. want, _ := os.ReadFile(kick)
  247. if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), want) {
  248. t.Errorf("wav: %d, %d bytes", rec.Code, rec.Body.Len())
  249. }
  250. rec = get("/audio/3?pcm=1")
  251. b := rec.Body.Bytes()
  252. if rec.Code != 200 || len(b) < 12 {
  253. t.Fatalf("pcm: %d", rec.Code)
  254. }
  255. rate, ch, frames := binary.LittleEndian.Uint32(b), binary.LittleEndian.Uint32(b[4:]), binary.LittleEndian.Uint32(b[8:])
  256. if rate != 44100 || ch != 1 || frames != 8213 || len(b) != 12+4*8213 {
  257. t.Errorf("pcm header %d Hz, %d ch, %d frames, %d bytes", rate, ch, frames, len(b))
  258. }
  259. peak := float32(0)
  260. for i := 12; i < len(b); i += 4 {
  261. v := math.Float32frombits(binary.LittleEndian.Uint32(b[i:]))
  262. peak = max(peak, v, -v)
  263. }
  264. if peak < 0.1 || peak > 1 {
  265. t.Errorf("pcm peak %v", peak)
  266. }
  267. for _, url := range []string{"/audio/4", "/audio/x", "/audio/128", "/audio/-1"} {
  268. if rec := get(url); rec.Code != 404 {
  269. t.Errorf("%s: %d, want 404", url, rec.Code)
  270. }
  271. }
  272. }