From c6f1011722dc6fe50afb3a63ee414c70dbfd6abf Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 27 Mar 2023 12:47:04 -0300 Subject: data steps: basic custom mocking function support I checked out a few libraries but none really behaved the way I needed, and coding it myself means much lower- level access, which makes certain options a lot easier (e.g. excluding one argument of a mocked function from assertion while matching the rest against a pattern). --- package-lock.json | 82 ++++++++- package.json | 5 +- src/content/dependencies/index.js | 67 +++++-- test/lib/content-function.js | 93 +++++++++- test/lib/generic-mock.js | 262 +++++++++++++++++++++++++++ test/snapshot/linkArtist.js | 5 +- test/snapshot/linkTemplate.js | 5 +- test/unit/content/dependencies/linkArtist.js | 31 ++++ 8 files changed, 508 insertions(+), 42 deletions(-) create mode 100644 test/lib/generic-mock.js create mode 100644 test/unit/content/dependencies/linkArtist.js diff --git a/package-lock.json b/package-lock.json index e53ee60a..99b64643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "devDependencies": { "chokidar": "^3.5.3", "eslint": "^8.18.0", - "tap": "^16.3.4" + "tap": "^16.3.4", + "tcompare": "^6.0.0" } }, "node_modules/@ampproject/remapping": { @@ -1877,6 +1878,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/libtap/node_modules/tcompare": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz", + "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==", + "dev": true, + "dependencies": { + "diff": "^4.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -4250,6 +4263,18 @@ "yaml": "^1.10.2" } }, + "node_modules/tap/node_modules/tcompare": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz", + "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==", + "dev": true, + "dependencies": { + "diff": "^4.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tap/node_modules/to-fast-properties": { "version": "2.0.0", "dev": true, @@ -4502,15 +4527,24 @@ } }, "node_modules/tcompare": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz", - "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-6.0.0.tgz", + "integrity": "sha512-JeX89lSVkxTzYND0LxzFCGrXm/TqGEQ0heu1JTwplnpaYQNky6hIaO4lQBOrs+/P787i3CoK9T/O3/oEcnJXvA==", "dev": true, "dependencies": { - "diff": "^4.0.2" + "diff": "^5.1.0" }, "engines": { - "node": ">=10" + "node": ">=16" + } + }, + "node_modules/tcompare/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" } }, "node_modules/test-exclude": { @@ -6185,6 +6219,17 @@ "tap-yaml": "^1.0.0", "tcompare": "^5.0.6", "trivial-deferred": "^1.0.1" + }, + "dependencies": { + "tcompare": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz", + "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==", + "dev": true, + "requires": { + "diff": "^4.0.2" + } + } } }, "locate-path": { @@ -7762,6 +7807,15 @@ "yaml": "^1.10.2" } }, + "tcompare": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz", + "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==", + "dev": true, + "requires": { + "diff": "^4.0.2" + } + }, "to-fast-properties": { "version": "2.0.0", "bundled": true, @@ -7973,12 +8027,20 @@ } }, "tcompare": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz", - "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-6.0.0.tgz", + "integrity": "sha512-JeX89lSVkxTzYND0LxzFCGrXm/TqGEQ0heu1JTwplnpaYQNky6hIaO4lQBOrs+/P787i3CoK9T/O3/oEcnJXvA==", "dev": true, "requires": { - "diff": "^4.0.2" + "diff": "^5.1.0" + }, + "dependencies": { + "diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true + } } }, "test-exclude": { diff --git a/package.json b/package.json index 7f8e32e6..9d9a2cf4 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "hsmusic": "./src/upd8.js" }, "scripts": { - "test": "tap test/snapshot/*.js test/unit/**/*.js", + "test": "tap 'test/snapshot/*.js' 'test/unit/**/*.js'", "dev": "eslint src && node src/upd8.js" }, "dependencies": { @@ -22,7 +22,8 @@ "devDependencies": { "chokidar": "^3.5.3", "eslint": "^8.18.0", - "tap": "^16.3.4" + "tap": "^16.3.4", + "tcompare": "^6.0.0" }, "tap": { "coverage": false, diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js index 7f86abb1..767828ad 100644 --- a/src/content/dependencies/index.js +++ b/src/content/dependencies/index.js @@ -8,6 +8,7 @@ import {color, logWarn} from '../../util/cli.js'; import {annotateFunction} from '../../util/sugar.js'; export function watchContentDependencies({ + mock = null, logging = true, } = {}) { const events = new EventEmitter(); @@ -46,6 +47,23 @@ export function watchContentDependencies({ checkReadyConditions(); }); + if (mock) { + const errors = []; + for (const [functionName, spec] of Object.entries(mock)) { + try { + const fn = processFunctionSpec(functionName, spec); + contentDependencies[functionName] = fn; + } catch (error) { + error.message = `(${functionName}) ${error.message}`; + errors.push(error); + } + } + if (errors.length) { + throw new AggregateError(errors, `Errors processing mocked content functions`); + } + checkReadyConditions(); + } + return events; async function close() { @@ -81,13 +99,21 @@ export function watchContentDependencies({ return functionName; } + function isMocked(functionName) { + return !!mock && Object.keys(mock).includes(functionName); + } + async function handlePathRemoved(filePath) { const functionName = getFunctionName(filePath); + if (isMocked(functionName)) return; + delete contentDependencies[functionName]; } async function handlePathUpdated(filePath) { const functionName = getFunctionName(filePath); + if (isMocked(functionName)) return; + let error = null; main: { @@ -100,26 +126,11 @@ export function watchContentDependencies({ break main; } - try { - if (typeof spec.data === 'function') { - annotateFunction(spec.data, {name: functionName, description: 'data'}); - } - - if (typeof spec.generate === 'function') { - annotateFunction(spec.generate, {name: functionName}); - } - } catch (caughtError) { - error = caughtError; - error.message = `Error annotating functions: ${error.message}`; - break main; - } - let fn; try { - fn = contentFunction(spec); + fn = processFunctionSpec(functionName, spec); } catch (caughtError) { error = caughtError; - error.message = `Error loading spec: ${error.message}`; break main; } @@ -155,11 +166,31 @@ export function watchContentDependencies({ return false; } + + function processFunctionSpec(functionName, spec) { + if (typeof spec.data === 'function') { + annotateFunction(spec.data, {name: functionName, description: 'data'}); + } + + if (typeof spec.generate === 'function') { + annotateFunction(spec.generate, {name: functionName}); + } + + let fn; + try { + fn = contentFunction(spec); + } catch (error) { + error.message = `Error loading spec: ${error.message}`; + throw error; + } + + return fn; + } } -export function quickLoadContentDependencies() { +export function quickLoadContentDependencies(opts) { return new Promise((resolve, reject) => { - const watcher = watchContentDependencies(); + const watcher = watchContentDependencies(opts); watcher.on('error', (name, error) => { watcher.close().then(() => { diff --git a/test/lib/content-function.js b/test/lib/content-function.js index b51f2847..21af0e5a 100644 --- a/test/lib/content-function.js +++ b/test/lib/content-function.js @@ -7,11 +7,15 @@ import urlSpec from '../../src/url-spec.js'; import {getColors} from '../../src/util/colors.js'; import {generateURLs} from '../../src/util/urls.js'; +import mock from './generic-mock.js'; + export function testContentFunctions(t, message, fn) { const urls = generateURLs(urlSpec); t.test(message, async t => { - const loadedContentDependencies = await quickLoadContentDependencies(); + let loadedContentDependencies; + + const mocks = []; const evaluate = ({ from = 'localized.home', @@ -19,9 +23,13 @@ export function testContentFunctions(t, message, fn) { extraDependencies = {}, ...opts }) => { + if (!loadedContentDependencies) { + throw new Error(`Await .load() before performing tests`); + } + const {to} = urls.from(from); - try { + return cleanCatchAggregate(() => { return quickEvaluate({ ...opts, contentDependencies: { @@ -37,19 +45,88 @@ export function testContentFunctions(t, message, fn) { ...extraDependencies, }, }); - } catch (error) { - if (error instanceof AggregateError) { - error = new Error(`AggregateError: ${error.message}\n${error.errors.map(err => `** ${err}`).join('\n')}`); - } - throw error; + }); + }; + + evaluate.load = async (opts) => { + if (loadedContentDependencies) { + throw new Error(`Already loaded!`); } + + loadedContentDependencies = await asyncCleanCatchAggregate(() => + quickLoadContentDependencies(opts)); }; evaluate.snapshot = (opts, fn) => { + if (!loadedContentDependencies) { + throw new Error(`Await .load() before performing tests`); + } + const result = (fn ? fn(evaluate(opts)) : evaluate(opts)); t.matchSnapshot(result.toString(), 'output'); }; - return fn(t, evaluate); + evaluate.mock = (...opts) => { + const {value, close} = mock(...opts); + mocks.push({close}); + return value; + }; + + await fn(t, evaluate); + + if (mocks.length) { + cleanCatchAggregate(() => { + const errors = []; + for (const {close} of mocks) { + try { + close(); + } catch (error) { + errors.push(error); + } + } + if (errors.length) { + throw new AggregateError(errors, `Errors closing mocks`); + } + }); + } }); } + +function cleanAggregate(error) { + if (error instanceof AggregateError) { + return new Error(`[AggregateError: ${error.message}\n${ + error.errors + .map(cleanAggregate) + .map(err => ` * ${err.message.split('\n').map((l, i) => (i > 0 ? ' ' + l : l)).join('\n')}`) + .join('\n')}]`); + } else { + return error; + } +} + +function printAggregate(error) { + if (error instanceof AggregateError) { + const {message} = cleanAggregate(error); + 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..841ba462 --- /dev/null +++ b/test/lib/generic-mock.js @@ -0,0 +1,262 @@ +import {same} from 'tcompare'; + +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 (errors.length) { + 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.once = (...args) => { + if (args.length) { + throw new TypeError(`Didn't expect any arguments`); + } + + if (allCallDescriptions.length > 1) { + throw new TypeError(`Unexpected .once() when providing multiple descriptions`); + } + + limitCallCount = true; + markedAsOnce = true; + + return fn; + }; + + fn.next = (...args) => { + if (args.length) { + 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 value === 'number' && + value === parseInt(value) && + value >= 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: () => { + 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++; + const currentCallNumber = runningCallCount; + const currentDescription = selectCallDescription(currentCallNumber); + + const { + argumentCount, + argsPattern, + } = currentDescription; + + if (argumentCount !== null) { + if (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 (callErrors.length) { + const aggregate = new AggregateError(callErrors, `Errors in call #${currentCallNumber}`); + topLevelErrors.push(aggregate); + } + + return currentDescription; + } + + function selectCallDescription(currentCallNumber) { + // console.log(currentCallNumber, allCallDescriptions[0]); + + const lastDescription = allCallDescriptions[allCallDescriptions.length - 1]; + const describedCount = + (lastDescription.described + ? allCallDescriptions.length + : allCallDescriptions.length - 1); + + if (currentCallNumber > describedCount) { + if (lastDescription.described) { + return newCallDescription(); + } else { + return lastDescription; + } + } else { + return allCallDescriptions[currentCallNumber - 1]; + } + } +} diff --git a/test/snapshot/linkArtist.js b/test/snapshot/linkArtist.js index 383dcab2..633e2ae6 100644 --- a/test/snapshot/linkArtist.js +++ b/test/snapshot/linkArtist.js @@ -1,8 +1,9 @@ import t from 'tap'; - import {testContentFunctions} from '../lib/content-function.js'; -testContentFunctions(t, 'linkArtist', (t, evaluate) => { +testContentFunctions(t, 'linkArtist', async (t, evaluate) => { + await evaluate.load(); + evaluate.snapshot({ name: 'linkArtist', args: [ diff --git a/test/snapshot/linkTemplate.js b/test/snapshot/linkTemplate.js index 6a629682..10321897 100644 --- a/test/snapshot/linkTemplate.js +++ b/test/snapshot/linkTemplate.js @@ -1,8 +1,9 @@ import t from 'tap'; - import {testContentFunctions} from '../lib/content-function.js'; -testContentFunctions(t, 'linkTemplate', (t, evaluate) => { +testContentFunctions(t, 'linkTemplate', async (t, evaluate) => { + await evaluate.load(); + evaluate.snapshot({ name: 'linkTemplate', extraDependencies: { diff --git a/test/unit/content/dependencies/linkArtist.js b/test/unit/content/dependencies/linkArtist.js new file mode 100644 index 00000000..6d9e637d --- /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', 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); +}); -- cgit 1.3.0-6-gf8a5