1
0

app_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  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, project, folder, zip string
  21. zipDefault string
  22. }
  23. func (f *fakeDialogs) OpenWAV() (string, error) { return f.wav, nil }
  24. func (f *fakeDialogs) OpenProject() (string, error) { return f.project, nil }
  25. func (f *fakeDialogs) ChooseFolder() (string, error) { return f.folder, nil }
  26. func (f *fakeDialogs) SaveZip(name string) (string, error) {
  27. f.zipDefault = name
  28. return f.zip, nil
  29. }
  30. func newService() (*Service, *fakeDialogs) {
  31. d := &fakeDialogs{}
  32. return NewService(d), d
  33. }
  34. func must(t *testing.T, err error) {
  35. t.Helper()
  36. if err != nil {
  37. t.Fatal(err)
  38. }
  39. }
  40. var kickDir = filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
  41. func TestNewState(t *testing.T) {
  42. s, _ := newService()
  43. st := s.State()
  44. if st.Name != DefaultName || st.Dirty || st.SampleCount != 0 {
  45. t.Errorf("state %q dirty %v samples %d", st.Name, st.Dirty, st.SampleCount)
  46. }
  47. if len(st.Pads) != 128 || st.Pads[0].Label != "A01" || st.Pads[17].Label != "B02" || st.Pads[127].Label != "H16" {
  48. t.Fatalf("pads: %d", len(st.Pads))
  49. }
  50. for _, p := range st.Pads {
  51. if p.File != "" || p.HasNotes || p.Audio != "" {
  52. t.Fatalf("pad %s not empty", p.Label)
  53. }
  54. }
  55. if st.SequenceCount != 1 || st.Sequences[0] != (Sequence{Slot: 0, Label: "A01", Name: "Sequence 01", BPM: 128, Bars: 2}) {
  56. t.Errorf("sequences %+v", st.Sequences)
  57. }
  58. if st.Limits.MaxMuteGroup != 16 || st.Limits.MinBPM != 20 || st.Limits.MaxBPM != 999 || st.Limits.MinCoarse != -24 {
  59. t.Errorf("limits %+v", st.Limits)
  60. }
  61. if st2 := s.New(); st2.Name != DefaultName || len(st2.Pads) != 128 {
  62. t.Error("New")
  63. }
  64. }
  65. func TestStateJSON(t *testing.T) {
  66. s, _ := newService()
  67. b, err := json.Marshal(s.State())
  68. must(t, err)
  69. for _, key := range []string{`"name":"NewKit"`, `"pads":[{"slot":0,"label":"A01"`, `"sequences":[{"slot":0`, `"limits":{`, `"muteGroup":0`, `"audio":""`} {
  70. if !strings.Contains(string(b), key) {
  71. t.Errorf("JSON lacks %s", key)
  72. }
  73. }
  74. }
  75. func TestAbout(t *testing.T) {
  76. s, _ := newService()
  77. a := s.About()
  78. if a.Version != Version || !strings.Contains(a.License, "2026 bajdev") || !strings.HasPrefix(a.Repo, "https://") {
  79. t.Errorf("%+v", a)
  80. }
  81. }
  82. func TestPadEdits(t *testing.T) {
  83. s, d := newService()
  84. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  85. // Cancelled dialog: nothing changes, no error.
  86. st, err := s.AssignSampleDialog(0)
  87. must(t, err)
  88. if st.Pads[0].File != "" || st.Dirty {
  89. t.Error("cancelled dialog changed the project")
  90. }
  91. d.wav = kick
  92. st, err = s.AssignSampleDialog(0)
  93. must(t, err)
  94. p := st.Pads[0]
  95. 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 {
  96. t.Errorf("after assign: %+v", p)
  97. }
  98. st, err = s.AssignSamplePath(16, kick)
  99. must(t, err)
  100. if st.Pads[16].File != "Linn 9000 kick.wav" || st.SampleCount != 1 {
  101. t.Error("drop of the same file did not share the pool entry")
  102. }
  103. 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, "?"):]) {
  104. t.Errorf("audio URLs %q %q: want same key, different slot", st.Pads[0].Audio, st.Pads[16].Audio)
  105. }
  106. st, err = s.SetTune(16, 5, -20)
  107. must(t, err)
  108. if st.Pads[16].Coarse != 5 || st.Pads[16].Fine != -20 {
  109. t.Error("tune")
  110. }
  111. st, err = s.SetMuteGroup(16, 3)
  112. must(t, err)
  113. if st.Pads[16].MuteGroup != 3 {
  114. t.Error("mute group")
  115. }
  116. st, err = s.SetMuteGroup(16, 17)
  117. if err == nil || st.Pads[16].MuteGroup != 3 {
  118. t.Error("mute group 17 accepted, or state lost on error")
  119. }
  120. st, err = s.ClearPad(0)
  121. must(t, err)
  122. if st.Pads[0].File != "" || st.SampleCount != 1 {
  123. t.Error("clear")
  124. }
  125. // Errors still return the current state.
  126. st, err = s.AssignSamplePath(1, filepath.Join(testdata, "nope.wav"))
  127. if err == nil || st.Pads[16].File == "" {
  128. t.Errorf("bad path: %v", err)
  129. }
  130. }
  131. func TestSetName(t *testing.T) {
  132. s, _ := newService()
  133. st, err := s.SetName(" My Kit ")
  134. must(t, err)
  135. if st.Name != "My Kit" || !st.Dirty {
  136. t.Errorf("name %q", st.Name)
  137. }
  138. st, err = s.SetName("bad/name")
  139. if err == nil || st.Name != "My Kit" {
  140. t.Errorf("bad name: %v, name now %q", err, st.Name)
  141. }
  142. }
  143. func gunzip(t *testing.T, path string) []byte {
  144. t.Helper()
  145. f, err := os.Open(path)
  146. must(t, err)
  147. defer f.Close()
  148. zr, err := gzip.NewReader(f)
  149. must(t, err)
  150. b, err := io.ReadAll(zr)
  151. must(t, err)
  152. return b
  153. }
  154. // The M5 done-when in miniature: the UI's calls build the same file as the
  155. // prototype and the CLI (MpckitT1 without its sequences).
  156. func TestExport(t *testing.T) {
  157. s, d := newService()
  158. _, err := s.SetName("Kit")
  159. must(t, err)
  160. _, err = s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  161. must(t, err)
  162. d.folder = t.TempDir()
  163. dir, err := s.ChooseExportFolder()
  164. must(t, err)
  165. res, err := s.ExportFolder(dir, false)
  166. must(t, err)
  167. if res.Path != filepath.Join(dir, "Kit.xpj") || len(res.Exists) != 0 {
  168. t.Fatalf("export: %+v", res)
  169. }
  170. if s.State().Dirty {
  171. t.Error("still dirty after export")
  172. }
  173. if _, err := os.Stat(filepath.Join(dir, "Kit"+project.DataSuffix, "Linn 9000 kick.wav")); err != nil {
  174. t.Error(err)
  175. }
  176. // Second time: reported, not written, until overwrite.
  177. res, err = s.ExportFolder(dir, false)
  178. must(t, err)
  179. if res.Path != "" || len(res.Exists) != 2 {
  180. t.Errorf("re-export: %+v", res)
  181. }
  182. res, err = s.ExportFolder(dir, true)
  183. must(t, err)
  184. if res.Path == "" {
  185. t.Error("overwrite export failed")
  186. }
  187. d.zip = filepath.Join(dir, "kit-export") // no extension: .zip is added
  188. zpath, err := s.ChooseZipPath()
  189. must(t, err)
  190. if d.zipDefault != "Kit.zip" {
  191. t.Errorf("zip dialog default %q", d.zipDefault)
  192. }
  193. res, err = s.ExportZip(zpath, false)
  194. must(t, err)
  195. if res.Path != filepath.Join(dir, "kit-export.zip") {
  196. t.Errorf("zip: %+v", res)
  197. }
  198. if res, _ = s.ExportZip(zpath, false); len(res.Exists) != 1 {
  199. t.Errorf("zip re-export: %+v", res)
  200. }
  201. // Missing folder: a real error.
  202. if _, err := s.ExportFolder(filepath.Join(dir, "missing", "deeper"), false); err == nil {
  203. t.Error("export into a missing folder succeeded")
  204. }
  205. }
  206. func TestExportMatchesCLIRecipe(t *testing.T) {
  207. // Same edits as MpckitT1's pads, through the service.
  208. s, _ := newService()
  209. _, err := s.SetName("MpckitT1")
  210. must(t, err)
  211. _, err = s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  212. must(t, err)
  213. _, err = s.AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav"))
  214. must(t, err)
  215. _, err = s.SetTune(16, 5, 0)
  216. must(t, err)
  217. dir := t.TempDir()
  218. res, err := s.ExportFolder(dir, false)
  219. must(t, err)
  220. p, err := project.New("MpckitT1")
  221. must(t, err)
  222. must(t, p.AssignSample(0, filepath.Join(kickDir, "Linn 9000 kick.wav")))
  223. must(t, p.AssignSample(16, filepath.Join(kickDir, "Linn 9000 kick001.wav")))
  224. must(t, p.SetTune(16, 5, 0))
  225. want, err := p.File().Encode()
  226. must(t, err)
  227. if !bytes.Equal(gunzip(t, res.Path), want) {
  228. t.Error("service export differs from the model's")
  229. }
  230. }
  231. func TestAudio(t *testing.T) {
  232. s, _ := newService()
  233. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  234. st, err := s.AssignSamplePath(3, kick)
  235. must(t, err)
  236. next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("asset")) })
  237. h := s.AudioMiddleware(next)
  238. get := func(url string) *httptest.ResponseRecorder {
  239. rec := httptest.NewRecorder()
  240. h.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
  241. return rec
  242. }
  243. if rec := get("/index.html"); rec.Body.String() != "asset" {
  244. t.Error("other paths are not passed on")
  245. }
  246. rec := get(st.Pads[3].Audio)
  247. want, _ := os.ReadFile(kick)
  248. if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), want) {
  249. t.Errorf("wav: %d, %d bytes", rec.Code, rec.Body.Len())
  250. }
  251. rec = get("/audio/3?pcm=1")
  252. b := rec.Body.Bytes()
  253. if rec.Code != 200 || len(b) < 12 {
  254. t.Fatalf("pcm: %d", rec.Code)
  255. }
  256. rate, ch, frames := binary.LittleEndian.Uint32(b), binary.LittleEndian.Uint32(b[4:]), binary.LittleEndian.Uint32(b[8:])
  257. if rate != 44100 || ch != 1 || frames != 8213 || len(b) != 12+4*8213 {
  258. t.Errorf("pcm header %d Hz, %d ch, %d frames, %d bytes", rate, ch, frames, len(b))
  259. }
  260. peak := float32(0)
  261. for i := 12; i < len(b); i += 4 {
  262. v := math.Float32frombits(binary.LittleEndian.Uint32(b[i:]))
  263. peak = max(peak, v, -v)
  264. }
  265. if peak < 0.1 || peak > 1 {
  266. t.Errorf("pcm peak %v", peak)
  267. }
  268. for _, url := range []string{"/audio/4", "/audio/x", "/audio/128", "/audio/-1"} {
  269. if rec := get(url); rec.Code != 404 {
  270. t.Errorf("%s: %d, want 404", url, rec.Code)
  271. }
  272. }
  273. }
  274. func TestMoveCopySwap(t *testing.T) {
  275. s, _ := newService()
  276. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  277. kick2 := filepath.Join(kickDir, "Linn 9000 kick001.wav")
  278. _, err := s.AssignSamplePath(0, kick)
  279. must(t, err)
  280. _, err = s.SetTune(0, 3, 0)
  281. must(t, err)
  282. _, err = s.AssignSamplePath(1, kick2)
  283. must(t, err)
  284. // Copy across banks keeps the tuning and shares the sample.
  285. st, err := s.CopyPad(0, 40)
  286. must(t, err)
  287. if st.Pads[40].File != "Linn 9000 kick.wav" || st.Pads[40].Coarse != 3 || st.SampleCount != 2 {
  288. t.Errorf("copy: %+v, %d samples", st.Pads[40], st.SampleCount)
  289. }
  290. // Swap.
  291. st, err = s.SwapPads(1, 40)
  292. must(t, err)
  293. if st.Pads[1].File != "Linn 9000 kick.wav" || st.Pads[40].File != "Linn 9000 kick001.wav" {
  294. t.Error("swap")
  295. }
  296. // Move onto a filled pad replaces it.
  297. st, err = s.MovePad(40, 0)
  298. must(t, err)
  299. if st.Pads[40].File != "" || st.Pads[0].File != "Linn 9000 kick001.wav" || st.Pads[0].Coarse != 0 {
  300. t.Errorf("move: A01 %+v", st.Pads[0])
  301. }
  302. if _, err := s.MovePad(0, 128); err == nil {
  303. t.Error("move to slot 128 accepted")
  304. }
  305. }
  306. func TestLoad(t *testing.T) {
  307. s, d := newService()
  308. // Cancelled: nothing changes.
  309. res, err := s.Load()
  310. must(t, err)
  311. if res.Path != "" || res.State.Name != DefaultName {
  312. t.Errorf("cancelled load: %+v", res.Path)
  313. }
  314. d.project = filepath.Join(testdata, "device", "DemoFile.xpj")
  315. res, err = s.Load()
  316. must(t, err)
  317. st := res.State
  318. if st.Name != "DemoFile" || st.Dirty || st.SampleCount != 6 || len(res.Warnings) != 0 || res.Path != d.project {
  319. t.Errorf("load: %q dirty %v samples %d warnings %v", st.Name, st.Dirty, st.SampleCount, res.Warnings)
  320. }
  321. // DemoFile has notes on A01-A04 only.
  322. for i, p := range st.Pads {
  323. if p.HasNotes != (i < 4) {
  324. t.Errorf("%s hasNotes %v", p.Label, p.HasNotes)
  325. }
  326. }
  327. if !strings.HasPrefix(st.Pads[0].Audio, "/audio/0?k=") || st.Pads[0].End != 44099 {
  328. t.Errorf("A01 %+v", st.Pads[0])
  329. }
  330. if st.SequenceCount != 2 || st.Sequences[0].Name != "Intro" || !st.Sequences[0].HasNotes {
  331. t.Errorf("sequences %+v", st.Sequences)
  332. }
  333. // A bad file keeps the current project.
  334. _, err = s.LoadPath(filepath.Join(testdata, "device", "DemoFile_[ProjectData]", "Bass-Pure C.wav"))
  335. if err == nil || s.State().Name != "DemoFile" {
  336. t.Errorf("bad load: %v", err)
  337. }
  338. }
  339. func TestSequences(t *testing.T) {
  340. s, _ := newService()
  341. st, err := s.AddSequence(20)
  342. must(t, err)
  343. if st.SequenceCount != 2 || st.Sequences[1] != (Sequence{Slot: 20, Label: "B05", Name: "Sequence 21", BPM: 120, Bars: 2}) {
  344. t.Errorf("add: %+v", st.Sequences)
  345. }
  346. st, err = s.SetSequence(20, " Drop ", 140.5, 16)
  347. must(t, err)
  348. if q := st.Sequences[1]; q.Name != "Drop" || q.BPM != 140.5 || q.Bars != 16 {
  349. t.Errorf("set: %+v", q)
  350. }
  351. // Refused edits keep the old values and still return the state.
  352. for _, bad := range []struct {
  353. name string
  354. bpm float64
  355. bars int
  356. }{{"", 120, 2}, {"x", 19, 2}, {"x", 1000, 2}, {"x", 120, 0}} {
  357. st, err = s.SetSequence(20, bad.name, bad.bpm, bad.bars)
  358. if err == nil || st.Sequences[1].Name != "Drop" {
  359. t.Errorf("SetSequence(%q, %v, %d): %v", bad.name, bad.bpm, bad.bars, err)
  360. }
  361. }
  362. if _, err := s.AddSequence(20); err == nil {
  363. t.Error("added twice")
  364. }
  365. st, err = s.RemoveSequence(0)
  366. must(t, err)
  367. if st.SequenceCount != 1 || st.Sequences[0].Slot != 20 {
  368. t.Errorf("remove: %+v", st.Sequences)
  369. }
  370. if _, err := s.RemoveSequence(20); err == nil {
  371. t.Error("removed the last sequence")
  372. }
  373. }
  374. // The M7 done-when through the service: MpckitT1, the M2 golden file, made
  375. // with the calls the UI makes.
  376. func TestGoldenT1ThroughService(t *testing.T) {
  377. s, _ := newService()
  378. calls := []func() (State, error){
  379. func() (State, error) { return s.SetName("MpckitT1") },
  380. func() (State, error) { return s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav")) },
  381. func() (State, error) { return s.AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav")) },
  382. func() (State, error) { return s.SetTune(16, 5, 0) },
  383. func() (State, error) { return s.SetSequence(0, "Verse", 96, 8) },
  384. func() (State, error) { return s.AddSequence(1) },
  385. func() (State, error) { return s.SetSequence(1, "Chorus", 96, 4) },
  386. }
  387. for _, c := range calls {
  388. _, err := c()
  389. must(t, err)
  390. }
  391. res, err := s.ExportFolder(t.TempDir(), false)
  392. must(t, err)
  393. if !bytes.Equal(gunzip(t, res.Path), gunzip(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj"))) {
  394. t.Error("differs from testdata/accepted/MpckitT1.xpj")
  395. }
  396. }
  397. // copyProject copies DemoFile and its data folder into dir.
  398. func copyProject(t *testing.T, dir string) string {
  399. t.Helper()
  400. src := filepath.Join(testdata, "device")
  401. must(t, os.Mkdir(filepath.Join(dir, "DemoFile"+project.DataSuffix), 0o755))
  402. b, err := os.ReadFile(filepath.Join(src, "DemoFile.xpj"))
  403. must(t, err)
  404. must(t, os.WriteFile(filepath.Join(dir, "DemoFile.xpj"), b, 0o644))
  405. ents, err := os.ReadDir(filepath.Join(src, "DemoFile"+project.DataSuffix))
  406. must(t, err)
  407. for _, e := range ents {
  408. b, err := os.ReadFile(filepath.Join(src, "DemoFile"+project.DataSuffix, e.Name()))
  409. must(t, err)
  410. must(t, os.WriteFile(filepath.Join(dir, "DemoFile"+project.DataSuffix, e.Name()), b, 0o644))
  411. }
  412. return filepath.Join(dir, "DemoFile.xpj")
  413. }
  414. func TestMissingFiles(t *testing.T) {
  415. dir := t.TempDir()
  416. path := copyProject(t, dir)
  417. must(t, os.Remove(filepath.Join(dir, "DemoFile"+project.DataSuffix, "80s-Snr-Dreamer SA.wav")))
  418. s, _ := newService()
  419. res, err := s.LoadPath(path)
  420. must(t, err)
  421. st := res.State
  422. if st.MissingCount != 1 {
  423. t.Fatalf("missing count %d, want 1", st.MissingCount)
  424. }
  425. a2 := st.Pads[1]
  426. if !a2.Missing || a2.Audio != "" || !strings.HasSuffix(a2.Source, "80s-Snr-Dreamer SA.wav") {
  427. t.Errorf("A02 %+v", a2)
  428. }
  429. if st.Pads[0].Missing || st.Pads[0].Audio == "" {
  430. t.Error("A01 marked missing")
  431. }
  432. // Export names the missing file and writes nothing.
  433. out := t.TempDir()
  434. if _, err := s.ExportFolder(out, false); err == nil || !strings.Contains(err.Error(), "80s-Snr-Dreamer SA.wav") {
  435. t.Errorf("export with a missing file: %v", err)
  436. }
  437. if ents, _ := os.ReadDir(out); len(ents) != 0 {
  438. t.Error("export wrote files")
  439. }
  440. // Replacing the pad's sample fixes it.
  441. st, err = s.AssignSamplePath(1, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  442. must(t, err)
  443. if st.MissingCount != 0 || st.Pads[1].Missing {
  444. t.Error("still missing after replace")
  445. }
  446. if _, err := s.ExportFolder(out, false); err != nil {
  447. t.Error(err)
  448. }
  449. }
  450. func TestCloseWindow(t *testing.T) {
  451. s, _ := newService()
  452. s.CloseWindow() // no closer set: does nothing
  453. closed := false
  454. s.SetCloser(func() { closed = true })
  455. if s.Dirty() {
  456. t.Error("new project dirty")
  457. }
  458. _, _ = s.SetName("Other")
  459. if !s.Dirty() {
  460. t.Error("not dirty after rename")
  461. }
  462. s.CloseWindow()
  463. if !closed {
  464. t.Error("closer not called")
  465. }
  466. }