« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/util/html.js73
-rw-r--r--test/lib/strict-match-error.js50
-rw-r--r--test/unit/util/html.js221
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: () => {},
+    }));
+});