« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/test/lib
diff options
context:
space:
mode:
Diffstat (limited to 'test/lib')
-rw-r--r--test/lib/content-function.js255
-rw-r--r--test/lib/generic-mock.js314
-rw-r--r--test/lib/index.js6
-rw-r--r--test/lib/strict-match-error.js50
-rw-r--r--test/lib/wiki-data.js72
5 files changed, 697 insertions, 0 deletions
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
new file mode 100644
index 0000000..7bc6213
--- /dev/null
+++ b/test/lib/content-function.js
@@ -0,0 +1,255 @@
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+import {inspect} from 'node:util';
+
+import chroma from 'chroma-js';
+
+import {showAggregate} from '#aggregate';
+import {getColors} from '#colors';
+import {quickLoadContentDependencies} from '#content-dependencies';
+import {quickEvaluate} from '#content-function';
+import * as html from '#html';
+import {internalDefaultStringsFile, processLanguageFile} from '#language';
+import {empty} from '#sugar';
+import {generateURLs, thumb, urlSpec} from '#urls';
+
+import mock from './generic-mock.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(internalDefaultStringsFile);
+    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,
+
+            cachebust: 413,
+            pagePath: ['home'],
+            appendIndexHTML: false,
+            getColors: c => getColors(c, {chroma}),
+
+            wikiData: {
+              wikiInfo: {},
+            },
+
+            ...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.stubTemplate = name =>
+      // Creates a particularly permissable template, allowing any slot values
+      // to be stored and just outputting the contents of those slots as-are.
+      _stubTemplate(name, false);
+
+    evaluate.stubContentFunction = name =>
+      // Like stubTemplate, but instead of a template directly, returns
+      // an object describing a content function - suitable for passing
+      // into evaluate.mock.
+      _stubTemplate(name, true);
+
+    const _stubTemplate = (name, mockContentFunction) => {
+      const inspectNicely = (value, opts = {}) =>
+        inspect(value, {
+          ...opts,
+          colors: false,
+          sort: true,
+        });
+
+      const makeTemplate = formatContentFn =>
+        new (class extends html.Template {
+          #slotValues = {};
+
+          constructor() {
+            super({
+              content: () => this.#getContent(formatContentFn),
+            });
+          }
+
+          setSlots(slotNamesToValues) {
+            Object.assign(this.#slotValues, slotNamesToValues);
+          }
+
+          setSlot(slotName, slotValue) {
+            this.#slotValues[slotName] = slotValue;
+          }
+
+          #getContent(formatContentFn) {
+            const toInspect =
+              Object.fromEntries(
+                Object.entries(this.#slotValues)
+                  .filter(([key, value]) => value !== null));
+
+            const inspected =
+              inspectNicely(toInspect, {
+                breakLength: Infinity,
+                compact: true,
+                depth: Infinity,
+              });
+
+            return formatContentFn(inspected); `${name}: ${inspected}`;
+          }
+        });
+
+      if (mockContentFunction) {
+        return {
+          data: (...args) => ({args}),
+          generate: (data) =>
+            makeTemplate(slots => {
+              const argsLines =
+                (empty(data.args)
+                  ? []
+                  : inspectNicely(data.args, {depth: Infinity})
+                      .split('\n'));
+
+              return (`[mocked: ${name}` +
+
+                (empty(data.args)
+                  ? ``
+               : argsLines.length === 1
+                  ? `\n args: ${argsLines[0]}`
+                  : `\n args: ${argsLines[0]}\n` +
+                    argsLines.slice(1).join('\n').replace(/^/gm, ' ')) +
+
+                (!empty(data.args)
+                  ? `\n `
+                  : ` - `) +
+
+                (slots
+                  ? `slots: ${slots}]`
+                  : `slots: none]`));
+            }),
+        };
+      } else {
+        return makeTemplate(slots => `${name}: ${slots}`);
+      }
+    };
+
+    evaluate.mock = (...opts) => {
+      const {value, close} = mock(...opts);
+      mocks.push({close});
+      return value;
+    };
+
+    evaluate.mock.transformContent = {
+      transformContent: {
+        extraDependencies: ['html'],
+        data: content => ({content}),
+        slots: {mode: {type: 'string'}},
+        generate: ({content}) => content,
+      },
+    };
+
+    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 0000000..28309ab
--- /dev/null
+++ b/test/lib/generic-mock.js
@@ -0,0 +1,314 @@
+import {same} from 'tcompare';
+
+import {empty} from '#sugar';
+
+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/index.js b/test/lib/index.js
new file mode 100644
index 0000000..5fb5bf7
--- /dev/null
+++ b/test/lib/index.js
@@ -0,0 +1,6 @@
+Error.stackTraceLimit = Infinity;
+
+export * from './content-function.js';
+export * from './generic-mock.js';
+export * from './wiki-data.js';
+export * from './strict-match-error.js';
diff --git a/test/lib/strict-match-error.js b/test/lib/strict-match-error.js
new file mode 100644
index 0000000..e3b36e9
--- /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/lib/wiki-data.js b/test/lib/wiki-data.js
new file mode 100644
index 0000000..d2d860c
--- /dev/null
+++ b/test/lib/wiki-data.js
@@ -0,0 +1,72 @@
+import CacheableObject from '#cacheable-object';
+import find from '#find';
+import {withEntries} from '#sugar';
+import {linkWikiDataArrays} from '#yaml';
+
+export function linkAndBindWikiData(wikiData, {
+  inferAlbumsOwnTrackData = true,
+} = {}) {
+  function customLinkWikiDataArrays(wikiData, options = {}) {
+    linkWikiDataArrays(
+      (options.XXX_decacheWikiData
+        ? withEntries(wikiData, entries => entries
+            .map(([key, value]) => [key, value.slice()]))
+        : wikiData));
+
+    // If albumData is present, automatically set albums' ownTrackData values
+    // by resolving track sections' references against the full array. This is
+    // just a nicety for working with albums throughout tests.
+    if (inferAlbumsOwnTrackData && wikiData.albumData && wikiData.trackData) {
+      for (const album of wikiData.albumData) {
+        const trackSections =
+          CacheableObject.getUpdateValue(album, 'trackSections');
+
+        const trackRefs =
+          trackSections.flatMap(section => section.tracks);
+
+        album.ownTrackData =
+          trackRefs.map(ref =>
+            find.track(ref, wikiData.trackData, {mode: 'error'}));
+      }
+    }
+  }
+
+  customLinkWikiDataArrays(wikiData);
+
+  return {
+    // Mutate to make the below functions aware of new data objects, or of
+    // reordering the existing ones. Don't mutate arrays such as trackData
+    // in-place; assign completely new arrays to this wikiData object instead.
+    wikiData,
+
+    // Use this after you've mutated wikiData to assign new data arrays.
+    // It'll automatically relink everything on wikiData so all the objects
+    // are caught up to date.
+    linkWikiDataArrays:
+      customLinkWikiDataArrays
+        .bind(null, wikiData),
+
+    // Use this if you HAVEN'T mutated wikiData and just need to decache
+    // indirect dependencies on exposed properties of other data objects.
+    //
+    // XXX_decacheWikiData option should be used specifically to mark points
+    // where you *aren't* replacing any of the arrays under wikiData with
+    // new values, and are using linkWikiDataArrays to instead "decache" data
+    // properties which depend on any of them. It's currently not possible for
+    // a CacheableObject to depend directly on the value of a property exposed
+    // on some other CacheableObject, so when those values change, you have to
+    // manually decache before the object will realize its cache isn't valid
+    // anymore.
+    //
+    // The previous implementation for this involved overwriting the relevant
+    // wikiData properties with null, then replacing it with the original
+    // array, which effectively cleared a CacheableObject cache. But it isn't
+    // enough to clear other caches that depend on the identity of wikiData
+    // arrays, such as withReverseReferenceList, so now it replaces with fresh
+    // copies of the data arrays instead; the original identities don't get
+    // reused.
+    XXX_decacheWikiData:
+      customLinkWikiDataArrays
+        .bind(null, wikiData, {XXX_decacheWikiData: true}),
+  };
+}