diff options
Diffstat (limited to 'test/unit')
20 files changed, 5070 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..988f8505 --- /dev/null +++ b/test/unit/content/dependencies/generateAlbumTrackList.js @@ -0,0 +1,43 @@ +import t from 'tap'; +import {testContentFunctions} from '#test-lib'; + +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}`), + }, + + image: + evaluate.stubContentFunction('image'), + }, + }); + + 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..e6e19d2f --- /dev/null +++ b/test/unit/content/dependencies/linkArtist.js @@ -0,0 +1,31 @@ +import t from 'tap'; +import {testContentFunctions} from '#test-lib'; + +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..3ffd71d2 --- /dev/null +++ b/test/unit/content/dependencies/linkContribution.js @@ -0,0 +1,137 @@ +import t from 'tap'; +import {testContentFunctions} from '#test-lib'; + +t.test('generateContributionLinks (unit)', async t => { + const artist1 = { + name: 'Clark Powell', + directory: 'clark-powell', + urls: ['https://soundcloud.com/plazmataz'], + }; + + const artist2 = { + name: 'Grounder & Scratch', + directory: 'the-big-baddies', + urls: [], + }; + + const artist3 = { + name: 'Toby Fox', + directory: 'toby-fox', + urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'], + }; + + const annotation1 = null; + const annotation2 = 'Snooping'; + const annotation3 = 'Arrangement'; + + await testContentFunctions(t, 'generateContributionLinks (unit 1)', async (t, evaluate) => { + const slots = { + showAnnotation: true, + showExternalLinks: true, + }; + + await evaluate.load({ + mock: evaluate.mock(mock => ({ + linkArtist: { + relations: mock + .function('linkArtist.relations', () => ({})) + .args([undefined, artist1]).next() + .args([undefined, artist2]).next() + .args([undefined, artist3]), + + data: mock + .function('linkArtist.data', () => ({})) + .args([artist1]).next() + .args([artist2]).next() + .args([artist3]), + + // 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), + }, + + generateExternalIcon: { + data: mock + .function('generateExternalIcon.data', () => ({})) + .args([artist1.urls[0]]).next() + .args([artist3.urls[0]]).next() + .args([artist3.urls[1]]), + + generate: mock + .function('generateExternalIcon.generate', () => ({ + toString: () => 'icon', + setSlot: () => {}, + })) + .repeat(3), + } + })), + }); + + evaluate({ + name: 'linkContribution', + multiple: [ + {args: [{artist: artist1, annotation: annotation1}]}, + {args: [{artist: artist2, annotation: annotation2}]}, + {args: [{artist: artist3, annotation: annotation3}]}, + ], + slots, + }); + }); + + await testContentFunctions(t, 'generateContributionLinks (unit 2)', async (t, evaluate) => { + const slots = { + showAnnotation: false, + showExternalLinks: false, + }; + + await evaluate.load({ + mock: evaluate.mock(mock => ({ + linkArtist: { + relations: mock + .function('linkArtist.relations', () => ({})) + .args([undefined, artist1]).next() + .args([undefined, artist2]).next() + .args([undefined, artist3]), + + data: mock + .function('linkArtist.data', () => ({})) + .args([artist1]).next() + .args([artist2]).next() + .args([artist3]), + + 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). + generateExternalIcon: { + data: mock + .function('generateExternalIcon.data', () => ({})) + .repeat(3), + + generate: mock + .function('generateExternalIcon.generate', () => ({ + toString: () => 'icon', + setSlot: () => {}, + })) + .repeat(3), + }, + })), + }); + + evaluate({ + name: 'linkContribution', + multiple: [ + {args: [{artist: artist1, annotation: annotation1}]}, + {args: [{artist: artist2, annotation: annotation2}]}, + {args: [{artist: artist3, annotation: annotation3}]}, + ], + slots, + }); + }); +}); diff --git a/test/unit/data/cacheable-object.js b/test/unit/data/cacheable-object.js new file mode 100644 index 00000000..4b927248 --- /dev/null +++ b/test/unit/data/cacheable-object.js @@ -0,0 +1,355 @@ +import t from 'tap'; + +import CacheableObject from '#cacheable-object'; + +function newCacheableObject(PD) { + return new (class extends CacheableObject { + static [CacheableObject.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'); + + t.throws( + () => { obj.directory = 25; }, + {cause: mockError}); + + t.equal(obj.directory, 'megalovania'); + + const date = new Date(`25 December 2009`); + + obj.date = date; + t.equal(obj.date, date); + + t.throws( + () => { obj.date = `TWELFTH PERIGEE'S EVE`; }, + {cause: TypeError}); + + t.equal(obj.date, date); +}); + +t.test(`CacheableObject transform on null value`, t => { + let computed = false; + + const obj = newCacheableObject({ + spookyFactor: { + flags: { + update: true, + expose: true, + }, + + expose: { + transform: value => { + computed = true; + return (value ? 2 * value : -1); + }, + }, + }, + }); + + t.plan(4); + + t.equal(obj.spookyFactor, -1); + t.ok(computed); + + computed = false; + obj.spookyFactor = 1; + + t.equal(obj.spookyFactor, 2); + t.ok(computed); +}); + +t.test(`CacheableObject don't transform on successful update`, t => { + let computed = false; + + const obj = newCacheableObject({ + original: { + flags: { + update: true, + expose: true, + }, + + update: { + validate: value => value.startsWith('track:'), + }, + + expose: { + transform: value => { + computed = true; + return (value ? value.split(':')[1] : null); + }, + }, + }, + }); + + t.plan(4); + + t.doesNotThrow(() => obj.original = 'track:foo'); + t.notOk(computed); + + t.equal(obj.original, 'foo'); + t.ok(computed); +}); + +t.test(`CacheableObject don't transform on failed update`, t => { + let computed = false; + + const obj = newCacheableObject({ + original: { + flags: { + update: true, + expose: true, + }, + + update: { + validate: value => value.startsWith('track:'), + }, + + expose: { + transform: value => { + computed = true; + return (value ? value.split(':')[1] : null); + }, + }, + }, + }); + + t.plan(4); + + t.throws(() => obj.original = 'album:foo'); + t.notOk(computed); + + t.equal(obj.original, null); + t.ok(computed); +}); + +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; + + t.throws( + () => newCacheableObject({ + string: { + flags: { + update: true + }, + + update: { + default: 123, + validate: value => { + if (typeof value !== 'string') { + throw mockError; + } + return true; + } + } + } + }), + {cause: mockError}); +}); diff --git a/test/unit/data/composite/control-flow/exposeConstant.js b/test/unit/data/composite/control-flow/exposeConstant.js new file mode 100644 index 00000000..0c75894b --- /dev/null +++ b/test/unit/data/composite/control-flow/exposeConstant.js @@ -0,0 +1,42 @@ +import t from 'tap'; + +import {compositeFrom, continuationSymbol, input} from '#composite'; +import {exposeConstant} from '#composite/control-flow'; + +t.test(`exposeConstant: basic behavior`, t => { + t.plan(2); + + const composite1 = compositeFrom({ + compose: false, + + steps: [ + exposeConstant({ + value: input.value('foo'), + }), + ], + }); + + t.match(composite1, { + expose: { + dependencies: [], + }, + }); + + t.equal(composite1.expose.compute(), 'foo'); +}); + +t.test(`exposeConstant: validate inputs`, t => { + t.plan(2); + + t.throws( + () => exposeConstant({}), + {message: `Errors in input options passed to exposeConstant`, errors: [ + {message: `Required these inputs: value`}, + ]}); + + t.throws( + () => exposeConstant({value: 'some dependency'}), + {message: `Errors in input options passed to exposeConstant`, errors: [ + {message: `value: Expected input.value() call, got dependency name`}, + ]}); +}); diff --git a/test/unit/data/composite/control-flow/exposeDependency.js b/test/unit/data/composite/control-flow/exposeDependency.js new file mode 100644 index 00000000..8f6bfd01 --- /dev/null +++ b/test/unit/data/composite/control-flow/exposeDependency.js @@ -0,0 +1,64 @@ +import t from 'tap'; + +import {compositeFrom, continuationSymbol, input} from '#composite'; +import {exposeDependency} from '#composite/control-flow'; + +t.test(`exposeDependency: basic behavior`, t => { + t.plan(4); + + const composite1 = compositeFrom({ + compose: false, + + steps: [ + exposeDependency({dependency: 'foo'}), + ], + }); + + t.match(composite1, { + expose: { + dependencies: ['foo'], + }, + }); + + t.equal(composite1.expose.compute({foo: 'bar'}), 'bar'); + + const composite2 = compositeFrom({ + compose: false, + + steps: [ + { + dependencies: ['foo'], + compute: (continuation, {foo}) => + continuation({'#bar': foo.toUpperCase()}), + }, + + exposeDependency({dependency: '#bar'}), + ], + }); + + t.match(composite2, { + expose: { + dependencies: ['foo'], + }, + }); + + t.equal(composite2.expose.compute({foo: 'bar'}), 'BAR'); +}); + +t.test(`exposeDependency: validate inputs`, t => { + t.plan(2); + + t.throws( + () => exposeDependency({}), + {message: `Errors in input options passed to exposeDependency`, errors: [ + {message: `Required these inputs: dependency`}, + ]}); + + t.throws( + () => exposeDependency({ + dependency: input.value('some static value'), + }), + {message: `Errors in input options passed to exposeDependency`, errors: [ + {message: `dependency: Expected dependency name, got input.value() call`}, + ]}); +}); diff --git a/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js new file mode 100644 index 00000000..9d588e4c --- /dev/null +++ b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js @@ -0,0 +1,197 @@ +import t from 'tap'; + +import {compositeFrom, continuationSymbol, input} from '#composite'; +import {withResultOfAvailabilityCheck} from '#composite/control-flow'; + +const composite = compositeFrom({ + compose: false, + + steps: [ + withResultOfAvailabilityCheck({ + from: 'from', + mode: 'mode', + }).outputs({ + ['#availability']: '#result', + }), + + { + dependencies: ['#result'], + compute: ({'#result': result}) => result, + }, + ], +}); + +t.test(`withResultOfAvailabilityCheck: basic behavior`, t => { + t.plan(1); + + t.match(composite, { + expose: { + dependencies: ['from', 'mode'], + }, + }); +}); + +const quickCompare = (t, expect, {from, mode}) => + t.equal(composite.expose.compute({from, mode}), expect); + +const quickThrows = (t, {from, mode}) => + t.throws(() => composite.expose.compute({from, mode})); + +t.test(`withResultOfAvailabilityCheck: mode = null`, t => { + t.plan(11); + + quickCompare(t, true, {mode: 'null', from: 'truthy string'}); + quickCompare(t, true, {mode: 'null', from: 123}); + quickCompare(t, true, {mode: 'null', from: true}); + + quickCompare(t, true, {mode: 'null', from: ''}); + quickCompare(t, true, {mode: 'null', from: 0}); + quickCompare(t, true, {mode: 'null', from: -1}); + quickCompare(t, true, {mode: 'null', from: false}); + + quickCompare(t, true, {mode: 'null', from: [1, 2, 3]}); + quickCompare(t, true, {mode: 'null', from: []}); + + quickCompare(t, false, {mode: 'null', from: null}); + quickCompare(t, false, {mode: 'null', from: undefined}); +}); + +t.test(`withResultOfAvailabilityCheck: mode = empty`, t => { + t.plan(11); + + quickThrows(t, {mode: 'empty', from: 'truthy string'}); + quickThrows(t, {mode: 'empty', from: 123}); + quickThrows(t, {mode: 'empty', from: true}); + + quickThrows(t, {mode: 'empty', from: ''}); + quickThrows(t, {mode: 'empty', from: 0}); + quickThrows(t, {mode: 'empty', from: -1}); + quickThrows(t, {mode: 'empty', from: false}); + + quickCompare(t, true, {mode: 'empty', from: [1, 2, 3]}); + quickCompare(t, false, {mode: 'empty', from: []}); + + quickCompare(t, false, {mode: 'empty', from: null}); + quickCompare(t, false, {mode: 'empty', from: undefined}); +}); + +t.test(`withResultOfAvailabilityCheck: mode = falsy`, t => { + t.plan(11); + + quickCompare(t, true, {mode: 'falsy', from: 'truthy string'}); + quickCompare(t, true, {mode: 'falsy', from: 123}); + quickCompare(t, true, {mode: 'falsy', from: true}); + + quickCompare(t, false, {mode: 'falsy', from: ''}); + quickCompare(t, false, {mode: 'falsy', from: 0}); + quickCompare(t, true, {mode: 'falsy', from: -1}); + quickCompare(t, false, {mode: 'falsy', from: false}); + + quickCompare(t, true, {mode: 'falsy', from: [1, 2, 3]}); + quickCompare(t, false, {mode: 'falsy', from: []}); + + quickCompare(t, false, {mode: 'falsy', from: null}); + quickCompare(t, false, {mode: 'falsy', from: undefined}); +}); + +t.test(`withResultOfAvailabilityCheck: mode = index`, t => { + t.plan(11); + + quickCompare(t, false, {mode: 'index', from: 'truthy string'}); + quickCompare(t, true, {mode: 'index', from: 123}); + quickCompare(t, false, {mode: 'index', from: true}); + + quickCompare(t, false, {mode: 'index', from: ''}); + quickCompare(t, true, {mode: 'index', from: 0}); + quickCompare(t, false, {mode: 'index', from: -1}); + quickCompare(t, false, {mode: 'index', from: false}); + + quickCompare(t, false, {mode: 'index', from: [1, 2, 3]}); + quickCompare(t, false, {mode: 'index', from: []}); + + quickCompare(t, false, {mode: 'index', from: null}); + quickCompare(t, false, {mode: 'index', from: undefined}); +}); + +t.test(`withResultOfAvailabilityCheck: default mode`, t => { + t.plan(1); + + const template = withResultOfAvailabilityCheck({ + from: 'foo', + }); + + t.match(template.toDescription(), { + inputMapping: { + from: input.dependency('foo'), + mode: input.value('null'), + }, + }); +}); + +t.test(`withResultOfAvailabilityCheck: validate static inputs`, t => { + t.plan(5); + + t.throws( + () => withResultOfAvailabilityCheck({}), + {message: `Errors in input options passed to withResultOfAvailabilityCheck`, errors: [ + {message: `Required these inputs: from`}, + ]}); + + t.doesNotThrow(() => + withResultOfAvailabilityCheck({ + from: 'dependency1', + mode: 'dependency2', + })); + + t.doesNotThrow(() => + withResultOfAvailabilityCheck({ + from: input.value('some static value'), + mode: input.value('null'), + })); + + t.throws( + () => withResultOfAvailabilityCheck({ + from: 'foo', + mode: input.value('invalid'), + }), + {message: `Errors in input options passed to withResultOfAvailabilityCheck`, errors: [ + {message: `mode: Expected one of null empty falsy index, got invalid`}, + ]}); + + t.throws(() => + withResultOfAvailabilityCheck({ + from: input.value(null), + mode: input.value(null), + }), + {message: `Errors in input options passed to withResultOfAvailabilityCheck`, errors: [ + {message: `mode: Expected a value, got null`}, + ]}); +}); + +t.test(`withResultOfAvailabilityCheck: validate dynamic inputs`, t => { + t.plan(2); + + t.throws( + () => composite.expose.compute({ + from: 'apple', + mode: 'banana', + }), + {message: `Error computing composition`, cause: + {message: `Error in step 1 of 2, withResultOfAvailabilityCheck`, cause: + {message: `Error computing composition withResultOfAvailabilityCheck`, cause: + {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [ + {message: `mode: Expected one of null empty falsy index, got banana`}, + ]}}}}); + + t.throws( + () => composite.expose.compute({ + from: null, + mode: null, + }), + {message: `Error computing composition`, cause: + {message: `Error in step 1 of 2, withResultOfAvailabilityCheck`, cause: + {message: `Error computing composition withResultOfAvailabilityCheck`, cause: + {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [ + {message: `mode: Expected a value, got null`}, + ]}}}}); +}); diff --git a/test/unit/data/composite/data/withPropertiesFromObject.js b/test/unit/data/composite/data/withPropertiesFromObject.js new file mode 100644 index 00000000..b81d51a5 --- /dev/null +++ b/test/unit/data/composite/data/withPropertiesFromObject.js @@ -0,0 +1,241 @@ +import t from 'tap'; +import {quickCheckCompositeOutputs} from '#test-lib'; + +import {compositeFrom, input} from '#composite'; +import {exposeDependency} from '#composite/control-flow'; +import {withPropertiesFromObject} from '#composite/data'; + +const composite = compositeFrom({ + compose: false, + + steps: [ + withPropertiesFromObject({ + object: 'object', + properties: 'properties', + }), + + exposeDependency({dependency: '#object'}), + ], +}); + +t.test(`withPropertiesFromObject: basic behavior`, t => { + t.plan(4); + + t.match(composite, { + expose: { + dependencies: ['object', 'properties'], + }, + }); + + t.same( + composite.expose.compute({ + object: {foo: 'bar', bim: 'BOOM', bam: 'baz'}, + properties: ['foo', 'bim'], + }), + {foo: 'bar', bim: 'BOOM'}); + + t.same( + composite.expose.compute({ + object: {value1: 'uwah', value2: 'arah'}, + properties: ['value1', 'value3'], + }), + {value1: 'uwah', value3: null}); + + t.same( + composite.expose.compute({ + object: null, + properties: ['ohMe', 'ohMy', 'ohDear'], + }), + {ohMe: null, ohMy: null, ohDear: null}); +}); + +t.test(`withPropertiesFromObject: output shapes & values`, t => { + t.plan(2 * 2 * 3 ** 2); + + const dependencies = { + ['object_dependency']: + {foo: 'apple', bar: 'banana', baz: 'orange'}, + [input('object_neither')]: + {foo: 'koala', bar: 'okapi', baz: 'mongoose'}, + ['properties_dependency']: + ['foo', 'bar', 'missing1'], + [input('properties_neither')]: + ['foo', 'baz', 'missing3'], + }; + + const qcco = quickCheckCompositeOutputs(t, dependencies); + + const mapLevel1 = [ + [input.value('prefix_value'), [ + ['object_dependency', [ + ['properties_dependency', { + '#object': {foo: 'apple', bar: 'banana', missing1: null}, + }], + [input.value(['bar', 'baz', 'missing2']), { + '#prefix_value.bar': 'banana', + '#prefix_value.baz': 'orange', + '#prefix_value.missing2': null, + }], + [input('properties_neither'), { + '#object': {foo: 'apple', baz: 'orange', missing3: null}, + }]]], + + [input.value({foo: 'ouh', bar: 'rah', baz: 'nyu'}), [ + ['properties_dependency', { + '#object': {foo: 'ouh', bar: 'rah', missing1: null}, + }], + [input.value(['bar', 'baz', 'missing2']), { + '#prefix_value.bar': 'rah', + '#prefix_value.baz': 'nyu', + '#prefix_value.missing2': null, + }], + [input('properties_neither'), { + '#object': {foo: 'ouh', baz: 'nyu', missing3: null}, + }]]], + + [input('object_neither'), [ + ['properties_dependency', { + '#object': {foo: 'koala', bar: 'okapi', missing1: null}, + }], + [input.value(['bar', 'baz', 'missing2']), { + '#prefix_value.bar': 'okapi', + '#prefix_value.baz': 'mongoose', + '#prefix_value.missing2': null, + }], + [input('properties_neither'), { + '#object': {foo: 'koala', baz: 'mongoose', missing3: null}, + }]]]]], + + [input.value(null), [ + ['object_dependency', [ + ['properties_dependency', { + '#object': {foo: 'apple', bar: 'banana', missing1: null}, + }], + [input.value(['bar', 'baz', 'missing2']), { + '#object_dependency.bar': 'banana', + '#object_dependency.baz': 'orange', + '#object_dependency.missing2': null, + }], + [input('properties_neither'), { + '#object': {foo: 'apple', baz: 'orange', missing3: null}, + }]]], + + [input.value({foo: 'ouh', bar: 'rah', baz: 'nyu'}), [ + ['properties_dependency', { + '#object': {foo: 'ouh', bar: 'rah', missing1: null}, + }], + [input.value(['bar', 'baz', 'missing2']), { + '#object.bar': 'rah', + '#object.baz': 'nyu', + '#object.missing2': null, + }], + [input('properties_neither'), { + '#object': {foo: 'ouh', baz: 'nyu', missing3: null}, + }]]], + + [input('object_neither'), [ + ['properties_dependency', { + '#object': {foo: 'koala', bar: 'okapi', missing1: null}, + }], + [input.value(['bar', 'baz', 'missing2']), { + '#object.bar': 'okapi', + '#object.baz': 'mongoose', + '#object.missing2': null, + }], + [input('properties_neither'), { + '#object': {foo: 'koala', baz: 'mongoose', missing3: null}, + }]]]]], + ]; + + for (const [prefixInput, mapLevel2] of mapLevel1) { + for (const [objectInput, mapLevel3] of mapLevel2) { + for (const [propertiesInput, outputDict] of mapLevel3) { + const step = withPropertiesFromObject({ + prefix: prefixInput, + object: objectInput, + properties: propertiesInput, + }); + + qcco(step, outputDict); + } + } + } +}); + +t.test(`withPropertiesFromObject: validate static inputs`, t => { + t.plan(3); + + t.throws( + () => withPropertiesFromObject({}), + {message: `Errors in input options passed to withPropertiesFromObject`, errors: [ + {message: `Required these inputs: object, properties`}, + ]}); + + t.throws( + () => withPropertiesFromObject({ + object: input.value('intriguing'), + properties: input.value('very'), + prefix: input.value({yes: 'yup'}), + }), + {message: `Errors in input options passed to withPropertiesFromObject`, errors: [ + {message: `object: Expected an object, got string`}, + {message: `properties: Expected an array, got string`}, + {message: `prefix: Expected a string, got object`}, + ]}); + + t.throws( + () => withPropertiesFromObject({ + object: input.value([['abc', 1], ['def', 2], [123, 3]]), + properties: input.value(['abc', 'def', 123]), + }), + {message: `Errors in input options passed to withPropertiesFromObject`, errors: [ + {message: `object: Expected an object, got array`}, + {message: `properties: Errors validating array items`, errors: [ + { + [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2, + message: `Error at zero-index 2: 123`, + cause: { + message: `Expected a string, got number`, + }, + }, + ]}, + ]}); +}); + +t.test(`withPropertiesFromObject: validate dynamic inputs`, t => { + t.plan(2); + + t.throws( + () => composite.expose.compute({ + object: 'intriguing', + properties: 'onceMore', + }), + {message: `Error computing composition`, cause: + {message: `Error in step 1 of 2, withPropertiesFromObject`, cause: + {message: `Error computing composition withPropertiesFromObject`, cause: + {message: `Errors in input values provided to withPropertiesFromObject`, errors: [ + {message: `object: Expected an object, got string`}, + {message: `properties: Expected an array, got string`}, + ]}}}}); + + t.throws( + () => composite.expose.compute({ + object: [['abc', 1], ['def', 2], [123, 3]], + properties: ['abc', 'def', 123], + }), + {message: `Error computing composition`, cause: + {message: `Error in step 1 of 2, withPropertiesFromObject`, cause: + {message: `Error computing composition withPropertiesFromObject`, cause: + {message: `Errors in input values provided to withPropertiesFromObject`, errors: [ + {message: `object: Expected an object, got array`}, + {message: `properties: Errors validating array items`, errors: [ + { + [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2, + message: `Error at zero-index 2: 123`, + cause: { + message: `Expected a string, got number`, + }, + }, + ]}, + ]}}}}); +}); diff --git a/test/unit/data/composite/data/withPropertyFromObject.js b/test/unit/data/composite/data/withPropertyFromObject.js new file mode 100644 index 00000000..912c924c --- /dev/null +++ b/test/unit/data/composite/data/withPropertyFromObject.js @@ -0,0 +1,191 @@ +import t from 'tap'; +import {quickCheckCompositeOutputs} from '#test-lib'; + +import CacheableObject from '#cacheable-object'; +import {compositeFrom, input} from '#composite'; +import {exposeDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +t.test(`withPropertyFromObject: basic behavior`, t => { + t.plan(4); + + const composite = compositeFrom({ + compose: false, + + steps: [ + withPropertyFromObject({ + object: 'object', + property: 'property', + }), + + exposeDependency({dependency: '#value'}), + ], + }); + + t.match(composite, { + expose: { + dependencies: ['object', 'property'], + }, + }); + + t.equal(composite.expose.compute({ + object: {foo: 'bar', bim: 'BOOM'}, + property: 'bim', + }), 'BOOM'); + + t.equal(composite.expose.compute({ + object: {value1: 'uwah'}, + property: 'value2', + }), null); + + t.equal(composite.expose.compute({ + object: null, + property: 'oml where did me object go', + }), null); +}); + +t.test(`withPropertyFromObject: "internal" input`, t => { + t.plan(7); + + const composite = compositeFrom({ + compose: false, + + steps: [ + withPropertyFromObject({ + object: 'object', + property: 'property', + internal: 'internal', + }), + + exposeDependency({dependency: '#value'}), + ], + }); + + const thing = new (class extends CacheableObject { + static [CacheableObject.propertyDescriptors] = { + foo: { + flags: {update: true, expose: false}, + }, + + bar: { + flags: {update: true, expose: true}, + }, + + baz: { + flags: {update: true, expose: true}, + expose: { + transform: baz => baz * 2, + }, + }, + }; + }); + + thing.foo = 100; + thing.bar = 200; + thing.baz = 300; + + t.match(composite, { + expose: { + dependencies: ['object', 'property', 'internal'], + }, + }); + + t.equal(composite.expose.compute({ + object: thing, + property: 'foo', + internal: true, + }), 100); + + t.equal(composite.expose.compute({ + object: thing, + property: 'bar', + internal: true, + }), 200); + + t.equal(composite.expose.compute({ + object: thing, + property: 'baz', + internal: true, + }), 300); + + t.equal(composite.expose.compute({ + object: thing, + property: 'baz', + internal: false, + }), 600); + + t.equal(composite.expose.compute({ + object: thing, + property: 'bimbam', + internal: false, + }), null); + + t.equal(composite.expose.compute({ + object: null, + property: 'bambim', + internal: false, + }), null); +}); + +t.test(`withPropertyFromObject: output shapes & values`, t => { + t.plan(2 * 3 ** 2); + + const dependencies = { + ['object_dependency']: + {foo: 'apple', bar: 'banana', baz: 'orange'}, + [input('object_neither')]: + {foo: 'koala', bar: 'okapi', baz: 'mongoose'}, + ['property_dependency']: + 'foo', + [input('property_neither')]: + 'baz', + }; + + const qcco = quickCheckCompositeOutputs(t, dependencies); + + const mapLevel1 = [ + ['object_dependency', [ + ['property_dependency', { + '#value': 'apple', + }], + [input.value('bar'), { + '#object_dependency.bar': 'banana', + }], + [input('property_neither'), { + '#value': 'orange', + }]]], + + [input.value({foo: 'ouh', bar: 'rah', baz: 'nyu'}), [ + ['property_dependency', { + '#value': 'ouh', + }], + [input.value('bar'), { + '#value': 'rah', + }], + [input('property_neither'), { + '#value': 'nyu', + }]]], + + [input('object_neither'), [ + ['property_dependency', { + '#value': 'koala', + }], + [input.value('bar'), { + '#value': 'okapi', + }], + [input('property_neither'), { + '#value': 'mongoose', + }]]], + ]; + + for (const [objectInput, mapLevel2] of mapLevel1) { + for (const [propertyInput, outputDict] of mapLevel2) { + const step = withPropertyFromObject({ + object: objectInput, + property: propertyInput, + }); + + qcco(step, outputDict); + } + } +}); diff --git a/test/unit/data/composite/data/withUniqueItemsOnly.js b/test/unit/data/composite/data/withUniqueItemsOnly.js new file mode 100644 index 00000000..50b16f43 --- /dev/null +++ b/test/unit/data/composite/data/withUniqueItemsOnly.js @@ -0,0 +1,69 @@ +import t from 'tap'; +import {quickCheckCompositeOutputs} from '#test-lib'; + +import {compositeFrom, input} from '#composite'; +import {exposeDependency} from '#composite/control-flow'; +import {withUniqueItemsOnly} from '#composite/data'; + +t.test(`withUniqueItemsOnly: basic behavior`, t => { + t.plan(3); + + const composite = compositeFrom({ + compose: false, + + steps: [ + withUniqueItemsOnly({ + list: 'list', + }), + + exposeDependency({dependency: '#list'}), + ], + }); + + t.match(composite, { + expose: { + dependencies: ['list'], + }, + }); + + t.same(composite.expose.compute({ + list: ['apple', 'banana', 'banana', 'banana', 'apple', 'watermelon'], + }), ['apple', 'banana', 'watermelon']); + + t.same(composite.expose.compute({ + list: [], + }), []); +}); + +t.test(`withUniqueItemsOnly: output shapes & values`, t => { + t.plan(2 * 3 ** 1); + + const dependencies = { + ['list_dependency']: + [1, 1, 2, 3, 3, 4, 'foo', false, false, 4], + [input('list_neither')]: + [8, 8, 7, 6, 6, 5, 'bar', true, true, 5], + }; + + const qcco = quickCheckCompositeOutputs(t, dependencies); + + const mapLevel1 = [ + ['list_dependency', { + '#list_dependency': [1, 2, 3, 4, 'foo', false], + }], + [input.value([-1, -1, 'interesting', 'very', 'interesting']), { + '#uniqueItems': [-1, 'interesting', 'very'], + }], + [input('list_neither'), { + '#uniqueItems': [8, 7, 6, 5, 'bar', true], + }], + ]; + + for (const [listInput, outputDict] of mapLevel1) { + const step = withUniqueItemsOnly({ + list: listInput, + }); + + qcco(step, outputDict); + } +}); diff --git a/test/unit/data/composite/things/track/withAlbum.js b/test/unit/data/composite/things/track/withAlbum.js new file mode 100644 index 00000000..6f50776b --- /dev/null +++ b/test/unit/data/composite/things/track/withAlbum.js @@ -0,0 +1,119 @@ +import t from 'tap'; + +import '#import-heck'; + +import Thing from '#thing'; + +import {compositeFrom, input} from '#composite'; +import {exposeConstant, exposeDependency} from '#composite/control-flow'; +import {withAlbum} from '#composite/things/track'; + +t.test(`withAlbum: basic behavior`, t => { + t.plan(3); + + const composite = compositeFrom({ + compose: false, + steps: [ + withAlbum(), + exposeDependency({dependency: '#album'}), + ], + }); + + t.match(composite, { + expose: { + dependencies: ['albumData', 'this'], + }, + }); + + const fakeTrack1 = { + [Thing.isThing]: true, + directory: 'foo', + }; + + const fakeTrack2 = { + [Thing.isThing]: true, + directory: 'bar', + }; + + const fakeAlbum = { + [Thing.isThing]: true, + directory: 'baz', + tracks: [fakeTrack1], + }; + + t.equal( + composite.expose.compute({ + albumData: [fakeAlbum], + this: fakeTrack1, + }), + fakeAlbum); + + t.equal( + composite.expose.compute({ + albumData: [fakeAlbum], + this: fakeTrack2, + }), + null); +}); + +t.test(`withAlbum: early exit conditions`, t => { + t.plan(4); + + const composite = compositeFrom({ + compose: false, + steps: [ + withAlbum(), + exposeConstant({ + value: input.value('bimbam'), + }), + ], + }); + + const fakeTrack1 = { + [Thing.isThing]: true, + directory: 'foo', + }; + + const fakeTrack2 = { + [Thing.isThing]: true, + directory: 'bar', + }; + + const fakeAlbum = { + [Thing.isThing]: true, + directory: 'baz', + tracks: [fakeTrack1], + }; + + t.equal( + composite.expose.compute({ + albumData: [fakeAlbum], + this: fakeTrack1, + }), + 'bimbam', + `does not early exit if albumData is present and contains the track`); + + t.equal( + composite.expose.compute({ + albumData: [fakeAlbum], + this: fakeTrack2, + }), + 'bimbam', + `does not early exit if albumData is present and does not contain the track`); + + t.equal( + composite.expose.compute({ + albumData: [], + this: fakeTrack1, + }), + 'bimbam', + `does not early exit if albumData is empty array`); + + t.equal( + composite.expose.compute({ + albumData: null, + this: fakeTrack1, + }), + null, + `early exits if albumData is null`); +}); diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js new file mode 100644 index 00000000..411fd11d --- /dev/null +++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -0,0 +1,373 @@ +import t from 'tap'; + +import {compositeFrom, input} from '#composite'; +import thingConstructors from '#things'; + +import {exposeDependency} from '#composite/control-flow'; +import {withParsedCommentaryEntries} from '#composite/wiki-data'; + +const {Artist} = thingConstructors; + +const composite = compositeFrom({ + compose: false, + + steps: [ + withParsedCommentaryEntries({ + from: 'from', + }), + + exposeDependency({dependency: '#parsedCommentaryEntries'}), + ], +}); + +function stubArtist(artistName = `Test Artist`) { + const artist = new Artist(); + artist.name = artistName; + + return artist; +} + +t.test(`withParsedCommentaryEntries: basic behavior`, t => { + t.plan(7); + + const artist1 = stubArtist(`Mobius Trip`); + const artist2 = stubArtist(`Hadron Kaleido`); + const artist3 = stubArtist('Homestuck'); + + const artistData = [artist1, artist2, artist3]; + + t.match(composite, { + expose: { + dependencies: ['from', 'artistData'], + }, + }); + + t.same(composite.expose.compute({ + artistData, + from: + `<i>Mobius Trip:</i>\n` + + `Some commentary.\n` + + `Very cool.\n`, + }), [ + { + artists: [artist1], + artistDisplayText: null, + annotation: null, + date: null, + accessDate: null, + accessKind: null, + secondDate: null, + dateKind: null, + body: `Some commentary.\nVery cool.`, + }, + ]); + + t.same(composite.expose.compute({ + artistData, + from: + `<i>Mobius Trip|Moo-bius Trip:</i> (music, art, 12 January 2015)\n` + + `First commentary entry.\n` + + `Very cool.\n` + + `<i>Hadron Kaleido|<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>:</i> (moral support, 4/4/2022)\n` + + `Second commentary entry. Yes. So cool.\n` + + `<i>Mystery Artist:</i> (pingas, August 25, 2023)\n` + + `Oh no.. Oh dear...\n` + + `<i>Mobius Trip, Hadron Kaleido:</i>\n` + + `And back around we go.`, + }), [ + { + artists: [artist1], + artistDisplayText: `Moo-bius Trip`, + annotation: `music, art`, + date: new Date('12 January 2015'), + body: `First commentary entry.\nVery cool.`, + secondDate: null, + dateKind: null, + accessDate: null, + accessKind: null, + }, + { + artists: [artist2], + artistDisplayText: `<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>`, + annotation: `moral support`, + date: new Date('4 April 2022'), + body: `Second commentary entry. Yes. So cool.`, + secondDate: null, + dateKind: null, + accessDate: null, + accessKind: null, + }, + { + artists: [], + artistDisplayText: null, + annotation: `pingas`, + date: new Date('25 August 2023'), + body: `Oh no.. Oh dear...`, + secondDate: null, + dateKind: null, + accessDate: null, + accessKind: null, + }, + { + artists: [artist1, artist2], + artistDisplayText: null, + annotation: null, + date: null, + body: `And back around we go.`, + secondDate: null, + dateKind: null, + accessDate: null, + accessKind: null, + }, + ]); + + t.same(composite.expose.compute({ + artistData, + from: + `<i>Homestuck:</i> ([Bandcamp credits blurb](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/track/sburban-countdown-3) on "Homestuck Vol. 1-4 (with Midnight Crew: Drawing Dead)", 10/25/2019)\n` + + `\n` + + `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` + + `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]\n` + + `\n` + + `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 captured 4/13/2024)\n` + + `This isn't real!\n` + + `\n` + + `<i>Homestuck:</i> ([fake](https://homestuck.com/fake), 10/25/2011 accessed 10/27/2011)\n` + + `This isn't real either!\n` + + `\n` + + `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 accessed 4/13/2024)\n` + + `Not this one, neither!\n` + }), [ + { + artists: [artist3], + artistDisplayText: null, + annotation: `[Bandcamp credits blurb](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/track/sburban-countdown-3) on "Homestuck Vol. 1-4 (with Midnight Crew: Drawing Dead)"`, + date: new Date('10/25/2019'), + body: + `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` + + `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]`, + secondDate: null, + dateKind: null, + accessDate: new Date('10/24/2020'), + accessKind: 'captured', + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`, + date: new Date('7/20/2019'), + body: `This isn't real!`, + secondDate: null, + dateKind: null, + accessDate: new Date('4/13/2024'), + accessKind: 'captured', + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: `[fake](https://homestuck.com/fake)`, + date: new Date('10/25/2011'), + body: `This isn't real either!`, + secondDate: null, + dateKind: null, + accessDate: new Date('10/27/2011'), + accessKind: 'accessed', + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`, + date: new Date('7/20/2019'), + body: `Not this one, neither!`, + secondDate: null, + dateKind: null, + accessDate: new Date('4/13/2024'), + accessKind: 'accessed', + }, + ]); + + t.same(composite.expose.compute({ + artistData, + from: + `<i>Homestuck:</i> ([MSPA sound credits](https://web.archive.org/web/20120805031705/http://www.mspaintadventures.com:80/soundcredits.html), sometime 6/21/2012 - 8/5/2012)\n` + + `\n` + + `[[flash:246|Page 2146]] - <b>"Sburban Countdown"</b><br>\n` + + `Available on Bandcamp in [[album:homestuck-vol-1-4|Homestuck Vol. 1-4]]<br>\n` + + `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` + + `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]\n` + + `\n` + + `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 - 7/20/2022 captured 4/13/2024)\n` + + `It's goin' once.\n` + + `\n` + + `<i>Homestuck:</i> (10/25/2011 - 10/28/2011 accessed 10/27/2011)\n` + + `It's goin' twice.\n` + + `\n` + + `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 - 7/20/2022 accessed 4/13/2024)\n` + + `It's goin' thrice!\n` + }), [ + { + artists: [artist3], + artistDisplayText: null, + annotation: `[MSPA sound credits](https://web.archive.org/web/20120805031705/http://www.mspaintadventures.com:80/soundcredits.html)`, + body: + `[[flash:246|Page 2146]] - <b>"Sburban Countdown"</b><br>\n` + + `Available on Bandcamp in [[album:homestuck-vol-1-4|Homestuck Vol. 1-4]]<br>\n` + + `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` + + `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]`, + date: new Date('6/21/2012'), + secondDate: new Date('8/5/2012'), + dateKind: 'sometime', + accessDate: new Date('8/5/2012'), + accessKind: 'captured', + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`, + body: `It's goin' once.`, + date: new Date('7/20/2019'), + secondDate: new Date('7/20/2022'), + dateKind: null, + accessDate: new Date('4/13/2024'), + accessKind: 'captured', + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: '', // TODO: This should be null, but the regex isn't structured for that, at the moment. + body: `It's goin' twice.`, + date: new Date('10/25/2011'), + secondDate: new Date('10/28/2011'), + dateKind: null, + accessDate: new Date('10/27/2011'), + accessKind: 'accessed', + + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`, + body: `It's goin' thrice!`, + date: new Date('7/20/2019'), + secondDate: new Date('7/20/2022'), + dateKind: null, + accessDate: new Date('4/13/2024'), + accessKind: 'accessed', + }, + ]); + + t.same(composite.expose.compute({ + artistData, + from: + `<i>Homestuck:</i> ([MSPA sound credits](https://web.archive.org/web/20120805031705/http://www.mspaintadventures.com:80/soundcredits.html), sometime 6/21/2012 - 8/5/2012)\n` + + `\n` + + `[[flash:246|Page 2146]] - <b>"Sburban Countdown"</b><br>\n` + + `Available on Bandcamp in [[album:homestuck-vol-1-4|Homestuck Vol. 1-4]]<br>\n` + + `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` + + `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]\n` + + `\n` + + `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 - 7/20/2022 captured 4/13/2024)\n` + + `It's goin' once.\n` + + `\n` + + `<i>Homestuck:</i> (10/25/2011 - 10/28/2011 accessed 10/27/2011)\n` + + `It's goin' twice.\n` + + `\n` + + `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 - 7/20/2022 accessed 4/13/2024)\n` + + `It's goin' thrice!\n` + }), [ + { + artists: [artist3], + artistDisplayText: null, + annotation: `[MSPA sound credits](https://web.archive.org/web/20120805031705/http://www.mspaintadventures.com:80/soundcredits.html)`, + body: + `[[flash:246|Page 2146]] - <b>"Sburban Countdown"</b><br>\n` + + `Available on Bandcamp in [[album:homestuck-vol-1-4|Homestuck Vol. 1-4]]<br>\n` + + `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` + + `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]`, + date: new Date('6/21/2012'), + secondDate: new Date('8/5/2012'), + dateKind: 'sometime', + accessDate: new Date('8/5/2012'), + accessKind: 'captured', + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`, + body: `It's goin' once.`, + date: new Date('7/20/2019'), + secondDate: new Date('7/20/2022'), + dateKind: null, + accessDate: new Date('4/13/2024'), + accessKind: 'captured', + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: '', // TODO: This should be null, but the regex isn't structured for that, at the moment. + body: `It's goin' twice.`, + date: new Date('10/25/2011'), + secondDate: new Date('10/28/2011'), + dateKind: null, + accessDate: new Date('10/27/2011'), + accessKind: 'accessed', + + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`, + body: `It's goin' thrice!`, + date: new Date('7/20/2019'), + secondDate: new Date('7/20/2022'), + dateKind: null, + accessDate: new Date('4/13/2024'), + accessKind: 'accessed', + }, + ]); + + t.same(composite.expose.compute({ + artistData, + from: + `<i>Homestuck:</i> ([Homestuck sound credits](https://web.archive.org/web/20180717171235/https://www.homestuck.com/credits/sound), excerpt, around 4/3/2018)\n` + + `blablabla\n` + + `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), around 7/20/2019 - 7/20/2022 captured 4/13/2024)\n` + + `Snoopin', snoopin', snoo,\n` + + `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), throughout 7/20/2019 - 7/20/2022 accessed 4/13/2024)\n` + + `~ pingas ~\n` + }), [ + { + artists: [artist3], + artistDisplayText: null, + annotation: `[Homestuck sound credits](https://web.archive.org/web/20180717171235/https://www.homestuck.com/credits/sound), excerpt`, + body: `blablabla`, + date: new Date('4/3/2018'), + secondDate: null, + dateKind: 'around', + accessDate: new Date('7/17/2018'), + accessKind: 'captured', + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`, + body: `Snoopin', snoopin', snoo,`, + date: new Date('7/20/2019'), + secondDate: new Date('7/20/2022'), + dateKind: 'around', + accessDate: new Date('4/13/2024'), + accessKind: 'captured', + }, + { + artists: [artist3], + artistDisplayText: null, + annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`, + body: `~ pingas ~`, + date: new Date('7/20/2019'), + secondDate: new Date('7/20/2022'), + dateKind: 'throughout', + accessDate: new Date('4/13/2024'), + accessKind: 'accessed', + }, + ]); +}); diff --git a/test/unit/data/compositeFrom.js b/test/unit/data/compositeFrom.js new file mode 100644 index 00000000..00296675 --- /dev/null +++ b/test/unit/data/compositeFrom.js @@ -0,0 +1,345 @@ +import t from 'tap'; + +import {compositeFrom, continuationSymbol, input} from '#composite'; +import {isString} from '#validators'; + +t.test(`compositeFrom: basic behavior`, t => { + t.plan(2); + + const composite = compositeFrom({ + annotation: `myComposite`, + compose: false, + + steps: [ + { + dependencies: ['foo'], + compute: (continuation, {foo}) => + continuation({'#bar': foo * 2}), + }, + + { + dependencies: ['#bar', 'baz', 'suffix'], + compute: ({'#bar': bar, baz, suffix}) => + baz.repeat(bar) + suffix, + }, + ], + }); + + t.match(composite, { + annotation: `myComposite`, + + flags: {expose: true, compose: false, update: false}, + + expose: { + dependencies: ['foo', 'baz', 'suffix'], + compute: Function, + transform: null, + }, + + update: null, + }); + + t.equal( + composite.expose.compute({ + foo: 3, + baz: 'ba', + suffix: 'BOOM', + }), + 'babababababaBOOM'); +}); + +t.test(`compositeFrom: input-shaped step dependencies`, t => { + t.plan(2); + + const composite = compositeFrom({ + compose: false, + steps: [ + { + dependencies: [ + input.myself(), + input.updateValue(), + ], + + transform: (updateValue1, { + [input.myself()]: me, + [input.updateValue()]: updateValue2, + }) => ({me, updateValue1, updateValue2}), + }, + ], + }); + + t.match(composite, { + expose: { + dependencies: ['this'], + transform: Function, + compute: null, + }, + }); + + const myself = {foo: 'bar'}; + + t.same( + composite.expose.transform('banana', { + this: myself, + pomelo: 'delicious', + }), + { + me: myself, + updateValue1: 'banana', + updateValue2: 'banana', + }); +}); + +t.test(`compositeFrom: dependencies from inputs`, t => { + t.plan(3); + + const composite = compositeFrom({ + annotation: `myComposite`, + + compose: true, + + inputMapping: { + foo: input('bar'), + pomelo: input.value('delicious'), + humorous: input.dependency('#mammal'), + data: input.dependency('albumData'), + ref: input.updateValue(), + }, + + inputDescriptions: { + foo: input(), + pomelo: input(), + humorous: input(), + data: input(), + ref: input(), + }, + + steps: [ + { + dependencies: [ + input('foo'), + input('pomelo'), + input('humorous'), + input('data'), + input('ref'), + ], + + compute: (continuation, { + [input('foo')]: foo, + [input('pomelo')]: pomelo, + [input('humorous')]: humorous, + [input('data')]: data, + [input('ref')]: ref, + }) => continuation.exit({foo, pomelo, humorous, data, ref}), + }, + ], + }); + + t.match(composite, { + expose: { + dependencies: [ + input('bar'), + '#mammal', + 'albumData', + ], + + transform: Function, + compute: null, + }, + }); + + const exitData = {}; + const continuation = { + exit(value) { + Object.assign(exitData, value); + return continuationSymbol; + }, + }; + + t.equal( + composite.expose.transform('album:bepis', continuation, { + [input('bar')]: 'squid time', + '#mammal': 'fox', + 'albumData': ['album1', 'album2'], + }), + continuationSymbol); + + t.same(exitData, { + foo: 'squid time', + pomelo: 'delicious', + humorous: 'fox', + data: ['album1', 'album2'], + ref: 'album:bepis', + }); +}); + +t.test(`compositeFrom: update from various sources`, t => { + t.plan(3); + + const match = { + flags: {update: true, expose: true, compose: false}, + + update: { + validate: isString, + default: 'foo', + }, + + expose: { + transform: Function, + compute: null, + }, + }; + + t.test(`compositeFrom: update from composition description`, t => { + t.plan(2); + + const composite = compositeFrom({ + compose: false, + + update: { + validate: isString, + default: 'foo', + }, + + steps: [ + {transform: (value, continuation) => continuation(value.repeat(2))}, + {transform: (value) => `Xx_${value}_xX`}, + ], + }); + + t.match(composite, match); + t.equal(composite.expose.transform('foo'), `Xx_foofoo_xX`); + }); + + t.test(`compositeFrom: update from step dependencies`, t => { + t.plan(2); + + const composite = compositeFrom({ + compose: false, + + steps: [ + { + dependencies: [ + input.updateValue({ + validate: isString, + default: 'foo', + }), + ], + + compute: ({ + [input.updateValue()]: value, + }) => `Xx_${value.repeat(2)}_xX`, + }, + ], + }); + + t.match(composite, match); + t.equal(composite.expose.transform('foo'), 'Xx_foofoo_xX'); + }); + + t.test(`compositeFrom: update from inputs`, t => { + t.plan(3); + + const composite = compositeFrom({ + inputMapping: { + myInput: input.updateValue({ + validate: isString, + default: 'foo', + }), + }, + + inputDescriptions: { + myInput: input(), + }, + + steps: [ + { + dependencies: [input('myInput')], + compute: (continuation, { + [input('myInput')]: value, + }) => continuation({ + '#value': `Xx_${value.repeat(2)}_xX`, + }), + }, + + { + dependencies: ['#value'], + transform: (_value, continuation, {'#value': value}) => + continuation(value), + }, + ], + }); + + let continuationValue = null; + const continuation = value => { + continuationValue = value; + return continuationSymbol; + }; + + t.match(composite, { + ...match, + + flags: {update: true, expose: true, compose: true}, + }); + + t.equal( + composite.expose.transform('foo', continuation), + continuationSymbol); + + t.equal(continuationValue, 'Xx_foofoo_xX'); + }); +}); + +t.test(`compositeFrom: dynamic input validation from type`, t => { + t.plan(2); + + const composite = compositeFrom({ + inputMapping: { + string: input('string'), + number: input('number'), + boolean: input('boolean'), + function: input('function'), + object: input('object'), + array: input('array'), + }, + + inputDescriptions: { + string: input({null: true, type: 'string'}), + number: input({null: true, type: 'number'}), + boolean: input({null: true, type: 'boolean'}), + function: input({null: true, type: 'function'}), + object: input({null: true, type: 'object'}), + array: input({null: true, type: 'array'}), + }, + + outputs: {'#result': '#result'}, + + steps: [ + {compute: continuation => continuation({'#result': 'OK'})}, + ], + }); + + const notCalledSymbol = Symbol('continuation not called'); + + let continuationValue; + const continuation = value => { + continuationValue = value; + return continuationSymbol; + }; + + let thrownError; + + try { + continuationValue = notCalledSymbol; + thrownError = null; + composite.expose.compute(continuation, { + [input('string')]: 123, + }); + } catch (error) { + thrownError = error; + } + + t.equal(continuationValue, notCalledSymbol); + t.match(thrownError, { + }); +}); diff --git a/test/unit/data/templateCompositeFrom.js b/test/unit/data/templateCompositeFrom.js new file mode 100644 index 00000000..2de18730 --- /dev/null +++ b/test/unit/data/templateCompositeFrom.js @@ -0,0 +1,209 @@ +import t from 'tap'; + +import {isString} from '#validators'; + +import { + compositeFrom, + continuationSymbol, + input, + templateCompositeFrom, +} from '#composite'; + +t.test(`templateCompositeFrom: basic behavior`, t => { + t.plan(1); + + const myCoolUtility = templateCompositeFrom({ + annotation: `myCoolUtility`, + + inputs: { + foo: input(), + }, + + outputs: ['#bar'], + + steps: () => [ + { + dependencies: [input('foo')], + compute: (continuation, { + [input('foo')]: foo, + }) => continuation({ + ['#bar']: (typeof foo).toUpperCase() + }), + }, + ], + }); + + const instantiatedTemplate = myCoolUtility({ + foo: 'color', + }); + + t.match(instantiatedTemplate.toDescription(), { + annotation: `myCoolUtility`, + + inputMapping: { + foo: input.dependency('color'), + }, + + inputDescriptions: { + foo: input(), + }, + + outputs: { + '#bar': '#bar', + }, + + steps: Function, + }); +}); + +t.test(`templateCompositeFrom: validate static input values`, t => { + t.plan(3); + + const stub = { + annotation: 'stubComposite', + outputs: ['#result'], + steps: () => [{compute: continuation => continuation({'#result': 'OK'})}], + }; + + const quickThrows = (t, composite, inputOptions, ...errorMessages) => + t.throws( + () => composite(inputOptions), + { + message: `Errors in input options passed to stubComposite`, + errors: errorMessages.map(message => ({message})), + }); + + t.test(`templateCompositeFrom: validate input token shapes`, t => { + t.plan(15); + + const template1 = templateCompositeFrom({ + ...stub, inputs: { + foo: input(), + }, + }); + + t.doesNotThrow( + () => template1({foo: 'dependency'})); + + t.doesNotThrow( + () => template1({foo: input.dependency('dependency')})); + + t.doesNotThrow( + () => template1({foo: input.value('static value')})); + + t.doesNotThrow( + () => template1({foo: input('outerInput')})); + + t.doesNotThrow( + () => template1({foo: input.updateValue()})); + + t.doesNotThrow( + () => template1({foo: input.myself()})); + + quickThrows(t, template1, + {foo: input.staticValue()}, + `foo: Expected dependency name or value-providing input() call, got input.staticValue`); + + quickThrows(t, template1, + {foo: input.staticDependency()}, + `foo: Expected dependency name or value-providing input() call, got input.staticDependency`); + + const template2 = templateCompositeFrom({ + ...stub, inputs: { + bar: input.staticDependency(), + }, + }); + + t.doesNotThrow( + () => template2({bar: 'dependency'})); + + t.doesNotThrow( + () => template2({bar: input.dependency('dependency')})); + + quickThrows(t, template2, + {bar: input.value(123)}, + `bar: Expected dependency name, got input.value`); + + quickThrows(t, template2, + {bar: input('outOfPlace')}, + `bar: Expected dependency name, got input`); + + const template3 = templateCompositeFrom({ + ...stub, inputs: { + baz: input.staticValue(), + }, + }); + + t.doesNotThrow( + () => template3({baz: input.value(1025)})); + + quickThrows(t, template3, + {baz: 'dependency'}, + `baz: Expected input.value() call, got dependency name`); + + quickThrows(t, template3, + {baz: input('outOfPlace')}, + `baz: Expected input.value() call, got input() call`); + }); + + t.test(`templateCompositeFrom: validate missing / misplaced inputs`, t => { + t.plan(1); + + const template = templateCompositeFrom({ + ...stub, inputs: { + foo: input(), + bar: input(), + }, + }); + + t.throws( + () => template({ + baz: 'aeiou', + raz: input.value(123), + }), + {message: `Errors in input options passed to stubComposite`, errors: [ + {message: `Unexpected input names: baz, raz`}, + {message: `Required these inputs: foo, bar`}, + ]}); + }); + + t.test(`templateCompositeFrom: validate acceptsNull / defaultValue: null`, t => { + t.plan(3); + + const template1 = templateCompositeFrom({ + ...stub, inputs: { + foo: input(), + }, + }); + + t.throws( + () => template1({}), + {message: `Errors in input options passed to stubComposite`, errors: [ + {message: `Required these inputs: foo`}, + ]}, + `throws if input missing and not marked specially`); + + const template2 = templateCompositeFrom({ + ...stub, inputs: { + bar: input({acceptsNull: true}), + }, + }); + + t.throws( + () => template2({}), + {message: `Errors in input options passed to stubComposite`, errors: [ + {message: `Required these inputs: bar`}, + ]}, + `throws if input missing even if marked {acceptsNull}`); + + const template3 = templateCompositeFrom({ + ...stub, inputs: { + baz: input({defaultValue: null}), + }, + }); + + t.doesNotThrow( + () => template3({}), + `does not throw if input missing if marked {defaultValue: null}`); + }); +}); diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js new file mode 100644 index 00000000..a64488f7 --- /dev/null +++ b/test/unit/data/things/album.js @@ -0,0 +1,469 @@ +import t from 'tap'; + +import thingConstructors from '#things'; + +import { + linkAndBindWikiData, + stubArtistAndContribs, + stubThing, + stubWikiData, +} from '#test-lib'; + +t.test(`Album.artTags`, t => { + const {Album, ArtTag} = thingConstructors; + + t.plan(3); + + const wikiData = stubWikiData(); + + const {contribs} = stubArtistAndContribs(wikiData); + const album = stubThing(wikiData, Album); + const tag1 = stubThing(wikiData, ArtTag, {name: `Tag 1`}); + const tag2 = stubThing(wikiData, ArtTag, {name: `Tag 2`}); + + linkAndBindWikiData(wikiData); + + t.same(album.artTags, [], + `artTags #1: defaults to empty array`); + + album.artTags = [`Tag 1`, `Tag 2`]; + + t.same(album.artTags, [], + `artTags #2: is empty if album doesn't have cover artists`); + + album.coverArtistContribs = contribs; + + t.same(album.artTags, [tag1, tag2], + `artTags #3: resolves if album has cover artists`); +}); + +t.test(`Album.bannerDimensions`, t => { + const {Album} = thingConstructors; + + t.plan(4); + + const wikiData = stubWikiData(); + + const album = stubThing(wikiData, Album); + const {contribs, badContribs} = stubArtistAndContribs(wikiData); + + linkAndBindWikiData(wikiData); + + t.equal(album.bannerDimensions, null, + `Album.bannerDimensions #1: defaults to null`); + + album.bannerDimensions = [1200, 275]; + + t.equal(album.bannerDimensions, null, + `Album.bannerDimensions #2: is null if bannerArtistContribs empty`); + + album.bannerArtistContribs = badContribs; + + t.equal(album.bannerDimensions, null, + `Album.bannerDimensions #3: is null if bannerArtistContribs resolves empty`); + + album.bannerArtistContribs = contribs; + + t.same(album.bannerDimensions, [1200, 275], + `Album.bannerDimensions #4: is own value`); +}); + +t.test(`Album.bannerFileExtension`, t => { + const {Album} = thingConstructors; + + t.plan(5); + + const wikiData = stubWikiData(); + + const album = stubThing(wikiData, Album); + const {contribs, badContribs} = stubArtistAndContribs(wikiData); + + linkAndBindWikiData(wikiData); + + t.equal(album.bannerFileExtension, null, + `Album.bannerFileExtension #1: defaults to null`); + + album.bannerFileExtension = 'png'; + + t.equal(album.bannerFileExtension, null, + `Album.bannerFileExtension #2: is null if bannerArtistContribs empty`); + + album.bannerArtistContribs = badContribs; + + t.equal(album.bannerFileExtension, null, + `Album.bannerFileExtension #3: is null if bannerArtistContribs resolves empty`); + + album.bannerArtistContribs = contribs; + + t.equal(album.bannerFileExtension, 'png', + `Album.bannerFileExtension #4: is own value`); + + album.bannerFileExtension = null; + + t.equal(album.bannerFileExtension, 'jpg', + `Album.bannerFileExtension #5: defaults to jpg`); +}); + +t.test(`Album.bannerStyle`, t => { + const {Album} = thingConstructors; + + t.plan(4); + + const wikiData = stubWikiData(); + + const album = stubThing(wikiData, Album); + const {contribs, badContribs} = stubArtistAndContribs(wikiData); + + linkAndBindWikiData(wikiData); + + t.equal(album.bannerStyle, null, + `Album.bannerStyle #1: defaults to null`); + + album.bannerStyle = `opacity: 0.5`; + + t.equal(album.bannerStyle, null, + `Album.bannerStyle #2: is null if bannerArtistContribs empty`); + + album.bannerArtistContribs = badContribs; + + t.equal(album.bannerStyle, null, + `Album.bannerStyle #3: is null if bannerArtistContribs resolves empty`); + + album.bannerArtistContribs = contribs; + + t.equal(album.bannerStyle, `opacity: 0.5`, + `Album.bannerStyle #4: is own value`); +}); + +t.test(`Album.coverArtDate`, t => { + const {Album} = thingConstructors; + + t.plan(6); + + const wikiData = stubWikiData(); + + const album = stubThing(wikiData, Album); + const {contribs, badContribs} = stubArtistAndContribs(wikiData); + + linkAndBindWikiData(wikiData); + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #1: defaults to null`); + + album.date = new Date('2012-10-25'); + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #2: is null if coverArtistContribs empty (1/2)`); + + album.coverArtDate = new Date('2011-04-13'); + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #3: is null if coverArtistContribs empty (2/2)`); + + album.coverArtistContribs = contribs; + + t.same(album.coverArtDate, new Date('2011-04-13'), + `Album.coverArtDate #4: is own value`); + + album.coverArtDate = null; + + t.same(album.coverArtDate, new Date(`2012-10-25`), + `Album.coverArtDate #5: inherits album release date`); + + album.coverArtistContribs = badContribs; + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #6: is null if coverArtistContribs resolves empty`); +}); + +t.test(`Album.coverArtFileExtension`, t => { + const {Album} = thingConstructors; + + t.plan(5); + + const wikiData = stubWikiData(); + + const album = stubThing(wikiData, Album); + const {contribs, badContribs} = stubArtistAndContribs(wikiData); + + linkAndBindWikiData(wikiData); + + t.equal(album.coverArtFileExtension, null, + `Album.coverArtFileExtension #1: is null if coverArtistContribs empty (1/2)`); + + album.coverArtFileExtension = 'png'; + + t.equal(album.coverArtFileExtension, null, + `Album.coverArtFileExtension #2: is null if coverArtistContribs empty (2/2)`); + + album.coverArtFileExtension = null; + album.coverArtistContribs = contribs; + + t.equal(album.coverArtFileExtension, 'jpg', + `Album.coverArtFileExtension #3: defaults to jpg`); + + album.coverArtFileExtension = 'png'; + + t.equal(album.coverArtFileExtension, 'png', + `Album.coverArtFileExtension #4: is own value`); + + album.coverArtistContribs = badContribs; + + t.equal(album.coverArtFileExtension, null, + `Album.coverArtFileExtension #5: is null if coverArtistContribs resolves empty`); +}); + +t.test(`Album.tracks`, t => { + const {Album, Track, TrackSection} = thingConstructors; + + t.plan(4); + + let wikiData = stubWikiData(); + + const album = stubThing(wikiData, Album); + album.directory = 'foo'; + + const track1 = stubThing(wikiData, Track, {directory: 'track1'}); + const track2 = stubThing(wikiData, Track, {directory: 'track2'}); + const track3 = stubThing(wikiData, Track, {directory: 'track3'}); + const tracks = [track1, track2, track3]; + + const section1 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section1'}); + const section2 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section2'}); + const section3 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section3'}); + const section4 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section4'}); + const section5 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section5'}); + const section6 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section6'}); + const sections = [section1, section2, section3, section4, section5, section6]; + + wikiData = null; + + for (const track of tracks) { + track.albumData = [album]; + } + + for (const section of sections) { + section.albumData = [album]; + } + + t.same(album.tracks, [], + `Album.tracks #1: defaults to empty array`); + + section1.tracks = [track1, track2, track3]; + + album.trackSections = [section1]; + + t.same(album.tracks, [track1, track2, track3], + `Album.tracks #2: pulls tracks from one track section`); + + section1.tracks = [track1]; + section2.tracks = [track2, track3]; + + album.trackSections = [section1, section2]; + + t.same(album.tracks, [track1, track2, track3], + `Album.tracks #3: pulls tracks from multiple track sections`); + + section1.tracks = [track1]; + section2.tracks = []; + section3.tracks = [track2]; + section4.tracks = []; + section5.tracks = []; + section6.tracks = [track3]; + + album.trackSections = [section1, section2, section3, section4, section5, section6]; + + t.same(album.tracks, [track1, track2, track3], + `Album.tracks #4: skips empty track sections`); +}); + +t.test(`Album.trackSections`, t => { + const {Album, Track, TrackSection} = thingConstructors; + + t.plan(7); + + let wikiData = stubWikiData(); + + const album = stubThing(wikiData, Album); + + const track1 = stubThing(wikiData, Track, {directory: 'track1'}); + const track2 = stubThing(wikiData, Track, {directory: 'track2'}); + const track3 = stubThing(wikiData, Track, {directory: 'track3'}); + const track4 = stubThing(wikiData, Track, {directory: 'track4'}); + const tracks = [track1, track2, track3, track4]; + + const section1 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section1'}); + const section2 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section2'}); + const section3 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section3'}); + const section4 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section4'}); + const section5 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section5'}); + const sections = [section1, section2, section3, section4, section5]; + + wikiData = null; + + for (const track of tracks) { + track.albumData = [album]; + } + + for (const section of sections) { + section.albumData = [album]; + } + + section1.tracks = [track1, track2]; + section2.tracks = [track3, track4]; + + album.trackSections = [section1, section2]; + + t.match(album.trackSections, [ + {tracks: [track1, track2]}, + {tracks: [track3, track4]}, + ], `Album.trackSections #1: exposes tracks`); + + t.match(album.trackSections, [ + {tracks: [track1, track2], startIndex: 0}, + {tracks: [track3, track4], startIndex: 2}, + ], `Album.trackSections #2: exposes startIndex`); + + section1.tracks = [track1]; + section2.tracks = [track2]; + section3.tracks = [track3]; + + section1.name = 'First section'; + section2.name = 'Second section'; + + album.trackSections = [section1, section2, section3]; + + t.match(album.trackSections, [ + {name: 'First section', tracks: [track1]}, + {name: 'Second section', tracks: [track2]}, + {name: 'Unnamed Track Section', tracks: [track3]}, + ], `Album.trackSections #3: exposes name, with fallback value`); + + album.color = '#123456'; + + section2.color = '#abcdef'; + + // XXX_decacheWikiData + album.trackSections = []; + album.trackSections = [section1, section2, section3]; + + t.match(album.trackSections, [ + {tracks: [track1], color: '#123456'}, + {tracks: [track2], color: '#abcdef'}, + {tracks: [track3], color: '#123456'}, + ], `Album.trackSections #4: exposes color, inherited from album`); + + section2.dateOriginallyReleased = new Date('2009-04-11'); + + // XXX_decacheWikiData + album.trackSections = []; + album.trackSections = [section1, section2, section3]; + + t.match(album.trackSections, [ + {tracks: [track1], dateOriginallyReleased: null}, + {tracks: [track2], dateOriginallyReleased: new Date('2009-04-11')}, + {tracks: [track3], dateOriginallyReleased: null}, + ], `Album.trackSections #5: exposes dateOriginallyReleased, if present`); + + section1.isDefaultTrackSection = true; + section2.isDefaultTrackSection = false; + + // XXX_decacheWikiData + album.trackSections = []; + album.trackSections = [section1, section2, section3]; + + t.match(album.trackSections, [ + {tracks: [track1], isDefaultTrackSection: true}, + {tracks: [track2], isDefaultTrackSection: false}, + {tracks: [track3], isDefaultTrackSection: false}, + ], `Album.trackSections #6: exposes isDefaultTrackSection, defaults to false`); + + section1.tracks = [track1, track2]; + section2.tracks = [track3]; + section3.tracks = []; + section4.tracks = []; + section5.tracks = [track4]; + + section1.color = '#112233'; + section2.color = '#334455'; + section3.color = '#bbbbba'; + section4.color = '#556677'; + section5.color = '#778899'; + + album.trackSections = [section1, section2, section3, section4, section5]; + + t.match(album.trackSections, [ + {tracks: [track1, track2], color: '#112233'}, + {tracks: [track3], color: '#334455'}, + {tracks: [], color: '#bbbbba'}, + {tracks: [], color: '#556677'}, + {tracks: [track4], color: '#778899'}, + ], `Album.trackSections #7: keeps empty sections`); +}); + +t.test(`Album.wallpaperFileExtension`, t => { + const {Album} = thingConstructors; + + t.plan(5); + + const wikiData = stubWikiData(); + + const album = stubThing(wikiData, Album); + const {contribs, badContribs} = stubArtistAndContribs(wikiData); + + linkAndBindWikiData(wikiData); + + t.equal(album.wallpaperFileExtension, null, + `Album.wallpaperFileExtension #1: defaults to null`); + + album.wallpaperFileExtension = 'png'; + + t.equal(album.wallpaperFileExtension, null, + `Album.wallpaperFileExtension #2: is null if wallpaperArtistContribs empty`); + + album.wallpaperArtistContribs = contribs; + + t.equal(album.wallpaperFileExtension, 'png', + `Album.wallpaperFileExtension #3: is own value`); + + album.wallpaperFileExtension = null; + + t.equal(album.wallpaperFileExtension, 'jpg', + `Album.wallpaperFileExtension #4: defaults to jpg`); + + album.wallpaperArtistContribs = badContribs; + + t.equal(album.wallpaperFileExtension, null, + `Album.wallpaperFileExtension #5: is null if wallpaperArtistContribs resolves empty`); +}); + +t.test(`Album.wallpaperStyle`, t => { + const {Album} = thingConstructors; + + t.plan(4); + + const wikiData = stubWikiData(); + + const album = stubThing(wikiData, Album); + const {contribs, badContribs} = stubArtistAndContribs(wikiData); + + linkAndBindWikiData(wikiData); + + t.equal(album.wallpaperStyle, null, + `Album.wallpaperStyle #1: defaults to null`); + + album.wallpaperStyle = `opacity: 0.5`; + + t.equal(album.wallpaperStyle, null, + `Album.wallpaperStyle #2: is null if wallpaperArtistContribs empty`); + + album.wallpaperArtistContribs = badContribs; + + t.equal(album.wallpaperStyle, null, + `Album.wallpaperStyle #3: is null if wallpaperArtistContribs resolves empty`); + + album.wallpaperArtistContribs = contribs; + + t.equal(album.wallpaperStyle, `opacity: 0.5`, + `Album.wallpaperStyle #4: is own value`); +}); diff --git a/test/unit/data/things/art-tag.js b/test/unit/data/things/art-tag.js new file mode 100644 index 00000000..015acd0e --- /dev/null +++ b/test/unit/data/things/art-tag.js @@ -0,0 +1,26 @@ +import t from 'tap'; + +import thingConstructors from '#things'; + +t.test(`ArtTag.nameShort`, t => { + const {ArtTag} = thingConstructors; + + t.plan(3); + + const artTag = new ArtTag(); + + artTag.name = `Dave Strider`; + + t.equal(artTag.nameShort, `Dave Strider`, + `ArtTag #1: defaults to name`); + + artTag.name = `Dave Strider (Homestuck)`; + + t.equal(artTag.nameShort, `Dave Strider`, + `ArtTag #2: trims parenthical part at end`); + + artTag.name = `This (And) That (Then)`; + + t.equal(artTag.nameShort, `This (And) That`, + `ArtTag #2: doesn't trim midlde parenthical part`); +}); diff --git a/test/unit/data/things/flash.js b/test/unit/data/things/flash.js new file mode 100644 index 00000000..de39e80d --- /dev/null +++ b/test/unit/data/things/flash.js @@ -0,0 +1,40 @@ +import t from 'tap'; + +import thingConstructors from '#things'; + +import { + linkAndBindWikiData, + stubThing, + stubWikiData, +} from '#test-lib'; + +t.test(`Flash.color`, t => { + const {Flash, FlashAct} = thingConstructors; + + t.plan(4); + + const wikiData = stubWikiData(); + + const flash = stubThing(wikiData, Flash, {directory: 'my-flash'}); + const flashAct = stubThing(wikiData, FlashAct, {flashes: ['flash:my-flash']}); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.equal(flash.color, null, + `color #1: defaults to null`); + + flashAct.color = '#abcdef'; + XXX_decacheWikiData(); + + t.equal(flash.color, '#abcdef', + `color #2: inherits from flash act`); + + flash.color = '#123456'; + + t.equal(flash.color, '#123456', + `color #3: is own value`); + + t.throws(() => { flash.color = '#aeiouw'; }, + {cause: TypeError}, + `color #4: must be set to valid color`); +}); diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js new file mode 100644 index 00000000..403dc064 --- /dev/null +++ b/test/unit/data/things/track.js @@ -0,0 +1,752 @@ +import t from 'tap'; + +import {bindFind} from '#find'; +import thingConstructors from '#things'; + +import { + linkAndBindWikiData, + stubArtistAndContribs, + stubFlashAndAct, + stubThing, + stubTrackAndAlbum, + stubWikiData, +} from '#test-lib'; + +t.test(`Track.album`, t => { + const {Album, Track, TrackSection} = thingConstructors; + + t.plan(6); + + // Note: These asserts use manual albumData/trackData relationships + // to illustrate more specifically the properties which are expected to + // be relevant for this case. Other properties use the same underlying + // get-album behavior as Track.album so aren't tested as aggressively. + + let wikiData = stubWikiData(); + + const track1 = stubThing(wikiData, Track, {directory: 'track1'}); + const track2 = stubThing(wikiData, Track, {directory: 'track2'}); + const album1 = stubThing(wikiData, Album); + const album2 = stubThing(wikiData, Album); + const section1 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section1'}); + const section2 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section2'}); + + wikiData = null; + + t.equal(track1.album, null, + `album #1: defaults to null`); + + track1.albumData = [album1, album2]; + track2.albumData = [album1, album2]; + section1.tracks = [track1]; + section2.tracks = [track2]; + section1.albumData = [album1]; + section2.albumData = [album2]; + album1.trackSections = [section1]; + album2.trackSections = [section2]; + + t.equal(track1.album, album1, + `album #2: is album when album's trackSections matches track`); + + track1.albumData = [album2, album1]; + + t.equal(track1.album, album1, + `album #3: is album when albumData is in different order`); + + track1.albumData = []; + + t.equal(track1.album, null, + `album #4: is null when track missing albumData`); + + section1.tracks = []; + + // XXX_decacheWikiData + album1.trackSections = []; + album1.trackSections = [section1]; + track1.albumData = []; + track1.albumData = [album2, album1]; + + t.equal(track1.album, null, + `album #5: is null when album track section missing tracks`); + + section1.tracks = [track2]; + + // XXX_decacheWikiData + album1.trackSections = []; + album1.trackSections = [section1]; + track1.albumData = []; + track1.albumData = [album2, album1]; + + t.equal(track1.album, null, + `album #6: is null when album track section doesn't match track`); +}); + +t.test(`Track.alwaysReferenceByDirectory`, t => { + t.plan(7); + + const wikiData = stubWikiData(); + + const {track: originalTrack} = + stubTrackAndAlbum(wikiData, 'original-track', 'original-album'); + + const {track: rereleaseTrack, album: rereleaseAlbum} = + stubTrackAndAlbum(wikiData, 'rerelease-track', 'rerelease-album'); + + originalTrack.name = 'Cowabunga'; + rereleaseTrack.name = 'Cowabunga'; + + originalTrack.dataSourceAlbum = 'album:original-album'; + rereleaseTrack.dataSourceAlbum = 'album:rerelease-album'; + + rereleaseTrack.originalReleaseTrack = 'track:original-track'; + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.equal(originalTrack.alwaysReferenceByDirectory, false, + `alwaysReferenceByDirectory #1: defaults to false`); + + t.equal(rereleaseTrack.alwaysReferenceByDirectory, true, + `alwaysReferenceByDirectory #2: is true if rerelease name matches original`); + + rereleaseTrack.name = 'Foo Dog!'; + + t.equal(rereleaseTrack.alwaysReferenceByDirectory, false, + `alwaysReferenceByDirectory #3: is false if rerelease name doesn't match original`); + + rereleaseTrack.name = `COWabunga`; + + t.equal(rereleaseTrack.alwaysReferenceByDirectory, false, + `alwaysReferenceByDirectory #4: is false if rerelease name doesn't match original exactly`); + + rereleaseAlbum.alwaysReferenceTracksByDirectory = true; + XXX_decacheWikiData(); + + t.equal(rereleaseTrack.alwaysReferenceByDirectory, true, + `alwaysReferenceByDirectory #5: is true if album's alwaysReferenceTracksByDirectory is true`); + + rereleaseTrack.alwaysReferenceByDirectory = false; + + t.equal(rereleaseTrack.alwaysReferenceByDirectory, false, + `alwaysReferenceByDirectory #6: doesn't inherit from album if set to false`); + + rereleaseTrack.name = 'Cowabunga'; + + t.equal(rereleaseTrack.alwaysReferenceByDirectory, false, + `alwaysReferenceByDirectory #7: doesn't compare original release name if set to false`); +}); + +t.test(`Track.artTags`, t => { + const {ArtTag} = thingConstructors; + + t.plan(6); + + const wikiData = stubWikiData(); + + const {track, album} = stubTrackAndAlbum(wikiData); + const {contribs} = stubArtistAndContribs(wikiData); + + const tag1 = + stubThing(wikiData, ArtTag, {name: `Tag 1`}); + + const tag2 = + stubThing(wikiData, ArtTag, {name: `Tag 2`}); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.same(track.artTags, [], + `artTags #1: defaults to empty array`); + + track.artTags = [`Tag 1`, `Tag 2`]; + + t.same(track.artTags, [], + `artTags #2: is empty if track doesn't have cover artists`); + + track.coverArtistContribs = contribs; + + t.same(track.artTags, [tag1, tag2], + `artTags #3: resolves if track has cover artists`); + + track.coverArtistContribs = null; + album.trackCoverArtistContribs = contribs; + + XXX_decacheWikiData(); + + t.same(track.artTags, [tag1, tag2], + `artTags #4: resolves if track inherits cover artists`); + + track.disableUniqueCoverArt = true; + + t.same(track.artTags, [], + `artTags #5: is empty if track disables unique cover artwork`); + + album.coverArtistContribs = contribs; + album.artTags = [`Tag 2`]; + + XXX_decacheWikiData(); + + t.notSame(track.artTags, [tag2], + `artTags #6: doesn't inherit from album's art tags`); +}); + +t.test(`Track.artistContribs`, t => { + const {Album, Artist, Track, TrackSection} = thingConstructors; + + t.plan(4); + + const wikiData = stubWikiData(); + + const track = + stubThing(wikiData, Track); + + const section = + stubThing(wikiData, TrackSection, {tracks: [track]}); + + const album = + stubThing(wikiData, Album, {trackSections: [section]}); + + const artist1 = + stubThing(wikiData, Artist, {name: `Artist 1`}); + + const artist2 = + stubThing(wikiData, Artist, {name: `Artist 2`}); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.same(track.artistContribs, [], + `artistContribs #1: defaults to empty array`); + + album.artistContribs = [ + {artist: `Artist 1`, annotation: `composition`}, + {artist: `Artist 2`, annotation: null}, + ]; + + XXX_decacheWikiData(); + + t.match(track.artistContribs, + [{artist: artist1, annotation: `composition`}, {artist: artist2, annotation: null}], + `artistContribs #2: inherits album artistContribs`); + + track.artistContribs = [ + {artist: `Artist 1`, annotation: `arrangement`}, + ]; + + t.match(track.artistContribs, [{artist: artist1, annotation: `arrangement`}], + `artistContribs #3: resolves from own value`); + + track.artistContribs = [ + {artist: `Artist 1`, annotation: `snooping`}, + {artist: `Artist 413`, annotation: `as`}, + {artist: `Artist 2`, annotation: `usual`}, + ]; + + t.match(track.artistContribs, + [{artist: artist1, annotation: `snooping`}, {artist: artist2, annotation: `usual`}], + `artistContribs #4: filters out names without matches`); +}); + +t.test(`Track.color`, t => { + t.plan(4); + + const wikiData = stubWikiData(); + + const {track, album, section} = stubTrackAndAlbum(wikiData); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.equal(track.color, null, + `color #1: defaults to null`); + + album.color = '#abcdef'; + section.color = '#beeeef'; + + album.trackSections = [section]; + + XXX_decacheWikiData(); + + t.equal(track.color, '#beeeef', + `color #2: inherits from track section before album`); + + track.color = '#123456'; + + t.equal(track.color, '#123456', + `color #3: is own value`); + + t.throws(() => { track.color = '#aeiouw'; }, + {cause: TypeError}, + `color #4: must be set to valid color`); +}); + +t.test(`Track.commentatorArtists`, t => { + const {Artist, Track} = thingConstructors; + + t.plan(8); + + const wikiData = stubWikiData(); + + const track = stubThing(wikiData, Track); + const artist1 = stubThing(wikiData, Artist, {name: `SnooPING`}); + const artist2 = stubThing(wikiData, Artist, {name: `ASUsual`}); + const artist3 = stubThing(wikiData, Artist, {name: `Icy`}); + + linkAndBindWikiData(wikiData); + + // Keep track of the last commentary string in a separate value, since + // the track.commentary property exposes as a completely different format + // (i.e. an array of objects, one for each entry), and so isn't compatible + // with the += operator on its own. + let commentary; + + track.commentary = commentary = + `<i>SnooPING:</i>\n` + + `Wow.\n`; + + t.same(track.commentatorArtists, [artist1], + `Track.commentatorArtists #1: works with one commentator`); + + track.commentary = commentary += + `<i>ASUsual:</i>\n` + + `Yes!\n`; + + t.same(track.commentatorArtists, [artist1, artist2], + `Track.commentatorArtists #2: works with two commentators`); + + track.commentary = commentary += + `<i>Icy|<b>Icy annotation You Did There</b>:</i>\n` + + `Incredible.\n`; + + t.same(track.commentatorArtists, [artist1, artist2, artist3], + `Track.commentatorArtists #3: works with custom artist text`); + + track.commentary = commentary = + `<i>Icy:</i> (project manager)\n` + + `Very good track.\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #4: works with annotation`); + + track.commentary = commentary = + `<i>Icy:</i> (project manager, 08/15/2023)\n` + + `Very very good track.\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #5: works with date`); + + track.commentary = commentary += + `<i>Ohohohoho:</i>\n` + + `OHOHOHOHOHOHO...\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #6: ignores artist names not found`); + + track.commentary = commentary += + `<i>Icy:</i>\n` + + `I'm back!\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #7: ignores duplicate artist`); + + track.commentary = commentary += + `<i>SNooPING, ASUsual, Icy:</i>\n` + + `WITH ALL THREE POWERS COMBINED...`; + + t.same(track.commentatorArtists, [artist3, artist1, artist2], + `Track.commentatorArtists #8: works with more than one artist in one entry`); +}); + +t.test(`Track.coverArtistContribs`, t => { + const {Artist} = thingConstructors; + + t.plan(5); + + const wikiData = stubWikiData(); + + const {track, album} = stubTrackAndAlbum(wikiData); + const artist1 = stubThing(wikiData, Artist, {name: `Artist 1`}); + const artist2 = stubThing(wikiData, Artist, {name: `Artist 2`}); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.same(track.coverArtistContribs, [], + `coverArtistContribs #1: defaults to empty array`); + + album.trackCoverArtistContribs = [ + {artist: `Artist 1`, annotation: `lines`}, + {artist: `Artist 2`, annotation: null}, + ]; + + XXX_decacheWikiData(); + + t.match(track.coverArtistContribs, + [{artist: artist1, annotation: `lines`}, {artist: artist2, annotation: null}], + `coverArtistContribs #2: inherits album trackCoverArtistContribs`); + + track.coverArtistContribs = [ + {artist: `Artist 1`, annotation: `collage`}, + ]; + + t.match(track.coverArtistContribs, [{artist: artist1, annotation: `collage`}], + `coverArtistContribs #3: resolves from own value`); + + track.coverArtistContribs = [ + {artist: `Artist 1`, annotation: `snooping`}, + {artist: `Artist 413`, annotation: `as`}, + {artist: `Artist 2`, annotation: `usual`}, + ]; + + t.match(track.coverArtistContribs, + [{artist: artist1, annotation: `snooping`}, {artist: artist2, annotation: `usual`}], + `coverArtistContribs #4: filters out names without matches`); + + track.disableUniqueCoverArt = true; + + t.same(track.coverArtistContribs, [], + `coverArtistContribs #5: is empty if track disables unique cover artwork`); +}); + +t.test(`Track.coverArtDate`, t => { + t.plan(8); + + const wikiData = stubWikiData(); + + const {track, album} = stubTrackAndAlbum(wikiData); + const {contribs, badContribs} = stubArtistAndContribs(wikiData); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + track.coverArtistContribs = contribs; + + t.equal(track.coverArtDate, null, + `coverArtDate #1: defaults to null`); + + album.trackArtDate = new Date('2012-12-12'); + + XXX_decacheWikiData(); + + t.same(track.coverArtDate, new Date('2012-12-12'), + `coverArtDate #2: inherits album trackArtDate`); + + track.coverArtDate = new Date('2009-09-09'); + + t.same(track.coverArtDate, new Date('2009-09-09'), + `coverArtDate #3: is own value`); + + track.coverArtistContribs = []; + + t.equal(track.coverArtDate, null, + `coverArtDate #4: is null if track coverArtistContribs empty`); + + album.trackCoverArtistContribs = contribs; + + XXX_decacheWikiData(); + + t.same(track.coverArtDate, new Date('2009-09-09'), + `coverArtDate #5: is not null if album trackCoverArtistContribs specified`); + + album.trackCoverArtistContribs = badContribs; + + XXX_decacheWikiData(); + + t.equal(track.coverArtDate, null, + `coverArtDate #6: is null if album trackCoverArtistContribs resolves empty`); + + track.coverArtistContribs = badContribs; + + t.equal(track.coverArtDate, null, + `coverArtDate #7: is null if track coverArtistContribs resolves empty`); + + track.coverArtistContribs = contribs; + track.disableUniqueCoverArt = true; + + t.equal(track.coverArtDate, null, + `coverArtDate #8: is null if track disables unique cover artwork`); +}); + +t.test(`Track.coverArtFileExtension`, t => { + t.plan(8); + + const wikiData = stubWikiData(); + + const {track, album} = stubTrackAndAlbum(wikiData); + const {contribs} = stubArtistAndContribs(wikiData); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #1: defaults to null`); + + track.coverArtistContribs = contribs; + + t.equal(track.coverArtFileExtension, 'jpg', + `coverArtFileExtension #2: is jpg if has cover art and not further specified`); + + track.coverArtistContribs = []; + + album.coverArtistContribs = contribs; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #3: only has value for unique cover art`); + + track.coverArtistContribs = contribs; + + album.trackCoverArtFileExtension = 'png'; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, 'png', + `coverArtFileExtension #4: inherits album trackCoverArtFileExtension (1/2)`); + + track.coverArtFileExtension = 'gif'; + + t.equal(track.coverArtFileExtension, 'gif', + `coverArtFileExtension #5: is own value (1/2)`); + + track.coverArtistContribs = []; + + album.trackCoverArtistContribs = contribs; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, 'gif', + `coverArtFileExtension #6: is own value (2/2)`); + + track.coverArtFileExtension = null; + + t.equal(track.coverArtFileExtension, 'png', + `coverArtFileExtension #7: inherits album trackCoverArtFileExtension (2/2)`); + + track.disableUniqueCoverArt = true; + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #8: is null if track disables unique cover art`); +}); + +t.test(`Track.date`, t => { + t.plan(3); + + const wikiData = stubWikiData(); + + const {track, album} = stubTrackAndAlbum(wikiData); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.equal(track.date, null, + `date #1: defaults to null`); + + album.date = new Date('2012-12-12'); + XXX_decacheWikiData(); + + t.same(track.date, album.date, + `date #2: inherits from album`); + + track.dateFirstReleased = new Date('2009-09-09'); + + t.same(track.date, new Date('2009-09-09'), + `date #3: is own dateFirstReleased`); +}); + +t.test(`Track.featuredInFlashes`, t => { + t.plan(2); + + const wikiData = stubWikiData(); + + const {track} = stubTrackAndAlbum(wikiData, 'track1'); + const {flash: flash1} = stubFlashAndAct(wikiData, 'flash1'); + const {flash: flash2} = stubFlashAndAct(wikiData, 'flash2'); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.same(track.featuredInFlashes, [], + `featuredInFlashes #1: defaults to empty array`); + + flash1.featuredTracks = ['track:track1']; + flash2.featuredTracks = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track.featuredInFlashes, [flash1, flash2], + `featuredInFlashes #2: matches flashes' featuredTracks`); +}); + +t.test(`Track.hasUniqueCoverArt`, t => { + t.plan(7); + + const wikiData = stubWikiData(); + + const {track, album} = stubTrackAndAlbum(wikiData); + const {contribs, badContribs} = stubArtistAndContribs(wikiData); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #1: defaults to false`); + + album.trackCoverArtistContribs = contribs; + XXX_decacheWikiData(); + + t.equal(track.hasUniqueCoverArt, true, + `hasUniqueCoverArt #2: is true if album specifies trackCoverArtistContribs`); + + track.disableUniqueCoverArt = true; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #3: is false if disableUniqueCoverArt is true (1/2)`); + + track.disableUniqueCoverArt = false; + + album.trackCoverArtistContribs = badContribs; + XXX_decacheWikiData(); + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #4: is false if album's trackCoverArtistContribs resolve empty`); + + track.coverArtistContribs = contribs; + + t.equal(track.hasUniqueCoverArt, true, + `hasUniqueCoverArt #5: is true if track specifies coverArtistContribs`); + + track.disableUniqueCoverArt = true; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #6: is false if disableUniqueCoverArt is true (2/2)`); + + track.disableUniqueCoverArt = false; + + track.coverArtistContribs = badContribs; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #7: is false if track's coverArtistContribs resolve empty`); +}); + +t.test(`Track.originalReleaseTrack`, t => { + t.plan(3); + + const wikiData = stubWikiData(); + + const {track: track1} = stubTrackAndAlbum(wikiData, 'track1'); + const {track: track2} = stubTrackAndAlbum(wikiData, 'track2'); + + linkAndBindWikiData(wikiData); + + t.equal(track2.originalReleaseTrack, null, + `originalReleaseTrack #1: defaults to null`); + + track2.originalReleaseTrack = 'track:track1'; + + t.equal(track2.originalReleaseTrack, track1, + `originalReleaseTrack #2: is resolved from own value`); + + track2.trackData = []; + + t.equal(track2.originalReleaseTrack, null, + `originalReleaseTrack #3: is null when track missing trackData`); +}); + +t.test(`Track.otherReleases`, t => { + t.plan(6); + + const wikiData = stubWikiData(); + + const {track: track1} = stubTrackAndAlbum(wikiData, 'track1'); + const {track: track2} = stubTrackAndAlbum(wikiData, 'track2'); + const {track: track3} = stubTrackAndAlbum(wikiData, 'track3'); + const {track: track4} = stubTrackAndAlbum(wikiData, 'track4'); + + const {linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.same(track1.otherReleases, [], + `otherReleases #1: defaults to empty array`); + + track2.originalReleaseTrack = 'track:track1'; + track3.originalReleaseTrack = 'track:track1'; + track4.originalReleaseTrack = 'track:track1'; + XXX_decacheWikiData(); + + t.same(track1.otherReleases, [track2, track3, track4], + `otherReleases #2: otherReleases of original release are its rereleases`); + + wikiData.trackData = [track1, track3, track2, track4]; + linkWikiDataArrays({bindFind}); + + t.same(track1.otherReleases, [track3, track2, track4], + `otherReleases #3: otherReleases matches trackData order`); + + wikiData.trackData = [track3, track2, track1, track4]; + linkWikiDataArrays({bindFind}); + + t.same(track2.otherReleases, [track1, track3, track4], + `otherReleases #4: otherReleases of rerelease are original track then other rereleases (1/3)`); + + t.same(track3.otherReleases, [track1, track2, track4], + `otherReleases #5: otherReleases of rerelease are original track then other rereleases (2/3)`); + + t.same(track4.otherReleases, [track1, track3, track2], + `otherReleases #6: otherReleases of rerelease are original track then other rereleases (3/3)`); +}); + +t.test(`Track.referencedByTracks`, t => { + t.plan(4); + + const wikiData = stubWikiData(); + + const {track: track1} = stubTrackAndAlbum(wikiData, 'track1'); + const {track: track2} = stubTrackAndAlbum(wikiData, 'track2'); + const {track: track3} = stubTrackAndAlbum(wikiData, 'track3'); + const {track: track4} = stubTrackAndAlbum(wikiData, 'track4'); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.same(track1.referencedByTracks, [], + `referencedByTracks #1: defaults to empty array`); + + track2.referencedTracks = ['track:track1']; + track3.referencedTracks = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2, track3], + `referencedByTracks #2: matches tracks' referencedTracks`); + + track4.sampledTracks = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2, track3], + `referencedByTracks #3: doesn't match tracks' sampledTracks`); + + track3.originalReleaseTrack = 'track:track2'; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2], + `referencedByTracks #4: doesn't include rereleases`); +}); + +t.test(`Track.sampledByTracks`, t => { + t.plan(4); + + const wikiData = stubWikiData(); + + const {track: track1} = stubTrackAndAlbum(wikiData, 'track1'); + const {track: track2} = stubTrackAndAlbum(wikiData, 'track2'); + const {track: track3} = stubTrackAndAlbum(wikiData, 'track3'); + const {track: track4} = stubTrackAndAlbum(wikiData, 'track4'); + + const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData); + + t.same(track1.sampledByTracks, [], + `sampledByTracks #1: defaults to empty array`); + + track2.sampledTracks = ['track:track1']; + track3.sampledTracks = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.sampledByTracks, [track2, track3], + `sampledByTracks #2: matches tracks' sampledTracks`); + + track4.referencedTracks = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.sampledByTracks, [track2, track3], + `sampledByTracks #3: doesn't match tracks' referencedTracks`); + + track3.originalReleaseTrack = 'track:track2'; + XXX_decacheWikiData(); + + t.same(track1.sampledByTracks, [track2], + `sampledByTracks #4: doesn't include rereleases`); +}); diff --git a/test/unit/data/validators.js b/test/unit/data/validators.js new file mode 100644 index 00000000..3a217d6f --- /dev/null +++ b/test/unit/data/validators.js @@ -0,0 +1,440 @@ +import t from 'tap'; +import {showAggregate} from '#aggregate'; + +import { + // Basic types + isBoolean, + isCountingNumber, + isDate, + isNumber, + isString, + isStringNonEmpty, + + // Complex types + isArray, + isObject, + validateArrayItems, + + // Wiki data + isColor, + isCommentary, + isContentString, + isContribution, + isContributionList, + isDimensions, + isDirectory, + isDuration, + isFileExtension, + isName, + isURL, + validateReference, + validateReferenceList, + + // Compositional utilities + anyOf, +} from '#validators'; + +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(9); + + 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 Error); + t.equal(caughtError.errors[0][Symbol.for('hsmusic.annotateError.indexInSourceArray')], 2); + t.not(caughtError.errors[0].cause, null); + t.ok(caughtError.errors[0].cause 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(9); + + // TODO: Test specific error messages. + t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`)); + t.ok(isCommentary(`<i>Toby Fox:</i> (music)\ndogsong.mp3`)); + t.throws(() => isCommentary(`dogsong.mp3\n<i>Toby Fox:</i>\ndogsong.mp3`)); + t.throws(() => isCommentary(`<i>Toby Fox:</i> dogsong.mp3`)); + t.throws(() => isCommentary(`<i>Toby Fox:</i> (music) dogsong.mp3`)); + t.throws(() => isCommentary(`<i>I Have Nothing To Say:</i>`)); + t.throws(() => isCommentary(123)); + t.throws(() => isCommentary(``)); + t.throws(() => isCommentary(`Technically, ah, er:</i>\nCorrect`)); +}); + +t.test('isContentString', t => { + t.plan(12); + + t.ok(isContentString(`Hello, world!`)); + t.ok(isContentString(`Hello...\nWorld!`)); + + const quickThrows = (string, description) => + t.throws(() => isContentString(string), description); + + quickThrows( + `Snooping\xa0as usual, I\xa0\xa0\xa0SEE.`, + Object.assign( + new AggregateError([ + new AggregateError([ + new TypeError(`Replace "\xa0" (non-breaking space) with " " (normal space) between "ing" and "as " (pos: 9)`), + new TypeError(`Replace "\xa0\xa0\xa0" (non-breaking space) with " " (normal space) between ", I" and "SEE" (pos: 21)`), + ], `Illegal characters found in content string`), + ], `Errors validating content string`), + {[Symbol.for(`hsmusic.aggregate.translucent`)]: 'single'})); + + quickThrows( + `Oh\u200bdear,\n` + + `Oh dear,\n` + + `oh-dear-oh-dear-oh\u200bdear.`, + new AggregateError([ + new AggregateError([ + new TypeError(`Delete "\u200b" (zero-width space) between "Oh" and "dea" (line: 1, col: 3)`), + new TypeError(`Delete "\u200b" (zero-width space) between "-oh" and "dea" (line: 3, col: 19)`), + ]), + ])); + + quickThrows( + `Well the days start comin'\xa0\xa0\xa0\xa0\u200b\u200b\xa0\xa0\xa0\u200b\u200b\u200band they don't stop comin'`, + new AggregateError([ + new AggregateError([ + new TypeError(`Replace "\xa0\xa0\xa0\xa0" (non-breaking space) with " " (normal space) after "in'" (pos: 27)`), + new TypeError(`Delete "\u200b\u200b" (zero-width space) (pos: 31)`), + new TypeError(`Replace "\xa0\xa0\xa0" (non-breaking space) with " " (normal space) (pos: 33)`), + new TypeError(`Delete "\u200b\u200b\u200b" (zero-width space) before "and" (pos: 36)`), + ]), + ])); + + quickThrows( + `It's go-\u200bin',\n` + + `\u200bIt's goin',\u200b\n` + + `\u200b\u200bIt's going!`, + new AggregateError([ + new AggregateError([ + new TypeError(`Delete "\u200b" (zero-width space) between "go-" and "in'" (line: 1, col: 9)`), + new TypeError(`Delete "\u200b" (zero-width space) before "It'" (line: 2, col: 1)`), + new TypeError(`Delete "\u200b" (zero-width space) after "n'," (line: 2, col: 13)`), + new TypeError(`Delete "\u200b\u200b" (zero-width space) before "It'" (line: 3, col: 1)`), + ]), + ])); + + quickThrows( + ` Room at the start.`, + new AggregateError([ + new AggregateError([ + new TypeError(`Matched " " at start`), + ], `Whitespace found at start or end`), + ])); + + quickThrows( + `Room at the end. `, + new AggregateError([ + new AggregateError([ + new TypeError(`Matched " " at end`), + ], `Whitespace found at start or end`), + ])); + + quickThrows( + ` Room on both sides. `, + new AggregateError([ + new AggregateError([ + new TypeError(`Matched " " at start`), + new TypeError(`Matched " " at end`), + ], `Whitespace found at start or end`), + ])); + + quickThrows( + `We're going multiline! \n` + + `That we are, aye. \n` + + ` \n`, + `Yessir.`, + new AggregateError([ + new AggregateError([ + new TypeError(`Matched " " at end of line 1`), + new TypeError(`Matched " " at end of line 2`), + new TypeError(`Matched " " as all of line 3`), + ], `Whitespace found at end of line`), + ])); + + t.doesNotThrow(() => + isContentString( + `It's cool.\n` + + ` It's cool.\n` + + ` It's cool.\n` + + ` It's so cool.`)); + + t.doesNotThrow(() => + isContentString( + `\n` + + `\n` + + `It's okay for\n` + + `blank lines\n` + + `\n` + + `just about anywhere.\n` + + ``)); +}); + +t.test('isContribution', t => { + t.plan(4); + t.ok(isContribution({artist: 'artist:toby-fox', annotation: 'Music'})); + t.ok(isContribution({artist: 'Toby Fox'})); + t.throws(() => isContribution(({artist: 'group:umspaf', annotation: 'Organizing'})), + {errors: /artist/}); + t.throws(() => isContribution(({artist: 'artist:toby-fox', annotation: 123})), + {errors: /annotation/}); +}); + +t.test('isContributionList', t => { + t.plan(4); + t.ok(isContributionList([{artist: 'Beavis'}, {artist: 'Butthead', annotation: '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.throws(() => 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(11); + + 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 Error); + t.ok(caughtError.errors[0].cause instanceof TypeError); + t.ok(caughtError.errors[1] instanceof Error); + t.ok(caughtError.errors[0].cause instanceof TypeError); +}); + +test(t, 'anyOf', t => { + t.plan(11); + + const isStringOrNumber = anyOf(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 = anyOf(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..1652aee2 --- /dev/null +++ b/test/unit/util/html.js @@ -0,0 +1,927 @@ +import t from 'tap'; + +import * as html from '#html'; +import {strictlyThrows} from '#test-lib'; + +const {Tag, Attributes, Template} = html; + +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(`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', mutable: false}, + }, + 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', mutable: false}, + }, + + 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: { + strictArrayOfStrings: { + validate: v => v.strictArrayOf(v.isString), + default: `Array Of Strings Fallback`.split(' '), + }, + + sparseArrayOfStrings: { + validate: v => v.sparseArrayOf(v.isString), + default: ['sparse', null, false, 'strings'], + }, + + arrayOfHTML: { + validate: v => v.strictArrayOf(v.isHTML), + default: [], + }, + }, + + content: slots => + html.tag('p', [ + html.tag('strong', slots.strictArrayOfStrings), + `sparseArrayOfStrings length: ${slots.sparseArrayOfStrings.length}`, + `arrayOfHTML length: ${slots.arrayOfHTML.length}`, + ]), + }); + + // 7: isHTML behaves as it should, validate fails with validate throw + + strictlyThrows(t, + () => template2.setSlots({ + strictArrayOfStrings: ['you got it', 'pingas', 0xdeadbeef], + sparseArrayOfStrings: ['you got it', null, false, 'pingas'], + 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: /^\(strictArrayOfStrings\)/, + 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`, + ]), + `sparseArrayOfStrings length: 4`, + `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>`); +}); |