diff options
Diffstat (limited to 'test')
31 files changed, 2667 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..5216c4f2 --- /dev/null +++ b/test/lib/content-function.js @@ -0,0 +1,157 @@ +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; + }; + + 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 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..60b46ae8 --- /dev/null +++ b/test/snapshot/generateAdditionalFilesList.js @@ -0,0 +1,64 @@ +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', + ], + }, + ], + ], + slots: { + 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', + }, + 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/generateAlbumBanner.js b/test/snapshot/generateAlbumBanner.js new file mode 100644 index 00000000..e2260cdd --- /dev/null +++ b/test/snapshot/generateAlbumBanner.js @@ -0,0 +1,34 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateAlbumBanner (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + evaluate.snapshot('basic behavior', { + name: 'generateAlbumBanner', + args: [{ + directory: 'cool-album', + hasBannerArt: true, + bannerDimensions: [800, 200], + bannerFileExtension: 'png', + }], + }); + + evaluate.snapshot('no dimensions', { + name: 'generateAlbumBanner', + args: [{ + directory: 'cool-album', + hasBannerArt: true, + bannerDimensions: null, + bannerFileExtension: 'png', + }], + }); + + evaluate.snapshot('no banner', { + name: 'generateAlbumBanner', + args: [{ + directory: 'cool-album', + hasBannerArt: false, + }], + }); +}); diff --git a/test/snapshot/generateAlbumCoverArtwork.js b/test/snapshot/generateAlbumCoverArtwork.js new file mode 100644 index 00000000..150b12bc --- /dev/null +++ b/test/snapshot/generateAlbumCoverArtwork.js @@ -0,0 +1,35 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateAlbumCoverArtwork (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + const extraDependencies = { + getSizeOfImageFile: () => 0, + }; + + const album = { + directory: 'bee-forus-seatbelt-safebee', + coverArtFileExtension: 'png', + artTags: [ + {name: 'Damara', directory: 'damara', isContentWarning: false}, + {name: 'Cronus', directory: 'cronus', isContentWarning: false}, + {name: 'Bees', directory: 'bees', isContentWarning: false}, + {name: 'creepy crawlies', isContentWarning: true}, + ], + }; + + evaluate.snapshot('display: primary', { + name: 'generateAlbumCoverArtwork', + args: [album], + slots: {mode: 'primary'}, + extraDependencies, + }); + + evaluate.snapshot('display: thumbnail', { + name: 'generateAlbumCoverArtwork', + args: [album], + slots: {mode: 'thumbnail'}, + extraDependencies, + }); +}); diff --git a/test/snapshot/generateAlbumReleaseInfo.js b/test/snapshot/generateAlbumReleaseInfo.js new file mode 100644 index 00000000..7e3b37bd --- /dev/null +++ b/test/snapshot/generateAlbumReleaseInfo.js @@ -0,0 +1,74 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateAlbumReleaseInfo (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + evaluate.snapshot('basic behavior', { + name: 'generateAlbumReleaseInfo', + args: [{ + artistContribs: [ + {who: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, what: 'music probably'}, + {who: {name: 'Tensei', directory: 'tensei', urls: ['https://tenseimusic.bandcamp.com/']}, what: 'hot jams'}, + ], + + coverArtistContribs: [ + {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null}, + ], + + wallpaperArtistContribs: [ + {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null}, + {who: {name: 'Niklink', directory: 'niklink', urls: null}, what: 'edits'}, + ], + + bannerArtistContribs: [ + {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null}, + {who: {name: 'Niklink', directory: 'niklink', urls: null}, what: 'edits'}, + ], + + name: 'AlterniaBound', + date: new Date('March 14, 2011'), + coverArtDate: new Date('April 1, 1991'), + urls: [ + 'https://homestuck.bandcamp.com/album/alterniabound-with-alternia', + 'https://www.youtube.com/playlist?list=PLnVpmehyaOFZWO9QOZmD6A3TIK0wZ6xE2', + 'https://www.youtube.com/watch?v=HO5V2uogkYc', + ], + + tracks: [{duration: 253}, {duration: 372}], + }], + }); + + const sparse = { + artistContribs: [], + coverArtistContribs: [], + wallpaperArtistContribs: [], + bannerArtistContribs: [], + + name: 'Suspicious Album', + urls: [], + tracks: [], + }; + + evaluate.snapshot('reduced details', { + name: 'generateAlbumReleaseInfo', + args: [sparse], + }); + + evaluate.snapshot('URLs only', { + name: 'generateAlbumReleaseInfo', + args: [{ + ...sparse, + urls: ['https://homestuck.bandcamp.com/foo', 'https://soundcloud.com/bar'], + }], + }); + + evaluate.snapshot('equal cover art date', { + name: 'generateAlbumReleaseInfo', + args: [{ + ...sparse, + date: new Date('2020-04-13'), + coverArtDate: new Date('2020-04-13'), + }], + }); +}); diff --git a/test/snapshot/generateAlbumSecondaryNav.js b/test/snapshot/generateAlbumSecondaryNav.js new file mode 100644 index 00000000..180cb6e0 --- /dev/null +++ b/test/snapshot/generateAlbumSecondaryNav.js @@ -0,0 +1,55 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateAlbumSecondaryNav (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + let album, group1, group2; + + group1 = {name: 'VCG', directory: 'vcg'}; + group2 = {name: 'Bepis', directory: 'bepis'}; + + album = { + date: new Date('2010-04-13'), + groups: [group1, group2], + }; + + group1.albums = [ + {name: 'First', directory: 'first', date: new Date('2010-04-10')}, + album, + {name: 'Last', directory: 'last', date: new Date('2010-06-12')}, + ]; + + group2.albums = [ + album, + {name: 'Second', directory: 'second', date: new Date('2011-04-13')}, + ]; + + evaluate.snapshot('basic behavior, mode: album', { + name: 'generateAlbumSecondaryNav', + args: [album], + slots: {mode: 'album'}, + }); + + evaluate.snapshot('basic behavior, mode: track', { + name: 'generateAlbumSecondaryNav', + args: [album], + slots: {mode: 'track'}, + }); + + album = { + date: null, + groups: [group1, group2], + }; + + group1.albums = [ + ...group1.albums, + album, + ]; + + evaluate.snapshot('dateless album in mixed group', { + name: 'generateAlbumSecondaryNav', + args: [album], + slots: {mode: 'album'}, + }); +}); diff --git a/test/snapshot/generateAlbumSidebarGroupBox.js b/test/snapshot/generateAlbumSidebarGroupBox.js new file mode 100644 index 00000000..52348230 --- /dev/null +++ b/test/snapshot/generateAlbumSidebarGroupBox.js @@ -0,0 +1,55 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateAlbumSidebarGroupBox (snapshot)', async (t, evaluate) => { + await evaluate.load({ + mock: { + ...evaluate.mock.transformContent, + }, + }); + + let album, group; + + album = { + date: new Date('2010-04-13'), + }; + + group = { + name: 'VCG', + directory: 'vcg', + descriptionShort: 'Very cool group.', + urls: ['https://vcg.bandcamp.com/', 'https://youtube.com/@vcg'], + albums: [ + {name: 'First', directory: 'first', date: new Date('2010-04-10')}, + album, + {name: 'Last', directory: 'last', date: new Date('2010-06-12')}, + ], + }; + + evaluate.snapshot('basic behavior, mode: album', { + name: 'generateAlbumSidebarGroupBox', + args: [album, group], + slots: {mode: 'album'}, + }); + + evaluate.snapshot('basic behavior, mode: track', { + name: 'generateAlbumSidebarGroupBox', + args: [album, group], + slots: {mode: 'track'}, + }); + + album = { + date: null, + }; + + group.albums = [ + ...group.albums, + album, + ]; + + evaluate.snapshot('dateless album in mixed group', { + name: 'generateAlbumSidebarGroupBox', + args: [album, group], + slots: {mode: 'album'}, + }); +}); diff --git a/test/snapshot/generateAlbumTrackList.js b/test/snapshot/generateAlbumTrackList.js new file mode 100644 index 00000000..ea3cfef3 --- /dev/null +++ b/test/snapshot/generateAlbumTrackList.js @@ -0,0 +1,48 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateAlbumTrackList (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + const contribs1 = [ + {who: {name: 'Apricot', directory: 'apricot', urls: null}}, + ]; + + const contribs2 = [ + {who: {name: 'Apricot', directory: 'apricot', urls: null}}, + {who: {name: 'Peach', directory: 'peach', urls: ['https://peach.bandcamp.com/']}}, + ]; + + const color1 = '#fb07ff'; + const color2 = '#ea2e83'; + + const tracks = [ + {name: 'Track 1', directory: 't1', duration: 20, artistContribs: contribs1, color: color1}, + {name: 'Track 2', directory: 't2', duration: 30, artistContribs: contribs1, color: color1}, + {name: 'Track 3', directory: 't3', duration: 40, artistContribs: contribs1, color: color1}, + {name: 'Track 4', directory: 't4', duration: 5, artistContribs: contribs2, color: color2}, + ]; + + evaluate.snapshot('basic behavior, with track sections', { + name: 'generateAlbumTrackList', + args: [{ + color: color1, + artistContribs: contribs1, + trackSections: [ + {name: 'First section', tracks: tracks.slice(0, 3)}, + {name: 'Second section', tracks: tracks.slice(3)}, + ], + tracks, + }], + }); + + evaluate.snapshot('basic behavior, default track section', { + name: 'generateAlbumTrackList', + args: [{ + color: color1, + artistContribs: contribs1, + trackSections: [{isDefaultTrackSection: true, tracks}], + tracks, + }], + }); +}); diff --git a/test/snapshot/generateBanner.js b/test/snapshot/generateBanner.js new file mode 100644 index 00000000..587ce04a --- /dev/null +++ b/test/snapshot/generateBanner.js @@ -0,0 +1,22 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateBanner (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + evaluate.snapshot('basic behavior', { + name: 'generateBanner', + slots: { + path: ['media.albumBanner', 'cool-album', 'png'], + alt: 'Very cool banner art.', + dimensions: [800, 200], + }, + }); + + evaluate.snapshot('no dimensions', { + name: 'generateBanner', + slots: { + path: ['media.albumBanner', 'cool-album', 'png'], + }, + }); +}); diff --git a/test/snapshot/generateCoverArtwork.js b/test/snapshot/generateCoverArtwork.js new file mode 100644 index 00000000..025a6d0d --- /dev/null +++ b/test/snapshot/generateCoverArtwork.js @@ -0,0 +1,33 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateCoverArtwork (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + const extraDependencies = { + getSizeOfImageFile: () => 0, + }; + + const artTags = [ + {name: 'Damara', directory: 'damara', isContentWarning: false}, + {name: 'Cronus', directory: 'cronus', isContentWarning: false}, + {name: 'Bees', directory: 'bees', isContentWarning: false}, + {name: 'creepy crawlies', isContentWarning: true}, + ]; + + const path = ['media.albumCover', 'bee-forus-seatbelt-safebee', 'png']; + + evaluate.snapshot('display: primary', { + name: 'generateCoverArtwork', + args: [artTags], + slots: {path, mode: 'primary'}, + extraDependencies, + }); + + evaluate.snapshot('display: thumbnail', { + name: 'generateCoverArtwork', + args: [artTags], + slots: {path, mode: 'thumbnail'}, + extraDependencies, + }); +}); diff --git a/test/snapshot/generateTrackCoverArtwork.js b/test/snapshot/generateTrackCoverArtwork.js new file mode 100644 index 00000000..bfefb5fb --- /dev/null +++ b/test/snapshot/generateTrackCoverArtwork.js @@ -0,0 +1,63 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + const extraDependencies = { + getSizeOfImageFile: () => 0, + }; + + const album = { + directory: 'bee-forus-seatbelt-safebee', + coverArtFileExtension: 'png', + artTags: [ + {name: 'Damara', directory: 'damara', isContentWarning: false}, + {name: 'Cronus', directory: 'cronus', isContentWarning: false}, + {name: 'Bees', directory: 'bees', isContentWarning: false}, + {name: 'creepy crawlies', isContentWarning: true}, + ], + }; + + const track1 = { + directory: 'beesmp3', + hasUniqueCoverArt: true, + coverArtFileExtension: 'jpg', + artTags: [{name: 'Bees', directory: 'bees', isContentWarning: false}], + album, + }; + + const track2 = { + directory: 'fake-bonus-track', + hasUniqueCoverArt: false, + album, + }; + + evaluate.snapshot('display: primary - unique art', { + name: 'generateTrackCoverArtwork', + args: [track1], + slots: {mode: 'primary'}, + extraDependencies, + }); + + evaluate.snapshot('display: thumbnail - unique art', { + name: 'generateTrackCoverArtwork', + args: [track1], + slots: {mode: 'thumbnail'}, + extraDependencies, + }); + + evaluate.snapshot('display: primary - no unique art', { + name: 'generateTrackCoverArtwork', + args: [track2], + slots: {mode: 'primary'}, + extraDependencies, + }); + + evaluate.snapshot('display: thumbnail - no unique art', { + name: 'generateTrackCoverArtwork', + args: [track2], + slots: {mode: 'thumbnail'}, + extraDependencies, + }); +}); diff --git a/test/snapshot/generateTrackReleaseInfo.js b/test/snapshot/generateTrackReleaseInfo.js new file mode 100644 index 00000000..5556f38b --- /dev/null +++ b/test/snapshot/generateTrackReleaseInfo.js @@ -0,0 +1,51 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'generateTrackReleaseInfo (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + const artistContribs = [{who: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, what: null}]; + const coverArtistContribs = [{who: {name: 'Alpaca', directory: 'alpaca', urls: null}, what: '🔥'}]; + + evaluate.snapshot('basic behavior', { + name: 'generateTrackReleaseInfo', + args: [{ + artistContribs, + name: 'An Apple Disaster!!', + date: new Date('2011-11-30'), + duration: 58, + urls: ['https://soundcloud.com/foo', 'https://youtube.com/watch?v=bar'], + }], + }); + + const sparse = { + artistContribs, + name: 'Suspicious Track', + date: null, + duration: null, + urls: [], + }; + + evaluate.snapshot('reduced details', { + name: 'generateTrackReleaseInfo', + args: [sparse], + }); + + evaluate.snapshot('cover artist contribs, non-unique', { + name: 'generateTrackReleaseInfo', + args: [{ + ...sparse, + coverArtistContribs, + hasUniqueCoverArt: false, + }], + }); + + evaluate.snapshot('cover artist contribs, unique', { + name: 'generateTrackReleaseInfo', + args: [{ + ...sparse, + coverArtistContribs, + hasUniqueCoverArt: true, + }], + }); +}); diff --git a/test/snapshot/image.js b/test/snapshot/image.js new file mode 100644 index 00000000..62e1cd9a --- /dev/null +++ b/test/snapshot/image.js @@ -0,0 +1,101 @@ +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', { + slots: { + path: ['media.albumCover', 'beyond-canon', 'png'], + }, + }); + + quickSnapshot('source via src', { + slots: { + src: 'https://example.com/bananas.gif', + }, + }); + + quickSnapshot('source missing', { + slots: { + missingSourceContent: 'Example of missing source message.', + }, + }); + + quickSnapshot('id without link', { + slots: { + src: 'foobar', + id: 'banana', + }, + }); + + quickSnapshot('id with link', { + slots: { + src: 'foobar', + link: true, + id: 'banana', + }, + }); + + quickSnapshot('id with square', { + slots: { + src: 'foobar', + square: true, + id: 'banana', + }, + }); + + quickSnapshot('width & height', { + slots: { + src: 'foobar', + width: 600, + height: 400, + }, + }); + + quickSnapshot('square', { + slots: { + src: 'foobar', + square: true, + }, + }); + + quickSnapshot('lazy with square', { + slots: { + src: 'foobar', + lazy: true, + square: true, + }, + }); + + quickSnapshot('link with file size', { + extraDependencies: { + getSizeOfImageFile: () => 10 ** 6, + }, + slots: { + path: ['media.albumCover', 'pingas', 'png'], + link: true, + }, + }); + + quickSnapshot('content warnings via tags', { + args: [ + [ + {name: 'Dirk Strider', directory: 'dirk'}, + {name: 'too cool for school', isContentWarning: true}, + ], + ], + slots: { + path: ['media.albumCover', 'beyond-canon', 'png'], + }, + }); +}); diff --git a/test/snapshot/linkArtist.js b/test/snapshot/linkArtist.js new file mode 100644 index 00000000..684affc3 --- /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', + }, + ], + slots: { + preferShortName: true, + }, + }); +}); diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js new file mode 100644 index 00000000..10b1bd08 --- /dev/null +++ b/test/snapshot/linkContribution.js @@ -0,0 +1,67 @@ +import t from 'tap'; +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => { + await evaluate.load(); + + const quickSnapshot = (message, slots) => + evaluate.snapshot(message, { + name: 'linkContribution', + multiple: [ + {args: [ + {who: { + name: 'Clark Powell', + directory: 'clark-powell', + urls: ['https://soundcloud.com/plazmataz'], + }, what: null}, + ]}, + {args: [ + {who: { + name: 'Grounder & Scratch', + directory: 'the-big-baddies', + urls: [], + }, what: 'Snooping'}, + ]}, + {args: [ + {who: { + name: 'Toby Fox', + directory: 'toby-fox', + urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'], + }, what: 'Arrangement'}, + ]}, + ], + slots, + }); + + quickSnapshot('showContribution & showIcons', { + showContribution: true, + showIcons: true, + }); + + quickSnapshot('only showContribution', { + showContribution: true, + }); + + quickSnapshot('only showIcons', { + showIcons: true, + }); + + quickSnapshot('no accents', {}); + + evaluate.snapshot('loads of links', { + name: 'linkContribution', + args: [ + {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [ + 'https://loremipsum.io', + 'https://loremipsum.io/generator/', + 'https://loremipsum.io/#meaning', + 'https://loremipsum.io/#usage-and-examples', + 'https://loremipsum.io/#controversy', + 'https://loremipsum.io/#when-to-use-lorem-ipsum', + 'https://loremipsum.io/#lorem-ipsum-all-the-things', + 'https://loremipsum.io/#original-source', + ]}, what: null}, + ], + slots: {showIcons: true}, + }); +}); diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js new file mode 100644 index 00000000..391d5a10 --- /dev/null +++ b/test/snapshot/linkExternal.js @@ -0,0 +1,54 @@ +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 - album', { + name: 'linkExternal', + multiple: [ + {args: ['https://youtu.be/abc']}, + {args: ['https://youtube.com/watch?v=abc']}, + {args: ['https://youtube.com/Playlist?list=kweh']}, + ], + slots: { + mode: 'album', + }, + }); + + 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..bef0935c --- /dev/null +++ b/test/snapshot/linkTemplate.js @@ -0,0 +1,35 @@ +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'}), + }, + + slots: { + 'color': '#123456', + 'href': 'https://hsmusic.wiki/media/cool file.pdf', + 'hash': 'fooey', + 'attributes': {class: 'dog', id: 'cat1'}, + 'content': 'My Cool Link', + }, + }); + + evaluate.snapshot('fill path slot & provide appendIndexHTML', { + name: 'linkTemplate', + + extraDependencies: { + to: (...path) => '/c*lzone/' + path.join('/') + '/', + appendIndexHTML: true, + }, + + slots: { + path: ['myCoolPath', 'ham', 'pineapple', 'tomato'], + }, + }); +}); diff --git a/test/unit/content/dependencies/generateAlbumTrackList.js b/test/unit/content/dependencies/generateAlbumTrackList.js new file mode 100644 index 00000000..80b086ca --- /dev/null +++ b/test/unit/content/dependencies/generateAlbumTrackList.js @@ -0,0 +1,40 @@ +import t from 'tap'; +import {testContentFunctions} from '../../../lib/content-function.js'; + +testContentFunctions(t, 'generateAlbumTrackList (unit)', async (t, evaluate) => { + await evaluate.load({ + mock: { + generateAlbumTrackListItem: { + extraDependencies: ['html'], + data: track => track.name, + generate: (name, {html}) => + html.tag('li', `Item: ${name}`), + }, + }, + }); + + let readDuration = false; + + const track = (name, duration) => ({ + name, + get duration() { + readDuration = true; + return duration; + }, + }); + + const tracks = [ + track('Track 1', 30), + track('Track 2', 15), + ]; + + evaluate({ + name: 'generateAlbumTrackList', + args: [{ + trackSections: [{isDefaultTrackSection: true, tracks}], + tracks, + }], + }); + + t.notOk(readDuration, 'expect no access to track.duration property'); +}); 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/unit/content/dependencies/linkContribution.js b/test/unit/content/dependencies/linkContribution.js new file mode 100644 index 00000000..bed2b6d5 --- /dev/null +++ b/test/unit/content/dependencies/linkContribution.js @@ -0,0 +1,122 @@ +import t from 'tap'; +import {testContentFunctions} from '../../../lib/content-function.js'; + +t.test('generateContributionLinks (unit)', async t => { + const who1 = { + name: 'Clark Powell', + directory: 'clark-powell', + urls: ['https://soundcloud.com/plazmataz'], + }; + + const who2 = { + name: 'Grounder & Scratch', + directory: 'the-big-baddies', + urls: [], + }; + + const who3 = { + name: 'Toby Fox', + directory: 'toby-fox', + urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'], + }; + + const what1 = null; + const what2 = 'Snooping'; + const what3 = 'Arrangement'; + + await testContentFunctions(t, 'generateContributionLinks (unit 1)', async (t, evaluate) => { + const slots = { + showContribution: true, + showIcons: true, + }; + + await evaluate.load({ + mock: evaluate.mock(mock => ({ + linkArtist: { + relations: mock.function('linkArtist.relations', () => ({})) + .args([undefined, who1]).next() + .args([undefined, who2]).next() + .args([undefined, who3]), + + data: mock.function('linkArtist.data', () => ({})) + .args([who1]).next() + .args([who2]).next() + .args([who3]), + + // 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([who1.urls[0]]).next() + .args([who3.urls[0]]).next() + .args([who3.urls[1]]), + + generate: mock.function('linkExternalAsIcon.generate', () => 'icon') + .repeat(3), + } + })), + }); + + evaluate({ + name: 'linkContribution', + multiple: [ + {args: [{who: who1, what: what1}]}, + {args: [{who: who2, what: what2}]}, + {args: [{who: who3, what: what3}]}, + ], + slots, + }); + }); + + await testContentFunctions(t, 'generateContributionLinks (unit 2)', async (t, evaluate) => { + const slots = { + showContribution: false, + showIcons: false, + }; + + await evaluate.load({ + mock: evaluate.mock(mock => ({ + linkArtist: { + relations: mock.function('linkArtist.relations', () => ({})) + .args([undefined, who1]).next() + .args([undefined, who2]).next() + .args([undefined, who3]), + + data: mock.function('linkArtist.data', () => ({})) + .args([who1]).next() + .args([who2]).next() + .args([who3]), + + generate: mock.function(() => 'artist link') + .repeat(3), + }, + + // Even though icons are hidden, these are still called! The dependency + // tree is the same since whether or not the external icon links are + // shown is dependent on a slot, which is undefined and arbitrary at + // relations/data time (it might change on a whim at generate time). + linkExternalAsIcon: { + data: mock.function('linkExternalAsIcon.data', () => ({})) + .repeat(3), + + generate: mock.function('linkExternalAsIcon.generate', () => 'icon') + .repeat(3), + }, + })), + }); + + evaluate({ + name: 'linkContribution', + multiple: [ + {args: [{who: who1, what: what1}]}, + {args: [{who: who2, what: what2}]}, + {args: [{who: who3, what: what3}]}, + ], + slots, + }); + }); +}); 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..01a510ec --- /dev/null +++ b/test/unit/util/html.js @@ -0,0 +1,934 @@ +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=""To light a candle is to cast a shadow..."" ` + + `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()); +}); + +t.test(`Stationery`, t => { + t.plan(3); + + // 1-3: basic behavior + + const stationery1 = new html.Stationery({ + slots: { + slot1: {type: 'string', default: 'apricot'}, + slot2: {type: 'string', default: 'disaster'}, + }, + + content: ({slot1, slot2}) => html.tag('span', `${slot1} ${slot2}`), + }); + + const template1 = stationery1.template(); + const template2 = stationery1.template(); + + template2.setSlots({slot1: 'aquaduct', slot2: 'dichotomy'}); + + const template3 = stationery1.template(); + + template3.setSlots({slot2: 'vinaigrette'}); + + t.equal(template1.toString(), `<span>apricot disaster</span>`); + t.equal(template2.toString(), `<span>aquaduct dichotomy</span>`); + t.equal(template3.toString(), `<span>apricot vinaigrette</span>`); +}); |