« 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/composite.js33
-rw-r--r--test/lib/content-function.js18
-rw-r--r--test/lib/index.js1
-rw-r--r--test/lib/wiki-data.js17
-rw-r--r--test/snapshot/generateAlbumReleaseInfo.js12
-rw-r--r--test/snapshot/generateAlbumTrackList.js6
-rw-r--r--test/snapshot/generateTrackReleaseInfo.js4
-rw-r--r--test/snapshot/linkContribution.js45
-rw-r--r--test/unit/content/dependencies/linkContribution.js41
-rw-r--r--test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js18
-rw-r--r--test/unit/data/composite/data/withPropertiesFromObject.js59
-rw-r--r--test/unit/data/composite/data/withPropertyFromObject.js107
-rw-r--r--test/unit/data/composite/data/withUniqueItemsOnly.js23
-rw-r--r--test/unit/data/things/album.js40
-rw-r--r--test/unit/data/things/art-tag.js3
-rw-r--r--test/unit/data/things/track.js34
16 files changed, 274 insertions, 187 deletions
diff --git a/test/lib/composite.js b/test/lib/composite.js
new file mode 100644
index 00000000..359d364d
--- /dev/null
+++ b/test/lib/composite.js
@@ -0,0 +1,33 @@
+import {compositeFrom} from '#composite';
+
+export function quickCheckCompositeOutputs(t, dependencies) {
+  return (step, outputDict) => {
+    t.same(
+      Object.keys(step.toDescription().outputs),
+      Object.keys(outputDict));
+
+    const composite = compositeFrom({
+      compose: false,
+      steps: [
+        step,
+
+        {
+          dependencies: Object.keys(outputDict),
+
+          // Access all dependencies by their expected keys -
+          // the composition runner actually provides a proxy
+          // and is checking that *we* access the dependencies
+          // we've specified.
+          compute: dependencies =>
+            Object.fromEntries(
+              Object.keys(outputDict)
+                .map(key => [key, dependencies[key]])),
+        },
+      ],
+    });
+
+    t.same(
+      composite.expose.compute(dependencies),
+      outputDict);
+  };
+}
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
index 7bc62139..a46d18c9 100644
--- a/test/lib/content-function.js
+++ b/test/lib/content-function.js
@@ -17,8 +17,23 @@ import mock from './generic-mock.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
+function cleanURLSpec(reference) {
+  const prepared = structuredClone(reference);
+
+  for (const spec of Object.values(prepared)) {
+    if (spec.prefix) {
+      // Strip out STATIC_VERSION. This updates fairly regularly and we
+      // don't want it to affect snapshot tests.
+      spec.prefix = spec.prefix
+        .replace(/static-\d+[a-z]\d+/i, 'static');
+    }
+  }
+
+  return prepared;
+}
+
 export function testContentFunctions(t, message, fn) {
-  const urls = generateURLs(urlSpec);
+  const urls = generateURLs(cleanURLSpec(urlSpec));
 
   t.test(message, async t => {
     let loadedContentDependencies;
@@ -52,7 +67,6 @@ export function testContentFunctions(t, message, fn) {
             to,
             urls,
 
-            cachebust: 413,
             pagePath: ['home'],
             appendIndexHTML: false,
             getColors: c => getColors(c, {chroma}),
diff --git a/test/lib/index.js b/test/lib/index.js
index 5fb5bf78..4c9ee23f 100644
--- a/test/lib/index.js
+++ b/test/lib/index.js
@@ -1,5 +1,6 @@
 Error.stackTraceLimit = Infinity;
 
+export * from './composite.js';
 export * from './content-function.js';
 export * from './generic-mock.js';
 export * from './wiki-data.js';
diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js
index 75b1170a..c373aadd 100644
--- a/test/lib/wiki-data.js
+++ b/test/lib/wiki-data.js
@@ -12,23 +12,6 @@ export function linkAndBindWikiData(wikiData, {
         ? withEntries(wikiData, entries => entries
             .map(([key, value]) => [key, value.slice()]))
         : wikiData));
-
-    // If albumData is present, automatically set their sections' ownTrackData
-    // by resolving 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');
-
-        for (const trackSection of trackSections) {
-          trackSection.ownTrackData =
-            CacheableObject.getUpdateValue(trackSection, 'tracks')
-              .map(ref =>
-                find.track(ref, wikiData.trackData, {mode: 'error'}));
-        }
-      }
-    }
   }
 
   customLinkWikiDataArrays(wikiData);
diff --git a/test/snapshot/generateAlbumReleaseInfo.js b/test/snapshot/generateAlbumReleaseInfo.js
index a109912f..f41e502d 100644
--- a/test/snapshot/generateAlbumReleaseInfo.js
+++ b/test/snapshot/generateAlbumReleaseInfo.js
@@ -8,22 +8,22 @@ testContentFunctions(t, 'generateAlbumReleaseInfo (snapshot)', async (t, evaluat
     name: 'generateAlbumReleaseInfo',
     args: [{
       artistContribs: [
-        {artist: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, annotation: 'music probably'},
+        {artist: {name: 'Toby Fox', directory: 'toby-fox', urls: []}, annotation: 'music probably'},
         {artist: {name: 'Tensei', directory: 'tensei', urls: ['https://tenseimusic.bandcamp.com/']}, annotation: 'hot jams'},
       ],
 
       coverArtistContribs: [
-        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: null}, annotation: null},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: []}, annotation: null},
       ],
 
       wallpaperArtistContribs: [
-        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: null}, annotation: null},
-        {artist: {name: 'Niklink', directory: 'niklink', urls: null}, annotation: 'edits'},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: []}, annotation: null},
+        {artist: {name: 'Niklink', directory: 'niklink', urls: []}, annotation: 'edits'},
       ],
 
       bannerArtistContribs: [
-        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: null}, annotation: null},
-        {artist: {name: 'Niklink', directory: 'niklink', urls: null}, annotation: 'edits'},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: []}, annotation: null},
+        {artist: {name: 'Niklink', directory: 'niklink', urls: []}, annotation: 'edits'},
       ],
 
       name: 'AlterniaBound',
diff --git a/test/snapshot/generateAlbumTrackList.js b/test/snapshot/generateAlbumTrackList.js
index 08b31902..a7c3f591 100644
--- a/test/snapshot/generateAlbumTrackList.js
+++ b/test/snapshot/generateAlbumTrackList.js
@@ -10,13 +10,13 @@ testContentFunctions(t, 'generateAlbumTrackList (snapshot)', async (t, evaluate)
   });
 
   const contribs1 = [
-    {artist: {name: 'Apricot', directory: 'apricot', urls: null}},
+    {artist: {name: 'Apricot', directory: 'apricot', urls: []}},
   ];
 
   const contribs2 = [
-    {artist: {name: 'Apricot', directory: 'apricot', urls: null}},
+    {artist: {name: 'Apricot', directory: 'apricot', urls: []}},
     {artist: {name: 'Peach', directory: 'peach', urls: ['https://peach.bandcamp.com/']}},
-    {artist: {name: 'Cerise', directory: 'cerise', urls: null}},
+    {artist: {name: 'Cerise', directory: 'cerise', urls: []}},
   ];
 
   const color1 = '#fb07ff';
diff --git a/test/snapshot/generateTrackReleaseInfo.js b/test/snapshot/generateTrackReleaseInfo.js
index 78f0fee7..931377c8 100644
--- a/test/snapshot/generateTrackReleaseInfo.js
+++ b/test/snapshot/generateTrackReleaseInfo.js
@@ -4,8 +4,8 @@ import {testContentFunctions} from '#test-lib';
 testContentFunctions(t, 'generateTrackReleaseInfo (snapshot)', async (t, evaluate) => {
   await evaluate.load();
 
-  const artistContribs = [{artist: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, annotation: null}];
-  const coverArtistContribs = [{artist: {name: 'Alpaca', directory: 'alpaca', urls: null}, annotation: '🔥'}];
+  const artistContribs = [{artist: {name: 'Toby Fox', directory: 'toby-fox', urls: []}, annotation: null}];
+  const coverArtistContribs = [{artist: {name: 'Alpaca', directory: 'alpaca', urls: []}, annotation: '🔥'}];
 
   evaluate.snapshot('basic behavior', {
     name: 'generateTrackReleaseInfo',
diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js
index 1043ddc6..5844b0b9 100644
--- a/test/snapshot/linkContribution.js
+++ b/test/snapshot/linkContribution.js
@@ -33,53 +33,22 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
       slots,
     });
 
-  quickSnapshot('showContribution & showIcons (inline)', {
+  quickSnapshot('showContribution & showExternalLinks', {
     showContribution: true,
-    showIcons: true,
-    iconMode: 'inline',
-  });
-
-  quickSnapshot('showContribution & showIcons (tooltip)', {
-    showContribution: true,
-    showIcons: true,
-    iconMode: 'tooltip',
+    showExternalLinks: true,
   });
 
   quickSnapshot('only showContribution', {
     showContribution: true,
   });
 
-  quickSnapshot('only showIcons (inline)', {
-    showIcons: true,
-    iconMode: 'inline',
-  });
-
-  quickSnapshot('only showIcons (tooltip)', {
-    showContribution: true,
-    showIcons: true,
-    iconMode: 'tooltip',
+  quickSnapshot('only showExternalLinks', {
+    showExternalLinks: true,
   });
 
   quickSnapshot('no accents', {});
 
-  evaluate.snapshot('loads of links (inline)', {
-    name: 'linkContribution',
-    args: [
-      {artist: {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',
-      ]}, annotation: null},
-    ],
-    slots: {showIcons: true},
-  });
-
-  evaluate.snapshot('loads of links (tooltip)', {
+  evaluate.snapshot('loads of links', {
     name: 'linkContribution',
     args: [
       {artist: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
@@ -93,12 +62,12 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
         'https://loremipsum.io/#original-source',
       ]}, annotation: null},
     ],
-    slots: {showIcons: true, iconMode: 'tooltip'},
+    slots: {showExternalLinks: true},
   });
 
   quickSnapshot('no preventWrapping', {
     showContribution: true,
-    showIcons: true,
+    showExternalLinks: true,
     preventWrapping: false,
   });
 });
diff --git a/test/unit/content/dependencies/linkContribution.js b/test/unit/content/dependencies/linkContribution.js
index ab45b03a..e7a29310 100644
--- a/test/unit/content/dependencies/linkContribution.js
+++ b/test/unit/content/dependencies/linkContribution.js
@@ -27,18 +27,20 @@ t.test('generateContributionLinks (unit)', async t => {
   await testContentFunctions(t, 'generateContributionLinks (unit 1)', async (t, evaluate) => {
     const slots = {
       showContribution: true,
-      showIcons: true,
+      showExternalLinks: true,
     };
 
     await evaluate.load({
       mock: evaluate.mock(mock => ({
         linkArtist: {
-          relations: mock.function('linkArtist.relations', () => ({}))
+          relations: mock
+            .function('linkArtist.relations', () => ({}))
             .args([undefined, artist1]).next()
             .args([undefined, artist2]).next()
             .args([undefined, artist3]),
 
-          data: mock.function('linkArtist.data', () => ({}))
+          data: mock
+            .function('linkArtist.data', () => ({}))
             .args([artist1]).next()
             .args([artist2]).next()
             .args([artist3]),
@@ -49,13 +51,18 @@ t.test('generateContributionLinks (unit)', async t => {
             .repeat(3),
         },
 
-        linkExternalAsIcon: {
-          data: mock.function('linkExternalAsIcon.data', () => ({}))
+        generateExternalIcon: {
+          data: mock
+            .function('generateExternalIcon.data', () => ({}))
             .args([artist1.urls[0]]).next()
             .args([artist3.urls[0]]).next()
             .args([artist3.urls[1]]),
 
-          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+          generate: mock
+            .function('generateExternalIcon.generate', () => ({
+              toString: () => 'icon',
+              setSlot: () => {},
+            }))
             .repeat(3),
         }
       })),
@@ -75,23 +82,26 @@ t.test('generateContributionLinks (unit)', async t => {
   await testContentFunctions(t, 'generateContributionLinks (unit 2)', async (t, evaluate) => {
     const slots = {
       showContribution: false,
-      showIcons: false,
+      showExternalLinks: false,
     };
 
     await evaluate.load({
       mock: evaluate.mock(mock => ({
         linkArtist: {
-          relations: mock.function('linkArtist.relations', () => ({}))
+          relations: mock
+            .function('linkArtist.relations', () => ({}))
             .args([undefined, artist1]).next()
             .args([undefined, artist2]).next()
             .args([undefined, artist3]),
 
-          data: mock.function('linkArtist.data', () => ({}))
+          data: mock
+            .function('linkArtist.data', () => ({}))
             .args([artist1]).next()
             .args([artist2]).next()
             .args([artist3]),
 
-          generate: mock.function(() => 'artist link')
+          generate: mock
+            .function(() => 'artist link')
             .repeat(3),
         },
 
@@ -99,11 +109,16 @@ t.test('generateContributionLinks (unit)', async t => {
         // tree is the same since whether or not the external icon links are
         // shown is dependent on a slot, which is undefined and arbitrary at
         // relations/data time (it might change on a whim at generate time).
-        linkExternalAsIcon: {
-          data: mock.function('linkExternalAsIcon.data', () => ({}))
+        generateExternalIcon: {
+          data: mock
+            .function('generateExternalIcon.data', () => ({}))
             .repeat(3),
 
-          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+          generate: mock
+            .function('generateExternalIcon.generate', () => ({
+              toString: () => 'icon',
+              setSlot: () => {},
+            }))
             .repeat(3),
         },
       })),
diff --git a/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
index 2bcabb4f..9d588e4c 100644
--- a/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
+++ b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -177,10 +177,11 @@ t.test(`withResultOfAvailabilityCheck: validate dynamic inputs`, t => {
       mode: 'banana',
     }),
     {message: `Error computing composition`, cause:
-      {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
-        {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
-          {message: `mode: Expected one of null empty falsy index, got banana`},
-        ]}}});
+      {message: `Error in step 1 of 2, withResultOfAvailabilityCheck`, cause:
+        {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
+          {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
+            {message: `mode: Expected one of null empty falsy index, got banana`},
+          ]}}}});
 
   t.throws(
     () => composite.expose.compute({
@@ -188,8 +189,9 @@ t.test(`withResultOfAvailabilityCheck: validate dynamic inputs`, t => {
       mode: null,
     }),
     {message: `Error computing composition`, cause:
-      {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
-        {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
-          {message: `mode: Expected a value, got null`},
-        ]}}});
+      {message: `Error in step 1 of 2, withResultOfAvailabilityCheck`, cause:
+        {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
+          {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
+            {message: `mode: Expected a value, got null`},
+          ]}}}});
 });
diff --git a/test/unit/data/composite/data/withPropertiesFromObject.js b/test/unit/data/composite/data/withPropertiesFromObject.js
index 750dc8c4..b81d51a5 100644
--- a/test/unit/data/composite/data/withPropertiesFromObject.js
+++ b/test/unit/data/composite/data/withPropertiesFromObject.js
@@ -1,4 +1,5 @@
 import t from 'tap';
+import {quickCheckCompositeOutputs} from '#test-lib';
 
 import {compositeFrom, input} from '#composite';
 import {exposeDependency} from '#composite/control-flow';
@@ -62,6 +63,8 @@ t.test(`withPropertiesFromObject: output shapes & values`, t => {
       ['foo', 'baz', 'missing3'],
   };
 
+  const qcco = quickCheckCompositeOutputs(t, dependencies);
+
   const mapLevel1 = [
     [input.value('prefix_value'), [
       ['object_dependency', [
@@ -153,28 +156,10 @@ t.test(`withPropertiesFromObject: output shapes & values`, t => {
           properties: propertiesInput,
         });
 
-        quickCheckOutputs(step, outputDict);
+        qcco(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);
-  }
 });
 
 t.test(`withPropertiesFromObject: validate static inputs`, t => {
@@ -226,11 +211,12 @@ t.test(`withPropertiesFromObject: validate dynamic inputs`, t => {
       properties: 'onceMore',
     }),
     {message: `Error computing composition`, cause:
-      {message: `Error computing composition withPropertiesFromObject`, cause:
-        {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
-          {message: `object: Expected an object, got string`},
-          {message: `properties: Expected an array, got string`},
-        ]}}});
+      {message: `Error in step 1 of 2, withPropertiesFromObject`, cause:
+        {message: `Error computing composition withPropertiesFromObject`, cause:
+          {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
+            {message: `object: Expected an object, got string`},
+            {message: `properties: Expected an array, got string`},
+          ]}}}});
 
   t.throws(
     () => composite.expose.compute({
@@ -238,17 +224,18 @@ t.test(`withPropertiesFromObject: validate dynamic inputs`, t => {
       properties: ['abc', 'def', 123],
     }),
     {message: `Error computing composition`, cause:
-      {message: `Error computing composition withPropertiesFromObject`, cause:
-        {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
-          {message: `object: Expected an object, got array`},
-          {message: `properties: Errors validating array items`, errors: [
-            {
-              [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2,
-              message: `Error at zero-index 2: 123`,
-              cause: {
-                message: `Expected a string, got number`,
+      {message: `Error in step 1 of 2, withPropertiesFromObject`, cause:
+        {message: `Error computing composition withPropertiesFromObject`, cause:
+          {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
+            {message: `object: Expected an object, got array`},
+            {message: `properties: Errors validating array items`, errors: [
+              {
+                [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2,
+                message: `Error at zero-index 2: 123`,
+                cause: {
+                  message: `Expected a string, got number`,
+                },
               },
-            },
-          ]},
-        ]}}});
+            ]},
+          ]}}}});
 });
diff --git a/test/unit/data/composite/data/withPropertyFromObject.js b/test/unit/data/composite/data/withPropertyFromObject.js
index 6a772c36..912c924c 100644
--- a/test/unit/data/composite/data/withPropertyFromObject.js
+++ b/test/unit/data/composite/data/withPropertyFromObject.js
@@ -1,5 +1,7 @@
 import t from 'tap';
+import {quickCheckCompositeOutputs} from '#test-lib';
 
+import CacheableObject from '#cacheable-object';
 import {compositeFrom, input} from '#composite';
 import {exposeDependency} from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
@@ -42,6 +44,89 @@ t.test(`withPropertyFromObject: basic behavior`, t => {
   }), null);
 });
 
+t.test(`withPropertyFromObject: "internal" input`, t => {
+  t.plan(7);
+
+  const composite = compositeFrom({
+    compose: false,
+
+    steps: [
+      withPropertyFromObject({
+        object: 'object',
+        property: 'property',
+        internal: 'internal',
+      }),
+
+      exposeDependency({dependency: '#value'}),
+    ],
+  });
+
+  const thing = new (class extends CacheableObject {
+    static [CacheableObject.propertyDescriptors] = {
+      foo: {
+        flags: {update: true, expose: false},
+      },
+
+      bar: {
+        flags: {update: true, expose: true},
+      },
+
+      baz: {
+        flags: {update: true, expose: true},
+        expose: {
+          transform: baz => baz * 2,
+        },
+      },
+    };
+  });
+
+  thing.foo = 100;
+  thing.bar = 200;
+  thing.baz = 300;
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['object', 'property', 'internal'],
+    },
+  });
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'foo',
+    internal: true,
+  }), 100);
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'bar',
+    internal: true,
+  }), 200);
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'baz',
+    internal: true,
+  }), 300);
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'baz',
+    internal: false,
+  }), 600);
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'bimbam',
+    internal: false,
+  }), null);
+
+  t.equal(composite.expose.compute({
+    object: null,
+    property: 'bambim',
+    internal: false,
+  }), null);
+});
+
 t.test(`withPropertyFromObject: output shapes & values`, t => {
   t.plan(2 * 3 ** 2);
 
@@ -56,6 +141,8 @@ t.test(`withPropertyFromObject: output shapes & values`, t => {
       'baz',
   };
 
+  const qcco = quickCheckCompositeOutputs(t, dependencies);
+
   const mapLevel1 = [
     ['object_dependency', [
       ['property_dependency', {
@@ -98,25 +185,7 @@ t.test(`withPropertyFromObject: output shapes & values`, t => {
         property: propertyInput,
       });
 
-      quickCheckOutputs(step, outputDict);
+      qcco(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/data/withUniqueItemsOnly.js b/test/unit/data/composite/data/withUniqueItemsOnly.js
index 965b14b5..50b16f43 100644
--- a/test/unit/data/composite/data/withUniqueItemsOnly.js
+++ b/test/unit/data/composite/data/withUniqueItemsOnly.js
@@ -1,4 +1,5 @@
 import t from 'tap';
+import {quickCheckCompositeOutputs} from '#test-lib';
 
 import {compositeFrom, input} from '#composite';
 import {exposeDependency} from '#composite/control-flow';
@@ -44,6 +45,8 @@ t.test(`withUniqueItemsOnly: output shapes & values`, t => {
       [8, 8, 7, 6, 6, 5, 'bar', true, true, 5],
   };
 
+  const qcco = quickCheckCompositeOutputs(t, dependencies);
+
   const mapLevel1 = [
     ['list_dependency', {
       '#list_dependency': [1, 2, 3, 4, 'foo', false],
@@ -61,24 +64,6 @@ t.test(`withUniqueItemsOnly: output shapes & values`, t => {
       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);
+    qcco(step, outputDict);
   }
 });
diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js
index d28ab709..06265b09 100644
--- a/test/unit/data/things/album.js
+++ b/test/unit/data/things/album.js
@@ -262,6 +262,13 @@ t.test(`Album.tracks`, t => {
   const section6 = stubTrackSection(album, [], 'section6');
   const sections = [section1, section2, section3, section4, section5, section6];
 
+  const section1_ref = `unqualified-track-section:section1`;
+  const section2_ref = `unqualified-track-section:section2`;
+  const section3_ref = `unqualified-track-section:section3`;
+  const section4_ref = `unqualified-track-section:section4`;
+  const section5_ref = `unqualified-track-section:section5`;
+  const section6_ref = `unqualified-track-section:section6`;
+
   for (const track of tracks) {
     track.albumData = [album];
   }
@@ -276,7 +283,8 @@ t.test(`Album.tracks`, t => {
   section1.tracks = ['track:track1', 'track:track2', 'track:track3'];
   section1.ownTrackData = [track1, track2, track3];
 
-  album.trackSections = [section1];
+  album.trackSections = [section1_ref];
+  album.ownTrackSectionData = [section1];
 
   t.same(album.tracks, [track1, track2, track3],
     `Album.tracks #2: pulls tracks from one track section`);
@@ -287,7 +295,8 @@ t.test(`Album.tracks`, t => {
   section1.ownTrackData = [track1];
   section2.ownTrackData = [track2, track3];
 
-  album.trackSections = [section1, section2];
+  album.trackSections = [section1_ref, section2_ref];
+  album.ownTrackSectionData = [section1, section2];
 
   t.same(album.tracks, [track1, track2, track3],
     `Album.tracks #3: pulls tracks from multiple track sections`);
@@ -302,7 +311,8 @@ t.test(`Album.tracks`, t => {
   section3.ownTrackData = [];
   section4.ownTrackData = [track3];
 
-  album.trackSections = [section1, section2, section3, section4];
+  album.trackSections = [section1_ref, section2_ref, section3_ref, section4_ref];
+  album.ownTrackSectionData = [section1, section2, section3, section4];
 
   t.same(album.tracks, [track1, track2, track3],
     `Album.tracks #4: filters out references without matches`);
@@ -321,7 +331,8 @@ t.test(`Album.tracks`, t => {
   section5.ownTrackData = [];
   section6.ownTrackData = [track3];
 
-  album.trackSections = [section1, section2, section3, section4, section5, section6];
+  album.trackSections = [section1_ref, section2_ref, section3_ref, section4_ref, section5_ref, section6_ref];
+  album.ownTrackSectionData = [section1, section2, section3, section4, section5, section6];
 
   t.same(album.tracks, [track1, track2, track3],
     `Album.tracks #5: skips empty track sections`);
@@ -345,6 +356,12 @@ t.test(`Album.trackSections`, t => {
   const section5 = stubTrackSection(album, [], 'section5');
   const sections = [section1, section2, section3, section4, section5];
 
+  const section1_ref = `unqualified-track-section:section1`;
+  const section2_ref = `unqualified-track-section:section2`;
+  const section3_ref = `unqualified-track-section:section3`;
+  const section4_ref = `unqualified-track-section:section4`;
+  const section5_ref = `unqualified-track-section:section5`;
+
   for (const track of tracks) {
     track.albumData = [album];
   }
@@ -355,7 +372,8 @@ t.test(`Album.trackSections`, t => {
   section1.ownTrackData = [track1, track2];
   section2.ownTrackData = [track3, track4];
 
-  album.trackSections = [section1, section2];
+  album.trackSections = [section1_ref, section2_ref];
+  album.ownTrackSectionData = [section1, section2];
 
   t.match(album.trackSections, [
     {tracks: [track1, track2]},
@@ -378,7 +396,8 @@ t.test(`Album.trackSections`, t => {
   section1.name = 'First section';
   section2.name = 'Second section';
 
-  album.trackSections = [section1, section2, section3];
+  album.trackSections = [section1_ref, section2_ref, section3_ref];
+  album.ownTrackSectionData = [section1, section2, section3];
 
   t.match(album.trackSections, [
     {name: 'First section', tracks: [track1]},
@@ -392,7 +411,7 @@ t.test(`Album.trackSections`, t => {
 
   // XXX_decacheWikiData
   album.trackSections = [];
-  album.trackSections = [section1, section2, section3];
+  album.trackSections = [section1_ref, section2_ref, section3_ref];
 
   t.match(album.trackSections, [
     {tracks: [track1], color: '#123456'},
@@ -404,7 +423,7 @@ t.test(`Album.trackSections`, t => {
 
   // XXX_decacheWikiData
   album.trackSections = [];
-  album.trackSections = [section1, section2, section3];
+  album.trackSections = [section1_ref, section2_ref, section3_ref];
 
   t.match(album.trackSections, [
     {tracks: [track1], dateOriginallyReleased: null},
@@ -417,7 +436,7 @@ t.test(`Album.trackSections`, t => {
 
   // XXX_decacheWikiData
   album.trackSections = [];
-  album.trackSections = [section1, section2, section3];
+  album.trackSections = [section1_ref, section2_ref, section3_ref];
 
   t.match(album.trackSections, [
     {tracks: [track1], isDefaultTrackSection: true},
@@ -443,7 +462,8 @@ t.test(`Album.trackSections`, t => {
   section4.color = '#556677';
   section5.color = '#778899';
 
-  album.trackSections = [section1, section2, section3, section4, section5];
+  album.trackSections = [section1_ref, section2_ref, section3_ref, section4_ref, section5_ref];
+  album.ownTrackSectionData = [section1, section2, section3, section4, section5];
 
   t.match(album.trackSections, [
     {tracks: [track1, track2], color: '#112233'},
diff --git a/test/unit/data/things/art-tag.js b/test/unit/data/things/art-tag.js
index 427b357b..cf2135c6 100644
--- a/test/unit/data/things/art-tag.js
+++ b/test/unit/data/things/art-tag.js
@@ -16,7 +16,8 @@ function stubAlbum(tracks, directory = 'bar') {
   album.directory = directory;
 
   const trackSection = stubTrackSection(album, tracks);
-  album.trackSections = [trackSection];
+  album.trackSections = [`unqualified-track-section:${trackSection.unqualifiedDirectory}`];
+  album.ownTrackSectionData = [trackSection];
 
   return album;
 }
diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js
index 644d21ce..74231e20 100644
--- a/test/unit/data/things/track.js
+++ b/test/unit/data/things/track.js
@@ -19,7 +19,8 @@ function stubAlbum(tracks, directory = 'bar') {
   album.directory = directory;
 
   const trackSection = stubTrackSection(album, tracks);
-  album.trackSections = [trackSection];
+  album.trackSections = [`unqualified-track-section:${trackSection.unqualifiedDirectory}`];
+  album.ownTrackSectionData = [trackSection];
 
   return album;
 }
@@ -93,6 +94,10 @@ t.test(`Track.album`, t => {
   const album2 = new Album();
   const section1 = new TrackSection();
   const section2 = new TrackSection();
+  section1.unqualifiedDirectory = 'section1';
+  section2.unqualifiedDirectory = 'section2';
+  const section1_ref = `unqualified-track-section:section1`;
+  const section2_ref = `unqualified-track-section:section2`;
 
   t.equal(track1.album, null,
     `album #1: defaults to null`);
@@ -105,8 +110,10 @@ t.test(`Track.album`, t => {
   section2.ownAlbumData = [album2];
   section1.tracks = ['track:track1'];
   section2.tracks = ['track:track2'];
-  album1.trackSections = [section1];
-  album2.trackSections = [section2];
+  album1.trackSections = [section1_ref];
+  album2.trackSections = [section2_ref];
+  album1.ownTrackSectionData = [section1];
+  album2.ownTrackSectionData = [section2];
 
   t.equal(track1.album, album1,
     `album #2: is album when album's trackSections matches track`);
@@ -125,7 +132,7 @@ t.test(`Track.album`, t => {
 
   // XXX_decacheWikiData
   album1.trackSections = [];
-  album1.trackSections = [section1];
+  album1.trackSections = [section1_ref];
   track1.albumData = [];
   track1.albumData = [album2, album1];
 
@@ -137,7 +144,7 @@ t.test(`Track.album`, t => {
 
   // XXX_decacheWikiData
   album1.trackSections = [];
-  album1.trackSections = [section1];
+  album1.trackSections = [section1_ref];
   track1.albumData = [];
   track1.albumData = [album2, album1];
 
@@ -273,7 +280,7 @@ t.test(`Track.artistContribs`, t => {
 
   XXX_decacheWikiData();
 
-  t.same(track.artistContribs,
+  t.match(track.artistContribs,
     [{artist: artist1, annotation: `composition`}, {artist: artist2, annotation: null}],
     `artistContribs #2: inherits album artistContribs`);
 
@@ -281,7 +288,7 @@ t.test(`Track.artistContribs`, t => {
     {artist: `Artist 1`, annotation: `arrangement`},
   ];
 
-  t.same(track.artistContribs, [{artist: artist1, annotation: `arrangement`}],
+  t.match(track.artistContribs, [{artist: artist1, annotation: `arrangement`}],
     `artistContribs #3: resolves from own value`);
 
   track.artistContribs = [
@@ -290,7 +297,7 @@ t.test(`Track.artistContribs`, t => {
     {artist: `Artist 2`, annotation: `usual`},
   ];
 
-  t.same(track.artistContribs,
+  t.match(track.artistContribs,
     [{artist: artist1, annotation: `snooping`}, {artist: artist2, annotation: `usual`}],
     `artistContribs #4: filters out names without matches`);
 });
@@ -308,12 +315,13 @@ t.test(`Track.color`, t => {
   t.equal(track.color, null,
     `color #1: defaults to null`);
 
-  const section = stubTrackSection(album, [track]);
+  const section = stubTrackSection(album, [track], 'section');
 
   album.color = '#abcdef';
   section.color = '#beeeef';
 
-  album.trackSections = [section];
+  album.trackSections = [`unqualified-track-section:section`];
+  album.ownTrackSectionData = [section];
 
   XXX_decacheWikiData();
 
@@ -449,7 +457,7 @@ t.test(`Track.coverArtistContribs`, t => {
 
   XXX_decacheWikiData();
 
-  t.same(track.coverArtistContribs,
+  t.match(track.coverArtistContribs,
     [{artist: artist1, annotation: `lines`}, {artist: artist2, annotation: null}],
     `coverArtistContribs #2: inherits album trackCoverArtistContribs`);
 
@@ -457,7 +465,7 @@ t.test(`Track.coverArtistContribs`, t => {
     {artist: `Artist 1`, annotation: `collage`},
   ];
 
-  t.same(track.coverArtistContribs, [{artist: artist1, annotation: `collage`}],
+  t.match(track.coverArtistContribs, [{artist: artist1, annotation: `collage`}],
     `coverArtistContribs #3: resolves from own value`);
 
   track.coverArtistContribs = [
@@ -466,7 +474,7 @@ t.test(`Track.coverArtistContribs`, t => {
     {artist: `Artist 2`, annotation: `usual`},
   ];
 
-  t.same(track.coverArtistContribs,
+  t.match(track.coverArtistContribs,
     [{artist: artist1, annotation: `snooping`}, {artist: artist2, annotation: `usual`}],
     `coverArtistContribs #4: filters out names without matches`);