From cb13d591c6965dc52d89ec4d1e10558e6b22456b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 27 Mar 2023 09:59:43 -0300 Subject: reorganize test directory layout Avoids unsavory "no tests found in _support.js" message and makes structure match src directory layout more closely --- test/cacheable-object.js | 270 ---------------- test/data-validators.js | 318 ------------------- test/html.js | 502 ------------------------------ test/lib/content-function.js | 55 ++++ test/snapshot/linkArtist.js | 26 ++ test/snapshot/linkTemplate.js | 28 ++ test/snapshots/_support.js | 55 ---- test/snapshots/linkArtist.js | 26 -- test/snapshots/linkTemplate.js | 28 -- test/things.js | 75 ----- test/unit/data/things/cacheable-object.js | 270 ++++++++++++++++ test/unit/data/things/track.js | 75 +++++ test/unit/data/things/validators.js | 318 +++++++++++++++++++ test/unit/util/html.js | 502 ++++++++++++++++++++++++++++++ 14 files changed, 1274 insertions(+), 1274 deletions(-) delete mode 100644 test/cacheable-object.js delete mode 100644 test/data-validators.js delete mode 100644 test/html.js create mode 100644 test/lib/content-function.js create mode 100644 test/snapshot/linkArtist.js create mode 100644 test/snapshot/linkTemplate.js delete mode 100644 test/snapshots/_support.js delete mode 100644 test/snapshots/linkArtist.js delete mode 100644 test/snapshots/linkTemplate.js delete mode 100644 test/things.js create mode 100644 test/unit/data/things/cacheable-object.js create mode 100644 test/unit/data/things/track.js create mode 100644 test/unit/data/things/validators.js create mode 100644 test/unit/util/html.js (limited to 'test') diff --git a/test/cacheable-object.js b/test/cacheable-object.js deleted file mode 100644 index 0dab9913..00000000 --- a/test/cacheable-object.js +++ /dev/null @@ -1,270 +0,0 @@ -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/data-validators.js b/test/data-validators.js deleted file mode 100644 index a1f870d3..00000000 --- a/test/data-validators.js +++ /dev/null @@ -1,318 +0,0 @@ -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(`Toby Fox:\ndogsong.mp3`)); - t.ok(isCommentary(`Technically, this works:`)); - t.ok(isCommentary(`Whodunnit:`)); - t.throws(() => isCommentary(123), TypeError); - t.throws(() => isCommentary(``), TypeError); - t.throws(() => isCommentary(`Toby Fox:`)); -}); - -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/html.js b/test/html.js deleted file mode 100644 index 6ca5a833..00000000 --- a/test/html.js +++ /dev/null @@ -1,502 +0,0 @@ -import t from 'tap'; - -import * as html from '../src/util/html.js'; -const {Tag, Attributes, Template, Slot} = html; - -t.test(`html.tag`, t => { - t.plan(16); - - 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'); - let genericSlot; - const genericTemplate = html.template(slot => { - genericSlot = slot('title'); - return []; - }); - - // 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: slot treated as content, not attributes - const tag5 = html.tag('div', genericSlot); - t.equal(tag5.content.length, 1); - t.equal(tag5.content[0], genericSlot); - - // 15-16: 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]: '
', - }); - - // 1-3: basic exposed properties from attributes in constructor - t.ok(tag.onlyIfContent); - t.ok(tag.noEdgeWhitespace); - t.equal(tag.joinChildren, '
'); - - // 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), '
'); -}); - -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]: '
', - }); - - 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]: '
', - }); - - 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(), - `
Content
`); - - // 2: stringifies nested element - - const tag2 = - html.tag('div', html.tag('p', 'Content')); - - t.equal(tag2.toString(), - `

Content

`); - - // 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(), - `
Content
`); - - // 4: attributes match input order - - const tag4 = - html.tag('div', - {class: ['foo', 'bar'], id: 'banana'}, - 'Content'); - - t.equal(tag4.toString(), - `
Content
`); - - // 5: multiline contented indented - - const tag5 = - html.tag('div', 'foo\nbar'); - - t.equal(tag5.toString(), - `
\n` + - ` foo\n` + - ` bar\n` + - `
`); - - // 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(), - `
\n` + - `

\n` + - ` foo\n` + - ` bar\n` + - `

\n` + - ` I'm on one line!\n` + - `
`); - - // 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(), - `
\n` + - `

Title

\n` + - `
\n` + - `

Shenanigans!

\n` + - `
`); - - // 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(), - `

Foo

\n` + - `

Bar

`); - - const tag9 = - html.tag(null, { - [html.joinChildren]: html.tag('br'), - }, [ - `Say it with me...`, - `Supercalifragilisticexpialidocious!` - ]); - - t.equal(tag9.toString(), - `Say it with me...\n` + - `
\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(), - `
Hello!
`); - - 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(), - `

`); -}); - -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(), - `
Foo\n` + - ` Bar\n` + - ` Baz
`); - - // 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(), - `
\n` + - ` Foo\n` + - `
\n` + - ` Bar\n` + - `
\n` + - ` Baz\n` + - `
`); - - // 3-4: joinChildren: blank string, noEdgeWhitespace: default (false) - - const tag3 = - html.tag('div', - {[html.joinChildren]: ''}, - [ - 'Foo', - 'Bar', - 'Baz', - ]); - - t.equal(tag3.toString(), - `
FooBarBaz
`); - - const tag4 = - html.tag('div', - {[html.joinChildren]: ''}, - [ - `Ain't I\na cute one?`, - `~` - ]); - - t.equal(tag4.toString(), - `
\n` + - ` Ain't I\n` + - ` a cute one?~\n` + - `
`); - - // 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(), - `
Foo\n` + - `
\n` + - ` Bar\n` + - `
\n` + - ` Baz
`); - - // 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(), - `Oh yes~ You're a cute one💕`); -}); - -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(), ``); - - const tag2 = html.tag('a', {href: `https://hsmusic.wiki/media/Album Booklet.pdf`}); - t.equal(tag2.toString(), ``); - }); -}); diff --git a/test/lib/content-function.js b/test/lib/content-function.js new file mode 100644 index 00000000..b51f2847 --- /dev/null +++ b/test/lib/content-function.js @@ -0,0 +1,55 @@ +import {quickEvaluate} from '../../src/content-function.js'; +import {quickLoadContentDependencies} from '../../src/content/dependencies/index.js'; + +import chroma from 'chroma-js'; +import * as html from '../../src/util/html.js'; +import urlSpec from '../../src/url-spec.js'; +import {getColors} from '../../src/util/colors.js'; +import {generateURLs} from '../../src/util/urls.js'; + +export function testContentFunctions(t, message, fn) { + const urls = generateURLs(urlSpec); + + t.test(message, async t => { + const loadedContentDependencies = await quickLoadContentDependencies(); + + const evaluate = ({ + from = 'localized.home', + contentDependencies = {}, + extraDependencies = {}, + ...opts + }) => { + const {to} = urls.from(from); + + try { + return quickEvaluate({ + ...opts, + contentDependencies: { + ...contentDependencies, + ...loadedContentDependencies, + }, + extraDependencies: { + html, + to, + urls, + appendIndexHTML: false, + getColors: c => getColors(c, {chroma}), + ...extraDependencies, + }, + }); + } catch (error) { + if (error instanceof AggregateError) { + error = new Error(`AggregateError: ${error.message}\n${error.errors.map(err => `** ${err}`).join('\n')}`); + } + throw error; + } + }; + + evaluate.snapshot = (opts, fn) => { + const result = (fn ? fn(evaluate(opts)) : evaluate(opts)); + t.matchSnapshot(result.toString(), 'output'); + }; + + return fn(t, evaluate); + }); +} diff --git a/test/snapshot/linkArtist.js b/test/snapshot/linkArtist.js new file mode 100644 index 00000000..383dcab2 --- /dev/null +++ b/test/snapshot/linkArtist.js @@ -0,0 +1,26 @@ +import t from 'tap'; + +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'linkArtist', (t, evaluate) => { + evaluate.snapshot({ + name: 'linkArtist', + args: [ + { + name: `Toby Fox`, + directory: `toby-fox`, + } + ], + }); + + evaluate.snapshot({ + name: 'linkArtist', + args: [ + { + name: 'ICCTTCMDMIROTMCWMWFTPFTDDOTARHPOESWGBTWEATFCWSEBTSSFOFG', + nameShort: '55gore', + directory: '55gore', + }, + ], + }, v => v.slot('preferShortName', true)); +}); diff --git a/test/snapshot/linkTemplate.js b/test/snapshot/linkTemplate.js new file mode 100644 index 00000000..6a629682 --- /dev/null +++ b/test/snapshot/linkTemplate.js @@ -0,0 +1,28 @@ +import t from 'tap'; + +import {testContentFunctions} from '../lib/content-function.js'; + +testContentFunctions(t, 'linkTemplate', (t, evaluate) => { + evaluate.snapshot({ + name: 'linkTemplate', + extraDependencies: { + getColors: c => ({primary: c + 'ff', dim: c + '77'}), + }, + }, + v => v + .slot('color', '#123456') + .slot('href', 'https://hsmusic.wiki/media/cool file.pdf') + .slot('hash', 'fooey') + .slot('attributes', {class: 'dog', id: 'cat1'}) + .slot('content', 'My Cool Link')); + + evaluate.snapshot({ + name: 'linkTemplate', + extraDependencies: { + to: (...path) => '/c*lzone/' + path.join('/') + '/', + appendIndexHTML: true, + }, + }, + v => v + .slot('path', ['myCoolPath', 'ham', 'pineapple', 'tomato'])); +}); diff --git a/test/snapshots/_support.js b/test/snapshots/_support.js deleted file mode 100644 index b51f2847..00000000 --- a/test/snapshots/_support.js +++ /dev/null @@ -1,55 +0,0 @@ -import {quickEvaluate} from '../../src/content-function.js'; -import {quickLoadContentDependencies} from '../../src/content/dependencies/index.js'; - -import chroma from 'chroma-js'; -import * as html from '../../src/util/html.js'; -import urlSpec from '../../src/url-spec.js'; -import {getColors} from '../../src/util/colors.js'; -import {generateURLs} from '../../src/util/urls.js'; - -export function testContentFunctions(t, message, fn) { - const urls = generateURLs(urlSpec); - - t.test(message, async t => { - const loadedContentDependencies = await quickLoadContentDependencies(); - - const evaluate = ({ - from = 'localized.home', - contentDependencies = {}, - extraDependencies = {}, - ...opts - }) => { - const {to} = urls.from(from); - - try { - return quickEvaluate({ - ...opts, - contentDependencies: { - ...contentDependencies, - ...loadedContentDependencies, - }, - extraDependencies: { - html, - to, - urls, - appendIndexHTML: false, - getColors: c => getColors(c, {chroma}), - ...extraDependencies, - }, - }); - } catch (error) { - if (error instanceof AggregateError) { - error = new Error(`AggregateError: ${error.message}\n${error.errors.map(err => `** ${err}`).join('\n')}`); - } - throw error; - } - }; - - evaluate.snapshot = (opts, fn) => { - const result = (fn ? fn(evaluate(opts)) : evaluate(opts)); - t.matchSnapshot(result.toString(), 'output'); - }; - - return fn(t, evaluate); - }); -} diff --git a/test/snapshots/linkArtist.js b/test/snapshots/linkArtist.js deleted file mode 100644 index 43fee88e..00000000 --- a/test/snapshots/linkArtist.js +++ /dev/null @@ -1,26 +0,0 @@ -import t from 'tap'; - -import {testContentFunctions} from './_support.js'; - -testContentFunctions(t, 'linkArtist', (t, evaluate) => { - evaluate.snapshot({ - name: 'linkArtist', - args: [ - { - name: `Toby Fox`, - directory: `toby-fox`, - } - ], - }); - - evaluate.snapshot({ - name: 'linkArtist', - args: [ - { - name: 'ICCTTCMDMIROTMCWMWFTPFTDDOTARHPOESWGBTWEATFCWSEBTSSFOFG', - nameShort: '55gore', - directory: '55gore', - }, - ], - }, v => v.slot('preferShortName', true)); -}); diff --git a/test/snapshots/linkTemplate.js b/test/snapshots/linkTemplate.js deleted file mode 100644 index 0dcf5b61..00000000 --- a/test/snapshots/linkTemplate.js +++ /dev/null @@ -1,28 +0,0 @@ -import t from 'tap'; - -import {testContentFunctions} from './_support.js'; - -testContentFunctions(t, 'linkTemplate', (t, evaluate) => { - evaluate.snapshot({ - name: 'linkTemplate', - extraDependencies: { - getColors: c => ({primary: c + 'ff', dim: c + '77'}), - }, - }, - v => v - .slot('color', '#123456') - .slot('href', 'https://hsmusic.wiki/media/cool file.pdf') - .slot('hash', 'fooey') - .slot('attributes', {class: 'dog', id: 'cat1'}) - .slot('content', 'My Cool Link')); - - evaluate.snapshot({ - name: 'linkTemplate', - extraDependencies: { - to: (...path) => '/c*lzone/' + path.join('/') + '/', - appendIndexHTML: true, - }, - }, - v => v - .slot('path', ['myCoolPath', 'ham', 'pineapple', 'tomato'])); -}); diff --git a/test/things.js b/test/things.js deleted file mode 100644 index df3a9f64..00000000 --- a/test/things.js +++ /dev/null @@ -1,75 +0,0 @@ -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/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(`Toby Fox:\ndogsong.mp3`)); + t.ok(isCommentary(`Technically, this works:`)); + t.ok(isCommentary(`Whodunnit:`)); + t.throws(() => isCommentary(123), TypeError); + t.throws(() => isCommentary(``), TypeError); + t.throws(() => isCommentary(`Toby Fox:`)); +}); + +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..c26d53b2 --- /dev/null +++ b/test/unit/util/html.js @@ -0,0 +1,502 @@ +import t from 'tap'; + +import * as html from '../../../src/util/html.js'; +const {Tag, Attributes, Template, Slot} = html; + +t.test(`html.tag`, t => { + t.plan(16); + + 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'); + let genericSlot; + const genericTemplate = html.template(slot => { + genericSlot = slot('title'); + return []; + }); + + // 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: slot treated as content, not attributes + const tag5 = html.tag('div', genericSlot); + t.equal(tag5.content.length, 1); + t.equal(tag5.content[0], genericSlot); + + // 15-16: 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]: '
', + }); + + // 1-3: basic exposed properties from attributes in constructor + t.ok(tag.onlyIfContent); + t.ok(tag.noEdgeWhitespace); + t.equal(tag.joinChildren, '
'); + + // 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), '
'); +}); + +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]: '
', + }); + + 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]: '
', + }); + + 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(), + `
Content
`); + + // 2: stringifies nested element + + const tag2 = + html.tag('div', html.tag('p', 'Content')); + + t.equal(tag2.toString(), + `

Content

`); + + // 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(), + `
Content
`); + + // 4: attributes match input order + + const tag4 = + html.tag('div', + {class: ['foo', 'bar'], id: 'banana'}, + 'Content'); + + t.equal(tag4.toString(), + `
Content
`); + + // 5: multiline contented indented + + const tag5 = + html.tag('div', 'foo\nbar'); + + t.equal(tag5.toString(), + `
\n` + + ` foo\n` + + ` bar\n` + + `
`); + + // 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(), + `
\n` + + `

\n` + + ` foo\n` + + ` bar\n` + + `

\n` + + ` I'm on one line!\n` + + `
`); + + // 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(), + `
\n` + + `

Title

\n` + + `
\n` + + `

Shenanigans!

\n` + + `
`); + + // 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(), + `

Foo

\n` + + `

Bar

`); + + const tag9 = + html.tag(null, { + [html.joinChildren]: html.tag('br'), + }, [ + `Say it with me...`, + `Supercalifragilisticexpialidocious!` + ]); + + t.equal(tag9.toString(), + `Say it with me...\n` + + `
\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(), + `
Hello!
`); + + 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(), + `

`); +}); + +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(), + `
Foo\n` + + ` Bar\n` + + ` Baz
`); + + // 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(), + `
\n` + + ` Foo\n` + + `
\n` + + ` Bar\n` + + `
\n` + + ` Baz\n` + + `
`); + + // 3-4: joinChildren: blank string, noEdgeWhitespace: default (false) + + const tag3 = + html.tag('div', + {[html.joinChildren]: ''}, + [ + 'Foo', + 'Bar', + 'Baz', + ]); + + t.equal(tag3.toString(), + `
FooBarBaz
`); + + const tag4 = + html.tag('div', + {[html.joinChildren]: ''}, + [ + `Ain't I\na cute one?`, + `~` + ]); + + t.equal(tag4.toString(), + `
\n` + + ` Ain't I\n` + + ` a cute one?~\n` + + `
`); + + // 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(), + `
Foo\n` + + `
\n` + + ` Bar\n` + + `
\n` + + ` Baz
`); + + // 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(), + `Oh yes~ You're a cute one💕`); +}); + +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(), ``); + + const tag2 = html.tag('a', {href: `https://hsmusic.wiki/media/Album Booklet.pdf`}); + t.equal(tag2.toString(), ``); + }); +}); -- cgit 1.3.0-6-gf8a5