diff options
-rw-r--r-- | src/util/html.js | 73 | ||||
-rw-r--r-- | test/lib/strict-match-error.js | 50 | ||||
-rw-r--r-- | test/unit/util/html.js | 221 |
3 files changed, 307 insertions, 37 deletions
diff --git a/src/util/html.js b/src/util/html.js index 4735c9dc..818c5804 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -490,12 +490,12 @@ export class Template { } static validateDescription(description) { - if (description === null) { - return; + if (typeof description !== 'object') { + throw new TypeError(`Expected object, got ${typeof description}`); } - if (typeof description !== 'object') { - throw new TypeError(`Expected object or null, got ${typeof description}`); + if (description === null) { + throw new TypeError(`Expected object, got null`); } const topErrors = []; @@ -512,43 +512,46 @@ export class Template { } } - const slotErrors = []; - if ('slots' in description) validateSlots: { if (typeof description.slots !== 'object') { - slotErrors.push(new TypeError(`Expected description.slots to be object`)); + topErrors.push(new TypeError(`Expected description.slots to be object`)); break validateSlots; } - for (const [key, value] of Object.entries(description.slots)) { - if (typeof value !== 'object' || value === null) { - slotErrors.push(new TypeError(`Expected slot description (of ${key}) to be object`)); + const slotErrors = []; + + for (const [slotName, slotDescription] of Object.entries(description.slots)) { + if (typeof slotDescription !== 'object' || slotDescription === null) { + slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`)); continue; } - if ('default' in value) validateDefault: { - if (value.default === undefined || value.default === null) { - slotErrors.push(new TypeError(`Leave slot default (of ${key}) unspecified instead of undefined or null`)); + if ('default' in slotDescription) validateDefault: { + if ( + slotDescription.default === undefined || + slotDescription.default === null + ) { + slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`)); break validateDefault; } try { - Template.validateSlotValueAgainstDescription(value, description); + Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription); } catch (error) { - error.message = `Error validating slot "${key}" default value: ${error.message}`; + error.message = `(${slotName}) Error validating slot default value: ${error.message}`; slotErrors.push(error); } } - if ('validate' in value && 'type' in value) { - slotErrors.push(new TypeError(`Don't specify both slot validate and type (of ${key})`)); - } else if (!('validate' in value || 'type' in value)) { - slotErrors.push(new TypeError(`Expected either slot validate or type (of ${key})`)); - } else if ('validate' in value) { - if (typeof value.validate !== 'function') { - slotErrors.push(new TypeError(`Expected slot validate of (${key}) to be function`)); + if ('validate' in slotDescription && 'type' in slotDescription) { + slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`)); + } else if (!('validate' in slotDescription || 'type' in slotDescription)) { + slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`)); + } else if ('validate' in slotDescription) { + if (typeof slotDescription.validate !== 'function') { + slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`)); } - } else if ('type' in value) { + } else if ('type' in slotDescription) { const acceptableSlotTypes = [ 'string', 'number', @@ -558,28 +561,24 @@ export class Template { 'html', ]; - if (value.type === 'function') { - slotErrors.push(new TypeError(`Functions shouldn't be provided to slots (${key})`)); - } - - if (value.type === 'object') { - slotErrors.push(new TypeError(`Provide validate function instead of type: object (${key})`)); - } - - if (!acceptableSlotTypes.includes(value.type)) { - slotErrors.push(new TypeError(`Expected slot type (of ${key}) to be one of ${acceptableSlotTypes.join(', ')}`)); + if (slotDescription.type === 'function') { + slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`)); + } else if (slotDescription.type === 'object') { + slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`)); + } else if (!acceptableSlotTypes.includes(slotDescription.type)) { + slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`)); } } } - } - if (!empty(slotErrors)) { - topErrors.push(new AggregateError(slotErrors, `Errors in slot descriptions`)); + if (!empty(slotErrors)) { + topErrors.push(new AggregateError(slotErrors, `Errors in slot descriptions`)); + } } if (!empty(topErrors)) { throw new AggregateError(topErrors, - (description.annotation + (typeof description.annotation === 'string' ? `Errors validating template "${description.annotation}" description` : `Errors validating template description`)); } diff --git a/test/lib/strict-match-error.js b/test/lib/strict-match-error.js new file mode 100644 index 00000000..e3b36e93 --- /dev/null +++ b/test/lib/strict-match-error.js @@ -0,0 +1,50 @@ +export function strictlyThrows(t, fn, pattern) { + const error = catchErrorOrNull(fn); + + t.currentAssert = strictlyThrows; + + if (error === null) { + t.fail(`expected to throw`); + return; + } + + const nameAndMessage = `${pattern.constructor.name} ${pattern.message}`; + t.match( + prepareErrorForMatch(error), + prepareErrorForMatch(pattern), + (pattern instanceof AggregateError + ? `expected to throw: ${nameAndMessage} (${pattern.errors.length} error(s))` + : `expected to throw: ${nameAndMessage}`)); +} + +function prepareErrorForMatch(error) { + if (error instanceof RegExp) { + return { + message: error, + }; + } + + if (!(error instanceof Error)) { + return error; + } + + const matchable = { + name: error.constructor.name, + message: error.message, + }; + + if (error instanceof AggregateError) { + matchable.errors = error.errors.map(prepareErrorForMatch); + } + + return matchable; +} + +function catchErrorOrNull(fn) { + try { + fn(); + return null; + } catch (error) { + return error; + } +} diff --git a/test/unit/util/html.js b/test/unit/util/html.js index 045dad91..faf67572 100644 --- a/test/unit/util/html.js +++ b/test/unit/util/html.js @@ -3,6 +3,8 @@ import t from 'tap'; import * as html from '../../../src/util/html.js'; const {Tag, Attributes, Template} = html; +import {strictlyThrows} from '../../lib/strict-match-error.js'; + t.test(`html.tag`, t => { t.plan(14); @@ -478,6 +480,7 @@ t.test(`Tag.toString (joinChildren, noEdgeWhitespace)`, t => { t.equal(tag6.toString(), `<span><i>Oh yes~ </i>You're a cute one<sup>💕</sup></span>`); + }); t.test(`Tag.toString (custom attributes)`, t => { @@ -493,3 +496,221 @@ t.test(`Tag.toString (custom attributes)`, t => { t.equal(tag2.toString(), `<a href="https://hsmusic.wiki/media/Album%20Booklet.pdf"></a>`); }); }); + +t.test(`html.template`, t => { + t.plan(10); + + 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); +}); + +t.test(`Template - description errors`, t => { + t.plan(14); + + // 1-3: top-level description is object + + strictlyThrows(t, + () => Template.validateDescription('snooping as usual'), + new TypeError(`Expected object, got string`)); + + strictlyThrows(t, + () => Template.validateDescription(), + new TypeError(`Expected object, got undefined`)); + + strictlyThrows(t, + () => Template.validateDescription(null), + new TypeError(`Expected object, got null`)); + + // 4-5: description.content is function + + strictlyThrows(t, + () => Template.validateDescription({}), + new AggregateError([ + new TypeError(`Expected description.content`), + ], `Errors validating template description`)); + + strictlyThrows(t, + () => Template.validateDescription({ + content: 'pingas', + }), + new AggregateError([ + new TypeError(`Expected description.content to be function`), + ], `Errors validating template description`)); + + // 6: aggregate error includes template annotation + + strictlyThrows(t, + () => Template.validateDescription({ + annotation: `my cool template`, + content: 'pingas', + }), + new AggregateError([ + new TypeError(`Expected description.content to be function`), + ], `Errors validating template "my cool template" description`)); + + // 7: description.slots is object + + strictlyThrows(t, + () => Template.validateDescription({ + slots: 'pingas', + content: () => {}, + }), + new AggregateError([ + new TypeError(`Expected description.slots to be object`), + ], `Errors validating template description`)); + + // 8: slot description is object + + strictlyThrows(t, + () => Template.validateDescription({ + slots: { + mySlot: 'pingas', + }, + + content: () => {}, + }), + new AggregateError([ + new AggregateError([ + new TypeError(`(mySlot) Expected slot description to be object`), + ], `Errors in slot descriptions`), + ], `Errors validating template description`)) + + // 9-10: slot description has validate or default, not both + + strictlyThrows(t, + () => Template.validateDescription({ + slots: { + mySlot: {}, + }, + content: () => {}, + }), + new AggregateError([ + new AggregateError([ + new TypeError(`(mySlot) Expected either slot validate or type`), + ], `Errors in slot descriptions`), + ], `Errors validating template description`)); + + strictlyThrows(t, + () => Template.validateDescription({ + slots: { + mySlot: { + validate: 'pingas', + type: 'pingas', + }, + }, + content: () => {}, + }), + new AggregateError([ + new AggregateError([ + new TypeError(`(mySlot) Don't specify both slot validate and type`), + ], `Errors in slot descriptions`), + ], `Errors validating template description`)); + + // 11: slot validate is function + + strictlyThrows(t, + () => Template.validateDescription({ + slots: { + mySlot: { + validate: 'pingas', + }, + }, + content: () => {}, + }), + new AggregateError([ + new AggregateError([ + new TypeError(`(mySlot) Expected slot validate to be function`), + ], `Errors in slot descriptions`), + ], `Errors validating template description`)); + + // 12: slot type is name of built-in type + + strictlyThrows(t, + () => Template.validateDescription({ + slots: { + mySlot: { + type: 'pingas', + }, + }, + content: () => {}, + }), + new AggregateError([ + new AggregateError([ + /\(mySlot\) Expected slot type to be one of/, + ], `Errors in slot descriptions`), + ], `Errors validating template description`)); + + // 13: slot type has specific errors for function & object + + strictlyThrows(t, + () => Template.validateDescription({ + slots: { + slot1: {type: 'function'}, + slot2: {type: 'object'}, + }, + content: () => {}, + }), + new AggregateError([ + new AggregateError([ + new TypeError(`(slot1) Functions shouldn't be provided to slots`), + new TypeError(`(slot2) Provide validate function instead of type: object`), + ], `Errors in slot descriptions`), + ], `Errors validating template description`)); + + // 14: all intended types are supported + + t.doesNotThrow( + () => Template.validateDescription({ + slots: { + slot1: {type: 'string'}, + slot2: {type: 'number'}, + slot3: {type: 'bigint'}, + slot4: {type: 'boolean'}, + slot5: {type: 'symbol'}, + slot6: {type: 'html'}, + }, + content: () => {}, + })); +}); |