« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/test/unit/data/things
diff options
context:
space:
mode:
Diffstat (limited to 'test/unit/data/things')
-rw-r--r--test/unit/data/things/album.js451
-rw-r--r--test/unit/data/things/art-tag.js71
-rw-r--r--test/unit/data/things/flash.js55
-rw-r--r--test/unit/data/things/track.js759
-rw-r--r--test/unit/data/things/validators.js440
5 files changed, 1776 insertions, 0 deletions
diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js
new file mode 100644
index 0000000..46ea83b
--- /dev/null
+++ b/test/unit/data/things/album.js
@@ -0,0 +1,451 @@
+import t from 'tap';
+
+import {linkAndBindWikiData} from '#test-lib';
+import thingConstructors from '#things';
+
+const {
+  Album,
+  ArtTag,
+  Artist,
+  Track,
+} = thingConstructors;
+
+function stubArtTag(tagName = `Test Art Tag`) {
+  const tag = new ArtTag();
+  tag.name = tagName;
+
+  return tag;
+}
+
+function stubArtistAndContribs() {
+  const artist = new Artist();
+  artist.name = `Test Artist`;
+
+  const contribs = [{who: `Test Artist`, what: null}];
+  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
+
+  return {artist, contribs, badContribs};
+}
+
+function stubTrack(directory = 'foo') {
+  const track = new Track();
+  track.directory = directory;
+
+  return track;
+}
+
+t.test(`Album.artTags`, t => {
+  t.plan(3);
+
+  const {artist, contribs} = stubArtistAndContribs();
+  const album = new Album();
+  const tag1 = stubArtTag(`Tag 1`);
+  const tag2 = stubArtTag(`Tag 2`);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    artTagData: [tag1, tag2],
+  });
+
+  t.same(album.artTags, [],
+    `artTags #1: defaults to empty array`);
+
+  album.artTags = [`Tag 1`, `Tag 2`];
+
+  t.same(album.artTags, [],
+    `artTags #2: is empty if album doesn't have cover artists`);
+
+  album.coverArtistContribs = contribs;
+
+  t.same(album.artTags, [tag1, tag2],
+    `artTags #3: resolves if album has cover artists`);
+});
+
+t.test(`Album.bannerDimensions`, t => {
+  t.plan(4);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.bannerDimensions, null,
+    `Album.bannerDimensions #1: defaults to null`);
+
+  album.bannerDimensions = [1200, 275];
+
+  t.equal(album.bannerDimensions, null,
+    `Album.bannerDimensions #2: is null if bannerArtistContribs empty`);
+
+  album.bannerArtistContribs = badContribs;
+
+  t.equal(album.bannerDimensions, null,
+    `Album.bannerDimensions #3: is null if bannerArtistContribs resolves empty`);
+
+  album.bannerArtistContribs = contribs;
+
+  t.same(album.bannerDimensions, [1200, 275],
+    `Album.bannerDimensions #4: is own value`);
+});
+
+t.test(`Album.bannerFileExtension`, t => {
+  t.plan(5);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.bannerFileExtension, null,
+    `Album.bannerFileExtension #1: defaults to null`);
+
+  album.bannerFileExtension = 'png';
+
+  t.equal(album.bannerFileExtension, null,
+    `Album.bannerFileExtension #2: is null if bannerArtistContribs empty`);
+
+  album.bannerArtistContribs = badContribs;
+
+  t.equal(album.bannerFileExtension, null,
+    `Album.bannerFileExtension #3: is null if bannerArtistContribs resolves empty`);
+
+  album.bannerArtistContribs = contribs;
+
+  t.equal(album.bannerFileExtension, 'png',
+    `Album.bannerFileExtension #4: is own value`);
+
+  album.bannerFileExtension = null;
+
+  t.equal(album.bannerFileExtension, 'jpg',
+    `Album.bannerFileExtension #5: defaults to jpg`);
+});
+
+t.test(`Album.bannerStyle`, t => {
+  t.plan(4);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.bannerStyle, null,
+    `Album.bannerStyle #1: defaults to null`);
+
+  album.bannerStyle = `opacity: 0.5`;
+
+  t.equal(album.bannerStyle, null,
+    `Album.bannerStyle #2: is null if bannerArtistContribs empty`);
+
+  album.bannerArtistContribs = badContribs;
+
+  t.equal(album.bannerStyle, null,
+    `Album.bannerStyle #3: is null if bannerArtistContribs resolves empty`);
+
+  album.bannerArtistContribs = contribs;
+
+  t.equal(album.bannerStyle, `opacity: 0.5`,
+    `Album.bannerStyle #4: is own value`);
+});
+
+t.test(`Album.coverArtDate`, t => {
+  t.plan(6);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.coverArtDate, null,
+    `Album.coverArtDate #1: defaults to null`);
+
+  album.date = new Date('2012-10-25');
+
+  t.equal(album.coverArtDate, null,
+    `Album.coverArtDate #2: is null if coverArtistContribs empty (1/2)`);
+
+  album.coverArtDate = new Date('2011-04-13');
+
+  t.equal(album.coverArtDate, null,
+    `Album.coverArtDate #3: is null if coverArtistContribs empty (2/2)`);
+
+  album.coverArtistContribs = contribs;
+
+  t.same(album.coverArtDate, new Date('2011-04-13'),
+    `Album.coverArtDate #4: is own value`);
+
+  album.coverArtDate = null;
+
+  t.same(album.coverArtDate, new Date(`2012-10-25`),
+    `Album.coverArtDate #5: inherits album release date`);
+
+  album.coverArtistContribs = badContribs;
+
+  t.equal(album.coverArtDate, null,
+    `Album.coverArtDate #6: is null if coverArtistContribs resolves empty`);
+});
+
+t.test(`Album.coverArtFileExtension`, t => {
+  t.plan(5);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.coverArtFileExtension, null,
+    `Album.coverArtFileExtension #1: is null if coverArtistContribs empty (1/2)`);
+
+  album.coverArtFileExtension = 'png';
+
+  t.equal(album.coverArtFileExtension, null,
+    `Album.coverArtFileExtension #2: is null if coverArtistContribs empty (2/2)`);
+
+  album.coverArtFileExtension = null;
+  album.coverArtistContribs = contribs;
+
+  t.equal(album.coverArtFileExtension, 'jpg',
+    `Album.coverArtFileExtension #3: defaults to jpg`);
+
+  album.coverArtFileExtension = 'png';
+
+  t.equal(album.coverArtFileExtension, 'png',
+    `Album.coverArtFileExtension #4: is own value`);
+
+  album.coverArtistContribs = badContribs;
+
+  t.equal(album.coverArtFileExtension, null,
+    `Album.coverArtFileExtension #5: is null if coverArtistContribs resolves empty`);
+});
+
+t.test(`Album.tracks`, t => {
+  t.plan(5);
+
+  const album = new Album();
+  const track1 = stubTrack('track1');
+  const track2 = stubTrack('track2');
+  const track3 = stubTrack('track3');
+  const tracks = [track1, track2, track3];
+
+  album.ownTrackData = tracks;
+
+  for (const track of tracks) {
+    track.albumData = [album];
+  }
+
+  t.same(album.tracks, [],
+    `Album.tracks #1: defaults to empty array`);
+
+  album.trackSections = [
+    {tracks: ['track:track1', 'track:track2', 'track:track3']},
+  ];
+
+  t.same(album.tracks, [track1, track2, track3],
+    `Album.tracks #2: pulls tracks from one track section`);
+
+  album.trackSections = [
+    {tracks: ['track:track1']},
+    {tracks: ['track:track2', 'track:track3']},
+  ];
+
+  t.same(album.tracks, [track1, track2, track3],
+    `Album.tracks #3: pulls tracks from multiple track sections`);
+
+  album.trackSections = [
+    {tracks: ['track:track1', 'track:does-not-exist']},
+    {tracks: ['track:this-one-neither', 'track:track2']},
+    {tracks: ['track:effectively-empty-section']},
+    {tracks: ['track:track3']},
+  ];
+
+  t.same(album.tracks, [track1, track2, track3],
+    `Album.tracks #4: filters out references without matches`);
+
+  album.trackSections = [
+    {tracks: ['track:track1']},
+    {},
+    {tracks: ['track:track2']},
+    {},
+    {},
+    {tracks: ['track:track3']},
+  ];
+
+  t.same(album.tracks, [track1, track2, track3],
+    `Album.tracks #5: skips missing tracks property`);
+});
+
+t.test(`Album.trackSections`, t => {
+  t.plan(7);
+
+  const album = new Album();
+  const track1 = stubTrack('track1');
+  const track2 = stubTrack('track2');
+  const track3 = stubTrack('track3');
+  const track4 = stubTrack('track4');
+  const tracks = [track1, track2, track3, track4];
+
+  album.ownTrackData = tracks;
+
+  for (const track of tracks) {
+    track.albumData = [album];
+  }
+
+  album.trackSections = [
+    {tracks: ['track:track1', 'track:track2']},
+    {tracks: ['track:track3', 'track:track4']},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1, track2]},
+    {tracks: [track3, track4]},
+  ], `Album.trackSections #1: exposes tracks`);
+
+  t.match(album.trackSections, [
+    {tracks: [track1, track2], startIndex: 0},
+    {tracks: [track3, track4], startIndex: 2},
+  ], `Album.trackSections #2: exposes startIndex`);
+
+  album.trackSections = [
+    {name: 'First section', tracks: ['track:track1']},
+    {name: 'Second section', tracks: ['track:track2']},
+    {tracks: ['track:track3']},
+  ];
+
+  t.match(album.trackSections, [
+    {name: 'First section', tracks: [track1]},
+    {name: 'Second section', tracks: [track2]},
+    {name: 'Unnamed Track Section', tracks: [track3]},
+  ], `Album.trackSections #3: exposes name, with fallback value`);
+
+  album.color = '#123456';
+
+  album.trackSections = [
+    {tracks: ['track:track1'], color: null},
+    {tracks: ['track:track2'], color: '#abcdef'},
+    {tracks: ['track:track3'], color: null},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1], color: '#123456'},
+    {tracks: [track2], color: '#abcdef'},
+    {tracks: [track3], color: '#123456'},
+  ], `Album.trackSections #4: exposes color, inherited from album`);
+
+  album.trackSections = [
+    {tracks: ['track:track1'], dateOriginallyReleased: null},
+    {tracks: ['track:track2'], dateOriginallyReleased: new Date('2009-04-11')},
+    {tracks: ['track:track3'], dateOriginallyReleased: null},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1], dateOriginallyReleased: null},
+    {tracks: [track2], dateOriginallyReleased: new Date('2009-04-11')},
+    {tracks: [track3], dateOriginallyReleased: null},
+  ], `Album.trackSections #5: exposes dateOriginallyReleased, if present`);
+
+  album.trackSections = [
+    {tracks: ['track:track1'], isDefaultTrackSection: true},
+    {tracks: ['track:track2'], isDefaultTrackSection: false},
+    {tracks: ['track:track3'], isDefaultTrackSection: null},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1], isDefaultTrackSection: true},
+    {tracks: [track2], isDefaultTrackSection: false},
+    {tracks: [track3], isDefaultTrackSection: false},
+  ], `Album.trackSections #6: exposes isDefaultTrackSection, defaults to false`);
+
+  album.trackSections = [
+    {tracks: ['track:track1', 'track:track2', 'track:snooping'], color: '#112233'},
+    {tracks: ['track:track3', 'track:as-usual'],                 color: '#334455'},
+    {tracks: [],                                                 color: '#bbbbba'},
+    {tracks: ['track:icy', 'track:chilly', 'track:frigid'],      color: '#556677'},
+    {tracks: ['track:track4'],                                   color: '#778899'},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1, track2], color: '#112233'},
+    {tracks: [track3],         color: '#334455'},
+    {tracks: [track4],         color: '#778899'},
+  ], `Album.trackSections #7: filters out references without matches & empty sections`);
+});
+
+t.test(`Album.wallpaperFileExtension`, t => {
+  t.plan(5);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.wallpaperFileExtension, null,
+    `Album.wallpaperFileExtension #1: defaults to null`);
+
+  album.wallpaperFileExtension = 'png';
+
+  t.equal(album.wallpaperFileExtension, null,
+    `Album.wallpaperFileExtension #2: is null if wallpaperArtistContribs empty`);
+
+  album.wallpaperArtistContribs = contribs;
+
+  t.equal(album.wallpaperFileExtension, 'png',
+    `Album.wallpaperFileExtension #3: is own value`);
+
+  album.wallpaperFileExtension = null;
+
+  t.equal(album.wallpaperFileExtension, 'jpg',
+    `Album.wallpaperFileExtension #4: defaults to jpg`);
+
+  album.wallpaperArtistContribs = badContribs;
+
+  t.equal(album.wallpaperFileExtension, null,
+    `Album.wallpaperFileExtension #5: is null if wallpaperArtistContribs resolves empty`);
+});
+
+t.test(`Album.wallpaperStyle`, t => {
+  t.plan(4);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.wallpaperStyle, null,
+    `Album.wallpaperStyle #1: defaults to null`);
+
+  album.wallpaperStyle = `opacity: 0.5`;
+
+  t.equal(album.wallpaperStyle, null,
+    `Album.wallpaperStyle #2: is null if wallpaperArtistContribs empty`);
+
+  album.wallpaperArtistContribs = badContribs;
+
+  t.equal(album.wallpaperStyle, null,
+    `Album.wallpaperStyle #3: is null if wallpaperArtistContribs resolves empty`);
+
+  album.wallpaperArtistContribs = contribs;
+
+  t.equal(album.wallpaperStyle, `opacity: 0.5`,
+    `Album.wallpaperStyle #4: is own value`);
+});
diff --git a/test/unit/data/things/art-tag.js b/test/unit/data/things/art-tag.js
new file mode 100644
index 0000000..561c93e
--- /dev/null
+++ b/test/unit/data/things/art-tag.js
@@ -0,0 +1,71 @@
+import t from 'tap';
+
+import {linkAndBindWikiData} from '#test-lib';
+import thingConstructors from '#things';
+
+const {
+  Album,
+  Artist,
+  ArtTag,
+  Track,
+} = thingConstructors;
+
+function stubAlbum(tracks, directory = 'bar') {
+  const album = new Album();
+  album.directory = directory;
+
+  const trackRefs = tracks.map(t => Thing.getReference(t));
+  album.trackSections = [{tracks: trackRefs}];
+
+  return album;
+}
+
+function stubTrack(directory = 'foo') {
+  const track = new Track();
+  track.directory = directory;
+
+  return track;
+}
+
+function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') {
+  const track = stubTrack(trackDirectory);
+  const album = stubAlbum([track], albumDirectory);
+
+  return {track, album};
+}
+
+function stubArtist(artistName = `Test Artist`) {
+  const artist = new Artist();
+  artist.name = artistName;
+
+  return artist;
+}
+
+function stubArtistAndContribs(artistName = `Test Artist`) {
+  const artist = stubArtist(artistName);
+  const contribs = [{who: artistName, what: null}];
+  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
+
+  return {artist, contribs, badContribs};
+}
+
+t.test(`ArtTag.nameShort`, t => {
+  t.plan(3);
+
+  const artTag = new ArtTag();
+
+  artTag.name = `Dave Strider`;
+
+  t.equal(artTag.nameShort, `Dave Strider`,
+    `ArtTag #1: defaults to name`);
+
+  artTag.name = `Dave Strider (Homestuck)`;
+
+  t.equal(artTag.nameShort, `Dave Strider`,
+    `ArtTag #2: trims parenthical part at end`);
+
+  artTag.name = `This (And) That (Then)`;
+
+  t.equal(artTag.nameShort, `This (And) That`,
+    `ArtTag #2: doesn't trim midlde parenthical part`);
+});
diff --git a/test/unit/data/things/flash.js b/test/unit/data/things/flash.js
new file mode 100644
index 0000000..6205960
--- /dev/null
+++ b/test/unit/data/things/flash.js
@@ -0,0 +1,55 @@
+import t from 'tap';
+
+import {linkAndBindWikiData} from '#test-lib';
+import thingConstructors from '#things';
+
+const {
+  Flash,
+  FlashAct,
+  Thing,
+} = thingConstructors;
+
+function stubFlash(directory = 'foo') {
+  const flash = new Flash();
+  flash.directory = directory;
+
+  return flash;
+}
+
+function stubFlashAct(flashes, directory = 'bar') {
+  const flashAct = new FlashAct();
+  flashAct.directory = directory;
+  flashAct.flashes = flashes.map(flash => Thing.getReference(flash));
+
+  return flashAct;
+}
+
+t.test(`Flash.color`, t => {
+  t.plan(4);
+
+  const flash = stubFlash();
+  const flashAct = stubFlashAct([flash]);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    flashData: [flash],
+    flashActData: [flashAct],
+  });
+
+  t.equal(flash.color, null,
+    `color #1: defaults to null`);
+
+  flashAct.color = '#abcdef';
+  XXX_decacheWikiData();
+
+  t.equal(flash.color, '#abcdef',
+    `color #2: inherits from flash act`);
+
+  flash.color = '#123456';
+
+  t.equal(flash.color, '#123456',
+    `color #3: is own value`);
+
+  t.throws(() => { flash.color = '#aeiouw'; },
+    {cause: TypeError},
+    `color #4: must be set to valid color`);
+});
diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js
new file mode 100644
index 0000000..b1c1611
--- /dev/null
+++ b/test/unit/data/things/track.js
@@ -0,0 +1,759 @@
+import t from 'tap';
+
+import {linkAndBindWikiData} from '#test-lib';
+import thingConstructors from '#things';
+
+const {
+  Album,
+  ArtTag,
+  Artist,
+  Flash,
+  FlashAct,
+  Thing,
+  Track,
+} = thingConstructors;
+
+function stubAlbum(tracks, directory = 'bar') {
+  const album = new Album();
+  album.directory = directory;
+
+  const trackRefs = tracks.map(t => Thing.getReference(t));
+  album.trackSections = [{tracks: trackRefs}];
+
+  return album;
+}
+
+function stubTrack(directory = 'foo') {
+  const track = new Track();
+  track.directory = directory;
+
+  return track;
+}
+
+function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') {
+  const track = stubTrack(trackDirectory);
+  const album = stubAlbum([track], albumDirectory);
+
+  return {track, album};
+}
+
+function stubArtist(artistName = `Test Artist`) {
+  const artist = new Artist();
+  artist.name = artistName;
+
+  return artist;
+}
+
+function stubArtistAndContribs(artistName = `Test Artist`) {
+  const artist = stubArtist(artistName);
+  const contribs = [{who: artistName, what: null}];
+  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
+
+  return {artist, contribs, badContribs};
+}
+
+function stubArtTag(tagName = `Test Art Tag`) {
+  const tag = new ArtTag();
+  tag.name = tagName;
+
+  return tag;
+}
+
+function stubFlashAndAct(directory = 'zam') {
+  const flash = new Flash();
+  flash.directory = directory;
+
+  const flashAct = new FlashAct();
+  flashAct.flashes = [Thing.getReference(flash)];
+
+  return {flash, flashAct};
+}
+
+t.test(`Track.album`, t => {
+  t.plan(6);
+
+  // Note: These asserts use manual albumData/trackData relationships
+  // to illustrate more specifically the properties which are expected to
+  // be relevant for this case. Other properties use the same underlying
+  // get-album behavior as Track.album so aren't tested as aggressively.
+
+  const track1 = stubTrack('track1');
+  const track2 = stubTrack('track2');
+  const album1 = new Album();
+  const album2 = new Album();
+
+  t.equal(track1.album, null,
+    `album #1: defaults to null`);
+
+  track1.albumData = [album1, album2];
+  track2.albumData = [album1, album2];
+  album1.ownTrackData = [track1, track2];
+  album2.ownTrackData = [track1, track2];
+  album1.trackSections = [{tracks: ['track:track1']}];
+  album2.trackSections = [{tracks: ['track:track2']}];
+
+  t.equal(track1.album, album1,
+    `album #2: is album when album's trackSections matches track`);
+
+  track1.albumData = [album2, album1];
+
+  t.equal(track1.album, album1,
+    `album #3: is album when albumData is in different order`);
+
+  track1.albumData = [];
+
+  t.equal(track1.album, null,
+    `album #4: is null when track missing albumData`);
+
+  album1.ownTrackData = [];
+  track1.albumData = [album1, album2];
+
+  t.equal(track1.album, null,
+    `album #5: is null when album missing ownTrackData`);
+
+  album1.ownTrackData = [track1, track2];
+  album1.trackSections = [{tracks: ['track:track2']}];
+
+  // XXX_decacheWikiData
+  track1.albumData = [];
+  track1.albumData = [album1, album2];
+
+  t.equal(track1.album, null,
+    `album #6: is null when album's trackSections don't match track`);
+});
+
+t.test(`Track.artTags`, t => {
+  t.plan(6);
+
+  const {track, album} = stubTrackAndAlbum();
+  const {artist, contribs} = stubArtistAndContribs();
+  const tag1 = stubArtTag(`Tag 1`);
+  const tag2 = stubArtTag(`Tag 2`);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    artTagData: [tag1, tag2],
+    trackData: [track],
+  });
+
+  t.same(track.artTags, [],
+    `artTags #1: defaults to empty array`);
+
+  track.artTags = [`Tag 1`, `Tag 2`];
+
+  t.same(track.artTags, [],
+    `artTags #2: is empty if track doesn't have cover artists`);
+
+  track.coverArtistContribs = contribs;
+
+  t.same(track.artTags, [tag1, tag2],
+    `artTags #3: resolves if track has cover artists`);
+
+  track.coverArtistContribs = null;
+  album.trackCoverArtistContribs = contribs;
+
+  XXX_decacheWikiData();
+
+  t.same(track.artTags, [tag1, tag2],
+    `artTags #4: resolves if track inherits cover artists`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.same(track.artTags, [],
+    `artTags #5: is empty if track disables unique cover artwork`);
+
+  album.coverArtistContribs = contribs;
+  album.artTags = [`Tag 2`];
+
+  XXX_decacheWikiData();
+
+  t.notSame(track.artTags, [tag2],
+    `artTags #6: doesn't inherit from album's art tags`);
+});
+
+t.test(`Track.artistContribs`, t => {
+  t.plan(4);
+
+  const {track, album} = stubTrackAndAlbum();
+  const artist1 = stubArtist(`Artist 1`);
+  const artist2 = stubArtist(`Artist 2`);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist1, artist2],
+    trackData: [track],
+  });
+
+  t.same(track.artistContribs, [],
+    `artistContribs #1: defaults to empty array`);
+
+  album.artistContribs = [
+    {who: `Artist 1`, what: `composition`},
+    {who: `Artist 2`, what: null},
+  ];
+
+  XXX_decacheWikiData();
+
+  t.same(track.artistContribs,
+    [{who: artist1, what: `composition`}, {who: artist2, what: null}],
+    `artistContribs #2: inherits album artistContribs`);
+
+  track.artistContribs = [
+    {who: `Artist 1`, what: `arrangement`},
+  ];
+
+  t.same(track.artistContribs, [{who: artist1, what: `arrangement`}],
+    `artistContribs #3: resolves from own value`);
+
+  track.artistContribs = [
+    {who: `Artist 1`, what: `snooping`},
+    {who: `Artist 413`, what: `as`},
+    {who: `Artist 2`, what: `usual`},
+  ];
+
+  t.same(track.artistContribs,
+    [{who: artist1, what: `snooping`}, {who: artist2, what: `usual`}],
+    `artistContribs #4: filters out names without matches`);
+});
+
+t.test(`Track.color`, t => {
+  t.plan(5);
+
+  const {track, album} = stubTrackAndAlbum();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    trackData: [track],
+  });
+
+  t.equal(track.color, null,
+    `color #1: defaults to null`);
+
+  album.color = '#abcdef';
+  album.trackSections = [{
+    color: '#beeeef',
+    tracks: [Thing.getReference(track)],
+  }];
+  XXX_decacheWikiData();
+
+  t.equal(track.color, '#beeeef',
+    `color #2: inherits from track section before album`);
+
+  // Replace the album with a completely fake one. This isn't realistic, since
+  // in correct data, Album.tracks depends on Albums.trackSections and so the
+  // track's album will always have a corresponding track section. But if that
+  // connection breaks for some future reason (with the album still present),
+  // Track.color should still inherit directly from the album.
+  track.albumData = [
+    {
+      constructor: {[Thing.referenceType]: 'album'},
+      color: '#abcdef',
+      tracks: [track],
+      trackSections: [
+        {color: '#baaaad', tracks: []},
+      ],
+    },
+  ];
+
+  t.equal(track.color, '#abcdef',
+    `color #3: inherits from album without matching track section`);
+
+  track.color = '#123456';
+
+  t.equal(track.color, '#123456',
+    `color #4: is own value`);
+
+  t.throws(() => { track.color = '#aeiouw'; },
+    {cause: TypeError},
+    `color #5: must be set to valid color`);
+});
+
+t.test(`Track.commentatorArtists`, t => {
+  t.plan(8);
+
+  const track = new Track();
+  const artist1 = stubArtist(`SnooPING`);
+  const artist2 = stubArtist(`ASUsual`);
+  const artist3 = stubArtist(`Icy`);
+
+  linkAndBindWikiData({
+    trackData: [track],
+    artistData: [artist1, artist2, artist3],
+  });
+
+  // Keep track of the last commentary string in a separate value, since
+  // the track.commentary property exposes as a completely different format
+  // (i.e. an array of objects, one for each entry), and so isn't compatible
+  // with the += operator on its own.
+  let commentary;
+
+  track.commentary = commentary =
+    `<i>SnooPING:</i>\n` +
+    `Wow.\n`;
+
+  t.same(track.commentatorArtists, [artist1],
+    `Track.commentatorArtists #1: works with one commentator`);
+
+  track.commentary = commentary +=
+    `<i>ASUsual:</i>\n` +
+    `Yes!\n`;
+
+  t.same(track.commentatorArtists, [artist1, artist2],
+    `Track.commentatorArtists #2: works with two commentators`);
+
+  track.commentary = commentary +=
+    `<i>Icy|<b>Icy What You Did There</b>:</i>\n` +
+    `Incredible.\n`;
+
+  t.same(track.commentatorArtists, [artist1, artist2, artist3],
+    `Track.commentatorArtists #3: works with custom artist text`);
+
+  track.commentary = commentary =
+    `<i>Icy:</i> (project manager)\n` +
+    `Very good track.\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #4: works with annotation`);
+
+  track.commentary = commentary =
+    `<i>Icy:</i> (project manager, 08/15/2023)\n` +
+    `Very very good track.\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #5: works with date`);
+
+  track.commentary = commentary +=
+    `<i>Ohohohoho:</i>\n` +
+    `OHOHOHOHOHOHO...\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #6: ignores artist names not found`);
+
+  track.commentary = commentary +=
+    `<i>Icy:</i>\n` +
+    `I'm back!\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #7: ignores duplicate artist`);
+
+  track.commentary = commentary +=
+    `<i>SNooPING, ASUsual, Icy:</i>\n` +
+    `WITH ALL THREE POWERS COMBINED...`;
+
+  t.same(track.commentatorArtists, [artist3, artist1, artist2],
+    `Track.commentatorArtists #8: works with more than one artist in one entry`);
+});
+
+t.test(`Track.coverArtistContribs`, t => {
+  t.plan(5);
+
+  const {track, album} = stubTrackAndAlbum();
+  const artist1 = stubArtist(`Artist 1`);
+  const artist2 = stubArtist(`Artist 2`);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist1, artist2],
+    trackData: [track],
+  });
+
+  t.same(track.coverArtistContribs, [],
+    `coverArtistContribs #1: defaults to empty array`);
+
+  album.trackCoverArtistContribs = [
+    {who: `Artist 1`, what: `lines`},
+    {who: `Artist 2`, what: null},
+  ];
+
+  XXX_decacheWikiData();
+
+  t.same(track.coverArtistContribs,
+    [{who: artist1, what: `lines`}, {who: artist2, what: null}],
+    `coverArtistContribs #2: inherits album trackCoverArtistContribs`);
+
+  track.coverArtistContribs = [
+    {who: `Artist 1`, what: `collage`},
+  ];
+
+  t.same(track.coverArtistContribs, [{who: artist1, what: `collage`}],
+    `coverArtistContribs #3: resolves from own value`);
+
+  track.coverArtistContribs = [
+    {who: `Artist 1`, what: `snooping`},
+    {who: `Artist 413`, what: `as`},
+    {who: `Artist 2`, what: `usual`},
+  ];
+
+  t.same(track.coverArtistContribs,
+    [{who: artist1, what: `snooping`}, {who: artist2, what: `usual`}],
+    `coverArtistContribs #4: filters out names without matches`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.same(track.coverArtistContribs, [],
+    `coverArtistContribs #5: is empty if track disables unique cover artwork`);
+});
+
+t.test(`Track.coverArtDate`, t => {
+  t.plan(8);
+
+  const {track, album} = stubTrackAndAlbum();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    trackData: [track],
+  });
+
+  track.coverArtistContribs = contribs;
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #1: defaults to null`);
+
+  album.trackArtDate = new Date('2012-12-12');
+
+  XXX_decacheWikiData();
+
+  t.same(track.coverArtDate, new Date('2012-12-12'),
+    `coverArtDate #2: inherits album trackArtDate`);
+
+  track.coverArtDate = new Date('2009-09-09');
+
+  t.same(track.coverArtDate, new Date('2009-09-09'),
+    `coverArtDate #3: is own value`);
+
+  track.coverArtistContribs = [];
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #4: is null if track coverArtistContribs empty`);
+
+  album.trackCoverArtistContribs = contribs;
+
+  XXX_decacheWikiData();
+
+  t.same(track.coverArtDate, new Date('2009-09-09'),
+    `coverArtDate #5: is not null if album trackCoverArtistContribs specified`);
+
+  album.trackCoverArtistContribs = badContribs;
+
+  XXX_decacheWikiData();
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #6: is null if album trackCoverArtistContribs resolves empty`);
+
+  track.coverArtistContribs = badContribs;
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #7: is null if track coverArtistContribs resolves empty`);
+
+  track.coverArtistContribs = contribs;
+  track.disableUniqueCoverArt = true;
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #8: is null if track disables unique cover artwork`);
+});
+
+t.test(`Track.coverArtFileExtension`, t => {
+  t.plan(8);
+
+  const {track, album} = stubTrackAndAlbum();
+  const {artist, contribs} = stubArtistAndContribs();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    trackData: [track],
+  });
+
+  t.equal(track.coverArtFileExtension, null,
+    `coverArtFileExtension #1: defaults to null`);
+
+  track.coverArtistContribs = contribs;
+
+  t.equal(track.coverArtFileExtension, 'jpg',
+    `coverArtFileExtension #2: is jpg if has cover art and not further specified`);
+
+  track.coverArtistContribs = [];
+
+  album.coverArtistContribs = contribs;
+  XXX_decacheWikiData();
+
+  t.equal(track.coverArtFileExtension, null,
+    `coverArtFileExtension #3: only has value for unique cover art`);
+
+  track.coverArtistContribs = contribs;
+
+  album.trackCoverArtFileExtension = 'png';
+  XXX_decacheWikiData();
+
+  t.equal(track.coverArtFileExtension, 'png',
+    `coverArtFileExtension #4: inherits album trackCoverArtFileExtension (1/2)`);
+
+  track.coverArtFileExtension = 'gif';
+
+  t.equal(track.coverArtFileExtension, 'gif',
+    `coverArtFileExtension #5: is own value (1/2)`);
+
+  track.coverArtistContribs = [];
+
+  album.trackCoverArtistContribs = contribs;
+  XXX_decacheWikiData();
+
+  t.equal(track.coverArtFileExtension, 'gif',
+    `coverArtFileExtension #6: is own value (2/2)`);
+
+  track.coverArtFileExtension = null;
+
+  t.equal(track.coverArtFileExtension, 'png',
+    `coverArtFileExtension #7: inherits album trackCoverArtFileExtension (2/2)`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.equal(track.coverArtFileExtension, null,
+    `coverArtFileExtension #8: is null if track disables unique cover art`);
+});
+
+t.test(`Track.date`, t => {
+  t.plan(3);
+
+  const {track, album} = stubTrackAndAlbum();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    trackData: [track],
+  });
+
+  t.equal(track.date, null,
+    `date #1: defaults to null`);
+
+  album.date = new Date('2012-12-12');
+  XXX_decacheWikiData();
+
+  t.same(track.date, album.date,
+    `date #2: inherits from album`);
+
+  track.dateFirstReleased = new Date('2009-09-09');
+
+  t.same(track.date, new Date('2009-09-09'),
+    `date #3: is own dateFirstReleased`);
+});
+
+t.test(`Track.featuredInFlashes`, t => {
+  t.plan(2);
+
+  const {track, album} = stubTrackAndAlbum('track1');
+
+  const {flash: flash1, flashAct: flashAct1} = stubFlashAndAct('flash1');
+  const {flash: flash2, flashAct: flashAct2} = stubFlashAndAct('flash2');
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    trackData: [track],
+    flashData: [flash1, flash2],
+    flashActData: [flashAct1, flashAct2],
+  });
+
+  t.same(track.featuredInFlashes, [],
+    `featuredInFlashes #1: defaults to empty array`);
+
+  flash1.featuredTracks = ['track:track1'];
+  flash2.featuredTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track.featuredInFlashes, [flash1, flash2],
+    `featuredInFlashes #2: matches flashes' featuredTracks`);
+});
+
+t.test(`Track.hasUniqueCoverArt`, t => {
+  t.plan(7);
+
+  const {track, album} = stubTrackAndAlbum();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    trackData: [track],
+  });
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #1: defaults to false`);
+
+  album.trackCoverArtistContribs = contribs;
+  XXX_decacheWikiData();
+
+  t.equal(track.hasUniqueCoverArt, true,
+    `hasUniqueCoverArt #2: is true if album specifies trackCoverArtistContribs`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #3: is false if disableUniqueCoverArt is true (1/2)`);
+
+  track.disableUniqueCoverArt = false;
+
+  album.trackCoverArtistContribs = badContribs;
+  XXX_decacheWikiData();
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #4: is false if album's trackCoverArtistContribs resolve empty`);
+
+  track.coverArtistContribs = contribs;
+
+  t.equal(track.hasUniqueCoverArt, true,
+    `hasUniqueCoverArt #5: is true if track specifies coverArtistContribs`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #6: is false if disableUniqueCoverArt is true (2/2)`);
+
+  track.disableUniqueCoverArt = false;
+
+  track.coverArtistContribs = badContribs;
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #7: is false if track's coverArtistContribs resolve empty`);
+});
+
+t.test(`Track.originalReleaseTrack`, t => {
+  t.plan(3);
+
+  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
+  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
+
+  const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album1, album2],
+    trackData: [track1, track2],
+  });
+
+  t.equal(track2.originalReleaseTrack, null,
+    `originalReleaseTrack #1: defaults to null`);
+
+  track2.originalReleaseTrack = 'track:track1';
+
+  t.equal(track2.originalReleaseTrack, track1,
+    `originalReleaseTrack #2: is resolved from own value`);
+
+  track2.trackData = [];
+
+  t.equal(track2.originalReleaseTrack, null,
+    `originalReleaseTrack #3: is null when track missing trackData`);
+});
+
+t.test(`Track.otherReleases`, t => {
+  t.plan(6);
+
+  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
+  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
+  const {track: track3, album: album3} = stubTrackAndAlbum('track3');
+  const {track: track4, album: album4} = stubTrackAndAlbum('track4');
+
+  const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album1, album2, album3, album4],
+    trackData: [track1, track2, track3, track4],
+  });
+
+  t.same(track1.otherReleases, [],
+    `otherReleases #1: defaults to empty array`);
+
+  track2.originalReleaseTrack = 'track:track1';
+  track3.originalReleaseTrack = 'track:track1';
+  track4.originalReleaseTrack = 'track:track1';
+  XXX_decacheWikiData();
+
+  t.same(track1.otherReleases, [track2, track3, track4],
+    `otherReleases #2: otherReleases of original release are its rereleases`);
+
+  wikiData.trackData = [track1, track3, track2, track4];
+  linkWikiDataArrays();
+
+  t.same(track1.otherReleases, [track3, track2, track4],
+    `otherReleases #3: otherReleases matches trackData order`);
+
+  wikiData.trackData = [track3, track2, track1, track4];
+  linkWikiDataArrays();
+
+  t.same(track2.otherReleases, [track1, track3, track4],
+    `otherReleases #4: otherReleases of rerelease are original track then other rereleases (1/3)`);
+
+  t.same(track3.otherReleases, [track1, track2, track4],
+    `otherReleases #5: otherReleases of rerelease are original track then other rereleases (2/3)`);
+
+  t.same(track4.otherReleases, [track1, track3, track2],
+    `otherReleases #6: otherReleases of rerelease are original track then other rereleases (3/3)`);
+});
+
+t.test(`Track.referencedByTracks`, t => {
+  t.plan(4);
+
+  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
+  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
+  const {track: track3, album: album3} = stubTrackAndAlbum('track3');
+  const {track: track4, album: album4} = stubTrackAndAlbum('track4');
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album1, album2, album3, album4],
+    trackData: [track1, track2, track3, track4],
+  });
+
+  t.same(track1.referencedByTracks, [],
+    `referencedByTracks #1: defaults to empty array`);
+
+  track2.referencedTracks = ['track:track1'];
+  track3.referencedTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track1.referencedByTracks, [track2, track3],
+    `referencedByTracks #2: matches tracks' referencedTracks`);
+
+  track4.sampledTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track1.referencedByTracks, [track2, track3],
+    `referencedByTracks #3: doesn't match tracks' sampledTracks`);
+
+  track3.originalReleaseTrack = 'track:track2';
+  XXX_decacheWikiData();
+
+  t.same(track1.referencedByTracks, [track2],
+    `referencedByTracks #4: doesn't include rereleases`);
+});
+
+t.test(`Track.sampledByTracks`, t => {
+  t.plan(4);
+
+  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
+  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
+  const {track: track3, album: album3} = stubTrackAndAlbum('track3');
+  const {track: track4, album: album4} = stubTrackAndAlbum('track4');
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album1, album2, album3, album4],
+    trackData: [track1, track2, track3, track4],
+  });
+
+  t.same(track1.sampledByTracks, [],
+    `sampledByTracks #1: defaults to empty array`);
+
+  track2.sampledTracks = ['track:track1'];
+  track3.sampledTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track1.sampledByTracks, [track2, track3],
+    `sampledByTracks #2: matches tracks' sampledTracks`);
+
+  track4.referencedTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track1.sampledByTracks, [track2, track3],
+    `sampledByTracks #3: doesn't match tracks' referencedTracks`);
+
+  track3.originalReleaseTrack = 'track:track2';
+  XXX_decacheWikiData();
+
+  t.same(track1.sampledByTracks, [track2],
+    `sampledByTracks #4: doesn't include rereleases`);
+});
diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js
new file mode 100644
index 0000000..11134a9
--- /dev/null
+++ b/test/unit/data/things/validators.js
@@ -0,0 +1,440 @@
+import t from 'tap';
+import {showAggregate} from '#sugar';
+
+import {
+  // Basic types
+  isBoolean,
+  isCountingNumber,
+  isDate,
+  isNumber,
+  isString,
+  isStringNonEmpty,
+
+  // Complex types
+  isArray,
+  isObject,
+  validateArrayItems,
+
+  // Wiki data
+  isColor,
+  isCommentary,
+  isContentString,
+  isContribution,
+  isContributionList,
+  isDimensions,
+  isDirectory,
+  isDuration,
+  isFileExtension,
+  isName,
+  isURL,
+  validateReference,
+  validateReferenceList,
+
+  // Compositional utilities
+  anyOf,
+} from '#validators';
+
+function test(t, msg, fn) {
+  t.test(msg, t => {
+    try {
+      fn(t);
+    } catch (error) {
+      if (error instanceof AggregateError) {
+        showAggregate(error);
+      }
+      throw error;
+    }
+  });
+}
+
+// Basic types
+
+test(t, 'isBoolean', t => {
+  t.plan(4);
+  t.ok(isBoolean(true));
+  t.ok(isBoolean(false));
+  t.throws(() => isBoolean(1), TypeError);
+  t.throws(() => isBoolean('yes'), TypeError);
+});
+
+test(t, 'isNumber', t => {
+  t.plan(6);
+  t.ok(isNumber(123));
+  t.ok(isNumber(0.05));
+  t.ok(isNumber(0));
+  t.ok(isNumber(-10));
+  t.throws(() => isNumber('413'), TypeError);
+  t.throws(() => isNumber(true), TypeError);
+});
+
+test(t, 'isCountingNumber', t => {
+  t.plan(6);
+  t.ok(isCountingNumber(3));
+  t.ok(isCountingNumber(1));
+  t.throws(() => isCountingNumber(1.75), TypeError);
+  t.throws(() => isCountingNumber(0), TypeError);
+  t.throws(() => isCountingNumber(-1), TypeError);
+  t.throws(() => isCountingNumber('612'), TypeError);
+});
+
+test(t, 'isString', t => {
+  t.plan(3);
+  t.ok(isString('hello!'));
+  t.ok(isString(''));
+  t.throws(() => isString(100), TypeError);
+});
+
+test(t, 'isStringNonEmpty', t => {
+  t.plan(4);
+  t.ok(isStringNonEmpty('hello!'));
+  t.throws(() => isStringNonEmpty(''), TypeError);
+  t.throws(() => isStringNonEmpty('     '), TypeError);
+  t.throws(() => isStringNonEmpty(100), TypeError);
+});
+
+// Complex types
+
+test(t, 'isArray', t => {
+  t.plan(3);
+  t.ok(isArray([]));
+  t.throws(() => isArray({}), TypeError);
+  t.throws(() => isArray('1, 2, 3'), TypeError);
+});
+
+test(t, 'isDate', t => {
+  t.plan(3);
+  t.ok(isDate(new Date('2023-03-27 09:24:15')));
+  t.throws(() => isDate(new Date(Infinity)), TypeError);
+  t.throws(() => isDimensions('2023-03-27 09:24:15'), TypeError);
+});
+
+test(t, 'isObject', t => {
+  t.plan(3);
+  t.ok(isObject({}));
+  t.ok(isObject([]));
+  t.throws(() => isObject(null), TypeError);
+});
+
+test(t, 'validateArrayItems', t => {
+  t.plan(9);
+
+  t.ok(validateArrayItems(isNumber)([3, 4, 5]));
+  t.ok(validateArrayItems(validateArrayItems(isNumber))([[3, 4], [4, 5], [6, 7]]));
+
+  let caughtError = null;
+  try {
+    validateArrayItems(isNumber)([10, 20, 'one hundred million consorts', 30]);
+  } catch (err) {
+    caughtError = err;
+  }
+
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 1);
+  t.ok(caughtError.errors[0] instanceof Error);
+  t.equal(caughtError.errors[0][Symbol.for('hsmusic.annotateError.indexInSourceArray')], 2);
+  t.not(caughtError.errors[0].cause, null);
+  t.ok(caughtError.errors[0].cause instanceof TypeError);
+});
+
+// Wiki data
+
+t.test('isColor', t => {
+  t.plan(9);
+  t.ok(isColor('#123'));
+  t.ok(isColor('#1234'));
+  t.ok(isColor('#112233'));
+  t.ok(isColor('#11223344'));
+  t.ok(isColor('#abcdef00'));
+  t.ok(isColor('#ABCDEF'));
+  t.throws(() => isColor('#ggg'), TypeError);
+  t.throws(() => isColor('red'), TypeError);
+  t.throws(() => isColor('hsl(150deg 30% 60%)'), TypeError);
+});
+
+t.test('isCommentary', t => {
+  t.plan(9);
+
+  // TODO: Test specific error messages.
+  t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`));
+  t.ok(isCommentary(`<i>Toby Fox:</i> (music)\ndogsong.mp3`));
+  t.throws(() => isCommentary(`dogsong.mp3\n<i>Toby Fox:</i>\ndogsong.mp3`));
+  t.throws(() => isCommentary(`<i>Toby Fox:</i> dogsong.mp3`));
+  t.throws(() => isCommentary(`<i>Toby Fox:</i> (music) dogsong.mp3`));
+  t.throws(() => isCommentary(`<i>I Have Nothing To Say:</i>`));
+  t.throws(() => isCommentary(123));
+  t.throws(() => isCommentary(``));
+  t.throws(() => isCommentary(`Technically, ah, er:</i>\nCorrect`));
+});
+
+t.test('isContentString', t => {
+  t.plan(12);
+
+  t.ok(isContentString(`Hello, world!`));
+  t.ok(isContentString(`Hello...\nWorld!`));
+
+  const quickThrows = (string, description) =>
+    t.throws(() => isContentString(string), description);
+
+  quickThrows(
+    `Snooping\xa0as usual, I\xa0\xa0\xa0SEE.`,
+    Object.assign(
+      new AggregateError([
+        new AggregateError([
+          new TypeError(`Replace "\xa0" (non-breaking space) with " " (normal space) between "ing" and "as " (pos: 9)`),
+          new TypeError(`Replace "\xa0\xa0\xa0" (non-breaking space) with "   " (normal space) between ", I" and "SEE" (pos: 21)`),
+        ], `Illegal characters found in content string`),
+      ], `Errors validating content string`),
+      {[Symbol.for(`hsmusic.aggregate.translucent`)]: 'single'}));
+
+  quickThrows(
+    `Oh\u200bdear,\n` +
+    `Oh dear,\n` +
+    `oh-dear-oh-dear-oh\u200bdear.`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Delete "\u200b" (zero-width space) between "Oh" and "dea" (line: 1, col: 3)`),
+        new TypeError(`Delete "\u200b" (zero-width space) between "-oh" and "dea" (line: 3, col: 19)`),
+      ]),
+    ]));
+
+  quickThrows(
+    `Well the days start comin'\xa0\xa0\xa0\xa0\u200b\u200b\xa0\xa0\xa0\u200b\u200b\u200band they don't stop comin'`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Replace "\xa0\xa0\xa0\xa0" (non-breaking space) with "    " (normal space) after "in'" (pos: 27)`),
+        new TypeError(`Delete "\u200b\u200b" (zero-width space) (pos: 31)`),
+        new TypeError(`Replace "\xa0\xa0\xa0" (non-breaking space) with "   " (normal space) (pos: 33)`),
+        new TypeError(`Delete "\u200b\u200b\u200b" (zero-width space) before "and" (pos: 36)`),
+      ]),
+    ]));
+
+  quickThrows(
+    `It's go-\u200bin',\n` +
+    `\u200bIt's goin',\u200b\n` +
+    `\u200b\u200bIt's going!`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Delete "\u200b" (zero-width space) between "go-" and "in'" (line: 1, col: 9)`),
+        new TypeError(`Delete "\u200b" (zero-width space) before "It'" (line: 2, col: 1)`),
+        new TypeError(`Delete "\u200b" (zero-width space) after "n'," (line: 2, col: 13)`),
+        new TypeError(`Delete "\u200b\u200b" (zero-width space) before "It'" (line: 3, col: 1)`),
+      ]),
+    ]));
+
+  quickThrows(
+    `  Room at the start.`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Matched "  " at start`),
+      ], `Whitespace found at start or end`),
+    ]));
+
+  quickThrows(
+    `Room at the end.      `,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Matched "      " at end`),
+      ], `Whitespace found at start or end`),
+    ]));
+
+  quickThrows(
+    `      Room on both sides. `,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Matched "      " at start`),
+        new TypeError(`Matched " " at end`),
+      ], `Whitespace found at start or end`),
+    ]));
+
+  quickThrows(
+    `We're going multiline! \n` +
+    `That we are, aye.    \n` +
+    `      \n`,
+    `Yessir.`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Matched " " at end of line 1`),
+        new TypeError(`Matched "    " at end of line 2`),
+        new TypeError(`Matched "      " as all of line 3`),
+      ], `Whitespace found at end of line`),
+    ]));
+
+  t.doesNotThrow(() =>
+    isContentString(
+      `It's cool.\n` +
+      `  It's cool.\n` +
+      `    It's cool.\n` +
+      `      It's so cool.`));
+
+  t.doesNotThrow(() =>
+    isContentString(
+      `\n` +
+      `\n` +
+      `It's okay for\n` +
+      `blank lines\n` +
+      `\n` +
+      `just about anywhere.\n` +
+      ``));
+});
+
+t.test('isContribution', t => {
+  t.plan(4);
+  t.ok(isContribution({who: 'artist:toby-fox', what: 'Music'}));
+  t.ok(isContribution({who: 'Toby Fox'}));
+  t.throws(() => isContribution(({who: 'group:umspaf', what: 'Organizing'})),
+    {errors: /who/});
+  t.throws(() => isContribution(({who: 'artist:toby-fox', what: 123})),
+    {errors: /what/});
+});
+
+t.test('isContributionList', t => {
+  t.plan(4);
+  t.ok(isContributionList([{who: 'Beavis'}, {who: 'Butthead', what: 'Wrangling'}]));
+  t.ok(isContributionList([]));
+  t.throws(() => isContributionList(2));
+  t.throws(() => isContributionList(['Charlie', 'Woodstock']));
+});
+
+test(t, 'isDimensions', t => {
+  t.plan(6);
+  t.ok(isDimensions([1, 1]));
+  t.ok(isDimensions([50, 50]));
+  t.ok(isDimensions([5000, 1]));
+  t.throws(() => isDimensions([1]), TypeError);
+  t.throws(() => isDimensions([413, 612, 1025]), TypeError);
+  t.throws(() => isDimensions('800x200'), TypeError);
+});
+
+test(t, 'isDirectory', t => {
+  t.plan(6);
+  t.ok(isDirectory('savior-of-the-waking-world'));
+  t.ok(isDirectory('MeGaLoVania'));
+  t.ok(isDirectory('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'));
+  t.throws(() => isDirectory(123), TypeError);
+  t.throws(() => isDirectory(''), TypeError);
+  t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError);
+});
+
+test(t, 'isDuration', t => {
+  t.plan(5);
+  t.ok(isDuration(60));
+  t.ok(isDuration(0.02));
+  t.ok(isDuration(0));
+  t.throws(() => isDuration(-1), TypeError);
+  t.throws(() => isDuration('10:25'), TypeError);
+});
+
+test(t, 'isFileExtension', t => {
+  t.plan(6);
+  t.ok(isFileExtension('png'));
+  t.ok(isFileExtension('jpg'));
+  t.ok(isFileExtension('sub_loc'));
+  t.throws(() => isFileExtension(''), TypeError);
+  t.throws(() => isFileExtension('.jpg'), TypeError);
+  t.throws(() => isFileExtension('just an image bro!!!!'), TypeError);
+});
+
+t.test('isName', t => {
+  t.plan(4);
+  t.ok(isName('Dogz 2.0'));
+  t.ok(isName('album:this-track-is-only-named-thusly-to-give-niklink-a-headache'));
+  t.throws(() => isName(''));
+  t.throws(() => isName(612));
+});
+
+t.test('isURL', t => {
+  t.plan(4);
+  t.ok(isURL(`https://hsmusic.wiki/foo/bar/hi?baz=25#hash`));
+  t.throws(() => isURL(`/the/dog/zone/`));
+  t.throws(() => isURL(25));
+  t.throws(() => isURL(new URL(`https://hsmusic.wiki/perfectly/reasonable/`)));
+});
+
+test(t, 'validateReference', t => {
+  t.plan(16);
+
+  const typeless = validateReference();
+  const track = validateReference('track');
+  const album = validateReference('album');
+
+  t.ok(track('track:doctor'));
+  t.ok(track('track:MeGaLoVania'));
+  t.ok(track('Showtime (Imp Strife Mix)'));
+  t.throws(() => track('track:troll saint nic'), TypeError);
+  t.throws(() => track('track:'), TypeError);
+  t.throws(() => track('album:homestuck-vol-1'), TypeError);
+
+  t.ok(album('album:sburb'));
+  t.ok(album('album:the-wanderers'));
+  t.ok(album('Homestuck Vol. 8'));
+  t.throws(() => album('album:Hiveswap Friendsim'), TypeError);
+  t.throws(() => album('album:'), TypeError);
+  t.throws(() => album('track:showtime-piano-refrain'), TypeError);
+
+  t.ok(typeless('Hopes and Dreams'));
+  t.ok(typeless('track:snowdin-town'));
+  t.throws(() => typeless(''), TypeError);
+  t.throws(() => typeless('album:undertale-soundtrack'));
+});
+
+test(t, 'validateReferenceList', t => {
+  const track = validateReferenceList('track');
+  const artist = validateReferenceList('artist');
+
+  t.plan(11);
+
+  t.ok(track(['track:fallen-down', 'Once Upon a Time']));
+  t.ok(artist(['artist:toby-fox', 'Mark Hadley']));
+  t.ok(track(['track:amalgam']));
+  t.ok(track([]));
+
+  let caughtError = null;
+  try {
+    track(['Dog', 'album:vaporwave-2016', 'Cat', 'artist:john-madden']);
+  } catch (err) {
+    caughtError = err;
+  }
+
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 2);
+  t.ok(caughtError.errors[0] instanceof Error);
+  t.ok(caughtError.errors[0].cause instanceof TypeError);
+  t.ok(caughtError.errors[1] instanceof Error);
+  t.ok(caughtError.errors[0].cause instanceof TypeError);
+});
+
+test(t, 'anyOf', t => {
+  t.plan(11);
+
+  const isStringOrNumber = anyOf(isString, isNumber);
+
+  t.ok(isStringOrNumber('hello world'));
+  t.ok(isStringOrNumber(42));
+  t.throws(() => isStringOrNumber(false));
+
+  const mockError = new Error();
+  const neverSucceeds = () => {
+    throw mockError;
+  };
+
+  const isStringOrGetRekt = anyOf(isString, neverSucceeds);
+
+  t.ok(isStringOrGetRekt('phew!'));
+
+  let caughtError = null;
+  try {
+    isStringOrGetRekt(0xdeadbeef);
+  } catch (err) {
+    caughtError = err;
+  }
+
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 2);
+  t.ok(caughtError.errors[0] instanceof TypeError);
+  t.equal(caughtError.errors[0].check, isString);
+  t.equal(caughtError.errors[1], mockError);
+  t.equal(caughtError.errors[1].check, neverSucceeds);
+});