diff options
Diffstat (limited to 'test/unit')
-rw-r--r-- | test/unit/content/dependencies/generateAlbumTrackList.js | 40 | ||||
-rw-r--r-- | test/unit/content/dependencies/linkArtist.js | 31 | ||||
-rw-r--r-- | test/unit/content/dependencies/linkContribution.js | 122 | ||||
-rw-r--r-- | test/unit/data/things/cacheable-object.js | 270 | ||||
-rw-r--r-- | test/unit/data/things/track.js | 75 | ||||
-rw-r--r-- | test/unit/data/things/validators.js | 318 | ||||
-rw-r--r-- | test/unit/util/html.js | 934 |
7 files changed, 1790 insertions, 0 deletions
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/unit/data/things/cacheable-object.js b/test/unit/data/things/cacheable-object.js new file mode 100644 index 00000000..d7a88ce7 --- /dev/null +++ b/test/unit/data/things/cacheable-object.js @@ -0,0 +1,270 @@ +import t from 'tap'; + +import CacheableObject from '../../../../src/data/things/cacheable-object.js'; + +function newCacheableObject(PD) { + return new (class extends CacheableObject { + static propertyDescriptors = PD; + }); +} + +t.test(`CacheableObject simple separate update & expose`, t => { + const obj = newCacheableObject({ + number: { + flags: { + update: true + } + }, + + timesTwo: { + flags: { + expose: true + }, + + expose: { + dependencies: ['number'], + compute: ({ number }) => number * 2 + } + } + }); + + t.plan(1); + obj.number = 5; + t.equal(obj.timesTwo, 10); +}); + +t.test(`CacheableObject basic cache behavior`, t => { + let computeCount = 0; + + const obj = newCacheableObject({ + string: { + flags: { + update: true + } + }, + + karkat: { + flags: { + expose: true + }, + + expose: { + dependencies: ['string'], + compute: ({ string }) => { + computeCount++; + return string.toUpperCase(); + } + } + } + }); + + t.plan(8); + + t.equal(computeCount, 0); + + obj.string = 'hello world'; + t.equal(computeCount, 0); + + obj.karkat; + t.equal(computeCount, 1); + + obj.karkat; + t.equal(computeCount, 1); + + obj.string = 'testing once again'; + t.equal(computeCount, 1); + + obj.karkat; + t.equal(computeCount, 2); + + obj.string = 'testing once again'; + t.equal(computeCount, 2); + + obj.karkat; + t.equal(computeCount, 2); +}); + +t.test(`CacheableObject combined update & expose (no transform)`, t => { + const obj = newCacheableObject({ + directory: { + flags: { + update: true, + expose: true + } + } + }); + + t.plan(2); + + obj.directory = 'the-world-revolving'; + t.equal(obj.directory, 'the-world-revolving'); + + obj.directory = 'chaos-king'; + t.equal(obj.directory, 'chaos-king'); +}); + +t.test(`CacheableObject combined update & expose (basic transform)`, t => { + const obj = newCacheableObject({ + getsRepeated: { + flags: { + update: true, + expose: true + }, + + expose: { + transform: value => value.repeat(2) + } + } + }); + + t.plan(1); + + obj.getsRepeated = 'dog'; + t.equal(obj.getsRepeated, 'dogdog'); +}); + +t.test(`CacheableObject combined update & expose (transform with dependency)`, t => { + const obj = newCacheableObject({ + customRepeat: { + flags: { + update: true, + expose: true + }, + + expose: { + dependencies: ['times'], + transform: (value, { times }) => value.repeat(times) + } + }, + + times: { + flags: { + update: true + } + } + }); + + t.plan(3); + + obj.customRepeat = 'dog'; + obj.times = 1; + t.equal(obj.customRepeat, 'dog'); + + obj.times = 5; + t.equal(obj.customRepeat, 'dogdogdogdogdog'); + + obj.customRepeat = 'cat'; + t.equal(obj.customRepeat, 'catcatcatcatcat'); +}); + +t.test(`CacheableObject validate on update`, t => { + const mockError = new TypeError(`Expected a string, not ${typeof value}`); + + const obj = newCacheableObject({ + directory: { + flags: { + update: true, + expose: true + }, + + update: { + validate: value => { + if (typeof value !== 'string') { + throw mockError; + } + return true; + } + } + }, + + date: { + flags: { + update: true, + expose: true + }, + + update: { + validate: value => (value instanceof Date) + } + } + }); + + let thrownError; + t.plan(6); + + obj.directory = 'megalovania'; + t.equal(obj.directory, 'megalovania'); + + try { + obj.directory = 25; + } catch (err) { + thrownError = err; + } + + t.equal(thrownError, mockError); + t.equal(obj.directory, 'megalovania'); + + const date = new Date(`25 December 2009`); + + obj.date = date; + t.equal(obj.date, date); + + try { + obj.date = `TWELFTH PERIGEE'S EVE`; + } catch (err) { + thrownError = err; + } + + t.equal(thrownError?.constructor, TypeError); + t.equal(obj.date, date); +}); + +t.test(`CacheableObject default update property value`, t => { + const obj = newCacheableObject({ + fruit: { + flags: { + update: true, + expose: true + }, + + update: { + default: 'potassium' + } + } + }); + + t.plan(1); + t.equal(obj.fruit, 'potassium'); +}); + +t.test(`CacheableObject default property throws if invalid`, t => { + const mockError = new TypeError(`Expected a string, not ${typeof value}`); + + t.plan(1); + + let thrownError; + + try { + newCacheableObject({ + string: { + flags: { + update: true + }, + + update: { + default: 123, + validate: value => { + if (typeof value !== 'string') { + throw mockError; + } + return true; + } + } + } + }); + } catch (err) { + thrownError = err; + } + + t.equal(thrownError, mockError); +}); diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js new file mode 100644 index 00000000..0dad0e62 --- /dev/null +++ b/test/unit/data/things/track.js @@ -0,0 +1,75 @@ +import t from 'tap'; + +import thingConstructors from '../../../../src/data/things/index.js'; + +const { + Album, + Thing, + Track, + TrackGroup, +} = thingConstructors; + +function stubAlbum(tracks) { + const album = new Album(); + album.trackSections = [ + { + tracksByRef: tracks.map(t => Thing.getReference(t)), + }, + ]; + album.trackData = tracks; + return album; +} + +t.test(`Track.coverArtDate`, t => { + t.plan(5); + + // Priority order is as follows, with the last (trackCoverArtDate) being + // greatest priority. + const albumDate = new Date('2010-10-10'); + const albumTrackArtDate = new Date('2012-12-12'); + const trackDateFirstReleased = new Date('2008-08-08'); + const trackCoverArtDate = new Date('2009-09-09'); + + const track = new Track(); + track.directory = 'foo'; + + const album = stubAlbum([track]); + + track.albumData = [album]; + + // 1. coverArtDate defaults to null + + t.equal(track.coverArtDate, null); + + // 2. coverArtDate inherits album release date + + album.date = albumDate; + + // XXX clear cache so change in album's property is reflected + track.albumData = []; + track.albumData = [album]; + + t.equal(track.coverArtDate, albumDate); + + // 3. coverArtDate inherits album trackArtDate + + album.trackArtDate = albumTrackArtDate; + + // XXX clear cache again + track.albumData = []; + track.albumData = [album]; + + t.equal(track.coverArtDate, albumTrackArtDate); + + // 4. coverArtDate is overridden dateFirstReleased + + track.dateFirstReleased = trackDateFirstReleased; + + t.equal(track.coverArtDate, trackDateFirstReleased); + + // 5. coverArtDate is overridden coverArtDate + + track.coverArtDate = trackCoverArtDate; + + t.equal(track.coverArtDate, trackCoverArtDate); +}); diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js new file mode 100644 index 00000000..53cba063 --- /dev/null +++ b/test/unit/data/things/validators.js @@ -0,0 +1,318 @@ +import t from 'tap'; +import { showAggregate } from '../../../../src/util/sugar.js'; + +import { + // Basic types + isBoolean, + isCountingNumber, + isDate, + isNumber, + isString, + isStringNonEmpty, + + // Complex types + isArray, + isObject, + validateArrayItems, + + // Wiki data + isColor, + isCommentary, + isContribution, + isContributionList, + isDimensions, + isDirectory, + isDuration, + isFileExtension, + isName, + isURL, + validateReference, + validateReferenceList, + + // Compositional utilities + oneOf, +} from '../../../../src/data/things/validators.js'; + +function test(t, msg, fn) { + t.test(msg, t => { + try { + fn(t); + } catch (error) { + if (error instanceof AggregateError) { + showAggregate(error); + } + throw error; + } + }); +} + +// Basic types + +test(t, 'isBoolean', t => { + t.plan(4); + t.ok(isBoolean(true)); + t.ok(isBoolean(false)); + t.throws(() => isBoolean(1), TypeError); + t.throws(() => isBoolean('yes'), TypeError); +}); + +test(t, 'isNumber', t => { + t.plan(6); + t.ok(isNumber(123)); + t.ok(isNumber(0.05)); + t.ok(isNumber(0)); + t.ok(isNumber(-10)); + t.throws(() => isNumber('413'), TypeError); + t.throws(() => isNumber(true), TypeError); +}); + +test(t, 'isCountingNumber', t => { + t.plan(6); + t.ok(isCountingNumber(3)); + t.ok(isCountingNumber(1)); + t.throws(() => isCountingNumber(1.75), TypeError); + t.throws(() => isCountingNumber(0), TypeError); + t.throws(() => isCountingNumber(-1), TypeError); + t.throws(() => isCountingNumber('612'), TypeError); +}); + +test(t, 'isString', t => { + t.plan(3); + t.ok(isString('hello!')); + t.ok(isString('')); + t.throws(() => isString(100), TypeError); +}); + +test(t, 'isStringNonEmpty', t => { + t.plan(4); + t.ok(isStringNonEmpty('hello!')); + t.throws(() => isStringNonEmpty(''), TypeError); + t.throws(() => isStringNonEmpty(' '), TypeError); + t.throws(() => isStringNonEmpty(100), TypeError); +}); + +// Complex types + +test(t, 'isArray', t => { + t.plan(3); + t.ok(isArray([])); + t.throws(() => isArray({}), TypeError); + t.throws(() => isArray('1, 2, 3'), TypeError); +}); + +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(t, 'isObject', t => { + t.plan(3); + t.ok(isObject({})); + t.ok(isObject([])); + t.throws(() => isObject(null), TypeError); +}); + +test(t, 'validateArrayItems', t => { + t.plan(6); + + t.ok(validateArrayItems(isNumber)([3, 4, 5])); + t.ok(validateArrayItems(validateArrayItems(isNumber))([[3, 4], [4, 5], [6, 7]])); + + let caughtError = null; + try { + validateArrayItems(isNumber)([10, 20, 'one hundred million consorts', 30]); + } catch (err) { + caughtError = err; + } + + t.not(caughtError, null); + t.ok(caughtError instanceof AggregateError); + t.equal(caughtError.errors.length, 1); + t.ok(caughtError.errors[0] instanceof TypeError); +}); + +// Wiki data + +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); +}); + +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>`)); +}); + +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/}); +}); + +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(t, 'isDimensions', t => { + t.plan(6); + t.ok(isDimensions([1, 1])); + t.ok(isDimensions([50, 50])); + t.ok(isDimensions([5000, 1])); + t.throws(() => isDimensions([1]), TypeError); + t.throws(() => isDimensions([413, 612, 1025]), TypeError); + t.throws(() => isDimensions('800x200'), TypeError); +}); + +test(t, 'isDirectory', t => { + t.plan(6); + t.ok(isDirectory('savior-of-the-waking-world')); + t.ok(isDirectory('MeGaLoVania')); + t.ok(isDirectory('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_')); + t.throws(() => isDirectory(123), TypeError); + t.throws(() => isDirectory(''), TypeError); + t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError); +}); + +test(t, 'isDuration', t => { + t.plan(5); + t.ok(isDuration(60)); + t.ok(isDuration(0.02)); + t.ok(isDuration(0)); + t.throws(() => isDuration(-1), TypeError); + t.throws(() => isDuration('10:25'), TypeError); +}); + +test(t, 'isFileExtension', t => { + t.plan(6); + t.ok(isFileExtension('png')); + t.ok(isFileExtension('jpg')); + t.ok(isFileExtension('sub_loc')); + t.throws(() => isFileExtension(''), TypeError); + t.throws(() => isFileExtension('.jpg'), TypeError); + t.throws(() => isFileExtension('just an image bro!!!!'), TypeError); +}); + +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)); +}); + +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(t, 'validateReference', t => { + t.plan(16); + + const typeless = validateReference(); + const track = validateReference('track'); + const album = validateReference('album'); + + t.ok(track('track:doctor')); + t.ok(track('track:MeGaLoVania')); + t.ok(track('Showtime (Imp Strife Mix)')); + t.throws(() => track('track:troll saint nic'), TypeError); + t.throws(() => track('track:'), TypeError); + t.throws(() => track('album:homestuck-vol-1'), TypeError); + + t.ok(album('album:sburb')); + t.ok(album('album:the-wanderers')); + t.ok(album('Homestuck Vol. 8')); + t.throws(() => album('album:Hiveswap Friendsim'), TypeError); + t.throws(() => album('album:'), TypeError); + t.throws(() => album('track:showtime-piano-refrain'), TypeError); + + t.ok(typeless('Hopes and Dreams')); + t.ok(typeless('track:snowdin-town')); + t.throws(() => typeless(''), TypeError); + t.throws(() => typeless('album:undertale-soundtrack')); +}); + +test(t, 'validateReferenceList', t => { + const track = validateReferenceList('track'); + const artist = validateReferenceList('artist'); + + t.plan(9); + + t.ok(track(['track:fallen-down', 'Once Upon a Time'])); + t.ok(artist(['artist:toby-fox', 'Mark Hadley'])); + t.ok(track(['track:amalgam'])); + t.ok(track([])); + + let caughtError = null; + try { + track(['Dog', 'album:vaporwave-2016', 'Cat', 'artist:john-madden']); + } catch (err) { + caughtError = err; + } + + 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(t, 'oneOf', t => { + t.plan(11); + + const isStringOrNumber = oneOf(isString, isNumber); + + t.ok(isStringOrNumber('hello world')); + t.ok(isStringOrNumber(42)); + t.throws(() => isStringOrNumber(false)); + + const mockError = new Error(); + const neverSucceeds = () => { + throw mockError; + }; + + const isStringOrGetRekt = oneOf(isString, neverSucceeds); + + t.ok(isStringOrGetRekt('phew!')); + + let caughtError = null; + try { + isStringOrGetRekt(0xdeadbeef); + } catch (err) { + caughtError = err; + } + + 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>`); +}); |