« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/lib/content-function.js11
-rw-r--r--test/lib/wiki-data.js35
-rw-r--r--test/snapshot/generateAlbumCoverArtwork.js1
-rw-r--r--test/snapshot/generateTrackAdditionalNamesBox.js107
-rw-r--r--test/snapshot/generateTrackCoverArtwork.js2
-rw-r--r--test/snapshot/linkContribution.js37
-rw-r--r--test/snapshot/linkExternal.js87
-rw-r--r--test/snapshot/linkExternalFlash.js24
-rw-r--r--test/snapshot/transformContent.js39
-rw-r--r--test/unit/data/cacheable-object.js2
-rw-r--r--test/unit/data/composite/data/withUniqueItemsOnly.js84
-rw-r--r--test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js102
-rw-r--r--test/unit/data/things/album.js20
-rw-r--r--test/unit/data/things/track.js69
-rw-r--r--test/unit/data/things/validators.js17
15 files changed, 524 insertions, 113 deletions
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
index 5cb499b1..a4c5dac1 100644
--- a/test/lib/content-function.js
+++ b/test/lib/content-function.js
@@ -8,7 +8,7 @@ import {getColors} from '#colors';
 import {quickLoadContentDependencies} from '#content-dependencies';
 import {quickEvaluate} from '#content-function';
 import * as html from '#html';
-import {processLanguageFile} from '#language';
+import {internalDefaultStringsFile, processLanguageFile} from '#language';
 import {empty, showAggregate} from '#sugar';
 import {generateURLs, thumb, urlSpec} from '#urls';
 
@@ -22,7 +22,7 @@ export function testContentFunctions(t, message, fn) {
   t.test(message, async t => {
     let loadedContentDependencies;
 
-    const language = await processLanguageFile('./src/strings-default.json');
+    const language = await processLanguageFile(internalDefaultStringsFile);
     const mocks = [];
 
     const evaluate = ({
@@ -50,8 +50,15 @@ export function testContentFunctions(t, message, fn) {
             thumb,
             to,
             urls,
+
+            pagePath: ['home'],
             appendIndexHTML: false,
             getColors: c => getColors(c, {chroma}),
+
+            wikiData: {
+              wikiInfo: {},
+            },
+
             ...extraDependencies,
           },
         });
diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js
index c4083a56..5433de29 100644
--- a/test/lib/wiki-data.js
+++ b/test/lib/wiki-data.js
@@ -1,7 +1,32 @@
+import CacheableObject from '#cacheable-object';
+import find from '#find';
 import {linkWikiDataArrays} from '#yaml';
 
-export function linkAndBindWikiData(wikiData) {
-  linkWikiDataArrays(wikiData);
+export function linkAndBindWikiData(wikiData, {
+  inferAlbumsOwnTrackData = true,
+} = {}) {
+  function customLinkWikiDataArrays(...args) {
+    linkWikiDataArrays(...args);
+
+    // If albumData is present, automatically set albums' ownTrackData values
+    // by resolving track sections' references against the full array. This is
+    // just a nicety for working with albums throughout tests.
+    if (inferAlbumsOwnTrackData && wikiData.albumData && wikiData.trackData) {
+      for (const album of wikiData.albumData) {
+        const trackSections =
+          CacheableObject.getUpdateValue(album, 'trackSections');
+
+        const trackRefs =
+          trackSections.flatMap(section => section.tracks);
+
+        album.ownTrackData =
+          trackRefs.map(ref =>
+            find.track(ref, wikiData.trackData, {mode: 'error'}));
+      }
+    }
+  }
+
+  customLinkWikiDataArrays(wikiData);
 
   return {
     // Mutate to make the below functions aware of new data objects, or of
@@ -13,12 +38,14 @@ export function linkAndBindWikiData(wikiData) {
     // It'll automatically relink everything on wikiData so all the objects
     // are caught up to date.
     linkWikiDataArrays:
-      linkWikiDataArrays.bind(null, wikiData),
+      customLinkWikiDataArrays
+        .bind(null, wikiData),
 
     // Use this if you HAVEN'T mutated wikiData and just need to decache
     // indirect dependencies on exposed properties of other data objects.
     // See documentation on linkWikiDataArarys (in yaml.js) for more info.
     XXX_decacheWikiData:
-      linkWikiDataArrays.bind(null, wikiData, {XXX_decacheWikiData: true}),
+      customLinkWikiDataArrays
+        .bind(null, wikiData, {XXX_decacheWikiData: true}),
   };
 }
diff --git a/test/snapshot/generateAlbumCoverArtwork.js b/test/snapshot/generateAlbumCoverArtwork.js
index b1c7885f..9244c034 100644
--- a/test/snapshot/generateAlbumCoverArtwork.js
+++ b/test/snapshot/generateAlbumCoverArtwork.js
@@ -13,6 +13,7 @@ testContentFunctions(t, 'generateAlbumCoverArtwork (snapshot)', async (t, evalua
   const album = {
     directory: 'bee-forus-seatbelt-safebee',
     coverArtFileExtension: 'png',
+    color: '#f28514',
     artTags: [
       {name: 'Damara', directory: 'damara', isContentWarning: false},
       {name: 'Cronus', directory: 'cronus', isContentWarning: false},
diff --git a/test/snapshot/generateTrackAdditionalNamesBox.js b/test/snapshot/generateTrackAdditionalNamesBox.js
new file mode 100644
index 00000000..9c1e3598
--- /dev/null
+++ b/test/snapshot/generateTrackAdditionalNamesBox.js
@@ -0,0 +1,107 @@
+import t from 'tap';
+
+import contentFunction from '#content-function';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateTrackAdditionalNamesBox (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      generateAdditionalNamesBox:
+        evaluate.stubContentFunction('generateAdditionalNamesBox'),
+    },
+  });
+
+  const stubTrack = {
+    additionalNames: [],
+    sharedAdditionalNames: [],
+    inferredAdditionalNames: [],
+  };
+
+  const quickSnapshot = (message, trackProperties) =>
+    evaluate.snapshot(message, {
+      name: 'generateTrackAdditionalNamesBox',
+      args: [{...stubTrack, ...trackProperties}],
+    });
+
+  quickSnapshot(`no additional names`, {});
+
+  quickSnapshot(`own additional names only`, {
+    additionalNames: [
+      {name: `Foo Bar`, annotation: `the Alps`},
+    ],
+  });
+
+  quickSnapshot(`shared additional names only`, {
+    sharedAdditionalNames: [
+      {name: `Bar Foo`, annotation: `the Rockies`},
+    ],
+  });
+
+  quickSnapshot(`inferred additional names only`, {
+    inferredAdditionalNames: [
+      {name: `Baz Baz`, from: [{directory: `the-pyrenees`}]},
+    ],
+  });
+
+  quickSnapshot(`multiple own`, {
+    additionalNames: [
+      {name: `Apple Time!`},
+      {name: `Pterodactyl Time!`},
+      {name: `Banana Time!`},
+    ],
+  });
+
+  quickSnapshot(`own and shared, some overlap`, {
+    additionalNames: [
+      {name: `weed dreams..`, annotation: `own annotation`},
+      {name: `夜間のMOON汗`, annotation: `own annotation`},
+    ],
+    sharedAdditionalNames: [
+      {name: `weed dreams..`, annotation: `shared annotation`},
+      {name: `GAMINGブラザー96`, annotation: `shared annotation`},
+    ],
+  });
+
+  quickSnapshot(`shared and inferred, some overlap`, {
+    sharedAdditionalNames: [
+      {name: `Coruscate`, annotation: `shared annotation`},
+      {name: `Arbroath`, annotation: `shared annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Arbroath`, from: [{directory: `inferred-from`}]},
+      {name: `Prana Ferox`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+
+  quickSnapshot(`own and inferred, some overlap`, {
+    additionalNames: [
+      {name: `Ke$halo Strike Back`, annotation: `own annotation`},
+      {name: `Ironic Mania`, annotation: `own annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Ironic Mania`, from: [{directory: `inferred-from`}]},
+      {name: `ANARCHY::MEGASTRIFE`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+
+  quickSnapshot(`own and shared and inferred, various overlap`, {
+    additionalNames: [
+      {name: `Own!`, annotation: `own annotation`},
+      {name: `Own! Shared!`, annotation: `own annotation`},
+      {name: `Own! Inferred!`, annotation: `own annotation`},
+      {name: `Own! Shared! Inferred!`, annotation: `own annotation`},
+    ],
+    sharedAdditionalNames: [
+      {name: `Shared!`, annotation: `shared annotation`},
+      {name: `Own! Shared!`, annotation: `shared annotation`},
+      {name: `Shared! Inferred!`, annotation: `shared annotation`},
+      {name: `Own! Shared! Inferred!`, annotation: `shared annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Own! Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Shared! Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Own! Shared! Inferred!`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+});
diff --git a/test/snapshot/generateTrackCoverArtwork.js b/test/snapshot/generateTrackCoverArtwork.js
index 03a181e7..1e651eb1 100644
--- a/test/snapshot/generateTrackCoverArtwork.js
+++ b/test/snapshot/generateTrackCoverArtwork.js
@@ -23,6 +23,7 @@ testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evalua
     directory: 'beesmp3',
     hasUniqueCoverArt: true,
     coverArtFileExtension: 'jpg',
+    color: '#f28514',
     artTags: [{name: 'Bees', directory: 'bees', isContentWarning: false}],
     album,
   };
@@ -30,6 +31,7 @@ testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evalua
   const track2 = {
     directory: 'fake-bonus-track',
     hasUniqueCoverArt: false,
+    color: '#abcdef',
     album,
   };
 
diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js
index ad5fb416..ebd3be58 100644
--- a/test/snapshot/linkContribution.js
+++ b/test/snapshot/linkContribution.js
@@ -33,22 +33,36 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
       slots,
     });
 
-  quickSnapshot('showContribution & showIcons', {
+  quickSnapshot('showContribution & showIcons (inline)', {
     showContribution: true,
     showIcons: true,
+    iconMode: 'inline',
+  });
+
+  quickSnapshot('showContribution & showIcons (tooltip)', {
+    showContribution: true,
+    showIcons: true,
+    iconMode: 'tooltip',
   });
 
   quickSnapshot('only showContribution', {
     showContribution: true,
   });
 
-  quickSnapshot('only showIcons', {
+  quickSnapshot('only showIcons (inline)', {
+    showIcons: true,
+    iconMode: 'inline',
+  });
+
+  quickSnapshot('only showIcons (tooltip)', {
+    showContribution: true,
     showIcons: true,
+    iconMode: 'tooltip',
   });
 
   quickSnapshot('no accents', {});
 
-  evaluate.snapshot('loads of links', {
+  evaluate.snapshot('loads of links (inline)', {
     name: 'linkContribution',
     args: [
       {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
@@ -65,6 +79,23 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
     slots: {showIcons: true},
   });
 
+  evaluate.snapshot('loads of links (tooltip)', {
+    name: 'linkContribution',
+    args: [
+      {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
+        'https://loremipsum.io',
+        'https://loremipsum.io/generator/',
+        'https://loremipsum.io/#meaning',
+        'https://loremipsum.io/#usage-and-examples',
+        'https://loremipsum.io/#controversy',
+        'https://loremipsum.io/#when-to-use-lorem-ipsum',
+        'https://loremipsum.io/#lorem-ipsum-all-the-things',
+        'https://loremipsum.io/#original-source',
+      ]}, what: null},
+    ],
+    slots: {showIcons: true, iconMode: 'tooltip'},
+  });
+
   quickSnapshot('no preventWrapping', {
     showContribution: true,
     showIcons: true,
diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js
index 3e8aee0d..434372a9 100644
--- a/test/snapshot/linkExternal.js
+++ b/test/snapshot/linkExternal.js
@@ -4,51 +4,58 @@ import {testContentFunctions} from '#test-lib';
 testContentFunctions(t, 'linkExternal (snapshot)', async (t, evaluate) => {
   await evaluate.load();
 
-  evaluate.snapshot('missing domain (arbitrary local path)', {
-    name: 'linkExternal',
-    args: ['/foo/bar/baz.mp3']
-  });
-
   evaluate.snapshot('unknown domain (arbitrary world wide web path)', {
     name: 'linkExternal',
     args: ['https://snoo.ping.as/usual/i/see/'],
   });
 
-  evaluate.snapshot('basic domain matches', {
-    name: 'linkExternal',
-    multiple: [
-      {args: ['https://homestuck.bandcamp.com/']},
-      {args: ['https://soundcloud.com/plazmataz']},
-      {args: ['https://aeritus.tumblr.com/']},
-      {args: ['https://twitter.com/awkwarddoesart']},
-      {args: ['https://www.deviantart.com/chesswanderlust-sama']},
-      {args: ['https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)']},
-      {args: ['https://www.poetryfoundation.org/poets/christina-rossetti']},
-      {args: ['https://www.instagram.com/levc_egm/']},
-      {args: ['https://www.patreon.com/CecilyRenns']},
-      {args: ['https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3']},
-      {args: ['https://buzinkai.newgrounds.com/']},
-    ],
-  });
+  const urlsToArgs = urls =>
+    urls.map(url => ({args: [url]}));
 
-  evaluate.snapshot('custom matches - album', {
-    name: 'linkExternal',
-    multiple: [
-      {args: ['https://youtu.be/abc']},
-      {args: ['https://youtube.com/watch?v=abc']},
-      {args: ['https://youtube.com/Playlist?list=kweh']},
-    ],
-    slots: {
-      mode: 'album',
-    },
-  });
+  const quickSnapshot = (message, urls, slots) =>
+    evaluate.snapshot(message, {
+      name: 'linkExternal',
+      slots,
+      multiple: urlsToArgs(urls),
+    });
 
-  evaluate.snapshot('custom domains for common platforms', {
-    name: 'linkExternal',
-    multiple: [
-      // Just one domain of each platform is OK here
-      {args: ['https://music.solatrus.com/']},
-      {args: ['https://types.pl/']},
-    ],
-  });
+  const quickSnapshotAllStyles = (context, urls) => {
+    for (const style of ['platform', 'normal', 'compact']) {
+      const message = `context: ${context}, style: ${style}`;
+      quickSnapshot(message, urls, {context, style});
+    }
+  };
+
+  quickSnapshotAllStyles('generic', [
+    'https://homestuck.bandcamp.com/',
+    'https://soundcloud.com/plazmataz',
+    'https://aeritus.tumblr.com/',
+    'https://twitter.com/awkwarddoesart',
+    'https://www.deviantart.com/chesswanderlust-sama',
+    'https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)',
+    'https://www.poetryfoundation.org/poets/christina-rossetti',
+    'https://www.instagram.com/levc_egm/',
+    'https://www.patreon.com/CecilyRenns',
+    'https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3',
+    'https://buzinkai.newgrounds.com/',
+
+    // Just one custom domain of each platform is OK here
+    'https://music.solatrus.com/',
+    'https://types.pl/',
+  ]);
+
+  quickSnapshotAllStyles('album', [
+    'https://youtu.be/abc',
+    'https://youtube.com/watch?v=abc',
+    'https://youtube.com/Playlist?list=kweh',
+  ]);
+
+  quickSnapshotAllStyles('flash', [
+    'https://www.bgreco.net/hsflash/002238.html',
+    'https://homestuck.com/story/1234',
+    'https://homestuck.com/story/pony',
+    'https://www.youtube.com/watch?v=wKgOp3Kg2wI',
+    'https://youtu.be/IOcvkkklWmY',
+    'https://some.external.site/foo/bar/',
+  ]);
 });
diff --git a/test/snapshot/linkExternalFlash.js b/test/snapshot/linkExternalFlash.js
deleted file mode 100644
index a4d44aff..00000000
--- a/test/snapshot/linkExternalFlash.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import t from 'tap';
-import {testContentFunctions} from '#test-lib';
-
-testContentFunctions(t, 'linkExternalFlash (snapshot)', async (t, evaluate) => {
-  await evaluate.load();
-
-  evaluate.snapshot('basic behavior', {
-    name: 'linkExternalFlash',
-    multiple: [
-      {args: ['https://homestuck.com/story/4109/', {page: '4109'}]},
-      {args: ['https://youtu.be/FDt-SLyEcjI', {page: '4109'}]},
-      {args: ['https://www.bgreco.net/hsflash/006009.html', {page: '4109'}]},
-      {args: ['https://www.newgrounds.com/portal/view/582345', {page: '4109'}]},
-    ],
-  });
-
-  evaluate.snapshot('secret page', {
-    name: 'linkExternalFlash',
-    multiple: [
-      {args: ['https://homestuck.com/story/pony/', {page: 'pony'}]},
-      {args: ['https://youtu.be/USB1pj6hAjU', {page: 'pony'}]},
-    ],
-  });
-});
diff --git a/test/snapshot/transformContent.js b/test/snapshot/transformContent.js
index b05beac1..740d94df 100644
--- a/test/snapshot/transformContent.js
+++ b/test/snapshot/transformContent.js
@@ -37,6 +37,45 @@ testContentFunctions(t, 'transformContent (snapshot)', async (t, evaluate) => {
       `That's right, [[album:cool-album]]!`);
 
   quickSnapshot(
+    'indent on a directly following line',
+      `<div>\n` +
+      `    <span>Wow!</span>\n` +
+      `</div>`);
+
+  quickSnapshot(
+    'indent on an indierctly following line',
+      `Some text.\n` +
+      `Yes, some more text.\n` +
+      `\n` +
+      `    I am hax0rz!!\n` +
+      `    All yor base r blong 2 us.\n` +
+      `\n` +
+      `Aye.\n` +
+      `Aye aye aye.`);
+
+  quickSnapshot(
+    'hanging indent list',
+      `Hello!\n` +
+      `\n` +
+      `* I am a list item and I\n` +
+      `  go on and on and on\n` +
+      `  and on and on and on.\n` +
+      `\n` +
+      `* I am another list item.\n` +
+      `  Yeah.\n` +
+      `\n` +
+      `In-between!\n` +
+      `\n` +
+      `* Spooky,\n` +
+      `  spooky, I say!\n` +
+      `* Following list item.\n` +
+      `  No empty line around me.\n` +
+      `* Very cool.\n` +
+      `  So, so cool.\n` +
+      `\n` +
+      `Goodbye!`);
+
+  quickSnapshot(
     'inline images',
       `<img src="snooping.png"> as USUAL...\n` +
       `What do you know? <img src="cowabunga.png" width="24" height="32">\n` +
diff --git a/test/unit/data/cacheable-object.js b/test/unit/data/cacheable-object.js
index 57e562d8..5ed9a4a9 100644
--- a/test/unit/data/cacheable-object.js
+++ b/test/unit/data/cacheable-object.js
@@ -1,6 +1,6 @@
 import t from 'tap';
 
-import {CacheableObject} from '#things';
+import CacheableObject from '#cacheable-object';
 
 function newCacheableObject(PD) {
   return new (class extends CacheableObject {
diff --git a/test/unit/data/composite/data/withUniqueItemsOnly.js b/test/unit/data/composite/data/withUniqueItemsOnly.js
new file mode 100644
index 00000000..965b14b5
--- /dev/null
+++ b/test/unit/data/composite/data/withUniqueItemsOnly.js
@@ -0,0 +1,84 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
+import {withUniqueItemsOnly} from '#composite/data';
+
+t.test(`withUniqueItemsOnly: basic behavior`, t => {
+  t.plan(3);
+
+  const composite = compositeFrom({
+    compose: false,
+
+    steps: [
+      withUniqueItemsOnly({
+        list: 'list',
+      }),
+
+      exposeDependency({dependency: '#list'}),
+    ],
+  });
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['list'],
+    },
+  });
+
+  t.same(composite.expose.compute({
+    list: ['apple', 'banana', 'banana', 'banana', 'apple', 'watermelon'],
+  }), ['apple', 'banana', 'watermelon']);
+
+  t.same(composite.expose.compute({
+    list: [],
+  }), []);
+});
+
+t.test(`withUniqueItemsOnly: output shapes & values`, t => {
+  t.plan(2 * 3 ** 1);
+
+  const dependencies = {
+    ['list_dependency']:
+      [1, 1, 2, 3, 3, 4, 'foo', false, false, 4],
+    [input('list_neither')]:
+      [8, 8, 7, 6, 6, 5, 'bar', true, true, 5],
+  };
+
+  const mapLevel1 = [
+    ['list_dependency', {
+      '#list_dependency': [1, 2, 3, 4, 'foo', false],
+    }],
+    [input.value([-1, -1, 'interesting', 'very', 'interesting']), {
+      '#uniqueItems': [-1, 'interesting', 'very'],
+    }],
+    [input('list_neither'), {
+      '#uniqueItems': [8, 7, 6, 5, 'bar', true],
+    }],
+  ];
+
+  for (const [listInput, outputDict] of mapLevel1) {
+    const step = withUniqueItemsOnly({
+      list: listInput,
+    });
+
+    quickCheckOutputs(step, outputDict);
+  }
+
+  function quickCheckOutputs(step, outputDict) {
+    t.same(
+      Object.keys(step.toDescription().outputs),
+      Object.keys(outputDict));
+
+    const composite = compositeFrom({
+      compose: false,
+      steps: [step, {
+        dependencies: Object.keys(outputDict),
+        compute: dependencies => dependencies,
+      }],
+    });
+
+    t.same(
+      composite.expose.compute(dependencies),
+      outputDict);
+  }
+});
diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
new file mode 100644
index 00000000..babe4fae
--- /dev/null
+++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -0,0 +1,102 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import thingConstructors from '#things';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
+
+const {Artist} = thingConstructors;
+
+const composite = compositeFrom({
+  compose: false,
+
+  steps: [
+    withParsedCommentaryEntries({
+      from: 'from',
+    }),
+
+    exposeDependency({dependency: '#parsedCommentaryEntries'}),
+  ],
+});
+
+function stubArtist(artistName = `Test Artist`) {
+  const artist = new Artist();
+  artist.name = artistName;
+
+  return artist;
+}
+
+t.test(`withParsedCommentaryEntries: basic behavior`, t => {
+  t.plan(3);
+
+  const artist1 = stubArtist(`Mobius Trip`);
+  const artist2 = stubArtist(`Hadron Kaleido`);
+
+  const artistData = [artist1, artist2];
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['from', 'artistData'],
+    },
+  });
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Mobius Trip:</i>\n` +
+      `Some commentary.\n` +
+      `Very cool.\n`,
+  }), [
+    {
+      artists: [artist1],
+      artistDisplayText: null,
+      annotation: null,
+      date: null,
+      body: `Some commentary.\nVery cool.`,
+    },
+  ]);
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Mobius Trip|Moo-bius Trip:</i> (music, art, 12 January 2015)\n` +
+      `First commentary entry.\n` +
+      `Very cool.\n` +
+      `<i>Hadron Kaleido|<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>:</i> (moral support, 4/4/2022)\n` +
+      `Second commentary entry. Yes. So cool.\n` +
+      `<i>Mystery Artist:</i> (pingas, August 25, 2023)\n` +
+      `Oh no.. Oh dear...\n` +
+      `<i>Mobius Trip, Hadron Kaleido:</i>\n` +
+      `And back around we go.`,
+  }), [
+    {
+      artists: [artist1],
+      artistDisplayText: `Moo-bius Trip`,
+      annotation: `music, art`,
+      date: new Date('12 January 2015'),
+      body: `First commentary entry.\nVery cool.`,
+    },
+    {
+      artists: [artist2],
+      artistDisplayText: `<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>`,
+      annotation: `moral support`,
+      date: new Date('4 April 2022'),
+      body: `Second commentary entry. Yes. So cool.`,
+    },
+    {
+      artists: [],
+      artistDisplayText: null,
+      annotation: `pingas`,
+      date: new Date('25 August 2023'),
+      body: `Oh no.. Oh dear...`,
+    },
+    {
+      artists: [artist1, artist2],
+      artistDisplayText: null,
+      annotation: null,
+      date: null,
+      body: `And back around we go.`,
+    },
+  ]);
+});
diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js
index 76a2b90f..5a1261a4 100644
--- a/test/unit/data/things/album.js
+++ b/test/unit/data/things/album.js
@@ -204,11 +204,13 @@ t.test(`Album.tracks`, t => {
   const track1 = stubTrack('track1');
   const track2 = stubTrack('track2');
   const track3 = stubTrack('track3');
+  const tracks = [track1, track2, track3];
 
-  linkAndBindWikiData({
-    albumData: [album],
-    trackData: [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`);
@@ -259,11 +261,13 @@ t.test(`Album.trackSections`, t => {
   const track2 = stubTrack('track2');
   const track3 = stubTrack('track3');
   const track4 = stubTrack('track4');
+  const tracks = [track1, track2, track3, track4];
 
-  linkAndBindWikiData({
-    albumData: [album],
-    trackData: [track1, track2, track3, track4],
-  });
+  album.ownTrackData = tracks;
+
+  for (const track of tracks) {
+    track.albumData = [album];
+  }
 
   album.trackSections = [
     {tracks: ['track:track1', 'track:track2']},
diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js
index 806efbf1..57bd4253 100644
--- a/test/unit/data/things/track.js
+++ b/test/unit/data/things/track.js
@@ -80,8 +80,8 @@ t.test(`Track.album`, t => {
 
   track1.albumData = [album1, album2];
   track2.albumData = [album1, album2];
-  album1.trackData = [track1, track2];
-  album2.trackData = [track1, track2];
+  album1.ownTrackData = [track1, track2];
+  album2.ownTrackData = [track1, track2];
   album1.trackSections = [{tracks: ['track:track1']}];
   album2.trackSections = [{tracks: ['track:track2']}];
 
@@ -98,13 +98,13 @@ t.test(`Track.album`, t => {
   t.equal(track1.album, null,
     `album #4: is null when track missing albumData`);
 
-  album1.trackData = [];
+  album1.ownTrackData = [];
   track1.albumData = [album1, album2];
 
   t.equal(track1.album, null,
-    `album #5: is null when album missing trackData`);
+    `album #5: is null when album missing ownTrackData`);
 
-  album1.trackData = [track1, track2];
+  album1.ownTrackData = [track1, track2];
   album1.trackSections = [{tracks: ['track:track2']}];
 
   // XXX_decacheWikiData
@@ -165,7 +165,7 @@ t.test(`Track.color`, t => {
 
   const {track, album} = stubTrackAndAlbum();
 
-  const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
     albumData: [album],
     trackData: [track],
   });
@@ -188,18 +188,17 @@ t.test(`Track.color`, t => {
   // 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.
-  wikiData.albumData = [
-    new Proxy({
+  track.albumData = [
+    {
+      constructor: {[Thing.referenceType]: 'album'},
       color: '#abcdef',
       tracks: [track],
       trackSections: [
         {color: '#baaaad', tracks: []},
       ],
-    }, {getPrototypeOf: () => Album.prototype}),
+    },
   ];
 
-  linkWikiDataArrays();
-
   t.equal(track.color, '#abcdef',
     `color #3: inherits from album without matching track section`);
 
@@ -214,7 +213,7 @@ t.test(`Track.color`, t => {
 });
 
 t.test(`Track.commentatorArtists`, t => {
-  t.plan(6);
+  t.plan(8);
 
   const track = new Track();
   const artist1 = stubArtist(`SnooPING`);
@@ -226,47 +225,67 @@ t.test(`Track.commentatorArtists`, t => {
     artistData: [artist1, artist2, artist3],
   });
 
-  track.commentary =
+  // 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 +=
+  track.commentary = commentary +=
     `<i>ASUsual:</i>\n` +
     `Yes!\n`;
 
   t.same(track.commentatorArtists, [artist1, artist2],
     `Track.commentatorArtists #2: works with two commentators`);
 
-  track.commentary +=
-    `<i><b>Icy:</b></i>\n` +
+  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 boldface name`);
+    `Track.commentatorArtists #3: works with custom artist text`);
 
-  track.commentary =
+  track.commentary = commentary =
     `<i>Icy:</i> (project manager)\n` +
     `Very good track.\n`;
 
   t.same(track.commentatorArtists, [artist3],
-    `Track.commentatorArtists #4: works with parenthical accent`);
+    `Track.commentatorArtists #4: works with annotation`);
 
-  track.commentary +=
-    `<i>SNooPING ASUsual Icy:</i>\n` +
-    `WITH ALL THREE POWERS COMBINED...`;
+  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 #5: ignores artist names not found`);
+    `Track.commentatorArtists #6: ignores artist names not found`);
 
-  track.commentary +=
+  track.commentary = commentary +=
     `<i>Icy:</i>\n` +
     `I'm back!\n`;
 
   t.same(track.commentatorArtists, [artist3],
-    `Track.commentatorArtists #6: ignores duplicate artist`);
+    `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 => {
diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js
index bb33bf86..aa56a10e 100644
--- a/test/unit/data/things/validators.js
+++ b/test/unit/data/things/validators.js
@@ -149,13 +149,18 @@ t.test('isColor', t => {
 });
 
 t.test('isCommentary', t => {
-  t.plan(6);
+  t.plan(9);
+
+  // TODO: Test specific error messages.
   t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`));
-  t.ok(isCommentary(`Technically, this works:</i>`));
-  t.ok(isCommentary(`<i><b>Whodunnit:</b></i>`));
-  t.throws(() => isCommentary(123), TypeError);
-  t.throws(() => isCommentary(``), TypeError);
-  t.throws(() => isCommentary(`<i><u>Toby Fox:</u></i>`));
+  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('isContribution', t => {