« 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/data-tests/index.js125
-rw-r--r--test/data-tests/test-no-short-tracks.js25
-rw-r--r--test/data-tests/test-order-of-album-groups.js55
-rw-r--r--test/lib/content-function.js148
-rw-r--r--test/lib/generic-mock.js314
-rw-r--r--test/lib/strict-match-error.js50
-rw-r--r--test/snapshot/generateAdditionalFilesList.js63
-rw-r--r--test/snapshot/generateAdditionalFilesShortcut.js36
-rw-r--r--test/snapshot/generateAlbumTrackListItem.js66
-rw-r--r--test/snapshot/generateContributionLinks.js55
-rw-r--r--test/snapshot/image.js92
-rw-r--r--test/snapshot/linkArtist.js30
-rw-r--r--test/snapshot/linkExternal.js57
-rw-r--r--test/snapshot/linkExternalFlash.js24
-rw-r--r--test/snapshot/linkTemplate.js33
-rw-r--r--test/unit/content/dependencies/generateContributionLinks.js109
-rw-r--r--test/unit/content/dependencies/linkArtist.js31
-rw-r--r--test/unit/data/things/cacheable-object.js (renamed from test/cacheable-object.js)72
-rw-r--r--test/unit/data/things/track.js (renamed from test/things.js)20
-rw-r--r--test/unit/data/things/validators.js (renamed from test/data-validators.js)145
-rw-r--r--test/unit/util/html.js906
21 files changed, 2152 insertions, 304 deletions
diff --git a/test/data-tests/index.js b/test/data-tests/index.js
deleted file mode 100644
index 1b9ec990..00000000
--- a/test/data-tests/index.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import chokidar from 'chokidar';
-import * as path from 'path';
-import {fileURLToPath} from 'url';
-
-import {quickLoadAllFromYAML} from '../../src/data/yaml.js';
-import {isMain} from '../../src/util/node-utils.js';
-import {getContextAssignments} from '../../src/repl.js';
-
-import {
-  color,
-  logError,
-  logInfo,
-  logWarn,
-  parseOptions,
-} from '../../src/util/cli.js';
-
-import {
-  bindOpts,
-  showAggregate,
-} from '../../src/util/sugar.js';
-
-async function main() {
-  const miscOptions = await parseOptions(process.argv.slice(2), {
-    'data-path': {
-      type: 'value',
-    },
-  });
-
-  const dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
-
-  if (!dataPath) {
-    logError`Expected --data-path option or HSMUSIC_DATA to be set`;
-    return;
-  }
-
-  console.log(`HSMusic automated data tests`);
-  console.log(`${color.bright(color.yellow(`:star:`))} Now featuring quick-reloading! ${color.bright(color.cyan(`:earth:`))}`);
-
-  // Watch adjacent files in data-tests directory
-  const metaPath = fileURLToPath(import.meta.url);
-  const metaDirname = path.dirname(metaPath);
-  const watcher = chokidar.watch(metaDirname);
-
-  const wikiData = await quickLoadAllFromYAML(dataPath, {
-    showAggregate: bindOpts(showAggregate, {
-      showTraces: false,
-    }),
-  });
-
-  const context = await getContextAssignments({
-    wikiData,
-  });
-
-  let resolveNext;
-
-  const queue = [];
-
-  watcher.on('all', (event, path) => {
-    if (!['add', 'change'].includes(event)) return;
-    if (path === metaPath) return;
-    if (resolveNext) {
-      resolveNext(path);
-    } else if (!queue.includes(path)) {
-      queue.push(path);
-    }
-  });
-
-  logInfo`Awaiting file changes.`;
-
-  /* eslint-disable-next-line no-constant-condition */
-  while (true) {
-    const testPath = (queue.length
-      ? queue.shift()
-      : await new Promise(resolve => {
-          resolveNext = resolve;
-        }));
-
-    resolveNext = null;
-
-    const shortPath = path.basename(testPath);
-
-    logInfo`Path updated: ${shortPath} - running this test!`;
-
-    let imp;
-    try {
-      imp = await import(`${testPath}?${Date.now()}`)
-    } catch (error) {
-      logWarn`Failed to import ${shortPath} - ${error.constructor.name} details below:`;
-      console.error(error);
-      continue;
-    }
-
-    const {default: testFn} = imp;
-
-    if (!testFn) {
-      logWarn`No default export for ${shortPath}`;
-      logWarn`Skipping this test for now!`;
-      continue;
-    }
-
-    if (typeof testFn !== 'function') {
-      logWarn`Default export for ${shortPath} is ${typeof testFn}, not function`;
-      logWarn`Skipping this test for now!`;
-      continue;
-    }
-
-    try {
-      await testFn(context);
-    } catch (error) {
-      showAggregate(error, {
-        pathToFileURL: f => path.relative(metaDirname, fileURLToPath(f)),
-      });
-    }
-  }
-}
-
-if (isMain(import.meta.url)) {
-  main().catch((error) => {
-    if (error instanceof AggregateError) {
-      showAggregate(error);
-    } else {
-      console.error(error);
-    }
-  });
-}
diff --git a/test/data-tests/test-no-short-tracks.js b/test/data-tests/test-no-short-tracks.js
deleted file mode 100644
index 76356099..00000000
--- a/test/data-tests/test-no-short-tracks.js
+++ /dev/null
@@ -1,25 +0,0 @@
-export default function({
-  albumData,
-  getTotalDuration,
-}) {
-  const shortAlbums = albumData
-    .filter(album => album.tracks.length > 1)
-    .map(album => ({
-      album,
-      duration: getTotalDuration(album.tracks),
-    }))
-    .filter(album => album.duration)
-    .filter(album => album.duration < 60 * 15);
-
-  if (!shortAlbums.length) return true;
-
-  shortAlbums.sort((a, b) => a.duration - b.duration);
-
-  console.log(`Found ${shortAlbums.length} short albums! Oh nooooooo!`);
-  console.log(`Here are the shortest 10:`);
-  for (const {duration, album} of shortAlbums.slice(0, 10)) {
-    console.log(`- (${duration}s)`, album);
-  }
-
-  return false;
-}
diff --git a/test/data-tests/test-order-of-album-groups.js b/test/data-tests/test-order-of-album-groups.js
deleted file mode 100644
index de2fcbed..00000000
--- a/test/data-tests/test-order-of-album-groups.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import * as util from 'util';
-
-export default function({
-  albumData,
-  groupCategoryData,
-}) {
-  const groupSchemaTemplate = [
-    ['Projects beyond Homestuck', 'Fandom projects'],
-    ['Solo musicians', 'Fan-musician groups'],
-    ['HSMusic'],
-  ];
-
-  const groupSchema =
-    groupSchemaTemplate.map(names => names.flatMap(
-      name => groupCategoryData
-        .find(gc => gc.name === name)
-        .groups));
-
-  const badAlbums = albumData.filter(album => {
-    const groups = album.groups.slice();
-    const disallowed = [];
-    for (const allowed of groupSchema) {
-      while (groups.length) {
-        if (disallowed.includes(groups[0]))
-          return true;
-        else if (allowed.includes(groups[0]))
-          groups.shift();
-        else break;
-      }
-      disallowed.push(...allowed);
-    }
-    return false;
-  });
-
-  if (!badAlbums.length) return true;
-
-  console.log(`Some albums don't list their groups in the right order:`);
-  for (const album of badAlbums) {
-    console.log('-', album);
-    for (const group of album.groups) {
-      console.log(`  - ${util.inspect(group)}`)
-    }
-  }
-
-  console.log(`Here's the group schema they should be updated to match:`);
-  for (const section of groupSchemaTemplate) {
-    if (section.length > 1) {
-      console.log(`- Groups from any of: ${section.join(', ')}`);
-    } else {
-      console.log(`- Groups from: ${section}`);
-    }
-  }
-
-  return false;
-}
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
new file mode 100644
index 00000000..ac527abc
--- /dev/null
+++ b/test/lib/content-function.js
@@ -0,0 +1,148 @@
+import chroma from 'chroma-js';
+import * as path from 'path';
+import {fileURLToPath} from 'url';
+
+import mock from './generic-mock.js';
+import {quickEvaluate} from '../../src/content-function.js';
+import {quickLoadContentDependencies} from '../../src/content/dependencies/index.js';
+
+import urlSpec from '../../src/url-spec.js';
+import * as html from '../../src/util/html.js';
+import {empty, showAggregate} from '../../src/util/sugar.js';
+import {getColors} from '../../src/util/colors.js';
+import {generateURLs, thumb} from '../../src/util/urls.js';
+import {processLanguageFile} from '../../src/data/language.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+export function testContentFunctions(t, message, fn) {
+  const urls = generateURLs(urlSpec);
+
+  t.test(message, async t => {
+    let loadedContentDependencies;
+
+    const language = await processLanguageFile('./src/strings-default.json');
+    const mocks = [];
+
+    const evaluate = ({
+      from = 'localized.home',
+      contentDependencies = {},
+      extraDependencies = {},
+      ...opts
+    }) => {
+      if (!loadedContentDependencies) {
+        throw new Error(`Await .load() before performing tests`);
+      }
+
+      const {to} = urls.from(from);
+
+      return cleanCatchAggregate(() => {
+        return quickEvaluate({
+          ...opts,
+          contentDependencies: {
+            ...contentDependencies,
+            ...loadedContentDependencies,
+          },
+          extraDependencies: {
+            html,
+            language,
+            thumb,
+            to,
+            urls,
+            appendIndexHTML: false,
+            getColors: c => getColors(c, {chroma}),
+            ...extraDependencies,
+          },
+        });
+      });
+    };
+
+    evaluate.load = async (opts) => {
+      if (loadedContentDependencies) {
+        throw new Error(`Already loaded!`);
+      }
+
+      loadedContentDependencies = await asyncCleanCatchAggregate(() =>
+        quickLoadContentDependencies({
+          logging: false,
+          ...opts,
+        }));
+    };
+
+    evaluate.snapshot = (...args) => {
+      if (!loadedContentDependencies) {
+        throw new Error(`Await .load() before performing tests`);
+      }
+
+      const [description, opts] =
+        (typeof args[0] === 'string'
+          ? args
+          : ['output', ...args]);
+
+      let result = evaluate(opts);
+
+      if (opts.multiple) {
+        result = result.map(item => item.toString()).join('\n');
+      } else {
+        result = result.toString();
+      }
+
+      t.matchSnapshot(result, description);
+    };
+
+    evaluate.mock = (...opts) => {
+      const {value, close} = mock(...opts);
+      mocks.push({close});
+      return value;
+    };
+
+    await fn(t, evaluate);
+
+    if (!empty(mocks)) {
+      cleanCatchAggregate(() => {
+        const errors = [];
+        for (const {close} of mocks) {
+          try {
+            close();
+          } catch (error) {
+            errors.push(error);
+          }
+        }
+        if (!empty(errors)) {
+          throw new AggregateError(errors, `Errors closing mocks`);
+        }
+      });
+    }
+  });
+}
+
+function printAggregate(error) {
+  if (error instanceof AggregateError) {
+    const message = showAggregate(error, {
+      showTraces: true,
+      print: false,
+      pathToFileURL: f => path.relative(path.join(__dirname, '../..'), fileURLToPath(f)),
+    });
+    for (const line of message.split('\n')) {
+      console.error(line);
+    }
+  }
+}
+
+function cleanCatchAggregate(fn) {
+  try {
+    return fn();
+  } catch (error) {
+    printAggregate(error);
+    throw error;
+  }
+}
+
+async function asyncCleanCatchAggregate(fn) {
+  try {
+    return await fn();
+  } catch (error) {
+    printAggregate(error);
+    throw error;
+  }
+}
diff --git a/test/lib/generic-mock.js b/test/lib/generic-mock.js
new file mode 100644
index 00000000..119f8731
--- /dev/null
+++ b/test/lib/generic-mock.js
@@ -0,0 +1,314 @@
+import {same} from 'tcompare';
+
+import {empty} from '../../src/util/sugar.js';
+
+export default function mock(callback) {
+  const mocks = [];
+
+  const track = callback => (...args) => {
+    const {value, close} = callback(...args);
+    mocks.push({close});
+    return value;
+  };
+
+  const mock = {
+    function: track(mockFunction),
+  };
+
+  return {
+    value: callback(mock),
+    close: () => {
+      const errors = [];
+      for (const mock of mocks) {
+        try {
+          mock.close();
+        } catch (error) {
+          errors.push(error);
+        }
+      }
+      if (!empty(errors)) {
+        throw new AggregateError(errors, `Errors closing sub-mocks`);
+      }
+    },
+  };
+}
+
+export function mockFunction(...args) {
+  let name = '(anonymous)';
+  let behavior = null;
+
+  if (args.length === 2) {
+    if (
+      typeof args[0] === 'string' &&
+      typeof args[1] === 'function'
+    ) {
+      name = args[0];
+      behavior = args[1];
+    } else {
+      throw new TypeError(`Expected name to be a string`);
+    }
+  } else if (args.length === 1) {
+    if (typeof args[0] === 'string') {
+      name = args[0];
+    } else if (typeof args[0] === 'function') {
+      behavior = args[0];
+    } else if (args[0] !== null) {
+      throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
+    }
+  } else if (args.length > 2) {
+    throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
+  }
+
+  let currentCallDescription = newCallDescription();
+  const allCallDescriptions = [currentCallDescription];
+
+  const topLevelErrors = [];
+  let runningCallCount = 0;
+  let limitCallCount = false;
+  let markedAsOnce = false;
+
+  const fn = (...args) => {
+    const description = processCall(...args);
+    return description.behavior(...args);
+  };
+
+  fn.behavior = value => {
+    if (!(value === null || (
+      typeof value === 'function'
+    ))) {
+      throw new TypeError(`Expected function or null`);
+    }
+
+    currentCallDescription.behavior = behavior;
+    currentCallDescription.described = true;
+
+    return fn;
+  }
+
+  fn.argumentCount = value => {
+    if (!(value === null || (
+      typeof value === 'number' &&
+      value === parseInt(value) &&
+      value >= 0
+    ))) {
+      throw new TypeError(`Expected whole number or null`);
+    }
+
+    if (currentCallDescription.argsPattern) {
+      throw new TypeError(`Unexpected .argumentCount() when .args() has been called`);
+    }
+
+    currentCallDescription.argsPattern = {length: value};
+    currentCallDescription.described = true;
+
+    return fn;
+  };
+
+  fn.args = (...args) => {
+    const value = args[0];
+
+    if (args.length > 1 || !(value === null || Array.isArray(value))) {
+      throw new TypeError(`Expected one array or null`);
+    }
+
+    currentCallDescription.argsPattern = Object.fromEntries(
+      value
+        .map((v, i) => v === undefined ? false : [i, v])
+        .filter(Boolean)
+        .concat([['length', value.length]]));
+
+    currentCallDescription.described = true;
+
+    return fn;
+  };
+
+  fn.neverCalled = (...args) => {
+    if (!empty(args)) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (allCallDescriptions[0].described) {
+      throw new TypeError(`Unexpected .neverCalled() when any descriptions provided`);
+    }
+
+    limitCallCount = true;
+    allCallDescriptions.splice(0, allCallDescriptions.length);
+
+    currentCallDescription = new Proxy({}, {
+      set() {
+        throw new Error(`Unexpected description when .neverCalled() has been called`);
+      },
+    });
+
+    return fn;
+  };
+
+  fn.once = (...args) => {
+    if (!empty(args)) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (allCallDescriptions.length > 1) {
+      throw new TypeError(`Unexpected .once() when providing multiple descriptions`);
+    }
+
+    currentCallDescription.described = true;
+    limitCallCount = true;
+    markedAsOnce = true;
+
+    return fn;
+  };
+
+  fn.next = (...args) => {
+    if (!empty(args)) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (markedAsOnce) {
+      throw new TypeError(`Unexpected .next() when .once() has been called`);
+    }
+
+    currentCallDescription = newCallDescription();
+    allCallDescriptions.push(currentCallDescription);
+
+    limitCallCount = true;
+
+    return fn;
+  };
+
+  fn.repeat = times => {
+    // Note: This function should be called AFTER filling out the
+    // call description which is being repeated.
+
+    if (!(
+      typeof times === 'number' &&
+      times === parseInt(times) &&
+      times >= 2
+    )) {
+      throw new TypeError(`Expected whole number of at least 2`);
+    }
+
+    if (markedAsOnce) {
+      throw new TypeError(`Unexpected .repeat() when .once() has been called`);
+    }
+
+    // The current call description is already in the full list,
+    // so skip the first push.
+    for (let i = 2; i <= times; i++) {
+      allCallDescriptions.push(currentCallDescription);
+    }
+
+    // Prep a new description like when calling .next().
+    currentCallDescription = newCallDescription();
+    allCallDescriptions.push(currentCallDescription);
+
+    limitCallCount = true;
+
+    return fn;
+  };
+
+  return {
+    value: fn,
+    close: () => {
+      const totalCallCount = runningCallCount;
+      const expectedCallCount = countDescribedCalls();
+
+      if (limitCallCount && totalCallCount !== expectedCallCount) {
+        if (expectedCallCount > 1) {
+          topLevelErrors.push(new Error(`Expected ${expectedCallCount} calls, got ${totalCallCount}`));
+        } else if (expectedCallCount === 1) {
+          topLevelErrors.push(new Error(`Expected 1 call, got ${totalCallCount}`));
+        } else {
+          topLevelErrors.push(new Error(`Expected no calls, got ${totalCallCount}`));
+        }
+      }
+
+      if (topLevelErrors.length) {
+        throw new AggregateError(topLevelErrors, `Errors in mock ${name}`);
+      }
+    },
+  };
+
+  function newCallDescription() {
+    return {
+      described: false,
+      behavior: behavior ?? null,
+      argumentCount: null,
+      argsPattern: null,
+    };
+  }
+
+  function processCall(...args) {
+    const callErrors = [];
+
+    runningCallCount++;
+
+    // No further processing, this indicates the function shouldn't have been
+    // called at all and there aren't any descriptions to match this call with.
+    if (empty(allCallDescriptions)) {
+      return newCallDescription();
+    }
+
+    const currentCallNumber = runningCallCount;
+    const currentDescription = selectCallDescription(currentCallNumber);
+
+    const {
+      argumentCount,
+      argsPattern,
+    } = currentDescription;
+
+    if (argumentCount !== null && args.length !== argumentCount) {
+      callErrors.push(
+        new Error(`Argument count mismatch: expected ${argumentCount}, got ${args.length}`));
+    }
+
+    if (argsPattern !== null) {
+      const keysToCheck = Object.keys(argsPattern);
+      const argsAsObject = Object.fromEntries(
+        args
+          .map((v, i) => [i.toString(), v])
+          .filter(([i]) => keysToCheck.includes(i))
+          .concat([['length', args.length]]));
+
+      const {match, diff} = same(argsAsObject, argsPattern);
+      if (!match) {
+        callErrors.push(new Error(`Argument pattern mismatch:\n` + diff));
+      }
+    }
+
+    if (!empty(callErrors)) {
+      const aggregate = new AggregateError(callErrors, `Errors in call #${currentCallNumber}`);
+      topLevelErrors.push(aggregate);
+    }
+
+    return currentDescription;
+  }
+
+  function selectCallDescription(currentCallNumber) {
+    if (currentCallNumber > countDescribedCalls()) {
+      const lastDescription = lastCallDescription();
+      if (lastDescription.described) {
+        return newCallDescription();
+      } else {
+        return lastDescription;
+      }
+    } else {
+      return allCallDescriptions[currentCallNumber - 1];
+    }
+  }
+
+  function countDescribedCalls() {
+    if (empty(allCallDescriptions)) {
+      return 0;
+    }
+
+    return (
+      (lastCallDescription().described
+        ? allCallDescriptions.length
+        : allCallDescriptions.length - 1));
+  }
+
+  function lastCallDescription() {
+    return allCallDescriptions[allCallDescriptions.length - 1];
+  }
+}
diff --git a/test/lib/strict-match-error.js b/test/lib/strict-match-error.js
new file mode 100644
index 00000000..e3b36e93
--- /dev/null
+++ b/test/lib/strict-match-error.js
@@ -0,0 +1,50 @@
+export function strictlyThrows(t, fn, pattern) {
+  const error = catchErrorOrNull(fn);
+
+  t.currentAssert = strictlyThrows;
+
+  if (error === null) {
+    t.fail(`expected to throw`);
+    return;
+  }
+
+  const nameAndMessage = `${pattern.constructor.name} ${pattern.message}`;
+  t.match(
+    prepareErrorForMatch(error),
+    prepareErrorForMatch(pattern),
+    (pattern instanceof AggregateError
+      ? `expected to throw: ${nameAndMessage} (${pattern.errors.length} error(s))`
+      : `expected to throw: ${nameAndMessage}`));
+}
+
+function prepareErrorForMatch(error) {
+  if (error instanceof RegExp) {
+    return {
+      message: error,
+    };
+  }
+
+  if (!(error instanceof Error)) {
+    return error;
+  }
+
+  const matchable = {
+    name: error.constructor.name,
+    message: error.message,
+  };
+
+  if (error instanceof AggregateError) {
+    matchable.errors = error.errors.map(prepareErrorForMatch);
+  }
+
+  return matchable;
+}
+
+function catchErrorOrNull(fn) {
+  try {
+    fn();
+    return null;
+  } catch (error) {
+    return error;
+  }
+}
diff --git a/test/snapshot/generateAdditionalFilesList.js b/test/snapshot/generateAdditionalFilesList.js
new file mode 100644
index 00000000..0c27ad19
--- /dev/null
+++ b/test/snapshot/generateAdditionalFilesList.js
@@ -0,0 +1,63 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'generateAdditionalFilesList (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('no additional files', {
+    name: 'generateAdditionalFilesList',
+    args: [[]],
+  });
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateAdditionalFilesList',
+    args: [
+      [
+        {
+          title: 'SBURB Wallpaper',
+          files: [
+            'sburbwp_1280x1024.jpg',
+            'sburbwp_1440x900.jpg',
+            'sburbwp_1920x1080.jpg',
+          ],
+        },
+        {
+          title: 'Fake Section',
+          description: 'Ooo, what happens if there are NO file links provided?',
+          files: [
+            'oops.mp3',
+            'Internet Explorer.gif',
+            'daisy.mp3',
+          ],
+        },
+        {
+          title: 'Alternate Covers',
+          description: 'This is just an example description.',
+          files: [
+            'Homestuck_Vol4_alt1.jpg',
+            'Homestuck_Vol4_alt2.jpg',
+            'Homestuck_Vol4_alt3.jpg',
+          ],
+        },
+      ],
+    ],
+    postprocess: template => template
+      .slot('fileLinks', {
+        'sburbwp_1280x1024.jpg': 'link to 1280x1024',
+        'sburbwp_1440x900.jpg': 'link to 1440x900',
+        'sburbwp_1920x1080.jpg': null,
+        'Homestuck_Vol4_alt1.jpg': 'link to alt1',
+        'Homestuck_Vol4_alt2.jpg': null,
+        'Homestuck_Vol4_alt3.jpg': 'link to alt3',
+      })
+      .slot('fileSizes', {
+        'sburbwp_1280x1024.jpg': 2500,
+        'sburbwp_1440x900.jpg': null,
+        'sburbwp_1920x1080.jpg': null,
+        'Internet Explorer.gif': 1,
+        'Homestuck_Vol4_alt1.jpg': 1234567,
+        'Homestuck_Vol4_alt2.jpg': 1234567,
+        'Homestuck_Vol4_alt3.jpg': 1234567,
+      }),
+  });
+});
diff --git a/test/snapshot/generateAdditionalFilesShortcut.js b/test/snapshot/generateAdditionalFilesShortcut.js
new file mode 100644
index 00000000..0ca777b4
--- /dev/null
+++ b/test/snapshot/generateAdditionalFilesShortcut.js
@@ -0,0 +1,36 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'generateAdditionalFilesShortcut (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('no additional files', {
+    name: 'generateAdditionalFilesShortcut',
+    args: [[]],
+  });
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateAdditionalFilesShortcut',
+    args: [
+      [
+        {
+          title: 'SBURB Wallpaper',
+          files: [
+            'sburbwp_1280x1024.jpg',
+            'sburbwp_1440x900.jpg',
+            'sburbwp_1920x1080.jpg',
+          ],
+        },
+        {
+          title: 'Alternate Covers',
+          description: 'This is just an example description.',
+          files: [
+            'Homestuck_Vol4_alt1.jpg',
+            'Homestuck_Vol4_alt2.jpg',
+            'Homestuck_Vol4_alt3.jpg',
+          ],
+        },
+      ],
+    ],
+  });
+});
diff --git a/test/snapshot/generateAlbumTrackListItem.js b/test/snapshot/generateAlbumTrackListItem.js
new file mode 100644
index 00000000..e87c6de0
--- /dev/null
+++ b/test/snapshot/generateAlbumTrackListItem.js
@@ -0,0 +1,66 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'generateAlbumTrackListItem (snapshot)', async (t, evaluate) => {
+  const artist1 = {directory: 'toby-fox', name: 'Toby Fox', urls: ['https://toby.fox/']};
+  const artist2 = {directory: 'james-roach', name: 'James Roach'};
+  const artist3 = {directory: 'clark-powell', name: 'Clark Powell'};
+  const artist4 = {directory: ''}
+  const albumContribs = [{who: artist1}, {who: artist2}];
+
+  await evaluate.load();
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateAlbumTrackListItem',
+    args: [
+      {
+        // Just pretend Hiveswap Act 1 OST doesn't have its own Artists field OK?
+        // We test that kind of case later!
+        name: 'Final Spice',
+        directory: 'final-spice',
+        duration: 54,
+        color: '#33cc77',
+        artistContribs: [
+          {who: artist1, what: 'composition & arrangement'},
+          {who: artist2, what: 'arrangement'},
+        ],
+      },
+      {artistContribs: []},
+    ],
+  });
+
+  evaluate.snapshot('zero duration, zero artists', {
+    name: 'generateAlbumTrackListItem',
+    args: [
+      {
+        name: 'You have got to be about the most superficial commentator on con-langues since the idiotic B. Gilson.',
+        directory: 'you-have-got-to-be-about-the-most-superficial-commentator-on-con-langues-since-the-idiotic-b-gilson',
+        duration: 0,
+        artistContribs: [],
+      },
+      {artistContribs: []},
+    ],
+  });
+
+  evaluate.snapshot('hide artists if inherited from album', {
+    name: 'generateAlbumTrackListItem',
+    multiple: [
+      {args: [
+        {directory: 'track1', name: 'Same artists, same order', artistContribs: [{who: artist1}, {who: artist2}]},
+        {artistContribs: albumContribs},
+      ]},
+      {args: [
+        {directory: 'track2', name: 'Same artists, different order', artistContribs: [{who: artist2}, {who: artist1}]},
+        {artistContribs: albumContribs},
+      ]},
+      {args: [
+        {directory: 'track3', name: 'Extra artist', artistContribs: [{who: artist1}, {who: artist2}, {who: artist3}]},
+        {artistContribs: albumContribs},
+      ]},
+      {args: [
+        {directory: 'track4', name: 'Missing artist', artistContribs: [{who: artist1}]},
+        {artistContribs: albumContribs},
+      ]},
+    ],
+  });
+});
diff --git a/test/snapshot/generateContributionLinks.js b/test/snapshot/generateContributionLinks.js
new file mode 100644
index 00000000..3283d3b2
--- /dev/null
+++ b/test/snapshot/generateContributionLinks.js
@@ -0,0 +1,55 @@
+// todo: this dependency was replaced with linkContribution, restructure test
+// remove generateContributionLinks.js.test.cjs snapshot file too!
+
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+t.skip('generateContributionLinks (snapshot)');
+
+void (() => testContentFunctions(t, 'generateContributionLinks (snapshot)', async (t, evaluate) => {
+  const artist1 = {
+    name: 'Clark Powell',
+    directory: 'clark-powell',
+    urls: ['https://soundcloud.com/plazmataz'],
+  };
+
+  const artist2 = {
+    name: 'Grounder & Scratch',
+    directory: 'the-big-baddies',
+    urls: [],
+  };
+
+  const artist3 = {
+    name: 'Toby Fox',
+    directory: 'toby-fox',
+    urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
+  };
+
+  const contributions = [
+    {who: artist1, what: null},
+    {who: artist2, what: 'Snooping'},
+    {who: artist3, what: 'Arrangement'},
+  ];
+
+  await evaluate.load();
+
+  evaluate.snapshot('showContribution & showIcons', {
+    name: 'generateContributionLinks',
+    args: [contributions, {showContribution: true, showIcons: true}],
+  });
+
+  evaluate.snapshot('only showContribution', {
+    name: 'generateContributionLinks',
+    args: [contributions, {showContribution: true, showIcons: false}],
+  });
+
+  evaluate.snapshot('only showIcons', {
+    name: 'generateContributionLinks',
+    args: [contributions, {showContribution: false, showIcons: true}],
+  });
+
+  evaluate.snapshot('no accents', {
+    name: 'generateContributionLinks',
+    args: [contributions, {showContribution: false, showIcons: false}],
+  });
+}));
diff --git a/test/snapshot/image.js b/test/snapshot/image.js
new file mode 100644
index 00000000..eeffb849
--- /dev/null
+++ b/test/snapshot/image.js
@@ -0,0 +1,92 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  const quickSnapshot = (message, opts) =>
+    evaluate.snapshot(message, {
+      name: 'image',
+      extraDependencies: {
+        getSizeOfImageFile: () => 0,
+      },
+      ...opts,
+    });
+
+  quickSnapshot('source via path', {
+    postprocess: template => template
+      .slot('path', ['media.albumCover', 'beyond-canon', 'png']),
+  });
+
+  quickSnapshot('source via src', {
+    postprocess: template => template
+      .slot('src', 'https://example.com/bananas.gif'),
+  });
+
+  quickSnapshot('source missing', {
+    postprocess: template => template
+      .slot('missingSourceContent', 'Example of missing source message.'),
+  });
+
+  quickSnapshot('id without link', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('id', 'banana'),
+  });
+
+  quickSnapshot('id with link', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('link', true)
+      .slot('id', 'banana'),
+  });
+
+  quickSnapshot('id with square', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('square', true)
+      .slot('id', 'banana'),
+  })
+
+  quickSnapshot('width & height', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('width', 600)
+      .slot('height', 400),
+  });
+
+  quickSnapshot('square', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('square', true),
+  });
+
+  quickSnapshot('lazy with square', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('lazy', true)
+      .slot('square', true),
+  });
+
+  quickSnapshot('link with file size', {
+    extraDependencies: {
+      getSizeOfImageFile: () => 10 ** 6,
+    },
+
+    postprocess: template => template
+      .slot('path', ['media.albumCover', 'pingas', 'png'])
+      .slot('link', true),
+  });
+
+  quickSnapshot('content warnings via tags', {
+    args: [
+      [
+        {name: 'Dirk Strider', directory: 'dirk'},
+        {name: 'too cool for school', isContentWarning: true},
+      ],
+    ],
+
+    postprocess: template => template
+      .slot('path', ['media.albumCover', 'beyond-canon', 'png']),
+  })
+});
diff --git a/test/snapshot/linkArtist.js b/test/snapshot/linkArtist.js
new file mode 100644
index 00000000..0451f1c5
--- /dev/null
+++ b/test/snapshot/linkArtist.js
@@ -0,0 +1,30 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'linkArtist (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('basic behavior', {
+    name: 'linkArtist',
+    args: [
+      {
+        name: `Toby Fox`,
+        directory: `toby-fox`,
+      }
+    ],
+  });
+
+  evaluate.snapshot('prefer short name', {
+    name: 'linkArtist',
+    args: [
+      {
+        name: 'ICCTTCMDMIROTMCWMWFTPFTDDOTARHPOESWGBTWEATFCWSEBTSSFOFG',
+        nameShort: '55gore',
+        directory: '55gore',
+      },
+    ],
+
+    postprocess: v => v
+      .slot('preferShortName', true),
+  });
+});
diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js
new file mode 100644
index 00000000..9661aead
--- /dev/null
+++ b/test/snapshot/linkExternal.js
@@ -0,0 +1,57 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+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/']},
+    ],
+  });
+
+  evaluate.snapshot('custom matches - type: album', {
+    name: 'linkExternal',
+    multiple: [
+      {args: ['https://youtu.be/abc', {type: 'album'}]},
+      {args: ['https://youtube.com/watch?v=abc', {type: 'album'}]},
+      {args: ['https://youtube.com/Playlist?list=kweh', {type: 'album'}]},
+
+      // Reuse default when no type specified
+      {args: ['https://youtu.be/abc']},
+      {args: ['https://youtu.be/abc?list=kweh']},
+      {args: ['https://youtube.com/watch?v=abc']},
+      {args: ['https://youtube.com/watch?v=abc&list=kweh']},
+    ],
+  });
+
+  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/']},
+    ],
+  });
+});
diff --git a/test/snapshot/linkExternalFlash.js b/test/snapshot/linkExternalFlash.js
new file mode 100644
index 00000000..7bb86c6a
--- /dev/null
+++ b/test/snapshot/linkExternalFlash.js
@@ -0,0 +1,24 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+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/linkTemplate.js b/test/snapshot/linkTemplate.js
new file mode 100644
index 00000000..e9a1ccd6
--- /dev/null
+++ b/test/snapshot/linkTemplate.js
@@ -0,0 +1,33 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'linkTemplate (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('fill many slots', {
+    name: 'linkTemplate',
+
+    extraDependencies: {
+      getColors: c => ({primary: c + 'ff', dim: c + '77'}),
+    },
+
+    postprocess: v => v
+      .slot('color', '#123456')
+      .slot('href', 'https://hsmusic.wiki/media/cool file.pdf')
+      .slot('hash', 'fooey')
+      .slot('attributes', {class: 'dog', id: 'cat1'})
+      .slot('content', 'My Cool Link'),
+  });
+
+  evaluate.snapshot('fill path slot & provide appendIndexHTML', {
+    name: 'linkTemplate',
+
+    extraDependencies: {
+      to: (...path) => '/c*lzone/' + path.join('/') + '/',
+      appendIndexHTML: true,
+    },
+
+    postprocess: v => v
+      .slot('path', ['myCoolPath', 'ham', 'pineapple', 'tomato']),
+  });
+});
diff --git a/test/unit/content/dependencies/generateContributionLinks.js b/test/unit/content/dependencies/generateContributionLinks.js
new file mode 100644
index 00000000..328adc0b
--- /dev/null
+++ b/test/unit/content/dependencies/generateContributionLinks.js
@@ -0,0 +1,109 @@
+// todo: this dependency was replaced with linkContribution, restructure test
+
+import t from 'tap';
+import {testContentFunctions} from '../../../lib/content-function.js';
+
+t.skip('generateContributionLinks (unit)', async t => {
+  const artist1 = {
+    name: 'Clark Powell',
+    urls: ['https://soundcloud.com/plazmataz'],
+  };
+
+  const artist2 = {
+    name: 'Grounder & Scratch',
+    urls: [],
+  };
+
+  const artist3 = {
+    name: 'Toby Fox',
+    urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
+  };
+
+  const contributions = [
+    {who: artist1, what: null},
+    {who: artist2, what: 'Snooping'},
+    {who: artist3, what: 'Arrangement'},
+  ];
+
+  await testContentFunctions(t, 'generateContributionLinks (unit 1)', async (t, evaluate) => {
+    const config = {
+      showContribution: true,
+      showIcons: true,
+    };
+
+    await evaluate.load({
+      mock: evaluate.mock(mock => ({
+        linkArtist: {
+          relations: mock.function('linkArtist.relations', () => ({}))
+            .args([undefined, artist1]).next()
+            .args([undefined, artist2]).next()
+            .args([undefined, artist3]),
+
+          data: mock.function('linkArtist.data', () => ({}))
+            .args([artist1]).next()
+            .args([artist2]).next()
+            .args([artist3]),
+
+          // This can be tweaked to return a specific (mocked) template
+          // for each artist if we need to test for slots in the future.
+          generate: mock.function('linkArtist.generate', () => 'artist link')
+            .repeat(3),
+        },
+
+        linkExternalAsIcon: {
+          data: mock.function('linkExternalAsIcon.data', () => ({}))
+            .args([artist1.urls[0]]).next()
+            .args([artist3.urls[0]]).next()
+            .args([artist3.urls[1]]),
+
+          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+            .repeat(3),
+        }
+      })),
+    });
+
+    evaluate({
+      name: 'generateContributionLinks',
+      args: [contributions, config],
+    });
+  });
+
+  await testContentFunctions(t, 'generateContributionLinks (unit 2)', async (t, evaluate) => {
+    const config = {
+      showContribution: false,
+      showIcons: false,
+    };
+
+    await evaluate.load({
+      mock: evaluate.mock(mock => ({
+        linkArtist: {
+          relations: mock.function('linkArtist.relations', () => ({}))
+            .args([undefined, artist1]).next()
+            .args([undefined, artist2]).next()
+            .args([undefined, artist3]),
+
+          data: mock.function('linkArtist.data', () => ({}))
+            .args([artist1]).next()
+            .args([artist2]).next()
+            .args([artist3]),
+
+          generate: mock.function(() => 'artist link')
+            .repeat(3),
+        },
+
+        linkExternalAsIcon: {
+          data: mock.function('linkExternalAsIcon.data', () => ({}))
+            .neverCalled(),
+
+          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+            .neverCalled(),
+        },
+      })),
+    });
+
+    evaluate({
+      name: 'generateContributionLinks',
+      args: [contributions, config],
+    });
+  });
+});
diff --git a/test/unit/content/dependencies/linkArtist.js b/test/unit/content/dependencies/linkArtist.js
new file mode 100644
index 00000000..9fceb97d
--- /dev/null
+++ b/test/unit/content/dependencies/linkArtist.js
@@ -0,0 +1,31 @@
+import t from 'tap';
+import {testContentFunctions} from '../../../lib/content-function.js';
+
+testContentFunctions(t, 'linkArtist (unit)', async (t, evaluate) => {
+  const artistObject = {};
+  const linkTemplate = {};
+
+  await evaluate.load({
+    mock: evaluate.mock(mock => ({
+      linkThing: {
+        relations: mock.function('linkThing.relations', () => ({}))
+          .args([undefined, 'localized.artist', artistObject])
+          .once(),
+
+        data: mock.function('linkThing.data', () => ({}))
+          .args(['localized.artist', artistObject])
+          .once(),
+
+        generate: mock.function('linkThing.data', () => linkTemplate)
+          .once(),
+      }
+    })),
+  });
+
+  const result = evaluate({
+    name: 'linkArtist',
+    args: [artistObject],
+  });
+
+  t.equal(result, linkTemplate);
+});
diff --git a/test/cacheable-object.js b/test/unit/data/things/cacheable-object.js
index 664a648b..d7a88ce7 100644
--- a/test/cacheable-object.js
+++ b/test/unit/data/things/cacheable-object.js
@@ -1,8 +1,6 @@
-import test from 'tape';
+import t from 'tap';
 
-import CacheableObject from '../src/data/cacheable-object.js';
-
-// Utility
+import CacheableObject from '../../../../src/data/things/cacheable-object.js';
 
 function newCacheableObject(PD) {
   return new (class extends CacheableObject {
@@ -10,9 +8,7 @@ function newCacheableObject(PD) {
   });
 }
 
-// Tests
-
-test(`CacheableObject simple separate update & expose`, t => {
+t.test(`CacheableObject simple separate update & expose`, t => {
   const obj = newCacheableObject({
     number: {
       flags: {
@@ -37,7 +33,7 @@ test(`CacheableObject simple separate update & expose`, t => {
   t.equal(obj.timesTwo, 10);
 });
 
-test(`CacheableObject basic cache behavior`, t => {
+t.test(`CacheableObject basic cache behavior`, t => {
   let computeCount = 0;
 
   const obj = newCacheableObject({
@@ -64,31 +60,31 @@ test(`CacheableObject basic cache behavior`, t => {
 
   t.plan(8);
 
-  t.is(computeCount, 0);
+  t.equal(computeCount, 0);
 
   obj.string = 'hello world';
-  t.is(computeCount, 0);
+  t.equal(computeCount, 0);
 
   obj.karkat;
-  t.is(computeCount, 1);
+  t.equal(computeCount, 1);
 
   obj.karkat;
-  t.is(computeCount, 1);
+  t.equal(computeCount, 1);
 
   obj.string = 'testing once again';
-  t.is(computeCount, 1);
+  t.equal(computeCount, 1);
 
   obj.karkat;
-  t.is(computeCount, 2);
+  t.equal(computeCount, 2);
 
   obj.string = 'testing once again';
-  t.is(computeCount, 2);
+  t.equal(computeCount, 2);
 
   obj.karkat;
-  t.is(computeCount, 2);
+  t.equal(computeCount, 2);
 });
 
-test(`CacheableObject combined update & expose (no transform)`, t => {
+t.test(`CacheableObject combined update & expose (no transform)`, t => {
   const obj = newCacheableObject({
     directory: {
       flags: {
@@ -100,14 +96,14 @@ test(`CacheableObject combined update & expose (no transform)`, t => {
 
   t.plan(2);
 
-  t.directory = 'the-world-revolving';
-  t.is(t.directory, 'the-world-revolving');
+  obj.directory = 'the-world-revolving';
+  t.equal(obj.directory, 'the-world-revolving');
 
-  t.directory = 'chaos-king';
-  t.is(t.directory, 'chaos-king');
+  obj.directory = 'chaos-king';
+  t.equal(obj.directory, 'chaos-king');
 });
 
-test(`CacheableObject combined update & expose (basic transform)`, t => {
+t.test(`CacheableObject combined update & expose (basic transform)`, t => {
   const obj = newCacheableObject({
     getsRepeated: {
       flags: {
@@ -124,10 +120,10 @@ test(`CacheableObject combined update & expose (basic transform)`, t => {
   t.plan(1);
 
   obj.getsRepeated = 'dog';
-  t.is(obj.getsRepeated, 'dogdog');
+  t.equal(obj.getsRepeated, 'dogdog');
 });
 
-test(`CacheableObject combined update & expose (transform with dependency)`, t => {
+t.test(`CacheableObject combined update & expose (transform with dependency)`, t => {
   const obj = newCacheableObject({
     customRepeat: {
       flags: {
@@ -152,16 +148,16 @@ test(`CacheableObject combined update & expose (transform with dependency)`, t =
 
   obj.customRepeat = 'dog';
   obj.times = 1;
-  t.is(obj.customRepeat, 'dog');
+  t.equal(obj.customRepeat, 'dog');
 
   obj.times = 5;
-  t.is(obj.customRepeat, 'dogdogdogdogdog');
+  t.equal(obj.customRepeat, 'dogdogdogdogdog');
 
   obj.customRepeat = 'cat';
-  t.is(obj.customRepeat, 'catcatcatcatcat');
+  t.equal(obj.customRepeat, 'catcatcatcatcat');
 });
 
-test(`CacheableObject validate on update`, t => {
+t.test(`CacheableObject validate on update`, t => {
   const mockError = new TypeError(`Expected a string, not ${typeof value}`);
 
   const obj = newCacheableObject({
@@ -197,7 +193,7 @@ test(`CacheableObject validate on update`, t => {
   t.plan(6);
 
   obj.directory = 'megalovania';
-  t.is(obj.directory, 'megalovania');
+  t.equal(obj.directory, 'megalovania');
 
   try {
     obj.directory = 25;
@@ -205,13 +201,13 @@ test(`CacheableObject validate on update`, t => {
     thrownError = err;
   }
 
-  t.is(thrownError, mockError);
-  t.is(obj.directory, 'megalovania');
+  t.equal(thrownError, mockError);
+  t.equal(obj.directory, 'megalovania');
 
   const date = new Date(`25 December 2009`);
 
   obj.date = date;
-  t.is(obj.date, date);
+  t.equal(obj.date, date);
 
   try {
     obj.date = `TWELFTH PERIGEE'S EVE`;
@@ -219,11 +215,11 @@ test(`CacheableObject validate on update`, t => {
     thrownError = err;
   }
 
-  t.is(thrownError?.constructor, TypeError);
-  t.is(obj.date, date);
+  t.equal(thrownError?.constructor, TypeError);
+  t.equal(obj.date, date);
 });
 
-test(`CacheableObject default update property value`, t => {
+t.test(`CacheableObject default update property value`, t => {
   const obj = newCacheableObject({
     fruit: {
       flags: {
@@ -238,10 +234,10 @@ test(`CacheableObject default update property value`, t => {
   });
 
   t.plan(1);
-  t.is(obj.fruit, 'potassium');
+  t.equal(obj.fruit, 'potassium');
 });
 
-test(`CacheableObject default property throws if invalid`, t => {
+t.test(`CacheableObject default property throws if invalid`, t => {
   const mockError = new TypeError(`Expected a string, not ${typeof value}`);
 
   t.plan(1);
@@ -270,5 +266,5 @@ test(`CacheableObject default property throws if invalid`, t => {
     thrownError = err;
   }
 
-  t.is(thrownError, mockError);
+  t.equal(thrownError, mockError);
 });
diff --git a/test/things.js b/test/unit/data/things/track.js
index 0d74b60d..0dad0e62 100644
--- a/test/things.js
+++ b/test/unit/data/things/track.js
@@ -1,11 +1,13 @@
-import test from 'tape';
+import t from 'tap';
 
-import {
+import thingConstructors from '../../../../src/data/things/index.js';
+
+const {
   Album,
   Thing,
   Track,
   TrackGroup,
-} from '../src/data/things.js';
+} = thingConstructors;
 
 function stubAlbum(tracks) {
   const album = new Album();
@@ -18,7 +20,7 @@ function stubAlbum(tracks) {
   return album;
 }
 
-test(`Track.coverArtDate`, t => {
+t.test(`Track.coverArtDate`, t => {
   t.plan(5);
 
   // Priority order is as follows, with the last (trackCoverArtDate) being
@@ -37,7 +39,7 @@ test(`Track.coverArtDate`, t => {
 
   // 1. coverArtDate defaults to null
 
-  t.is(track.coverArtDate, null);
+  t.equal(track.coverArtDate, null);
 
   // 2. coverArtDate inherits album release date
 
@@ -47,7 +49,7 @@ test(`Track.coverArtDate`, t => {
   track.albumData = [];
   track.albumData = [album];
 
-  t.is(track.coverArtDate, albumDate);
+  t.equal(track.coverArtDate, albumDate);
 
   // 3. coverArtDate inherits album trackArtDate
 
@@ -57,17 +59,17 @@ test(`Track.coverArtDate`, t => {
   track.albumData = [];
   track.albumData = [album];
 
-  t.is(track.coverArtDate, albumTrackArtDate);
+  t.equal(track.coverArtDate, albumTrackArtDate);
 
   // 4. coverArtDate is overridden dateFirstReleased
 
   track.dateFirstReleased = trackDateFirstReleased;
 
-  t.is(track.coverArtDate, trackDateFirstReleased);
+  t.equal(track.coverArtDate, trackDateFirstReleased);
 
   // 5. coverArtDate is overridden coverArtDate
 
   track.coverArtDate = trackCoverArtDate;
 
-  t.is(track.coverArtDate, trackCoverArtDate);
+  t.equal(track.coverArtDate, trackCoverArtDate);
 });
diff --git a/test/data-validators.js b/test/unit/data/things/validators.js
index f13f3f0f..53cba063 100644
--- a/test/data-validators.js
+++ b/test/unit/data/things/validators.js
@@ -1,10 +1,11 @@
-import _test from 'tape';
-import { showAggregate } from '../src/util/sugar.js';
+import t from 'tap';
+import { showAggregate } from '../../../../src/util/sugar.js';
 
 import {
   // Basic types
   isBoolean,
   isCountingNumber,
+  isDate,
   isNumber,
   isString,
   isStringNonEmpty,
@@ -15,19 +16,25 @@ import {
   validateArrayItems,
 
   // Wiki data
+  isColor,
+  isCommentary,
+  isContribution,
+  isContributionList,
   isDimensions,
   isDirectory,
   isDuration,
   isFileExtension,
+  isName,
+  isURL,
   validateReference,
   validateReferenceList,
 
   // Compositional utilities
   oneOf,
-} from '../src/data/validators.js';
+} from '../../../../src/data/things/validators.js';
 
-function test(msg, fn) {
-  _test(msg, t => {
+function test(t, msg, fn) {
+  t.test(msg, t => {
     try {
       fn(t);
     } catch (error) {
@@ -39,11 +46,9 @@ function test(msg, fn) {
   });
 }
 
-test.skip = _test.skip;
-
 // Basic types
 
-test('isBoolean', t => {
+test(t, 'isBoolean', t => {
   t.plan(4);
   t.ok(isBoolean(true));
   t.ok(isBoolean(false));
@@ -51,7 +56,7 @@ test('isBoolean', t => {
   t.throws(() => isBoolean('yes'), TypeError);
 });
 
-test('isNumber', t => {
+test(t, 'isNumber', t => {
   t.plan(6);
   t.ok(isNumber(123));
   t.ok(isNumber(0.05));
@@ -61,7 +66,7 @@ test('isNumber', t => {
   t.throws(() => isNumber(true), TypeError);
 });
 
-test('isCountingNumber', t => {
+test(t, 'isCountingNumber', t => {
   t.plan(6);
   t.ok(isCountingNumber(3));
   t.ok(isCountingNumber(1));
@@ -71,14 +76,14 @@ test('isCountingNumber', t => {
   t.throws(() => isCountingNumber('612'), TypeError);
 });
 
-test('isString', t => {
+test(t, 'isString', t => {
   t.plan(3);
   t.ok(isString('hello!'));
   t.ok(isString(''));
   t.throws(() => isString(100), TypeError);
 });
 
-test('isStringNonEmpty', t => {
+test(t, 'isStringNonEmpty', t => {
   t.plan(4);
   t.ok(isStringNonEmpty('hello!'));
   t.throws(() => isStringNonEmpty(''), TypeError);
@@ -88,25 +93,28 @@ test('isStringNonEmpty', t => {
 
 // Complex types
 
-test('isArray', t => {
+test(t, 'isArray', t => {
   t.plan(3);
   t.ok(isArray([]));
   t.throws(() => isArray({}), TypeError);
   t.throws(() => isArray('1, 2, 3'), TypeError);
 });
 
-test.skip('isDate', t => {
-  // TODO
+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('isObject', t => {
+test(t, 'isObject', t => {
   t.plan(3);
   t.ok(isObject({}));
   t.ok(isObject([]));
   t.throws(() => isObject(null), TypeError);
 });
 
-test('validateArrayItems', t => {
+test(t, 'validateArrayItems', t => {
   t.plan(6);
 
   t.ok(validateArrayItems(isNumber)([3, 4, 5]));
@@ -119,31 +127,56 @@ test('validateArrayItems', t => {
     caughtError = err;
   }
 
-  t.isNot(caughtError, null);
-  t.true(caughtError instanceof AggregateError);
-  t.is(caughtError.errors.length, 1);
-  t.true(caughtError.errors[0] instanceof TypeError);
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 1);
+  t.ok(caughtError.errors[0] instanceof TypeError);
 });
 
 // Wiki data
 
-test.skip('isColor', t => {
-  // TODO
+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);
 });
 
-test.skip('isCommentary', t => {
-  // TODO
+t.test('isCommentary', t => {
+  t.plan(6);
+  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>`));
 });
 
-test.skip('isContribution', t => {
-  // TODO
+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/});
 });
 
-test.skip('isContributionList', t => {
-  // TODO
+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('isDimensions', t => {
+test(t, 'isDimensions', t => {
   t.plan(6);
   t.ok(isDimensions([1, 1]));
   t.ok(isDimensions([50, 50]));
@@ -153,7 +186,7 @@ test('isDimensions', t => {
   t.throws(() => isDimensions('800x200'), TypeError);
 });
 
-test('isDirectory', t => {
+test(t, 'isDirectory', t => {
   t.plan(6);
   t.ok(isDirectory('savior-of-the-waking-world'));
   t.ok(isDirectory('MeGaLoVania'));
@@ -163,7 +196,7 @@ test('isDirectory', t => {
   t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError);
 });
 
-test('isDuration', t => {
+test(t, 'isDuration', t => {
   t.plan(5);
   t.ok(isDuration(60));
   t.ok(isDuration(0.02));
@@ -172,7 +205,7 @@ test('isDuration', t => {
   t.throws(() => isDuration('10:25'), TypeError);
 });
 
-test('isFileExtension', t => {
+test(t, 'isFileExtension', t => {
   t.plan(6);
   t.ok(isFileExtension('png'));
   t.ok(isFileExtension('jpg'));
@@ -182,15 +215,23 @@ test('isFileExtension', t => {
   t.throws(() => isFileExtension('just an image bro!!!!'), TypeError);
 });
 
-test.skip('isName', t => {
-  // TODO
+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.ok(isName(''));
+  t.throws(() => isName(612));
 });
 
-test.skip('isURL', t => {
-  // TODO
+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('validateReference', t => {
+test(t, 'validateReference', t => {
   t.plan(16);
 
   const typeless = validateReference();
@@ -217,7 +258,7 @@ test('validateReference', t => {
   t.throws(() => typeless('album:undertale-soundtrack'));
 });
 
-test('validateReferenceList', t => {
+test(t, 'validateReferenceList', t => {
   const track = validateReferenceList('track');
   const artist = validateReferenceList('artist');
 
@@ -235,14 +276,14 @@ test('validateReferenceList', t => {
     caughtError = err;
   }
 
-  t.isNot(caughtError, null);
-  t.true(caughtError instanceof AggregateError);
-  t.is(caughtError.errors.length, 2);
-  t.true(caughtError.errors[0] instanceof TypeError);
-  t.true(caughtError.errors[1] instanceof TypeError);
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 2);
+  t.ok(caughtError.errors[0] instanceof TypeError);
+  t.ok(caughtError.errors[1] instanceof TypeError);
 });
 
-test('oneOf', t => {
+test(t, 'oneOf', t => {
   t.plan(11);
 
   const isStringOrNumber = oneOf(isString, isNumber);
@@ -267,11 +308,11 @@ test('oneOf', t => {
     caughtError = err;
   }
 
-  t.isNot(caughtError, null);
-  t.true(caughtError instanceof AggregateError);
-  t.is(caughtError.errors.length, 2);
-  t.true(caughtError.errors[0] instanceof TypeError);
-  t.is(caughtError.errors[0].check, isString);
-  t.is(caughtError.errors[1], mockError);
-  t.is(caughtError.errors[1].check, neverSucceeds);
+  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);
 });
diff --git a/test/unit/util/html.js b/test/unit/util/html.js
new file mode 100644
index 00000000..82f96b48
--- /dev/null
+++ b/test/unit/util/html.js
@@ -0,0 +1,906 @@
+import t from 'tap';
+
+import * as html from '../../../src/util/html.js';
+const {Tag, Attributes, Template} = html;
+
+import {strictlyThrows} from '../../lib/strict-match-error.js';
+
+t.test(`html.tag`, t => {
+  t.plan(14);
+
+  const tag1 =
+    html.tag('div',
+      {[html.onlyIfContent]: true, foo: 'bar'},
+      'child');
+
+  // 1-5: basic behavior when passing attributes
+  t.ok(tag1 instanceof Tag);
+  t.ok(tag1.onlyIfContent);
+  t.equal(tag1.attributes.get('foo'), 'bar');
+  t.equal(tag1.content.length, 1);
+  t.equal(tag1.content[0], 'child');
+
+  const tag2 = html.tag('div', ['two', 'children']);
+
+  // 6-8: basic behavior when not passing attributes
+  t.equal(tag2.content.length, 2);
+  t.equal(tag2.content[0], 'two');
+  t.equal(tag2.content[1], 'children');
+
+  const genericTag = html.tag('div');
+  const genericTemplate = html.template({
+    content: () => html.blank(),
+  });
+
+  // 9-10: tag treated as content, not attributes
+  const tag3 = html.tag('div', genericTag);
+  t.equal(tag3.content.length, 1);
+  t.equal(tag3.content[0], genericTag);
+
+  // 11-12: template treated as content, not attributes
+  const tag4 = html.tag('div', genericTemplate);
+  t.equal(tag4.content.length, 1);
+  t.equal(tag4.content[0], genericTemplate);
+
+  // 13-14: deep flattening support
+  const tag6 =
+    html.tag('div', [
+      true &&
+        [[[[[[
+          true &&
+            [[[[[`That's deep.`]]]]],
+        ]]]]]],
+    ]);
+  t.equal(tag6.content.length, 1);
+  t.equal(tag6.content[0], `That's deep.`);
+});
+
+t.test(`Tag (basic interface)`, t => {
+  t.plan(11);
+
+  const tag1 = new Tag();
+
+  // 1-5: essential properties & no arguments provided
+  t.equal(tag1.tagName, '');
+  t.ok(Array.isArray(tag1.content));
+  t.equal(tag1.content.length, 0);
+  t.ok(tag1.attributes instanceof Attributes);
+  t.equal(tag1.attributes.toString(), '');
+
+  const tag2 = new Tag('div', {id: 'banana'}, ['one', 'two', tag1]);
+
+  // 6-11: properties on basic usage
+  t.equal(tag2.tagName, 'div');
+  t.equal(tag2.content.length, 3);
+  t.equal(tag2.content[0], 'one');
+  t.equal(tag2.content[1], 'two');
+  t.equal(tag2.content[2], tag1);
+  t.equal(tag2.attributes.get('id'), 'banana');
+});
+
+t.test(`Tag (self-closing)`, t => {
+  t.plan(10);
+
+  const tag1 = new Tag('br');
+  const tag2 = new Tag('div');
+  const tag3 = new Tag('div');
+  tag3.tagName = 'br';
+
+  // 1-3: selfClosing depends on tagName
+  t.ok(tag1.selfClosing);
+  t.notOk(tag2.selfClosing);
+  t.ok(tag3.selfClosing);
+
+  // 4: constructing self-closing tag with content throws
+  t.throws(() => new Tag('br', null, 'bananas'), /self-closing/);
+
+  // 5: setting content on self-closing tag throws
+  t.throws(() => { tag1.content = ['suspicious']; }, /self-closing/);
+
+  // 6-9: setting empty content on self-closing tag doesn't throw
+  t.doesNotThrow(() => { tag1.content = null; });
+  t.doesNotThrow(() => { tag1.content = undefined; });
+  t.doesNotThrow(() => { tag1.content = ''; });
+  t.doesNotThrow(() => { tag1.content = [null, '', false]; });
+
+  const tag4 = new Tag('div', null, 'bananas');
+
+  // 10: changing tagName to self-closing when tag has content throws
+  t.throws(() => { tag4.tagName = 'br'; }, /self-closing/);
+});
+
+t.test(`Tag (properties from attributes - from constructor)`, t => {
+  t.plan(6);
+
+  const tag = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<br>',
+  });
+
+  // 1-3: basic exposed properties from attributes in constructor
+  t.ok(tag.onlyIfContent);
+  t.ok(tag.noEdgeWhitespace);
+  t.equal(tag.joinChildren, '<br>');
+
+  // 4-6: property values stored on attributes with public symbols
+  t.equal(tag.attributes.get(html.onlyIfContent), true);
+  t.equal(tag.attributes.get(html.noEdgeWhitespace), true);
+  t.equal(tag.attributes.get(html.joinChildren), '<br>');
+});
+
+t.test(`Tag (properties from attributes - mutating)`, t => {
+  t.plan(12);
+
+  // 1-3: exposed properties reflect reasonable attribute values
+
+  const tag1 = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<br>',
+  });
+
+  tag1.attributes.set(html.onlyIfContent, false);
+  tag1.attributes.remove(html.noEdgeWhitespace);
+  tag1.attributes.set(html.joinChildren, '🍇');
+
+  t.equal(tag1.onlyIfContent, false);
+  t.equal(tag1.noEdgeWhitespace, false);
+  t.equal(tag1.joinChildren, '🍇');
+
+  // 4-6: exposed properties reflect unreasonable attribute values
+
+  const tag2 = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<br>',
+  });
+
+  tag2.attributes.set(html.onlyIfContent, '');
+  tag2.attributes.set(html.noEdgeWhitespace, 12345);
+  tag2.attributes.set(html.joinChildren, 0.0001);
+
+  t.equal(tag2.onlyIfContent, false);
+  t.equal(tag2.noEdgeWhitespace, true);
+  t.equal(tag2.joinChildren, '0.0001');
+
+  // 7-9: attribute values reflect reasonable mutated properties
+
+  const tag3 = new Tag('div', null, {
+    [html.onlyIfContent]: false,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '🍜',
+  })
+
+  tag3.onlyIfContent = true;
+  tag3.noEdgeWhitespace = false;
+  tag3.joinChildren = '🦑';
+
+  t.equal(tag3.attributes.get(html.onlyIfContent), true);
+  t.equal(tag3.attributes.get(html.noEdgeWhitespace), undefined);
+  t.equal(tag3.joinChildren, '🦑');
+
+  // 10-12: attribute values reflect unreasonable mutated properties
+
+  const tag4 = new Tag('div', null, {
+    [html.onlyIfContent]: false,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '🍜',
+  });
+
+  tag4.onlyIfContent = 'armadillo';
+  tag4.noEdgeWhitespace = 0;
+  tag4.joinChildren = Infinity;
+
+  t.equal(tag4.attributes.get(html.onlyIfContent), true);
+  t.equal(tag4.attributes.get(html.noEdgeWhitespace), undefined);
+  t.equal(tag4.attributes.get(html.joinChildren), 'Infinity');
+});
+
+t.test(`Tag.toString`, t => {
+  t.plan(9);
+
+  // 1: basic behavior
+
+  const tag1 =
+    html.tag('div', 'Content');
+
+  t.equal(tag1.toString(),
+    `<div>Content</div>`);
+
+  // 2: stringifies nested element
+
+  const tag2 =
+    html.tag('div', html.tag('p', 'Content'));
+
+  t.equal(tag2.toString(),
+    `<div><p>Content</p></div>`);
+
+  // 3: stringifies attributes
+
+  const tag3 =
+    html.tag('div',
+      {
+        id: 'banana',
+        class: ['foo', 'bar'],
+        contenteditable: true,
+        biggerthanabreadbox: false,
+        saying: `"To light a candle is to cast a shadow..."`,
+        tabindex: 413,
+      },
+      'Content');
+
+  t.equal(tag3.toString(),
+    `<div id="banana" class="foo bar" contenteditable ` +
+    `saying="&quot;To light a candle is to cast a shadow...&quot;" ` +
+    `tabindex="413">Content</div>`);
+
+  // 4: attributes match input order
+
+  const tag4 =
+    html.tag('div',
+      {class: ['foo', 'bar'], id: 'banana'},
+      'Content');
+
+  t.equal(tag4.toString(),
+    `<div class="foo bar" id="banana">Content</div>`);
+
+  // 5: multiline contented indented
+
+  const tag5 =
+    html.tag('div', 'foo\nbar');
+
+  t.equal(tag5.toString(),
+    `<div>\n` +
+    `    foo\n` +
+    `    bar\n` +
+    `</div>`);
+
+  // 6: nested multiline content double-indented
+
+  const tag6 =
+    html.tag('div', [
+      html.tag('p',
+        'foo\nbar'),
+      html.tag('span', `I'm on one line!`),
+    ]);
+
+  t.equal(tag6.toString(),
+    `<div>\n` +
+    `    <p>\n` +
+    `        foo\n` +
+    `        bar\n` +
+    `    </p>\n` +
+    `    <span>I'm on one line!</span>\n` +
+    `</div>`);
+
+  // 7: self-closing (with attributes)
+
+  const tag7 =
+    html.tag('article', [
+      html.tag('h1', `Title`),
+      html.tag('hr', {style: `color: magenta`}),
+      html.tag('p', `Shenanigans!`),
+    ]);
+
+  t.equal(tag7.toString(),
+    `<article>\n` +
+    `    <h1>Title</h1>\n` +
+    `    <hr style="color: magenta">\n` +
+    `    <p>Shenanigans!</p>\n` +
+    `</article>`);
+
+  // 8-9: empty tagName passes content through directly
+
+  const tag8 =
+    html.tag(null, [
+      html.tag('h1', `Foo`),
+      html.tag(`h2`, `Bar`),
+    ]);
+
+  t.equal(tag8.toString(),
+    `<h1>Foo</h1>\n` +
+    `<h2>Bar</h2>`);
+
+  const tag9 =
+    html.tag(null, {
+      [html.joinChildren]: html.tag('br'),
+    }, [
+      `Say it with me...`,
+      `Supercalifragilisticexpialidocious!`
+    ]);
+
+  t.equal(tag9.toString(),
+    `Say it with me...\n` +
+    `<br>\n` +
+    `Supercalifragilisticexpialidocious!`);
+});
+
+t.test(`Tag.toString (onlyIfContent)`, t => {
+  t.plan(4);
+
+  // 1-2: basic behavior
+
+  const tag1 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      `Hello!`);
+
+  t.equal(tag1.toString(),
+    `<div>Hello!</div>`);
+
+  const tag2 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      '');
+
+  t.equal(tag2.toString(),
+    '');
+
+  // 3-4: nested onlyIfContent with "more" content
+
+  const tag3 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      [
+        '',
+        0,
+        html.tag('h1',
+          {[html.onlyIfContent]: true},
+          html.tag('strong',
+            {[html.onlyIfContent]: true})),
+        null,
+        false,
+      ]);
+
+  t.equal(tag3.toString(),
+    '');
+
+  const tag4 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      [
+        '',
+        0,
+        html.tag('h1',
+          {[html.onlyIfContent]: true},
+          html.tag('strong')),
+        null,
+        false,
+      ]);
+
+  t.equal(tag4.toString(),
+    `<div><h1><strong></strong></h1></div>`);
+});
+
+t.test(`Tag.toString (joinChildren, noEdgeWhitespace)`, t => {
+  t.plan(6);
+
+  // 1: joinChildren: default (\n), noEdgeWhitespace: true
+
+  const tag1 =
+    html.tag('div',
+      {[html.noEdgeWhitespace]: true},
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag1.toString(),
+    `<div>Foo\n` +
+    `    Bar\n` +
+    `    Baz</div>`);
+
+  // 2: joinChildren: one-line string, noEdgeWhitespace: default (false)
+
+  const tag2 =
+    html.tag('div',
+      {
+        [html.joinChildren]:
+          html.tag('br', {location: '🍍'}),
+      },
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag2.toString(),
+    `<div>\n` +
+    `    Foo\n` +
+    `    <br location="🍍">\n` +
+    `    Bar\n` +
+    `    <br location="🍍">\n` +
+    `    Baz\n` +
+    `</div>`);
+
+  // 3-4: joinChildren: blank string, noEdgeWhitespace: default (false)
+
+  const tag3 =
+    html.tag('div',
+      {[html.joinChildren]: ''},
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag3.toString(),
+    `<div>FooBarBaz</div>`);
+
+  const tag4 =
+    html.tag('div',
+      {[html.joinChildren]: ''},
+      [
+        `Ain't I\na cute one?`,
+        `~`
+      ]);
+
+  t.equal(tag4.toString(),
+    `<div>\n` +
+    `    Ain't I\n` +
+    `    a cute one?~\n` +
+    `</div>`);
+
+  // 5: joinChildren: one-line string, noEdgeWhitespace: true
+
+  const tag5 =
+    html.tag('div',
+      {
+        [html.joinChildren]: html.tag('br'),
+        [html.noEdgeWhitespace]: true,
+      },
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag5.toString(),
+    `<div>Foo\n` +
+    `    <br>\n` +
+    `    Bar\n` +
+    `    <br>\n` +
+    `    Baz</div>`);
+
+  // 6: joinChildren: empty string, noEdgeWhitespace: true
+
+  const tag6 =
+    html.tag('span',
+      {
+        [html.joinChildren]: '',
+        [html.noEdgeWhitespace]: true,
+      },
+      [
+        html.tag('i', `Oh yes~ `),
+        `You're a cute one`,
+        html.tag('sup', `💕`),
+      ]);
+
+  t.equal(tag6.toString(),
+    `<span><i>Oh yes~ </i>You're a cute one<sup>💕</sup></span>`);
+
+});
+
+t.test(`Tag.toString (custom attributes)`, t => {
+  t.plan(1);
+
+  t.test(`Tag.toString (custom attribute: href)`, t => {
+    t.plan(2);
+
+    const tag1 = html.tag('a', {href: `https://hsmusic.wiki/`});
+    t.equal(tag1.toString(), `<a href="https://hsmusic.wiki/"></a>`);
+
+    const tag2 = html.tag('a', {href: `https://hsmusic.wiki/media/Album Booklet.pdf`});
+    t.equal(tag2.toString(), `<a href="https://hsmusic.wiki/media/Album%20Booklet.pdf"></a>`);
+  });
+});
+
+t.test(`html.template`, t => {
+  t.plan(11);
+
+  let contentCalls;
+
+  // 1-4: basic behavior - no slots
+
+  contentCalls = 0;
+
+  const template1 = html.template({
+    content() {
+      contentCalls++;
+      return html.tag('hr');
+    },
+  });
+
+  t.equal(contentCalls, 0);
+  t.equal(template1.toString(), `<hr>`);
+  t.equal(contentCalls, 1);
+  template1.toString();
+  t.equal(contentCalls, 2);
+
+  // 5-10: basic behavior - slots
+
+  contentCalls = 0;
+
+  const template2 = html.template({
+    slots: {
+      foo: {
+        type: 'string',
+        default: 'Default Message',
+      },
+    },
+
+    content(slots) {
+      contentCalls++;
+      return html.tag('sub', slots.foo.toLowerCase());
+    },
+  });
+
+  t.equal(contentCalls, 0);
+  t.equal(template2.toString(), `<sub>default message</sub>`);
+  t.equal(contentCalls, 1);
+  template2.setSlot('foo', `R-r-really, me?`);
+  t.equal(contentCalls, 1);
+  t.equal(template2.toString(), `<sub>r-r-really, me?</sub>`);
+  t.equal(contentCalls, 2);
+
+  // 11: slot uses default only for null, not falsey
+
+  const template3 = html.template({
+    slots: {
+      slot1: {type: 'number', default: 123},
+      slot2: {type: 'number', default: 456},
+      slot3: {type: 'boolean', default: true},
+      slot4: {type: 'string', default: 'banana'},
+    },
+
+    content(slots) {
+      return html.tag('span', [
+        slots.slot1,
+        slots.slot2,
+        slots.slot3,
+        `(length: ${slots.slot4.length})`,
+      ].join(' '));
+    },
+  });
+
+  template3.setSlots({
+    slot1: null,
+    slot2: 0,
+    slot3: false,
+    slot4: '',
+  });
+
+  t.equal(template3.toString(), `<span>123 0 false (length: 0)</span>`);
+});
+
+t.test(`Template - description errors`, t => {
+  t.plan(14);
+
+  // 1-3: top-level description is object
+
+  strictlyThrows(t,
+    () => Template.validateDescription('snooping as usual'),
+    new TypeError(`Expected object, got string`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription(),
+    new TypeError(`Expected object, got undefined`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription(null),
+    new TypeError(`Expected object, got null`));
+
+  // 4-5: description.content is function
+
+  strictlyThrows(t,
+    () => Template.validateDescription({}),
+    new AggregateError([
+      new TypeError(`Expected description.content`),
+    ], `Errors validating template description`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      content: 'pingas',
+    }),
+    new AggregateError([
+      new TypeError(`Expected description.content to be function`),
+    ], `Errors validating template description`));
+
+  // 6: aggregate error includes template annotation
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      annotation: `my cool template`,
+      content: 'pingas',
+    }),
+    new AggregateError([
+      new TypeError(`Expected description.content to be function`),
+    ], `Errors validating template "my cool template" description`));
+
+  // 7: description.slots is object
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: 'pingas',
+      content: () => {},
+    }),
+    new AggregateError([
+      new TypeError(`Expected description.slots to be object`),
+    ], `Errors validating template description`));
+
+  // 8: slot description is object
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: 'pingas',
+      },
+
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Expected slot description to be object`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`))
+
+  // 9-10: slot description has validate or default, not both
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {},
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Expected either slot validate or type`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {
+          validate: 'pingas',
+          type: 'pingas',
+        },
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Don't specify both slot validate and type`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 11: slot validate is function
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {
+          validate: 'pingas',
+        },
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Expected slot validate to be function`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 12: slot type is name of built-in type
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {
+          type: 'pingas',
+        },
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        /\(mySlot\) Expected slot type to be one of/,
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 13: slot type has specific errors for function & object
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        slot1: {type: 'function'},
+        slot2: {type: 'object'},
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(slot1) Functions shouldn't be provided to slots`),
+        new TypeError(`(slot2) Provide validate function instead of type: object`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 14: all intended types are supported
+
+  t.doesNotThrow(
+    () => Template.validateDescription({
+      slots: {
+        slot1: {type: 'string'},
+        slot2: {type: 'number'},
+        slot3: {type: 'bigint'},
+        slot4: {type: 'boolean'},
+        slot5: {type: 'symbol'},
+        slot6: {type: 'html'},
+      },
+      content: () => {},
+    }));
+});
+
+t.test(`Template - slot value errors`, t => {
+  t.plan(8);
+
+  const template1 = html.template({
+    slots: {
+      basicString: {type: 'string'},
+      basicNumber: {type: 'number'},
+      basicBigint: {type: 'bigint'},
+      basicBoolean: {type: 'boolean'},
+      basicSymbol: {type: 'symbol'},
+      basicHTML: {type: 'html'},
+    },
+
+    content: slots =>
+      html.tag('p', [
+        `string: ${slots.basicString}`,
+        `number: ${slots.basicNumber}`,
+        `bigint: ${slots.basicBigint}`,
+        `boolean: ${slots.basicBoolean}`,
+        `symbol: ${slots.basicSymbol?.toString()   ?? 'no symbol'}`,
+
+        `html:`,
+        slots.basicHTML,
+      ]),
+  });
+
+  // 1-2: basic values match type, no error & reflected in content
+
+  t.doesNotThrow(
+    () => template1.setSlots({
+      basicString: 'pingas',
+      basicNumber: 123,
+      basicBigint: 1234567891234567n,
+      basicBoolean: true,
+      basicSymbol: Symbol(`sup`),
+      basicHTML: html.tag('span', `SnooPING AS usual, I see!`),
+    }));
+
+  t.equal(
+    template1.toString(),
+    html.tag('p', [
+      `string: pingas`,
+      `number: 123`,
+      `bigint: 1234567891234567`,
+      `boolean: true`,
+      `symbol: Symbol(sup)`,
+      `html:`,
+      html.tag('span', `SnooPING AS usual, I see!`),
+    ]).toString());
+
+  // 3-4: null matches any type, no error & reflected in content
+
+  t.doesNotThrow(
+    () => template1.setSlots({
+      basicString: null,
+      basicNumber: null,
+      basicBigint: null,
+      basicBoolean: null,
+      basicSymbol: null,
+      basicHTML: null,
+    }));
+
+  t.equal(
+    template1.toString(),
+    html.tag('p', [
+      `string: null`,
+      `number: null`,
+      `bigint: null`,
+      `boolean: null`,
+      `symbol: no symbol`,
+      `html:`,
+    ]).toString());
+
+  // 5-6: type mismatch throws error, invalidates entire setSlots call
+
+  template1.setSlots({
+    basicString: 'pingas',
+    basicNumber: 123,
+  });
+
+  strictlyThrows(t,
+    () => template1.setSlots({
+      basicBoolean: false,
+      basicSymbol: `I'm not a symbol!`,
+    }),
+    new AggregateError([
+      new TypeError(`(basicSymbol) Slot expects symbol, got string`),
+    ], `Error validating template slots`))
+
+  t.equal(
+    template1.toString(),
+    html.tag('p', [
+      `string: pingas`,
+      `number: 123`,
+      `bigint: null`,
+      `boolean: null`,
+      `symbol: no symbol`,
+      `html:`,
+    ]).toString());
+
+  const template2 = html.template({
+    slots: {
+      arrayOfStrings: {
+        validate: v => v.arrayOf(v.isString),
+        default: `Array Of Strings Fallback`.split(' '),
+      },
+
+      arrayOfHTML: {
+        validate: v => v.arrayOf(v.isHTML),
+        default: [],
+      },
+    },
+
+    content: slots =>
+      html.tag('p', [
+        html.tag('strong', slots.arrayOfStrings),
+        `arrayOfHTML length: ${slots.arrayOfHTML.length}`,
+      ]),
+  });
+
+  // 7: isHTML behaves as it should, validate fails with validate throw
+
+  strictlyThrows(t,
+    () => template2.setSlots({
+      arrayOfStrings: ['you got it', 'pingas', 0xdeadbeef],
+      arrayOfHTML: [
+        html.tag('span'),
+        html.template({content: () => 'dog'}),
+        html.blank(),
+        false && 'dogs',
+        null,
+        undefined,
+        html.tags([
+          html.tag('span', 'usual'),
+          html.tag('span', 'i'),
+        ]),
+      ],
+    }),
+    new AggregateError([
+      {
+        name: 'AggregateError',
+        message: /^\(arrayOfStrings\)/,
+        errors: {length: 1},
+      },
+    ], `Error validating template slots`));
+
+  // 8: default slot values respected
+
+  t.equal(
+    template2.toString(),
+    html.tag('p', [
+      html.tag('strong', [
+        `Array`,
+        `Of`,
+        `Strings`,
+        `Fallback`,
+      ]),
+      `arrayOfHTML length: 0`,
+    ]).toString());
+});