1
0

app_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  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 != "A1" || st.Pads[17].Label != "B2" || 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: "A1", 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":"A1"`, `"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(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. // The target is forgotten after a successful export.
  177. if _, err := s.ExportFolder(false); err == nil || err.Error() != "choose where to export first" {
  178. t.Errorf("export without choosing again: %v", err)
  179. }
  180. // Second time: reported, not written, until overwrite. The confirmed
  181. // call uses the same target.
  182. _, err = s.ChooseExportFolder()
  183. must(t, err)
  184. res, err = s.ExportFolder(false)
  185. must(t, err)
  186. if res.Path != "" || len(res.Exists) != 2 {
  187. t.Errorf("re-export: %+v", res)
  188. }
  189. res, err = s.ExportFolder(true)
  190. must(t, err)
  191. if res.Path == "" {
  192. t.Error("overwrite export failed")
  193. }
  194. d.zip = filepath.Join(dir, "kit-export") // no extension: .zip is added
  195. zpath, err := s.ChooseZipPath()
  196. must(t, err)
  197. if d.zipDefault != "Kit.zip" || zpath != filepath.Join(dir, "kit-export.zip") {
  198. t.Errorf("zip dialog default %q, chosen %q", d.zipDefault, zpath)
  199. }
  200. res, err = s.ExportZip(false)
  201. must(t, err)
  202. if res.Path != filepath.Join(dir, "kit-export.zip") {
  203. t.Errorf("zip: %+v", res)
  204. }
  205. _, err = s.ChooseZipPath()
  206. must(t, err)
  207. if res, _ = s.ExportZip(false); len(res.Exists) != 1 {
  208. t.Errorf("zip re-export: %+v", res)
  209. }
  210. // Missing folder: a real error.
  211. d.folder = filepath.Join(dir, "missing", "deeper")
  212. _, err = s.ChooseExportFolder()
  213. must(t, err)
  214. if _, err := s.ExportFolder(false); err == nil {
  215. t.Error("export into a missing folder succeeded")
  216. }
  217. }
  218. func TestExportTarget(t *testing.T) {
  219. s, d := newService()
  220. for _, export := range []func(bool) (ExportResult, error){s.ExportFolder, s.ExportZip} {
  221. if _, err := export(false); err == nil || err.Error() != "choose where to export first" {
  222. t.Errorf("export with nothing chosen: %v", err)
  223. }
  224. }
  225. // A cancelled dialog forgets an earlier choice.
  226. d.folder, d.zip = t.TempDir(), filepath.Join(t.TempDir(), "kit.zip")
  227. _, err := s.ChooseExportFolder()
  228. must(t, err)
  229. _, err = s.ChooseZipPath()
  230. must(t, err)
  231. d.folder, d.zip = "", ""
  232. _, err = s.ChooseExportFolder()
  233. must(t, err)
  234. _, err = s.ChooseZipPath()
  235. must(t, err)
  236. if s.exportDir != "" || s.zipPath != "" {
  237. t.Errorf("cancel kept %q, %q", s.exportDir, s.zipPath)
  238. }
  239. // New and Load forget the targets chosen for the old project.
  240. d.folder, d.zip = t.TempDir(), filepath.Join(t.TempDir(), "kit.zip")
  241. choose := func() {
  242. _, err := s.ChooseExportFolder()
  243. must(t, err)
  244. _, err = s.ChooseZipPath()
  245. must(t, err)
  246. }
  247. choose()
  248. s.New()
  249. if s.exportDir != "" || s.zipPath != "" {
  250. t.Error("New kept the export targets")
  251. }
  252. choose()
  253. _, err = s.LoadPath(filepath.Join(testdata, "device", "DemoBlank.xpj"))
  254. must(t, err)
  255. if s.exportDir != "" || s.zipPath != "" {
  256. t.Error("Load kept the export targets")
  257. }
  258. }
  259. // exportTo chooses dir in the fake dialog and exports there.
  260. func exportTo(t *testing.T, s *Service, dir string) (ExportResult, error) {
  261. t.Helper()
  262. s.dialogs.(*fakeDialogs).folder = dir
  263. _, err := s.ChooseExportFolder()
  264. must(t, err)
  265. return s.ExportFolder(false)
  266. }
  267. func TestExportMatchesCLIRecipe(t *testing.T) {
  268. // Same edits as MpckitT1's pads, through the service.
  269. s, _ := newService()
  270. _, err := s.SetName("MpckitT1")
  271. must(t, err)
  272. _, err = s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  273. must(t, err)
  274. _, err = s.AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav"))
  275. must(t, err)
  276. _, err = s.SetTune(16, 5, 0)
  277. must(t, err)
  278. dir := t.TempDir()
  279. res, err := exportTo(t, s, dir)
  280. must(t, err)
  281. p, err := project.New("MpckitT1")
  282. must(t, err)
  283. must(t, p.AssignSample(0, filepath.Join(kickDir, "Linn 9000 kick.wav")))
  284. must(t, p.AssignSample(16, filepath.Join(kickDir, "Linn 9000 kick001.wav")))
  285. must(t, p.SetTune(16, 5, 0))
  286. want, err := p.File().Encode()
  287. must(t, err)
  288. if !bytes.Equal(gunzip(t, res.Path), want) {
  289. t.Error("service export differs from the model's")
  290. }
  291. }
  292. func TestAudio(t *testing.T) {
  293. s, _ := newService()
  294. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  295. st, err := s.AssignSamplePath(3, kick)
  296. must(t, err)
  297. next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("asset")) })
  298. h := s.AudioMiddleware(next)
  299. get := func(url string) *httptest.ResponseRecorder {
  300. rec := httptest.NewRecorder()
  301. h.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
  302. return rec
  303. }
  304. if rec := get("/index.html"); rec.Body.String() != "asset" {
  305. t.Error("other paths are not passed on")
  306. }
  307. rec := get(st.Pads[3].Audio)
  308. want, _ := os.ReadFile(kick)
  309. if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), want) {
  310. t.Errorf("wav: %d, %d bytes", rec.Code, rec.Body.Len())
  311. }
  312. rec = get("/audio/3?pcm=1")
  313. b := rec.Body.Bytes()
  314. if rec.Code != 200 || len(b) < 12 {
  315. t.Fatalf("pcm: %d", rec.Code)
  316. }
  317. rate, ch, frames := binary.LittleEndian.Uint32(b), binary.LittleEndian.Uint32(b[4:]), binary.LittleEndian.Uint32(b[8:])
  318. if rate != 44100 || ch != 1 || frames != 8213 || len(b) != 12+4*8213 {
  319. t.Errorf("pcm header %d Hz, %d ch, %d frames, %d bytes", rate, ch, frames, len(b))
  320. }
  321. peak := float32(0)
  322. for i := 12; i < len(b); i += 4 {
  323. v := math.Float32frombits(binary.LittleEndian.Uint32(b[i:]))
  324. peak = max(peak, v, -v)
  325. }
  326. if peak < 0.1 || peak > 1 {
  327. t.Errorf("pcm peak %v", peak)
  328. }
  329. for _, url := range []string{"/audio/4", "/audio/x", "/audio/128", "/audio/-1"} {
  330. if rec := get(url); rec.Code != 404 {
  331. t.Errorf("%s: %d, want 404", url, rec.Code)
  332. }
  333. }
  334. }
  335. func TestMoveCopySwap(t *testing.T) {
  336. s, _ := newService()
  337. kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
  338. kick2 := filepath.Join(kickDir, "Linn 9000 kick001.wav")
  339. _, err := s.AssignSamplePath(0, kick)
  340. must(t, err)
  341. _, err = s.SetTune(0, 3, 0)
  342. must(t, err)
  343. _, err = s.AssignSamplePath(1, kick2)
  344. must(t, err)
  345. // Copy across banks keeps the tuning and shares the sample.
  346. st, err := s.CopyPad(0, 40)
  347. must(t, err)
  348. if st.Pads[40].File != "Linn 9000 kick.wav" || st.Pads[40].Coarse != 3 || st.SampleCount != 2 {
  349. t.Errorf("copy: %+v, %d samples", st.Pads[40], st.SampleCount)
  350. }
  351. // Swap.
  352. st, err = s.SwapPads(1, 40)
  353. must(t, err)
  354. if st.Pads[1].File != "Linn 9000 kick.wav" || st.Pads[40].File != "Linn 9000 kick001.wav" {
  355. t.Error("swap")
  356. }
  357. // Move onto a filled pad replaces it.
  358. st, err = s.MovePad(40, 0)
  359. must(t, err)
  360. if st.Pads[40].File != "" || st.Pads[0].File != "Linn 9000 kick001.wav" || st.Pads[0].Coarse != 0 {
  361. t.Errorf("move: A1 %+v", st.Pads[0])
  362. }
  363. if _, err := s.MovePad(0, 128); err == nil {
  364. t.Error("move to slot 128 accepted")
  365. }
  366. }
  367. func TestLoad(t *testing.T) {
  368. s, d := newService()
  369. // Cancelled: nothing changes.
  370. res, err := s.Load()
  371. must(t, err)
  372. if res.Path != "" || res.State.Name != DefaultName {
  373. t.Errorf("cancelled load: %+v", res.Path)
  374. }
  375. d.project = filepath.Join(testdata, "device", "DemoFile.xpj")
  376. res, err = s.Load()
  377. must(t, err)
  378. st := res.State
  379. if st.Name != "DemoFile" || st.Dirty || st.SampleCount != 6 || len(res.Warnings) != 0 || res.Path != d.project {
  380. t.Errorf("load: %q dirty %v samples %d warnings %v", st.Name, st.Dirty, st.SampleCount, res.Warnings)
  381. }
  382. // DemoFile has notes on A1-A4 only.
  383. for i, p := range st.Pads {
  384. if p.HasNotes != (i < 4) {
  385. t.Errorf("%s hasNotes %v", p.Label, p.HasNotes)
  386. }
  387. }
  388. if !strings.HasPrefix(st.Pads[0].Audio, "/audio/0?k=") || st.Pads[0].End != 44099 {
  389. t.Errorf("A1 %+v", st.Pads[0])
  390. }
  391. if st.SequenceCount != 2 || st.Sequences[0].Name != "Intro" || !st.Sequences[0].HasNotes {
  392. t.Errorf("sequences %+v", st.Sequences)
  393. }
  394. // A bad file keeps the current project.
  395. _, err = s.LoadPath(filepath.Join(testdata, "device", "DemoFile_[ProjectData]", "Bass-Pure C.wav"))
  396. if err == nil || s.State().Name != "DemoFile" {
  397. t.Errorf("bad load: %v", err)
  398. }
  399. }
  400. func TestSequences(t *testing.T) {
  401. s, _ := newService()
  402. st, err := s.AddSequence(20)
  403. must(t, err)
  404. if st.SequenceCount != 2 || st.Sequences[1] != (Sequence{Slot: 20, Label: "B5", Name: "Sequence 21", BPM: 120, Bars: 2}) {
  405. t.Errorf("add: %+v", st.Sequences)
  406. }
  407. st, err = s.SetSequence(20, " Drop ", 140.5, 16)
  408. must(t, err)
  409. if q := st.Sequences[1]; q.Name != "Drop" || q.BPM != 140.5 || q.Bars != 16 {
  410. t.Errorf("set: %+v", q)
  411. }
  412. // Refused edits keep the old values and still return the state.
  413. for _, bad := range []struct {
  414. name string
  415. bpm float64
  416. bars int
  417. }{{"", 120, 2}, {"x", 19, 2}, {"x", 1000, 2}, {"x", 120, 0}} {
  418. st, err = s.SetSequence(20, bad.name, bad.bpm, bad.bars)
  419. if err == nil || st.Sequences[1].Name != "Drop" {
  420. t.Errorf("SetSequence(%q, %v, %d): %v", bad.name, bad.bpm, bad.bars, err)
  421. }
  422. }
  423. if _, err := s.AddSequence(20); err == nil {
  424. t.Error("added twice")
  425. }
  426. st, err = s.RemoveSequence(0)
  427. must(t, err)
  428. if st.SequenceCount != 1 || st.Sequences[0].Slot != 20 {
  429. t.Errorf("remove: %+v", st.Sequences)
  430. }
  431. if _, err := s.RemoveSequence(20); err == nil {
  432. t.Error("removed the last sequence")
  433. }
  434. }
  435. // The M7 done-when through the service: MpckitT1, the M2 golden file, made
  436. // with the calls the UI makes.
  437. func TestGoldenT1ThroughService(t *testing.T) {
  438. s, _ := newService()
  439. calls := []func() (State, error){
  440. func() (State, error) { return s.SetName("MpckitT1") },
  441. func() (State, error) { return s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav")) },
  442. func() (State, error) { return s.AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav")) },
  443. func() (State, error) { return s.SetTune(16, 5, 0) },
  444. func() (State, error) { return s.SetSequence(0, "Verse", 96, 8) },
  445. func() (State, error) { return s.AddSequence(1) },
  446. func() (State, error) { return s.SetSequence(1, "Chorus", 96, 4) },
  447. }
  448. for _, c := range calls {
  449. _, err := c()
  450. must(t, err)
  451. }
  452. res, err := exportTo(t, s, t.TempDir())
  453. must(t, err)
  454. if !bytes.Equal(gunzip(t, res.Path), gunzip(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj"))) {
  455. t.Error("differs from testdata/accepted/MpckitT1.xpj")
  456. }
  457. }
  458. // copyProject copies DemoFile and its data folder into dir.
  459. func copyProject(t *testing.T, dir string) string {
  460. t.Helper()
  461. src := filepath.Join(testdata, "device")
  462. must(t, os.Mkdir(filepath.Join(dir, "DemoFile"+project.DataSuffix), 0o755))
  463. b, err := os.ReadFile(filepath.Join(src, "DemoFile.xpj"))
  464. must(t, err)
  465. must(t, os.WriteFile(filepath.Join(dir, "DemoFile.xpj"), b, 0o644))
  466. ents, err := os.ReadDir(filepath.Join(src, "DemoFile"+project.DataSuffix))
  467. must(t, err)
  468. for _, e := range ents {
  469. b, err := os.ReadFile(filepath.Join(src, "DemoFile"+project.DataSuffix, e.Name()))
  470. must(t, err)
  471. must(t, os.WriteFile(filepath.Join(dir, "DemoFile"+project.DataSuffix, e.Name()), b, 0o644))
  472. }
  473. return filepath.Join(dir, "DemoFile.xpj")
  474. }
  475. func TestMissingFiles(t *testing.T) {
  476. dir := t.TempDir()
  477. path := copyProject(t, dir)
  478. must(t, os.Remove(filepath.Join(dir, "DemoFile"+project.DataSuffix, "80s-Snr-Dreamer SA.wav")))
  479. s, _ := newService()
  480. res, err := s.LoadPath(path)
  481. must(t, err)
  482. st := res.State
  483. if st.MissingCount != 1 {
  484. t.Fatalf("missing count %d, want 1", st.MissingCount)
  485. }
  486. a2 := st.Pads[1]
  487. if !a2.Missing || a2.Audio != "" || !strings.HasSuffix(a2.Source, "80s-Snr-Dreamer SA.wav") {
  488. t.Errorf("A2 %+v", a2)
  489. }
  490. if st.Pads[0].Missing || st.Pads[0].Audio == "" {
  491. t.Error("A1 marked missing")
  492. }
  493. // Export names the missing file and writes nothing.
  494. out := t.TempDir()
  495. if _, err := exportTo(t, s, out); err == nil || !strings.Contains(err.Error(), "80s-Snr-Dreamer SA.wav") {
  496. t.Errorf("export with a missing file: %v", err)
  497. }
  498. if ents, _ := os.ReadDir(out); len(ents) != 0 {
  499. t.Error("export wrote files")
  500. }
  501. // Replacing the pad's sample fixes it.
  502. st, err = s.AssignSamplePath(1, filepath.Join(kickDir, "Linn 9000 kick.wav"))
  503. must(t, err)
  504. if st.MissingCount != 0 || st.Pads[1].Missing {
  505. t.Error("still missing after replace")
  506. }
  507. if _, err := exportTo(t, s, out); err != nil {
  508. t.Error(err)
  509. }
  510. }
  511. func TestCloseWindow(t *testing.T) {
  512. s, _ := newService()
  513. s.CloseWindow() // no closer set: does nothing
  514. closed := false
  515. s.SetCloser(func() { closed = true })
  516. if s.Dirty() {
  517. t.Error("new project dirty")
  518. }
  519. _, _ = s.SetName("Other")
  520. if !s.Dirty() {
  521. t.Error("not dirty after rename")
  522. }
  523. s.CloseWindow()
  524. if !closed {
  525. t.Error("closer not called")
  526. }
  527. }
  528. func TestLinkedFileIsMissing(t *testing.T) {
  529. // A link in the data folder is shown as missing, as export treats it.
  530. dir := t.TempDir()
  531. path := copyProject(t, dir)
  532. snare := filepath.Join(dir, "DemoFile"+project.DataSuffix, "80s-Snr-Dreamer SA.wav")
  533. outside := filepath.Join(dir, "outside.wav")
  534. must(t, os.Rename(snare, outside))
  535. if err := os.Symlink(outside, snare); err != nil {
  536. t.Skipf("cannot create a symlink: %v", err)
  537. }
  538. s, _ := newService()
  539. res, err := s.LoadPath(path)
  540. must(t, err)
  541. if st := res.State; st.MissingCount != 1 || !st.Pads[1].Missing {
  542. t.Errorf("missing count %d, A2 missing %v; want 1, true", st.MissingCount, st.Pads[1].Missing)
  543. }
  544. }