1
0

export_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. package export
  2. import (
  3. "archive/zip"
  4. "bytes"
  5. "compress/gzip"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "io/fs"
  10. "os"
  11. "path/filepath"
  12. "runtime"
  13. "sort"
  14. "strings"
  15. "testing"
  16. "git.monoexp.com/bajdev/mpckit/internal/project"
  17. "git.monoexp.com/bajdev/mpckit/internal/xpj"
  18. )
  19. const testdata = "../../testdata"
  20. func must(t *testing.T, err error) {
  21. t.Helper()
  22. if err != nil {
  23. t.Fatal(err)
  24. }
  25. }
  26. func gunzip(t *testing.T, b []byte) []byte {
  27. t.Helper()
  28. zr, err := gzip.NewReader(bytes.NewReader(b))
  29. must(t, err)
  30. out, err := io.ReadAll(zr)
  31. must(t, err)
  32. return out
  33. }
  34. func readFile(t *testing.T, path string) []byte {
  35. t.Helper()
  36. b, err := os.ReadFile(path)
  37. must(t, err)
  38. return b
  39. }
  40. // buildT1 makes MpckitT1 through the model (as in the M2 golden test).
  41. func buildT1(t *testing.T) *project.Project {
  42. t.Helper()
  43. p, err := project.New("MpckitT1")
  44. must(t, err)
  45. dir := filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
  46. must(t, p.AssignSample(0, filepath.Join(dir, "Linn 9000 kick.wav")))
  47. must(t, p.AssignSample(16, filepath.Join(dir, "Linn 9000 kick001.wav")))
  48. must(t, p.SetTune(16, 5, 0))
  49. must(t, p.SetSequence(0, "Verse", 96, 8))
  50. must(t, p.AddSequence(1))
  51. must(t, p.SetSequence(1, "Chorus", 96, 4))
  52. return p
  53. }
  54. func listDir(t *testing.T, dir string) []string {
  55. t.Helper()
  56. ents, err := os.ReadDir(dir)
  57. must(t, err)
  58. var names []string
  59. for _, e := range ents {
  60. names = append(names, e.Name())
  61. }
  62. sort.Strings(names)
  63. return names
  64. }
  65. func TestFolder(t *testing.T) {
  66. dest := t.TempDir()
  67. p := buildT1(t)
  68. path, err := Folder(p, dest, false)
  69. must(t, err)
  70. if path != filepath.Join(dest, "MpckitT1.xpj") {
  71. t.Errorf("path = %s", path)
  72. }
  73. if got := listDir(t, dest); strings.Join(got, "|") != "MpckitT1.xpj|MpckitT1_[ProjectData]" {
  74. t.Errorf("dest holds %v", got)
  75. }
  76. want := gunzip(t, readFile(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj")))
  77. if !bytes.Equal(gunzip(t, readFile(t, path)), want) {
  78. t.Error("exported .xpj differs from MpckitT1.xpj")
  79. }
  80. src := filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
  81. data := filepath.Join(dest, "MpckitT1"+project.DataSuffix)
  82. if got := listDir(t, data); strings.Join(got, "|") != "Linn 9000 kick.wav|Linn 9000 kick001.wav" {
  83. t.Errorf("data folder holds %v", got)
  84. }
  85. for _, f := range listDir(t, data) {
  86. if !bytes.Equal(readFile(t, filepath.Join(data, f)), readFile(t, filepath.Join(src, f))) {
  87. t.Errorf("%s differs from its source", f)
  88. }
  89. }
  90. // Again without overwrite: refused, nothing touched.
  91. marker := filepath.Join(data, "keep.txt")
  92. must(t, os.WriteFile(marker, []byte("x"), 0o644))
  93. _, err = Folder(p, dest, false)
  94. var exists *ExistsError
  95. if !errors.As(err, &exists) || len(exists.Paths) != 2 {
  96. t.Fatalf("second export: %v", err)
  97. }
  98. if _, err := os.Stat(marker); err != nil {
  99. t.Error("refused export changed the folder")
  100. }
  101. // With overwrite: the old data folder is replaced, not merged.
  102. _, err = Folder(p, dest, true)
  103. must(t, err)
  104. if _, err := os.Stat(marker); err == nil {
  105. t.Error("overwrite kept a stale file in the data folder")
  106. }
  107. if got := listDir(t, dest); len(got) != 2 {
  108. t.Errorf("temporary files left behind: %v", got)
  109. }
  110. }
  111. func TestFolderEmptyProject(t *testing.T) {
  112. dest := t.TempDir()
  113. p, err := project.New("Empty")
  114. must(t, err)
  115. _, err = Folder(p, dest, false)
  116. must(t, err)
  117. // The device creates the data folder even with no samples; so do we.
  118. if got := listDir(t, filepath.Join(dest, "Empty"+project.DataSuffix)); len(got) != 0 {
  119. t.Errorf("data folder holds %v", got)
  120. }
  121. }
  122. func TestRenamedLoadedProject(t *testing.T) {
  123. p, _, err := project.Load(filepath.Join(testdata, "device", "DemoFile.xpj"))
  124. must(t, err)
  125. must(t, p.SetName("Renamed"))
  126. dest := t.TempDir()
  127. _, err = Folder(p, dest, false)
  128. must(t, err)
  129. got := listDir(t, filepath.Join(dest, "Renamed"+project.DataSuffix))
  130. if len(got) != 6 {
  131. t.Errorf("data folder holds %v", got)
  132. }
  133. want := gunzip(t, readFile(t, filepath.Join(testdata, "device", "DemoFile.xpj")))
  134. if !bytes.Equal(gunzip(t, readFile(t, filepath.Join(dest, "Renamed.xpj"))), want) {
  135. t.Error("renaming changed the file contents")
  136. }
  137. }
  138. func TestMissingSources(t *testing.T) {
  139. tmp := t.TempDir()
  140. p, err := project.New("Missing")
  141. must(t, err)
  142. for i, name := range []string{"a.wav", "b.wav", "c.wav"} {
  143. src := filepath.Join(tmp, name)
  144. must(t, os.WriteFile(src, readFile(t, filepath.Join(testdata, "accepted", "MpckitT2"+project.DataSuffix, "Linn 9000 kick.wav")), 0o644))
  145. must(t, p.AssignSample(i, src))
  146. }
  147. must(t, os.Remove(filepath.Join(tmp, "a.wav")))
  148. must(t, os.Remove(filepath.Join(tmp, "c.wav")))
  149. dest := t.TempDir()
  150. _, err = Folder(p, dest, false)
  151. var missing *MissingError
  152. if !errors.As(err, &missing) || strings.Join(missing.Files, ",") != "a.wav,c.wav" {
  153. t.Fatalf("Folder: %v", err)
  154. }
  155. if !strings.Contains(err.Error(), "2 sample file(s)") {
  156. t.Errorf("message: %v", err)
  157. }
  158. if err := Zip(p, filepath.Join(dest, "m.zip"), false); !errors.As(err, &missing) {
  159. t.Fatalf("Zip: %v", err)
  160. }
  161. if got := listDir(t, dest); len(got) != 0 {
  162. t.Errorf("failed export wrote %v", got)
  163. }
  164. }
  165. func TestZip(t *testing.T) {
  166. dest := t.TempDir()
  167. zpath := filepath.Join(dest, "kit.zip")
  168. p := buildT1(t)
  169. must(t, Zip(p, zpath, false))
  170. zr, err := zip.OpenReader(zpath)
  171. must(t, err)
  172. defer zr.Close()
  173. var names []string
  174. files := map[string][]byte{}
  175. for _, f := range zr.File {
  176. names = append(names, f.Name)
  177. rc, err := f.Open()
  178. must(t, err)
  179. b, err := io.ReadAll(rc)
  180. rc.Close()
  181. must(t, err)
  182. files[f.Name] = b
  183. }
  184. want := "MpckitT1.xpj|MpckitT1_[ProjectData]/|MpckitT1_[ProjectData]/Linn 9000 kick.wav|MpckitT1_[ProjectData]/Linn 9000 kick001.wav"
  185. if strings.Join(names, "|") != want {
  186. t.Errorf("entries %v", names)
  187. }
  188. if !bytes.Equal(gunzip(t, files["MpckitT1.xpj"]), gunzip(t, readFile(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj")))) {
  189. t.Error("zipped .xpj differs from MpckitT1.xpj")
  190. }
  191. src := filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix, "Linn 9000 kick001.wav")
  192. if !bytes.Equal(files["MpckitT1_[ProjectData]/Linn 9000 kick001.wav"], readFile(t, src)) {
  193. t.Error("zipped WAV differs from its source")
  194. }
  195. var exists *ExistsError
  196. if err := Zip(p, zpath, false); !errors.As(err, &exists) {
  197. t.Errorf("existing zip: %v", err)
  198. }
  199. must(t, Zip(p, zpath, true))
  200. if got := listDir(t, dest); strings.Join(got, "|") != "kit.zip" {
  201. t.Errorf("temporary files left behind: %v", got)
  202. }
  203. }
  204. // snapshot returns every file and folder below dir with its contents
  205. // ("<dir>" for a folder).
  206. func snapshot(t *testing.T, dir string) map[string]string {
  207. t.Helper()
  208. out := map[string]string{}
  209. err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
  210. if err != nil || path == dir {
  211. return err
  212. }
  213. rel, _ := filepath.Rel(dir, path)
  214. if d.IsDir() {
  215. out[rel] = "<dir>"
  216. return nil
  217. }
  218. b, err := os.ReadFile(path)
  219. out[rel] = string(b)
  220. return err
  221. })
  222. must(t, err)
  223. return out
  224. }
  225. func sameSnapshot(t *testing.T, got, want map[string]string) {
  226. t.Helper()
  227. if len(got) != len(want) {
  228. t.Errorf("%d entries, want %d: %v", len(got), len(want), keys(got))
  229. }
  230. for k, v := range want {
  231. if got[k] != v {
  232. t.Errorf("%s changed or missing", k)
  233. }
  234. }
  235. }
  236. func keys(m map[string]string) []string {
  237. var out []string
  238. for k := range m {
  239. out = append(out, k)
  240. }
  241. sort.Strings(out)
  242. return out
  243. }
  244. // symlink makes a link, or skips the test where the system does not allow
  245. // it (Windows without developer mode).
  246. func symlink(t *testing.T, target, link string) {
  247. t.Helper()
  248. if err := os.Symlink(target, link); err != nil {
  249. t.Skipf("cannot create a symlink: %v", err)
  250. }
  251. }
  252. func TestSymlinkedSource(t *testing.T) {
  253. // A copy of MpckitT1 whose data folder holds a link instead of a WAV.
  254. src := t.TempDir()
  255. must(t, os.WriteFile(filepath.Join(src, "Kit.xpj"), readFile(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj")), 0o644))
  256. data := filepath.Join(src, "Kit"+project.DataSuffix)
  257. must(t, os.Mkdir(data, 0o755))
  258. acc := filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
  259. must(t, os.WriteFile(filepath.Join(data, "Linn 9000 kick.wav"), readFile(t, filepath.Join(acc, "Linn 9000 kick.wav")), 0o644))
  260. outside := filepath.Join(t.TempDir(), "secret.txt")
  261. must(t, os.WriteFile(outside, []byte("secret"), 0o644))
  262. link := filepath.Join(data, "Linn 9000 kick001.wav")
  263. symlink(t, outside, link)
  264. if Usable(link) || !Usable(filepath.Join(data, "Linn 9000 kick.wav")) || Usable(data) || Usable(outside+"x") {
  265. t.Error("Usable: wrong answer for a link, a file, a folder or a missing file")
  266. }
  267. p, _, err := project.Load(filepath.Join(src, "Kit.xpj"))
  268. must(t, err)
  269. dest := t.TempDir()
  270. _, err = Folder(p, dest, false)
  271. var missing *MissingError
  272. if !errors.As(err, &missing) || strings.Join(missing.Files, ",") != "Linn 9000 kick001.wav" {
  273. t.Fatalf("Folder: %v", err)
  274. }
  275. if err := Zip(p, filepath.Join(dest, "kit.zip"), false); !errors.As(err, &missing) {
  276. t.Fatalf("Zip: %v", err)
  277. }
  278. if got := listDir(t, dest); len(got) != 0 {
  279. t.Errorf("failed export wrote %v", got)
  280. }
  281. }
  282. func TestLinkedDataFolder(t *testing.T) {
  283. // The whole data folder is a link to a folder holding a file with a
  284. // pool file's name.
  285. src := t.TempDir()
  286. must(t, os.WriteFile(filepath.Join(src, "Kit.xpj"), readFile(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj")), 0o644))
  287. elsewhere := t.TempDir()
  288. for _, f := range []string{"Linn 9000 kick.wav", "Linn 9000 kick001.wav"} {
  289. must(t, os.WriteFile(filepath.Join(elsewhere, f), []byte("private"), 0o644))
  290. }
  291. symlink(t, elsewhere, filepath.Join(src, "Kit"+project.DataSuffix))
  292. p, _, err := project.Load(filepath.Join(src, "Kit.xpj"))
  293. must(t, err)
  294. dest := t.TempDir()
  295. _, err = Folder(p, dest, false)
  296. var missing *MissingError
  297. if !errors.As(err, &missing) || len(missing.Files) != 2 {
  298. t.Fatalf("Folder: %v", err)
  299. }
  300. if got := listDir(t, dest); len(got) != 0 {
  301. t.Errorf("failed export wrote %v", got)
  302. }
  303. }
  304. func TestBadPoolName(t *testing.T) {
  305. // Load and AssignSample never produce such a name, and only project can
  306. // record a source, so the export check is tested directly: both Folder
  307. // and the zip writer call it before writing anything.
  308. src := filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix, "Linn 9000 kick.wav")
  309. items := []item{{name: "../evil.wav", src: src}}
  310. want := `sample "../evil.wav": file name "../evil.wav" contains a folder separator`
  311. if err := checkNames(items); err == nil || err.Error() != want {
  312. t.Errorf("checkNames: got %v, want %q", err, want)
  313. }
  314. var b bytes.Buffer
  315. if err := writeZip(&b, buildT1(t), nil, items); err == nil || err.Error() != want {
  316. t.Errorf("writeZip: got %v, want %q", err, want)
  317. }
  318. if b.Len() != 0 {
  319. t.Errorf("writeZip wrote %d bytes", b.Len())
  320. }
  321. if err := checkNames([]item{{name: "kick.wav", src: src}}); err != nil {
  322. t.Errorf("checkNames on a plain name: %v", err)
  323. }
  324. }
  325. func TestMissingDestination(t *testing.T) {
  326. dest := filepath.Join(t.TempDir(), "gone")
  327. _, err := Folder(buildT1(t), dest, false)
  328. if want := "export folder does not exist: " + dest; err == nil || err.Error() != want {
  329. t.Errorf("got %v, want %q", err, want)
  330. }
  331. }
  332. // failRename makes the listed renames from now fail, counting from 1, until
  333. // the test ends.
  334. func failRename(t *testing.T, fail ...int) {
  335. t.Helper()
  336. calls := 0
  337. rename = func(from, to string) error {
  338. calls++
  339. for _, n := range fail {
  340. if calls == n {
  341. return &os.LinkError{Op: "rename", Old: from, New: to, Err: errors.New("test failure")}
  342. }
  343. }
  344. return os.Rename(from, to)
  345. }
  346. t.Cleanup(func() { rename = os.Rename })
  347. }
  348. func TestOverwriteFailureKeepsOldExport(t *testing.T) {
  349. // The renames of an overwrite: 1 old data folder aside, 2 old .xpj
  350. // aside, 3 new data folder in, 4 new .xpj in. Failing any of them
  351. // leaves the previous export exactly as it was.
  352. for n := 1; n <= 4; n++ {
  353. t.Run(fmt.Sprint(n), func(t *testing.T) {
  354. dest := t.TempDir()
  355. _, err := Folder(buildT1(t), dest, false)
  356. must(t, err)
  357. before := snapshot(t, dest)
  358. changed := buildT1(t)
  359. must(t, changed.ClearPad(16))
  360. failRename(t, n)
  361. if _, err := Folder(changed, dest, true); err == nil || !strings.Contains(err.Error(), "test failure") {
  362. t.Fatalf("Folder: %v", err)
  363. }
  364. sameSnapshot(t, snapshot(t, dest), before)
  365. })
  366. }
  367. }
  368. func TestOverwriteUndoFails(t *testing.T) {
  369. dest := t.TempDir()
  370. _, err := Folder(buildT1(t), dest, false)
  371. must(t, err)
  372. before := snapshot(t, dest)
  373. // The new .xpj cannot be moved in (4), and moving the new data folder
  374. // back out fails too (5): the old pair stays in the temporary folder.
  375. failRename(t, 4, 5)
  376. _, err = Folder(buildT1(t), dest, true)
  377. if err == nil || !strings.Contains(err.Error(), "could not be put back and is kept in") {
  378. t.Fatalf("Folder: %v", err)
  379. }
  380. _, kept, _ := strings.Cut(err.Error(), "is kept in ")
  381. if got := snapshot(t, kept); len(got) != len(before) {
  382. t.Errorf("kept %v, want the %d entries of the old export", keys(got), len(before))
  383. }
  384. }
  385. func TestOverwriteHalfPair(t *testing.T) {
  386. // Only the .xpj of an earlier export is there: it is replaced and the
  387. // data folder created.
  388. dest := t.TempDir()
  389. must(t, os.WriteFile(filepath.Join(dest, "MpckitT1.xpj"), []byte("old"), 0o644))
  390. _, err := Folder(buildT1(t), dest, true)
  391. must(t, err)
  392. if got := listDir(t, dest); strings.Join(got, "|") != "MpckitT1.xpj|MpckitT1_[ProjectData]" {
  393. t.Errorf("dest holds %v", got)
  394. }
  395. if string(readFile(t, filepath.Join(dest, "MpckitT1.xpj"))) == "old" {
  396. t.Error("old .xpj not replaced")
  397. }
  398. }
  399. func TestBackslashNameExportsAndLoads(t *testing.T) {
  400. if runtime.GOOS == "windows" {
  401. t.Skip("Windows file names cannot hold a backslash")
  402. }
  403. src := filepath.Join(t.TempDir(), `kick\one.wav`)
  404. must(t, os.WriteFile(src, readFile(t, filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix, "Linn 9000 kick.wav")), 0o644))
  405. p, err := project.New("Backslash")
  406. must(t, err)
  407. must(t, p.AssignSample(0, src))
  408. if got := p.PoolFiles(); len(got) != 1 || got[0] != "kick_one.wav" {
  409. t.Fatalf("pool = %q, want kick_one.wav", got)
  410. }
  411. dest := t.TempDir()
  412. path, err := Folder(p, dest, false)
  413. must(t, err)
  414. q, _, err := project.Load(path)
  415. must(t, err)
  416. if v, err := q.Pad(0); err != nil || v.File != "kick_one.wav" || v.SampleName != "kick_one" {
  417. t.Errorf("reloaded pad A1 = %+v, %v", v, err)
  418. }
  419. }
  420. func TestExportErrors(t *testing.T) {
  421. dir := t.TempDir()
  422. file := filepath.Join(dir, "file")
  423. must(t, os.WriteFile(file, nil, 0o644))
  424. bad := buildT1(t)
  425. bad.Name = "a/b"
  426. if _, err := Folder(bad, dir, false); err == nil || !strings.HasPrefix(err.Error(), "project name:") {
  427. t.Errorf("Folder with a bad name: %v", err)
  428. }
  429. if err := Zip(bad, filepath.Join(dir, "kit.zip"), false); err == nil || !strings.HasPrefix(err.Error(), "project name:") {
  430. t.Errorf("Zip with a bad name: %v", err)
  431. }
  432. // A pool file with no recorded source: rename one in the tree.
  433. unknown := buildT1(t)
  434. d, err := unknown.File().Data()
  435. must(t, err)
  436. must(t, d.Set("samples[0].path", xpj.NewString("other.wav")))
  437. _, err = Folder(unknown, dir, false)
  438. if err == nil || !strings.Contains(err.Error(), "other.wav (no source recorded)") {
  439. t.Errorf("no source: %v", err)
  440. }
  441. if _, err := Folder(buildT1(t), file, false); err == nil {
  442. t.Error("Folder into a file: no error")
  443. }
  444. if err := Zip(buildT1(t), filepath.Join(dir, "gone", "kit.zip"), false); err == nil {
  445. t.Error("Zip into a missing folder: no error")
  446. }
  447. _, err = Folder(buildT1(t), dir, false)
  448. must(t, err)
  449. _, err = Folder(buildT1(t), dir, false)
  450. if want := "already exists: " + filepath.Join(dir, "MpckitT1.xpj") + ", " + filepath.Join(dir, "MpckitT1_[ProjectData]"); err == nil || err.Error() != want {
  451. t.Errorf("got %v, want %q", err, want)
  452. }
  453. if got := listDir(t, dir); strings.Join(got, "|") != "MpckitT1.xpj|MpckitT1_[ProjectData]|file" {
  454. t.Errorf("failed exports left %v", got)
  455. }
  456. }