diff options
Diffstat (limited to 'test/lib')
-rw-r--r-- | test/lib/content-function.js | 255 | ||||
-rw-r--r-- | test/lib/generic-mock.js | 314 | ||||
-rw-r--r-- | test/lib/index.js | 6 | ||||
-rw-r--r-- | test/lib/strict-match-error.js | 50 | ||||
-rw-r--r-- | test/lib/wiki-data.js | 72 |
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}), + }; +} |