project_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737
  1. package project
  2. import (
  3. "bytes"
  4. "compress/gzip"
  5. "encoding/binary"
  6. "fmt"
  7. "io"
  8. "math"
  9. "os"
  10. "path/filepath"
  11. "strconv"
  12. "strings"
  13. "testing"
  14. "git.monoexp.com/bajdev/mpckit/internal/xpj"
  15. )
  16. const testdata = "../../testdata"
  17. func decompress(t *testing.T, path string) []byte {
  18. t.Helper()
  19. fh, err := os.Open(path)
  20. if err != nil {
  21. t.Fatal(err)
  22. }
  23. defer fh.Close()
  24. zr, err := gzip.NewReader(fh)
  25. if err != nil {
  26. t.Fatal(err)
  27. }
  28. b, err := io.ReadAll(zr)
  29. if err != nil {
  30. t.Fatal(err)
  31. }
  32. return b
  33. }
  34. func encode(t *testing.T, p *Project) []byte {
  35. t.Helper()
  36. b, err := p.File().Encode()
  37. if err != nil {
  38. t.Fatal(err)
  39. }
  40. return b
  41. }
  42. func must(t *testing.T, err error) {
  43. t.Helper()
  44. if err != nil {
  45. t.Fatal(err)
  46. }
  47. }
  48. func newProject(t *testing.T) *Project {
  49. t.Helper()
  50. p, err := New("Test")
  51. must(t, err)
  52. return p
  53. }
  54. // leaves flattens a tree into path -> literal text, for comparing files.
  55. func leaves(n *xpj.Node, path string, out map[string]string) map[string]string {
  56. if out == nil {
  57. out = map[string]string{}
  58. }
  59. switch n.Kind {
  60. case xpj.Object:
  61. for _, m := range n.Members {
  62. leaves(m.Value, path+"."+m.Key, out)
  63. }
  64. case xpj.Array:
  65. for i, it := range n.Items {
  66. leaves(it, fmt.Sprintf("%s[%d]", path, i), out)
  67. }
  68. if len(n.Items) == 0 {
  69. out[path] = "[]"
  70. }
  71. default:
  72. b, _ := n.MarshalJSON()
  73. out[path] = string(b)
  74. }
  75. return out
  76. }
  77. func diffPaths(a, b *xpj.Node) []string {
  78. la, lb := leaves(a, "", nil), leaves(b, "", nil)
  79. var diff []string
  80. for k, v := range la {
  81. if lb[k] != v {
  82. diff = append(diff, k)
  83. }
  84. }
  85. for k := range lb {
  86. if _, ok := la[k]; !ok {
  87. diff = append(diff, k)
  88. }
  89. }
  90. return diff
  91. }
  92. // The prototype's edits for MpckitT1 and T3 (build plan appendix), made
  93. // through the model. The output must equal the files the device accepted.
  94. func buildAccepted(t *testing.T, name string, shared bool) *Project {
  95. t.Helper()
  96. p, err := New(name)
  97. must(t, err)
  98. dir := filepath.Join(testdata, "accepted", name+DataSuffix)
  99. must(t, p.AssignSample(0, filepath.Join(dir, "Linn 9000 kick.wav")))
  100. second := "Linn 9000 kick001.wav"
  101. if shared {
  102. second = "Linn 9000 kick.wav"
  103. }
  104. must(t, p.AssignSample(16, filepath.Join(dir, second)))
  105. must(t, p.SetTune(16, 5, 0))
  106. must(t, p.SetSequence(0, "Verse", 96, 8))
  107. must(t, p.AddSequence(1))
  108. must(t, p.SetSequence(1, "Chorus", 96, 4))
  109. return p
  110. }
  111. func TestGoldenMpckitT1(t *testing.T) {
  112. p := buildAccepted(t, "MpckitT1", false)
  113. want := decompress(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj"))
  114. if got := encode(t, p); !bytes.Equal(got, want) {
  115. f, _ := xpj.Decode(want)
  116. t.Fatalf("differs from MpckitT1.xpj at %v", diffPaths(p.File().Root, f.Root))
  117. }
  118. if p.SampleCount() != 2 {
  119. t.Errorf("%d samples, want 2", p.SampleCount())
  120. }
  121. }
  122. func TestGoldenMpckitT3(t *testing.T) {
  123. p := buildAccepted(t, "MpckitT3", true)
  124. want := decompress(t, filepath.Join(testdata, "accepted", "MpckitT3.xpj"))
  125. if got := encode(t, p); !bytes.Equal(got, want) {
  126. f, _ := xpj.Decode(want)
  127. t.Fatalf("differs from MpckitT3.xpj at %v", diffPaths(p.File().Root, f.Root))
  128. }
  129. if p.SampleCount() != 1 {
  130. t.Errorf("%d samples, want 1 shared", p.SampleCount())
  131. }
  132. }
  133. // Rebuild Demo02 from the blank template and compare with the device's file.
  134. func TestRebuildDemo02(t *testing.T) {
  135. dir := filepath.Join(testdata, "device", "Demo02"+DataSuffix)
  136. p := newProject(t)
  137. must(t, p.AssignSample(0, filepath.Join(dir, "Linn 9000 kick.wav")))
  138. must(t, p.AssignSample(4, filepath.Join(dir, "Linn 9000 kick001.wav")))
  139. must(t, p.SetTune(4, 12, 40))
  140. // Start/end trim is a 2.0 feature; its fields are verified, so set them
  141. // directly to complete the rebuild.
  142. l, err := layer(p.pad(4))
  143. must(t, err)
  144. must(t, l.Set("sliceInfo.Start", xpj.NewInt(328)))
  145. must(t, l.Set("sliceInfo.LoopStart", xpj.NewInt(328)))
  146. must(t, l.Set("sliceInfo.End", xpj.NewInt(3777)))
  147. must(t, p.SetSequence(0, "Sequence 01", 105, 2))
  148. must(t, p.AddSequence(1))
  149. must(t, p.SetSequence(1, "Sequence 02", 105, 4))
  150. want, err := xpj.ReadFile(filepath.Join(testdata, "device", "Demo02.xpj"))
  151. must(t, err)
  152. got, wantLeaves := leaves(p.File().Root, "", nil), leaves(want.Root, "", nil)
  153. var unexpected []string
  154. for _, path := range diffPaths(p.File().Root, want.Root) {
  155. switch {
  156. case sameFloat(got[path], wantLeaves[path]):
  157. // The device prints some floats with 17 digits where we print
  158. // the shortest form: pitch 12.399999618530274 vs ...273 is the
  159. // same float64.
  160. case path == ".data.tracks[0].colour",
  161. path == ".data.assignableEnvelopeFollowerAssignments.currentControlIndex",
  162. strings.Contains(path, ".eventList.events"):
  163. // Known noise and recorded notes (build plan M2).
  164. case path == ".data.tracks[0].program.drum.instruments[4].layersv[0].sliceInfo.LoopCrossfadeLength":
  165. // The device wrote a different value on the pad it trimmed; we
  166. // write -1 as for every assigned sample (format doc section 3).
  167. default:
  168. unexpected = append(unexpected, path)
  169. }
  170. }
  171. if len(unexpected) > 0 {
  172. t.Errorf("unexpected differences from Demo02: %v", unexpected)
  173. }
  174. }
  175. func sameFloat(a, b string) bool {
  176. fa, errA := strconv.ParseFloat(a, 64)
  177. fb, errB := strconv.ParseFloat(b, 64)
  178. return errA == nil && errB == nil && fa == fb
  179. }
  180. // writeWAV writes a short 16-bit mono WAV of frames frames.
  181. func writeWAV(t *testing.T, path string, frames int) string {
  182. t.Helper()
  183. data := make([]byte, frames*2)
  184. var b bytes.Buffer
  185. le := binary.LittleEndian
  186. b.WriteString("RIFF")
  187. binary.Write(&b, le, uint32(4+8+16+8+len(data)))
  188. b.WriteString("WAVEfmt ")
  189. for _, v := range []any{uint32(16), uint16(1), uint16(1), uint32(44100), uint32(88200), uint16(2), uint16(16)} {
  190. binary.Write(&b, le, v)
  191. }
  192. b.WriteString("data")
  193. binary.Write(&b, le, uint32(len(data)))
  194. b.Write(data)
  195. must(t, os.MkdirAll(filepath.Dir(path), 0o755))
  196. must(t, os.WriteFile(path, b.Bytes(), 0o644))
  197. return path
  198. }
  199. func padOf(t *testing.T, p *Project, slot int) Pad {
  200. t.Helper()
  201. v, err := p.Pad(slot)
  202. must(t, err)
  203. return v
  204. }
  205. func TestAssignAndPoolReuse(t *testing.T) {
  206. dir := t.TempDir()
  207. kick := writeWAV(t, filepath.Join(dir, "kick.wav"), 100)
  208. p := newProject(t)
  209. must(t, p.AssignSample(0, kick))
  210. must(t, p.AssignSample(1, kick))
  211. if p.SampleCount() != 1 {
  212. t.Fatalf("same source on two pads: %d pool entries, want 1", p.SampleCount())
  213. }
  214. a := padOf(t, p, 1)
  215. if a.File != "kick.wav" || a.SampleName != "kick" || a.End != 99 {
  216. t.Errorf("pad A2 = %+v", a)
  217. }
  218. if !p.dirty {
  219. t.Error("not dirty after assign")
  220. }
  221. // Reassigning the same source to its only pad keeps the entry.
  222. must(t, p.ClearPad(1))
  223. must(t, p.AssignSample(0, kick))
  224. if p.SampleCount() != 1 || padOf(t, p, 0).File != "kick.wav" {
  225. t.Errorf("reassign same source: %d entries, pad %+v", p.SampleCount(), padOf(t, p, 0))
  226. }
  227. // The source is the file itself, with any links resolved (macOS's
  228. // temporary folder is behind one).
  229. want, err := filepath.EvalSymlinks(kick)
  230. must(t, err)
  231. if got := p.sources["kick.wav"]; got != want {
  232. t.Errorf("source = %q, want %q", got, want)
  233. }
  234. // Replacing drops the old entry when no pad uses it.
  235. snare := writeWAV(t, filepath.Join(dir, "snare.wav"), 50)
  236. must(t, p.AssignSample(0, snare))
  237. if p.SampleCount() != 1 || p.inPool("kick.wav") || p.sources["kick.wav"] != "" {
  238. t.Errorf("replace did not drop kick: %d entries", p.SampleCount())
  239. }
  240. }
  241. func TestNameCollision(t *testing.T) {
  242. dir := t.TempDir()
  243. p := newProject(t)
  244. must(t, p.AssignSample(0, writeWAV(t, filepath.Join(dir, "a", "kick.wav"), 10)))
  245. must(t, p.AssignSample(1, writeWAV(t, filepath.Join(dir, "b", "kick.wav"), 20)))
  246. must(t, p.AssignSample(2, writeWAV(t, filepath.Join(dir, "c", "Kick.WAV"), 30)))
  247. want := []string{"kick.wav", "kick001.wav", "Kick002.WAV"}
  248. for i, w := range want {
  249. if got := padOf(t, p, i); got.File != w || got.SampleName != strings.TrimSuffix(w, filepath.Ext(w)) {
  250. t.Errorf("pad %d = %q/%q, want %q", i, got.SampleName, got.File, w)
  251. }
  252. }
  253. // Both pools hold the same entries; the track pool in reverse order.
  254. var pool, track []string
  255. for _, e := range p.pool().Items {
  256. pool = append(pool, e.Lookup("path").Text)
  257. }
  258. for _, e := range p.trackPool().Items {
  259. track = append(track, e.Lookup("path").Text)
  260. }
  261. if strings.Join(pool, ",") != "kick.wav,kick001.wav,Kick002.WAV" || strings.Join(track, ",") != "Kick002.WAV,kick001.wav,kick.wav" {
  262. t.Errorf("pool %v, track pool %v", pool, track)
  263. }
  264. }
  265. func TestSampleLimit(t *testing.T) {
  266. dir := t.TempDir()
  267. p := newProject(t)
  268. // With one sample per pad the pool can only fill up when it also holds
  269. // entries no pad uses, as a loaded project may. Simulate one.
  270. p.addPoolEntry("unused.wav")
  271. for i := 0; i < MaxSamples-1; i++ {
  272. must(t, p.AssignSample(i, writeWAV(t, filepath.Join(dir, fmt.Sprintf("s%03d.wav", i)), 4)))
  273. }
  274. if p.SampleCount() != MaxSamples {
  275. t.Fatalf("%d samples, want %d", p.SampleCount(), MaxSamples)
  276. }
  277. err := p.AssignSample(127, writeWAV(t, filepath.Join(dir, "x.wav"), 4))
  278. if err == nil || !strings.Contains(err.Error(), "128") {
  279. t.Fatalf("129th sample: %v", err)
  280. }
  281. if !padOf(t, p, 127).Empty() || p.SampleCount() != MaxSamples {
  282. t.Error("refused assign changed the project")
  283. }
  284. // Reusing a file already in the pool needs no new entry.
  285. must(t, p.AssignSample(127, filepath.Join(dir, "s000.wav")))
  286. // Replacing a pad's only sample frees its entry first.
  287. must(t, p.AssignSample(1, filepath.Join(dir, "x.wav")))
  288. if p.SampleCount() != MaxSamples || padOf(t, p, 1).File != "x.wav" {
  289. t.Errorf("replace at the limit: %d samples, pad %+v", p.SampleCount(), padOf(t, p, 1))
  290. }
  291. }
  292. func TestAssignRejectsBadFiles(t *testing.T) {
  293. dir := t.TempDir()
  294. p := newProject(t)
  295. bad := filepath.Join(dir, "bad.wav")
  296. must(t, os.WriteFile(bad, []byte("not a wav"), 0o644))
  297. if err := p.AssignSample(0, bad); err == nil {
  298. t.Error("non-WAV accepted")
  299. }
  300. if err := p.AssignSample(0, filepath.Join(dir, "missing.wav")); err == nil {
  301. t.Error("missing file accepted")
  302. }
  303. if err := p.AssignSample(128, writeWAV(t, filepath.Join(dir, "ok.wav"), 4)); err == nil {
  304. t.Error("slot 128 accepted")
  305. }
  306. if p.dirty || p.SampleCount() != 0 {
  307. t.Error("failed assigns changed the project")
  308. }
  309. }
  310. func TestClearCopyMoveSwap(t *testing.T) {
  311. dir := t.TempDir()
  312. p := newProject(t)
  313. kick := writeWAV(t, filepath.Join(dir, "kick.wav"), 10)
  314. snare := writeWAV(t, filepath.Join(dir, "snare.wav"), 20)
  315. must(t, p.AssignSample(0, kick))
  316. must(t, p.AssignSample(1, snare))
  317. must(t, p.SetTune(0, 3, -20))
  318. must(t, p.SetMuteGroup(0, 2))
  319. // Copy across banks keeps tuning and mute group and shares the entry.
  320. must(t, p.CopyPad(0, 40))
  321. c := padOf(t, p, 40)
  322. if c.File != "kick.wav" || c.Coarse != 3 || c.Fine != -20 || c.MuteGroup != 2 || p.SampleCount() != 2 {
  323. t.Errorf("copy: %+v, %d samples", c, p.SampleCount())
  324. }
  325. // The copy is independent of its source.
  326. must(t, p.SetTune(40, 0, 0))
  327. if padOf(t, p, 0).Coarse != 3 {
  328. t.Error("copy shares data with its source")
  329. }
  330. // Move onto a filled pad replaces it; the snare's entry goes.
  331. must(t, p.MovePad(0, 1))
  332. if !padOf(t, p, 0).Empty() || padOf(t, p, 1).File != "kick.wav" || p.inPool("snare.wav") {
  333. t.Errorf("move: A1 %+v, A2 %+v", padOf(t, p, 0), padOf(t, p, 1))
  334. }
  335. blank, _ := template.pad.MarshalJSON()
  336. if got, _ := p.pad(0).MarshalJSON(); !bytes.Equal(got, blank) {
  337. t.Error("moved-from pad is not the blank pad")
  338. }
  339. // Swap.
  340. must(t, p.AssignSample(5, snare))
  341. must(t, p.SwapPads(1, 5))
  342. if padOf(t, p, 1).File != "snare.wav" || padOf(t, p, 5).File != "kick.wav" {
  343. t.Error("swap")
  344. }
  345. // Clearing the last user drops the entry; clearing a shared one keeps it.
  346. must(t, p.ClearPad(5))
  347. if !p.inPool("kick.wav") {
  348. t.Error("cleared a shared entry (kick is still on pad 40)")
  349. }
  350. must(t, p.ClearPad(40))
  351. if p.inPool("kick.wav") || p.SampleCount() != 1 {
  352. t.Errorf("kick entry kept after its last pad was cleared (%d samples)", p.SampleCount())
  353. }
  354. // No-ops and range checks.
  355. p.dirty = false
  356. must(t, p.ClearPad(100))
  357. must(t, p.CopyPad(1, 1))
  358. must(t, p.MovePad(1, 1))
  359. must(t, p.SwapPads(1, 1))
  360. if p.dirty {
  361. t.Error("no-op marked the project dirty")
  362. }
  363. for _, err := range []error{p.CopyPad(-1, 0), p.MovePad(0, 128), p.SwapPads(200, 0), p.ClearPad(128), p.SetTune(128, 0, 0), p.SetMuteGroup(-1, 0)} {
  364. if err == nil {
  365. t.Error("out-of-range slot accepted")
  366. }
  367. }
  368. }
  369. func TestTuneAndMuteGroup(t *testing.T) {
  370. p := newProject(t)
  371. must(t, p.SetTune(0, 12, 40))
  372. l, _ := layer(p.pad(0))
  373. if got := l.Lookup("pitch").Text; got != "12.399999618530273" {
  374. t.Errorf("pitch = %s", got)
  375. }
  376. must(t, p.SetTune(0, 99, -500))
  377. if v := padOf(t, p, 0); v.Coarse != 24 || v.Fine != -90 {
  378. t.Errorf("clamped tune = %d %d", v.Coarse, v.Fine)
  379. }
  380. if got := l.Lookup("pitch").Text; got != "23.100000381469727" {
  381. t.Errorf("pitch = %s", got)
  382. }
  383. must(t, p.SetMuteGroup(3, 1))
  384. if padOf(t, p, 3).MuteGroup != 1 {
  385. t.Error("mute group")
  386. }
  387. must(t, p.SetMuteGroup(3, 16))
  388. if err := p.SetMuteGroup(3, MaxMuteGroup+1); err == nil {
  389. t.Error("mute group out of range accepted")
  390. }
  391. }
  392. func TestSequences(t *testing.T) {
  393. p := newProject(t)
  394. must(t, p.AddSequence(4))
  395. s := p.Sequences()
  396. if len(s) != 2 || s[1] != (Sequence{Slot: 4, Name: "Sequence 05", BPM: DefaultBPM, Bars: 2}) {
  397. t.Fatalf("after add: %+v", s)
  398. }
  399. if err := p.AddSequence(4); err == nil {
  400. t.Error("duplicate slot accepted")
  401. }
  402. if err := p.AddSequence(MaxSequences); err == nil {
  403. t.Error("slot 128 accepted")
  404. }
  405. must(t, p.SetSequence(4, "Drop", 140.5, 16))
  406. must(t, p.AddSequence(127))
  407. s = p.Sequences()
  408. if s[1].Name != "Drop" || s[1].BPM != 140.5 || s[1].Bars != 16 || s[2].Name != "Sequence 128" || s[2].BPM != 140.5 {
  409. t.Errorf("after edit: %+v", s)
  410. }
  411. // Newest first in the file, as the device writes.
  412. if num(p.sequences().Items[0].Lookup("key")) != 127 {
  413. t.Error("new sequence not inserted first")
  414. }
  415. // All 26 length fields.
  416. v := p.sequences().Items[p.findSequence(4)].Lookup("value")
  417. n := 0
  418. for path, val := range leaves(v, "", nil) {
  419. if strings.HasSuffix(path, "lengthBars") || strings.HasSuffix(path, "loopEndBar") {
  420. n++
  421. if val != "16" {
  422. t.Errorf("%s = %s", path, val)
  423. }
  424. }
  425. if strings.HasSuffix(path, ".lengthPulses") || strings.HasSuffix(path, "loopEndPulses") || strings.HasSuffix(path, ".endPulses") {
  426. n++
  427. if val != "61440" {
  428. t.Errorf("%s = %s", path, val)
  429. }
  430. }
  431. }
  432. if n != 26 {
  433. t.Errorf("%d length fields, want 26", n)
  434. }
  435. for _, bad := range []struct {
  436. name string
  437. bpm float64
  438. bars int
  439. }{{"", 120, 2}, {"a/b", 120, 2}, {"ok", 0, 2}, {"ok", -1, 2}, {"ok", 19.9, 2}, {"ok", 999.5, 2}, {"ok", math.NaN(), 2}, {"ok", math.Inf(1), 2}, {"ok", 120, 0}, {"ok", 120, MaxBars + 1}} {
  440. if err := p.SetSequence(4, bad.name, bad.bpm, bad.bars); err == nil {
  441. t.Errorf("SetSequence(%q, %v, %d) accepted", bad.name, bad.bpm, bad.bars)
  442. }
  443. }
  444. must(t, p.SetSequence(4, "Min", MinBPM, 1))
  445. must(t, p.SetSequence(4, "Max", MaxBPM, MaxBars))
  446. if err := p.SetSequence(9, "x", 120, 2); err == nil {
  447. t.Error("missing sequence accepted")
  448. }
  449. // Remove: current sequence moves to the lowest remaining slot.
  450. must(t, p.data.SetKey("currentSequence", xpj.NewInt(4)))
  451. must(t, p.RemoveSequence(4))
  452. if cur := num(p.data.Lookup("currentSequence")); cur != 0 {
  453. t.Errorf("currentSequence = %d, want 0", cur)
  454. }
  455. must(t, p.RemoveSequence(0))
  456. if cur := num(p.data.Lookup("currentSequence")); cur != 127 {
  457. t.Errorf("currentSequence = %d, want 127", cur)
  458. }
  459. if err := p.RemoveSequence(127); err == nil {
  460. t.Error("removed the last sequence")
  461. }
  462. if err := p.RemoveSequence(3); err == nil {
  463. t.Error("removed a missing sequence")
  464. }
  465. }
  466. func TestLoadDemoFile(t *testing.T) {
  467. p, warnings, err := Load(filepath.Join(testdata, "device", "DemoFile.xpj"))
  468. must(t, err)
  469. if len(warnings) != 0 {
  470. t.Errorf("warnings: %v", warnings)
  471. }
  472. if p.Name != "DemoFile" || p.dirty || p.SampleCount() != 6 {
  473. t.Errorf("name %q dirty %v samples %d", p.Name, p.dirty, p.SampleCount())
  474. }
  475. for file, src := range p.sources {
  476. if _, err := os.Stat(src); err != nil {
  477. t.Errorf("source for %s: %v", file, err)
  478. }
  479. }
  480. // The sequences have notes on A1-A4 (36-39); the other pads have none.
  481. for slot := 0; slot < Pads; slot++ {
  482. if got, want := p.PadHasNotes(slot), slot < 4; got != want {
  483. t.Errorf("PadHasNotes(%s) = %v", SlotName(slot), got)
  484. }
  485. }
  486. s := p.Sequences()
  487. if len(s) != 2 || s[0].Name != "Intro" || !s[0].HasNotes || s[1].Name != "Sequence 02" || !s[1].HasNotes {
  488. t.Errorf("sequences %+v", s)
  489. }
  490. if v := padOf(t, p, 2); v.MuteGroup != 1 || v.File != "80s-Hat-Dreamer1 SA.wav" {
  491. t.Errorf("A3 = %+v", v)
  492. }
  493. // Loaded and unchanged: writes back byte-identical.
  494. want := decompress(t, filepath.Join(testdata, "device", "DemoFile.xpj"))
  495. if !bytes.Equal(encode(t, p), want) {
  496. t.Error("loaded project does not write back unchanged")
  497. }
  498. // Assigning a file already in the project folder reuses its entry.
  499. must(t, p.AssignSample(50, filepath.Join(testdata, "device", "DemoFile"+DataSuffix, "Bass-Pure C.wav")))
  500. if p.SampleCount() != 6 {
  501. t.Errorf("%d samples after reusing a loaded file", p.SampleCount())
  502. }
  503. }
  504. func TestBlankPadsAndTemplate(t *testing.T) {
  505. p := newProject(t)
  506. if !bytes.Equal(encode(t, p), decompress(t, filepath.Join(testdata, "device", "DemoBlank.xpj"))) {
  507. t.Error("New() is not DemoBlank (is internal/project/template out of date?)")
  508. }
  509. for slot := 0; slot < Pads; slot++ {
  510. if !padOf(t, p, slot).Empty() || p.PadHasNotes(slot) {
  511. t.Fatalf("pad %d not empty", slot)
  512. }
  513. }
  514. }
  515. func TestNames(t *testing.T) {
  516. p := newProject(t)
  517. must(t, p.SetName("My Kit 2"))
  518. if p.Name != "My Kit 2" || !p.dirty {
  519. t.Error("SetName")
  520. }
  521. for _, bad := range []string{"", " ", "a:b", "a?", "end.", "end ", "CON", "com1.txt", "tab\there"} {
  522. if err := ValidName(bad); err == nil {
  523. t.Errorf("ValidName(%q) accepted", bad)
  524. }
  525. }
  526. if _, err := New("bad|name"); err == nil {
  527. t.Error("New with a bad name accepted")
  528. }
  529. for slot, want := range map[int]string{0: "A1", 4: "A5", 16: "B1", 17: "B2", 127: "H16"} {
  530. if got := SlotName(slot); got != want {
  531. t.Errorf("SlotName(%d) = %s, want %s", slot, got, want)
  532. }
  533. }
  534. }
  535. func TestPoolFileName(t *testing.T) {
  536. for in, want := range map[string]string{
  537. "kick.wav": "kick.wav",
  538. `kick\one.wav`: "kick_one.wav",
  539. "a:b*c?.wav": "a_b_c_.wav",
  540. `"q"<>|.wav`: "_q____.wav",
  541. "tab\there.wav": "tab_here.wav",
  542. "Ünïcode 80s.wav": "Ünïcode 80s.wav",
  543. "": "sample.wav",
  544. ".": "sample.wav",
  545. "..": "sample.wav",
  546. "CON.wav": "CON_.wav",
  547. "aux.wav": "aux_.wav",
  548. "com1.loop.wav": "com1_.loop.wav",
  549. "Lpt9": "Lpt9_",
  550. "console.wav": "console.wav",
  551. "com10.wav": "com10.wav",
  552. } {
  553. got := poolFileName(in)
  554. if got != want {
  555. t.Errorf("poolFileName(%q) = %q, want %q", in, got, want)
  556. }
  557. if err := PlainFileName(got); err != nil || reservedOnWindows(got) {
  558. t.Errorf("poolFileName(%q) = %q: not a safe name", in, got)
  559. }
  560. }
  561. }
  562. func TestAssignFollowsLink(t *testing.T) {
  563. dir := t.TempDir()
  564. kick := writeWAV(t, filepath.Join(dir, "library", "kick.wav"), 100)
  565. link := filepath.Join(dir, "favourite.wav")
  566. if err := os.Symlink(kick, link); err != nil {
  567. t.Skipf("cannot create a symlink: %v", err)
  568. }
  569. p := newProject(t)
  570. must(t, p.AssignSample(0, link))
  571. want, err := filepath.EvalSymlinks(kick)
  572. must(t, err)
  573. // The pool file is named after the link; it is copied from the target.
  574. if got := p.PoolFiles(); len(got) != 1 || got[0] != "favourite.wav" || p.sources["favourite.wav"] != want {
  575. t.Errorf("pool %q, source %q; want favourite.wav from %q", got, p.sources["favourite.wav"], want)
  576. }
  577. // The target itself is the same source: no second pool entry.
  578. must(t, p.AssignSample(1, kick))
  579. if p.SampleCount() != 1 {
  580. t.Errorf("%d pool entries, want 1", p.SampleCount())
  581. }
  582. }
  583. // loadDemo loads a copy of the device's DemoFile, with its data folder,
  584. // after applying change to its data.
  585. func loadDemo(t *testing.T, change func(d *xpj.Node)) *Project {
  586. t.Helper()
  587. dir := t.TempDir()
  588. f, err := xpj.ReadFile(filepath.Join(testdata, "device", "DemoFile.xpj"))
  589. must(t, err)
  590. d, err := f.Data()
  591. must(t, err)
  592. change(d)
  593. path := filepath.Join(dir, "DemoFile.xpj")
  594. must(t, f.WriteFile(path))
  595. p, _, err := Load(path)
  596. must(t, err)
  597. return p
  598. }
  599. // unchanged fails the test if p differs from the encoding and sources
  600. // taken before, or has become dirty.
  601. func unchanged(t *testing.T, p *Project, before []byte, sources map[string]string) {
  602. t.Helper()
  603. if !bytes.Equal(encode(t, p), before) {
  604. t.Error("the project file changed")
  605. }
  606. if fmt.Sprint(p.sources) != fmt.Sprint(sources) {
  607. t.Errorf("sources changed: %v, want %v", p.sources, sources)
  608. }
  609. if p.Dirty() {
  610. t.Error("dirty after a failed edit")
  611. }
  612. }
  613. func TestFailedEditsChangeNothing(t *testing.T) {
  614. dir := t.TempDir()
  615. good := writeWAV(t, filepath.Join(dir, "good.wav"), 100)
  616. notWAV := filepath.Join(dir, "notes.wav")
  617. must(t, os.WriteFile(notWAV, []byte("not a WAV file"), 0o644))
  618. p := loadDemo(t, func(d *xpj.Node) {
  619. // A2's first layer is not an object, B5 is not an object at all,
  620. // and a clip of the first sequence has no value.
  621. must(t, d.Set("tracks[0].program.drum.instruments[1].layersv[0]", xpj.NewInt(7)))
  622. must(t, d.Set("tracks[0].program.drum.instruments[20]", xpj.NewInt(7)))
  623. clip, err := d.Get("sequences[0].value.trackClipMaps[0][3]")
  624. must(t, err)
  625. clip.DeleteKey("value")
  626. })
  627. before := encode(t, p)
  628. sources := map[string]string{}
  629. for k, v := range p.sources {
  630. sources[k] = v
  631. }
  632. seq := p.Sequences()[1] // sequences[0] in the file is slot 1
  633. for _, c := range []struct {
  634. name string
  635. edit func() error
  636. }{
  637. {"assign a missing file", func() error { return p.AssignSample(0, filepath.Join(dir, "gone.wav")) }},
  638. {"assign a file that is not a WAV", func() error { return p.AssignSample(0, notWAV) }},
  639. {"tune a pad whose layer is not an object", func() error { return p.SetTune(1, 3, 0) }},
  640. {"mute group on a pad that is not an object", func() error { return p.SetMuteGroup(20, 2) }},
  641. {"sequence whose clip has no value", func() error { return p.SetSequence(seq.Slot, "Renamed", 99, 8) }},
  642. {"bad slot", func() error { return p.SetTune(Pads, 0, 0) }},
  643. } {
  644. t.Run(c.name, func(t *testing.T) {
  645. if err := c.edit(); err == nil {
  646. t.Fatal("no error")
  647. }
  648. unchanged(t, p, before, sources)
  649. })
  650. }
  651. // The sample limit: a full pool refuses a new file and changes nothing.
  652. full := newProject(t)
  653. for i := range MaxSamples - 1 {
  654. must(t, full.AssignSample(i, writeWAV(t, filepath.Join(dir, fmt.Sprintf("g%03d.wav", i)), 4)))
  655. }
  656. full.addPoolEntry("unused.wav") // a 128th entry no pad uses
  657. full.MarkExported()
  658. before = encode(t, full)
  659. sources = map[string]string{}
  660. for k, v := range full.sources {
  661. sources[k] = v
  662. }
  663. if err := full.AssignSample(MaxSamples-1, good); err == nil {
  664. t.Fatal("129th sample accepted")
  665. }
  666. unchanged(t, full, before, sources)
  667. }
  668. func TestAssignReplacesBrokenPad(t *testing.T) {
  669. // AssignSample starts from the blank pad, so a pad with no layers is
  670. // replaced whole rather than failing halfway.
  671. p := loadDemo(t, func(d *xpj.Node) {
  672. must(t, d.Set("tracks[0].program.drum.instruments[5].layersv", xpj.NewArray()))
  673. })
  674. kick := writeWAV(t, filepath.Join(t.TempDir(), "kick.wav"), 100)
  675. must(t, p.AssignSample(5, kick))
  676. if v := padOf(t, p, 5); v.File != "kick.wav" || v.End != 99 {
  677. t.Errorf("pad A6 = %+v", v)
  678. }
  679. }
  680. func TestAssignReusesFreedName(t *testing.T) {
  681. // Replacing a pad's only sample frees its name for the new file, as
  682. // before edits were made all-or-nothing.
  683. dir := t.TempDir()
  684. p := newProject(t)
  685. must(t, p.AssignSample(0, writeWAV(t, filepath.Join(dir, "a", "kick.wav"), 10)))
  686. must(t, p.AssignSample(0, writeWAV(t, filepath.Join(dir, "b", "kick.wav"), 20)))
  687. if got := p.PoolFiles(); len(got) != 1 || got[0] != "kick.wav" {
  688. t.Errorf("pool %q, want [kick.wav]", got)
  689. }
  690. }