1
0

app_test.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  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. "reflect"
  14. "strings"
  15. "testing"
  16. "git.monoexp.com/bajdev/mpckit/internal/project"
  17. )
  18. const testdata = "../../testdata"
  19. // fakeDialogs returns preset answers; "" is a cancelled dialog.
  20. type fakeDialogs struct {
  21. wav, project, folder, zip string
  22. zipDefault string
  23. }
  24. func (f *fakeDialogs) OpenWAV() (string, error) { return f.wav, nil }
  25. func (f *fakeDialogs) OpenProject() (string, error) { return f.project, nil }
  26. func (f *fakeDialogs) ChooseFolder() (string, error) { return f.folder, nil }
  27. func (f *fakeDialogs) SaveZip(name string) (string, error) {
  28. f.zipDefault = name
  29. return f.zip, nil
  30. }
  31. func newService() (*Service, *fakeDialogs) {
  32. d := &fakeDialogs{}
  33. s, _ := NewService(d)
  34. return s, d
  35. }
  36. // hostOf returns the Host that shares s, as NewService does.
  37. func hostOf(s *Service) *Host { return &Host{s: s} }
  38. func must(t *testing.T, err error) {
  39. t.Helper()
  40. if err != nil {
  41. t.Fatal(err)
  42. }
  43. }
  44. var kickDir = filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
  45. func TestNewState(t *testing.T) {
  46. s, _ := newService()
  47. st := s.State()
  48. if st.Name != DefaultName || st.Dirty || st.SampleCount != 0 {
  49. t.Errorf("state %q dirty %v samples %d", st.Name, st.Dirty, st.SampleCount)
  50. }
  51. if len(st.Pads) != 128 || st.Pads[0].Label != "A1" || st.Pads[17].Label != "B2" || st.Pads[127].Label != "H16" {
  52. t.Fatalf("pads: %d", len(st.Pads))
  53. }
  54. for _, p := range st.Pads {
  55. if p.File != "" || p.HasNotes || p.Audio != "" {
  56. t.Fatalf("pad %s not empty", p.Label)
  57. }
  58. }
  59. if st.SequenceCount != 1 || st.Sequences[0] != (Sequence{Slot: 0, Label: "A1", Name: "Sequence 01", BPM: 128, Bars: 2}) {
  60. t.Errorf("sequences %+v", st.Sequences)
  61. }
  62. if st.Limits.MaxMuteGroup != 16 || st.Limits.MinBPM != 20 || st.Limits.MaxBPM != 999 || st.Limits.MinCoarse != -24 {
  63. t.Errorf("limits %+v", st.Limits)
  64. }
  65. if st2 := s.New(); st2.Name != DefaultName || len(st2.Pads) != 128 {
  66. t.Error("New")
  67. }
  68. }
  69. func TestStateJSON(t *testing.T) {
  70. s, _ := newService()
  71. b, err := json.Marshal(s.State())
  72. must(t, err)
  73. for _, key := range []string{`"name":"NewKit"`, `"pads":[{"slot":0,"label":"A1"`, `"sequences":[{"slot":0`, `"limits":{`, `"muteGroup":0`, `"audio":""`} {
  74. if !strings.Contains(string(b), key) {
  75. t.Errorf("JSON lacks %s", key)
  76. }
  77. }
  78. }
  79. func TestAbout(t *testing.T) {
  80. s, _ := newService()
  81. a := s.About()
  82. if a.Version != Version || !strings.Contains(a.License, "2026 bajdev") || !strings.HasPrefix(a.Repo, "https://") {
  83. t.Errorf("%+v", a)
  84. }
  85. }
  86. func TestPadEdits(t *testing.T) {
  87. s, d := newService()
  88. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  89. // Cancelled dialog: nothing changes, no error.
  90. st, err := s.AssignSampleDialog(0)
  91. must(t, err)
  92. if st.Pads[0].File != "" || st.Dirty {
  93. t.Error("cancelled dialog changed the project")
  94. }
  95. d.wav = kick
  96. st, err = s.AssignSampleDialog(0)
  97. must(t, err)
  98. p := st.Pads[0]
  99. 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 {
  100. t.Errorf("after assign: %+v", p)
  101. }
  102. st, err = hostOf(s).AssignSamplePath(16, kick)
  103. must(t, err)
  104. if st.Pads[16].File != "Linn 9000 kick.wav" || st.SampleCount != 1 {
  105. t.Error("drop of the same file did not share the pool entry")
  106. }
  107. 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, "?"):]) {
  108. t.Errorf("audio URLs %q %q: want same key, different slot", st.Pads[0].Audio, st.Pads[16].Audio)
  109. }
  110. st, err = s.SetTune(16, 5, -20)
  111. must(t, err)
  112. if st.Pads[16].Coarse != 5 || st.Pads[16].Fine != -20 {
  113. t.Error("tune")
  114. }
  115. st, err = s.SetMuteGroup(16, 3)
  116. must(t, err)
  117. if st.Pads[16].MuteGroup != 3 {
  118. t.Error("mute group")
  119. }
  120. st, err = s.SetMuteGroup(16, 17)
  121. if err == nil || st.Pads[16].MuteGroup != 3 {
  122. t.Error("mute group 17 accepted, or state lost on error")
  123. }
  124. st, err = s.ClearPad(0)
  125. must(t, err)
  126. if st.Pads[0].File != "" || st.SampleCount != 1 {
  127. t.Error("clear")
  128. }
  129. // Errors still return the current state.
  130. st, err = hostOf(s).AssignSamplePath(1, filepath.Join(testdata, "nope.wav"))
  131. if err == nil || st.Pads[16].File == "" {
  132. t.Errorf("bad path: %v", err)
  133. }
  134. }
  135. func TestSetName(t *testing.T) {
  136. s, _ := newService()
  137. st, err := s.SetName(" My Kit ")
  138. must(t, err)
  139. if st.Name != "My Kit" || !st.Dirty {
  140. t.Errorf("name %q", st.Name)
  141. }
  142. st, err = s.SetName("bad/name")
  143. if err == nil || st.Name != "My Kit" {
  144. t.Errorf("bad name: %v, name now %q", err, st.Name)
  145. }
  146. }
  147. func gunzip(t *testing.T, path string) []byte {
  148. t.Helper()
  149. f, err := os.Open(path)
  150. must(t, err)
  151. defer f.Close()
  152. zr, err := gzip.NewReader(f)
  153. must(t, err)
  154. b, err := io.ReadAll(zr)
  155. must(t, err)
  156. return b
  157. }
  158. // The M5 done-when in miniature: the UI's calls build the same file as the
  159. // prototype and the CLI (MpckitT1 without its sequences).
  160. func TestExport(t *testing.T) {
  161. s, d := newService()
  162. _, err := s.SetName("Kit")
  163. must(t, err)
  164. _, err = hostOf(s).AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  165. must(t, err)
  166. d.folder = t.TempDir()
  167. dir, err := s.ChooseExportFolder()
  168. must(t, err)
  169. res, err := s.ExportFolder(false)
  170. must(t, err)
  171. if res.Path != filepath.Join(dir, "Kit.xpj") || len(res.Exists) != 0 {
  172. t.Fatalf("export: %+v", res)
  173. }
  174. if s.State().Dirty {
  175. t.Error("still dirty after export")
  176. }
  177. if _, err := os.Stat(filepath.Join(dir, "Kit"+project.DataSuffix, "Linn 9000 kick.wav")); err != nil {
  178. t.Error(err)
  179. }
  180. // The target is forgotten after a successful export.
  181. if _, err := s.ExportFolder(false); err == nil || err.Error() != "choose where to export first" {
  182. t.Errorf("export without choosing again: %v", err)
  183. }
  184. // Second time: reported, not written, until overwrite. The confirmed
  185. // call uses the same target.
  186. _, err = s.ChooseExportFolder()
  187. must(t, err)
  188. res, err = s.ExportFolder(false)
  189. must(t, err)
  190. if res.Path != "" || len(res.Exists) != 2 {
  191. t.Errorf("re-export: %+v", res)
  192. }
  193. res, err = s.ExportFolder(true)
  194. must(t, err)
  195. if res.Path == "" {
  196. t.Error("overwrite export failed")
  197. }
  198. d.zip = filepath.Join(dir, "kit-export") // no extension: .zip is added
  199. zpath, err := s.ChooseZipPath()
  200. must(t, err)
  201. if d.zipDefault != "Kit.zip" || zpath != filepath.Join(dir, "kit-export.zip") {
  202. t.Errorf("zip dialog default %q, chosen %q", d.zipDefault, zpath)
  203. }
  204. res, err = s.ExportZip(false)
  205. must(t, err)
  206. if res.Path != filepath.Join(dir, "kit-export.zip") {
  207. t.Errorf("zip: %+v", res)
  208. }
  209. _, err = s.ChooseZipPath()
  210. must(t, err)
  211. if res, _ = s.ExportZip(false); len(res.Exists) != 1 {
  212. t.Errorf("zip re-export: %+v", res)
  213. }
  214. // Missing folder: a real error.
  215. d.folder = filepath.Join(dir, "missing", "deeper")
  216. _, err = s.ChooseExportFolder()
  217. must(t, err)
  218. if _, err := s.ExportFolder(false); err == nil {
  219. t.Error("export into a missing folder succeeded")
  220. }
  221. }
  222. func TestExportTarget(t *testing.T) {
  223. s, d := newService()
  224. for _, export := range []func(bool) (ExportResult, error){s.ExportFolder, s.ExportZip} {
  225. if _, err := export(false); err == nil || err.Error() != "choose where to export first" {
  226. t.Errorf("export with nothing chosen: %v", err)
  227. }
  228. }
  229. // A cancelled dialog forgets an earlier choice.
  230. d.folder, d.zip = t.TempDir(), filepath.Join(t.TempDir(), "kit.zip")
  231. _, err := s.ChooseExportFolder()
  232. must(t, err)
  233. _, err = s.ChooseZipPath()
  234. must(t, err)
  235. d.folder, d.zip = "", ""
  236. _, err = s.ChooseExportFolder()
  237. must(t, err)
  238. _, err = s.ChooseZipPath()
  239. must(t, err)
  240. if s.exportDir != "" || s.zipPath != "" {
  241. t.Errorf("cancel kept %q, %q", s.exportDir, s.zipPath)
  242. }
  243. // New and Load forget the targets chosen for the old project.
  244. d.folder, d.zip = t.TempDir(), filepath.Join(t.TempDir(), "kit.zip")
  245. choose := func() {
  246. _, err := s.ChooseExportFolder()
  247. must(t, err)
  248. _, err = s.ChooseZipPath()
  249. must(t, err)
  250. }
  251. choose()
  252. s.New()
  253. if s.exportDir != "" || s.zipPath != "" {
  254. t.Error("New kept the export targets")
  255. }
  256. choose()
  257. _, err = hostOf(s).LoadPath(filepath.Join(testdata, "device", "DemoBlank.xpj"))
  258. must(t, err)
  259. if s.exportDir != "" || s.zipPath != "" {
  260. t.Error("Load kept the export targets")
  261. }
  262. }
  263. // exportTo chooses dir in the fake dialog and exports there.
  264. func exportTo(t *testing.T, s *Service, dir string) (ExportResult, error) {
  265. t.Helper()
  266. s.dialogs.(*fakeDialogs).folder = dir
  267. _, err := s.ChooseExportFolder()
  268. must(t, err)
  269. return s.ExportFolder(false)
  270. }
  271. func TestExportMatchesCLIRecipe(t *testing.T) {
  272. // Same edits as MpckitT1's pads, through the service.
  273. s, _ := newService()
  274. _, err := s.SetName("MpckitT1")
  275. must(t, err)
  276. _, err = hostOf(s).AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  277. must(t, err)
  278. _, err = hostOf(s).AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav"))
  279. must(t, err)
  280. _, err = s.SetTune(16, 5, 0)
  281. must(t, err)
  282. dir := t.TempDir()
  283. res, err := exportTo(t, s, dir)
  284. must(t, err)
  285. p, err := project.New("MpckitT1")
  286. must(t, err)
  287. must(t, p.AssignSample(0, filepath.Join(kickDir, "Linn 9000 kick.wav")))
  288. must(t, p.AssignSample(16, filepath.Join(kickDir, "Linn 9000 kick001.wav")))
  289. must(t, p.SetTune(16, 5, 0))
  290. want, err := p.File().Encode()
  291. must(t, err)
  292. if !bytes.Equal(gunzip(t, res.Path), want) {
  293. t.Error("service export differs from the model's")
  294. }
  295. }
  296. func TestAudio(t *testing.T) {
  297. s, _ := newService()
  298. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  299. st, err := hostOf(s).AssignSamplePath(3, kick)
  300. must(t, err)
  301. next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("asset")) })
  302. h := hostOf(s).AudioMiddleware(next)
  303. get := func(url string) *httptest.ResponseRecorder {
  304. rec := httptest.NewRecorder()
  305. h.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
  306. return rec
  307. }
  308. if rec := get("/index.html"); rec.Body.String() != "asset" {
  309. t.Error("other paths are not passed on")
  310. }
  311. rec := get(st.Pads[3].Audio)
  312. want, _ := os.ReadFile(kick)
  313. if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), want) {
  314. t.Errorf("wav: %d, %d bytes", rec.Code, rec.Body.Len())
  315. }
  316. rec = get("/audio/3?pcm=1")
  317. b := rec.Body.Bytes()
  318. if rec.Code != 200 || len(b) < 12 {
  319. t.Fatalf("pcm: %d", rec.Code)
  320. }
  321. rate, ch, frames := binary.LittleEndian.Uint32(b), binary.LittleEndian.Uint32(b[4:]), binary.LittleEndian.Uint32(b[8:])
  322. if rate != 44100 || ch != 1 || frames != 8213 || len(b) != 12+4*8213 {
  323. t.Errorf("pcm header %d Hz, %d ch, %d frames, %d bytes", rate, ch, frames, len(b))
  324. }
  325. peak := float32(0)
  326. for i := 12; i < len(b); i += 4 {
  327. v := math.Float32frombits(binary.LittleEndian.Uint32(b[i:]))
  328. peak = max(peak, v, -v)
  329. }
  330. if peak < 0.1 || peak > 1 {
  331. t.Errorf("pcm peak %v", peak)
  332. }
  333. for _, url := range []string{"/audio/4", "/audio/x", "/audio/128", "/audio/-1"} {
  334. if rec := get(url); rec.Code != 404 {
  335. t.Errorf("%s: %d, want 404", url, rec.Code)
  336. }
  337. }
  338. }
  339. func TestMoveCopySwap(t *testing.T) {
  340. s, _ := newService()
  341. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  342. kick2 := filepath.Join(kickDir, "Linn 9000 kick001.wav")
  343. _, err := hostOf(s).AssignSamplePath(0, kick)
  344. must(t, err)
  345. _, err = s.SetTune(0, 3, 0)
  346. must(t, err)
  347. _, err = hostOf(s).AssignSamplePath(1, kick2)
  348. must(t, err)
  349. // Copy across banks keeps the tuning and shares the sample.
  350. st, err := s.CopyPad(0, 40)
  351. must(t, err)
  352. if st.Pads[40].File != "Linn 9000 kick.wav" || st.Pads[40].Coarse != 3 || st.SampleCount != 2 {
  353. t.Errorf("copy: %+v, %d samples", st.Pads[40], st.SampleCount)
  354. }
  355. // Swap.
  356. st, err = s.SwapPads(1, 40)
  357. must(t, err)
  358. if st.Pads[1].File != "Linn 9000 kick.wav" || st.Pads[40].File != "Linn 9000 kick001.wav" {
  359. t.Error("swap")
  360. }
  361. // Move onto a filled pad replaces it.
  362. st, err = s.MovePad(40, 0)
  363. must(t, err)
  364. if st.Pads[40].File != "" || st.Pads[0].File != "Linn 9000 kick001.wav" || st.Pads[0].Coarse != 0 {
  365. t.Errorf("move: A1 %+v", st.Pads[0])
  366. }
  367. if _, err := s.MovePad(0, 128); err == nil {
  368. t.Error("move to slot 128 accepted")
  369. }
  370. }
  371. func TestLoad(t *testing.T) {
  372. s, d := newService()
  373. // Cancelled: nothing changes.
  374. res, err := s.Load()
  375. must(t, err)
  376. if res.Path != "" || res.State.Name != DefaultName {
  377. t.Errorf("cancelled load: %+v", res.Path)
  378. }
  379. d.project = filepath.Join(testdata, "device", "DemoFile.xpj")
  380. res, err = s.Load()
  381. must(t, err)
  382. st := res.State
  383. if st.Name != "DemoFile" || st.Dirty || st.SampleCount != 6 || len(res.Warnings) != 0 || res.Path != d.project {
  384. t.Errorf("load: %q dirty %v samples %d warnings %v", st.Name, st.Dirty, st.SampleCount, res.Warnings)
  385. }
  386. // DemoFile has notes on A1-A4 only.
  387. for i, p := range st.Pads {
  388. if p.HasNotes != (i < 4) {
  389. t.Errorf("%s hasNotes %v", p.Label, p.HasNotes)
  390. }
  391. }
  392. if !strings.HasPrefix(st.Pads[0].Audio, "/audio/0?k=") || st.Pads[0].End != 44099 {
  393. t.Errorf("A1 %+v", st.Pads[0])
  394. }
  395. if st.SequenceCount != 2 || st.Sequences[0].Name != "Intro" || !st.Sequences[0].HasNotes {
  396. t.Errorf("sequences %+v", st.Sequences)
  397. }
  398. // A bad file keeps the current project.
  399. _, err = hostOf(s).LoadPath(filepath.Join(testdata, "device", "DemoFile_[ProjectData]", "Bass-Pure C.wav"))
  400. if err == nil || s.State().Name != "DemoFile" {
  401. t.Errorf("bad load: %v", err)
  402. }
  403. }
  404. func TestSequences(t *testing.T) {
  405. s, _ := newService()
  406. st, err := s.AddSequence(20)
  407. must(t, err)
  408. if st.SequenceCount != 2 || st.Sequences[1] != (Sequence{Slot: 20, Label: "B5", Name: "Sequence 21", BPM: 120, Bars: 2}) {
  409. t.Errorf("add: %+v", st.Sequences)
  410. }
  411. st, err = s.SetSequence(20, " Drop ", 140.5, 16)
  412. must(t, err)
  413. if q := st.Sequences[1]; q.Name != "Drop" || q.BPM != 140.5 || q.Bars != 16 {
  414. t.Errorf("set: %+v", q)
  415. }
  416. // Refused edits keep the old values and still return the state.
  417. for _, bad := range []struct {
  418. name string
  419. bpm float64
  420. bars int
  421. }{{"", 120, 2}, {"x", 19, 2}, {"x", 1000, 2}, {"x", 120, 0}} {
  422. st, err = s.SetSequence(20, bad.name, bad.bpm, bad.bars)
  423. if err == nil || st.Sequences[1].Name != "Drop" {
  424. t.Errorf("SetSequence(%q, %v, %d): %v", bad.name, bad.bpm, bad.bars, err)
  425. }
  426. }
  427. if _, err := s.AddSequence(20); err == nil {
  428. t.Error("added twice")
  429. }
  430. st, err = s.RemoveSequence(0)
  431. must(t, err)
  432. if st.SequenceCount != 1 || st.Sequences[0].Slot != 20 {
  433. t.Errorf("remove: %+v", st.Sequences)
  434. }
  435. if _, err := s.RemoveSequence(20); err == nil {
  436. t.Error("removed the last sequence")
  437. }
  438. }
  439. // The M7 done-when through the service: MpckitT1, the M2 golden file, made
  440. // with the calls the UI makes.
  441. func TestGoldenT1ThroughService(t *testing.T) {
  442. s, _ := newService()
  443. calls := []func() (State, error){
  444. func() (State, error) { return s.SetName("MpckitT1") },
  445. func() (State, error) {
  446. return hostOf(s).AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  447. },
  448. func() (State, error) {
  449. return hostOf(s).AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav"))
  450. },
  451. func() (State, error) { return s.SetTune(16, 5, 0) },
  452. func() (State, error) { return s.SetSequence(0, "Verse", 96, 8) },
  453. func() (State, error) { return s.AddSequence(1) },
  454. func() (State, error) { return s.SetSequence(1, "Chorus", 96, 4) },
  455. }
  456. for _, c := range calls {
  457. _, err := c()
  458. must(t, err)
  459. }
  460. res, err := exportTo(t, s, t.TempDir())
  461. must(t, err)
  462. if !bytes.Equal(gunzip(t, res.Path), gunzip(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj"))) {
  463. t.Error("differs from testdata/accepted/MpckitT1.xpj")
  464. }
  465. }
  466. // copyProject copies DemoFile and its data folder into dir.
  467. func copyProject(t *testing.T, dir string) string {
  468. t.Helper()
  469. src := filepath.Join(testdata, "device")
  470. must(t, os.Mkdir(filepath.Join(dir, "DemoFile"+project.DataSuffix), 0o755))
  471. b, err := os.ReadFile(filepath.Join(src, "DemoFile.xpj"))
  472. must(t, err)
  473. must(t, os.WriteFile(filepath.Join(dir, "DemoFile.xpj"), b, 0o644))
  474. ents, err := os.ReadDir(filepath.Join(src, "DemoFile"+project.DataSuffix))
  475. must(t, err)
  476. for _, e := range ents {
  477. b, err := os.ReadFile(filepath.Join(src, "DemoFile"+project.DataSuffix, e.Name()))
  478. must(t, err)
  479. must(t, os.WriteFile(filepath.Join(dir, "DemoFile"+project.DataSuffix, e.Name()), b, 0o644))
  480. }
  481. return filepath.Join(dir, "DemoFile.xpj")
  482. }
  483. func TestMissingFiles(t *testing.T) {
  484. dir := t.TempDir()
  485. path := copyProject(t, dir)
  486. must(t, os.Remove(filepath.Join(dir, "DemoFile"+project.DataSuffix, "80s-Snr-Dreamer SA.wav")))
  487. s, _ := newService()
  488. res, err := hostOf(s).LoadPath(path)
  489. must(t, err)
  490. st := res.State
  491. if st.MissingCount != 1 {
  492. t.Fatalf("missing count %d, want 1", st.MissingCount)
  493. }
  494. a2 := st.Pads[1]
  495. if !a2.Missing || a2.Audio != "" || !strings.HasSuffix(a2.Source, "80s-Snr-Dreamer SA.wav") {
  496. t.Errorf("A2 %+v", a2)
  497. }
  498. if st.Pads[0].Missing || st.Pads[0].Audio == "" {
  499. t.Error("A1 marked missing")
  500. }
  501. // Export names the missing file and writes nothing.
  502. out := t.TempDir()
  503. if _, err := exportTo(t, s, out); err == nil || !strings.Contains(err.Error(), "80s-Snr-Dreamer SA.wav") {
  504. t.Errorf("export with a missing file: %v", err)
  505. }
  506. if ents, _ := os.ReadDir(out); len(ents) != 0 {
  507. t.Error("export wrote files")
  508. }
  509. // Replacing the pad's sample fixes it.
  510. st, err = hostOf(s).AssignSamplePath(1, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  511. must(t, err)
  512. if st.MissingCount != 0 || st.Pads[1].Missing {
  513. t.Error("still missing after replace")
  514. }
  515. if _, err := exportTo(t, s, out); err != nil {
  516. t.Error(err)
  517. }
  518. }
  519. func TestCloseWindow(t *testing.T) {
  520. s, _ := newService()
  521. s.CloseWindow() // no closer set: does nothing
  522. closed := false
  523. hostOf(s).SetCloser(func() { closed = true })
  524. if hostOf(s).Dirty() {
  525. t.Error("new project dirty")
  526. }
  527. _, _ = s.SetName("Other")
  528. if !hostOf(s).Dirty() {
  529. t.Error("not dirty after rename")
  530. }
  531. s.CloseWindow()
  532. if !closed {
  533. t.Error("closer not called")
  534. }
  535. }
  536. func TestLinkedFileIsMissing(t *testing.T) {
  537. // A link in the data folder is shown as missing, as export treats it.
  538. dir := t.TempDir()
  539. path := copyProject(t, dir)
  540. snare := filepath.Join(dir, "DemoFile"+project.DataSuffix, "80s-Snr-Dreamer SA.wav")
  541. outside := filepath.Join(dir, "outside.wav")
  542. must(t, os.Rename(snare, outside))
  543. if err := os.Symlink(outside, snare); err != nil {
  544. t.Skipf("cannot create a symlink: %v", err)
  545. }
  546. s, _ := newService()
  547. res, err := hostOf(s).LoadPath(path)
  548. must(t, err)
  549. if st := res.State; st.MissingCount != 1 || !st.Pads[1].Missing {
  550. t.Errorf("missing count %d, A2 missing %v; want 1, true", st.MissingCount, st.Pads[1].Missing)
  551. }
  552. }
  553. // TestBoundMethods pins the methods the window can call. Wails binds every
  554. // exported method of Service by reflection, so each one is reachable from
  555. // any script in the window by name.
  556. func TestBoundMethods(t *testing.T) {
  557. want := []string{
  558. "About", "AddSequence", "AssignSampleDialog", "CancelExport",
  559. "ChooseExportFolder", "ChooseZipPath", "ClearPad", "CloseWindow",
  560. "CopyPad", "ExportFolder", "ExportZip", "Load", "MovePad", "New",
  561. "RemoveSequence", "SetMuteGroup", "SetName", "SetSequence", "SetTune",
  562. "State", "SwapPads",
  563. }
  564. typ := reflect.TypeOf(&Service{})
  565. var got []string
  566. for i := range typ.NumMethod() {
  567. got = append(got, typ.Method(i).Name)
  568. }
  569. if strings.Join(got, ",") != strings.Join(want, ",") {
  570. t.Errorf("Service's exported methods are\n %v\nwant\n %v\n"+
  571. "Every exported method of Service can be called by any script in the window. "+
  572. "A new one must not take a file path (Go-only methods belong on Host), "+
  573. "and must be added to this list on purpose.", got, want)
  574. }
  575. }
  576. func TestCancelExport(t *testing.T) {
  577. // Declining "Replace existing files?" forgets the target, so a later
  578. // overwrite cannot reach it without the dialog.
  579. s, d := newService()
  580. d.folder, d.zip = t.TempDir(), filepath.Join(t.TempDir(), "kit.zip")
  581. _, err := s.ChooseExportFolder()
  582. must(t, err)
  583. _, err = s.ChooseZipPath()
  584. must(t, err)
  585. s.CancelExport()
  586. for _, export := range []func(bool) (ExportResult, error){s.ExportFolder, s.ExportZip} {
  587. if _, err := export(true); err == nil || err.Error() != "choose where to export first" {
  588. t.Errorf("overwrite after cancel: %v", err)
  589. }
  590. }
  591. if ents, _ := os.ReadDir(d.folder); len(ents) != 0 {
  592. t.Errorf("export after cancel wrote %d entries", len(ents))
  593. }
  594. }