« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/test/unit
diff options
context:
space:
mode:
Diffstat (limited to 'test/unit')
-rw-r--r--test/unit/data/things/cacheable-object.js270
-rw-r--r--test/unit/data/things/track.js75
-rw-r--r--test/unit/data/things/validators.js318
-rw-r--r--test/unit/util/html.js502
4 files changed, 1165 insertions, 0 deletions
diff --git a/test/unit/data/things/cacheable-object.js b/test/unit/data/things/cacheable-object.js
new file mode 100644
index 00000000..d7a88ce7
--- /dev/null
+++ b/test/unit/data/things/cacheable-object.js
@@ -0,0 +1,270 @@
+import t from 'tap';
+
+import CacheableObject from '../../../../src/data/things/cacheable-object.js';
+
+function newCacheableObject(PD) {
+  return new (class extends CacheableObject {
+    static propertyDescriptors = PD;
+  });
+}
+
+t.test(`CacheableObject simple separate update & expose`, t => {
+  const obj = newCacheableObject({
+    number: {
+      flags: {
+        update: true
+      }
+    },
+
+    timesTwo: {
+      flags: {
+        expose: true
+      },
+
+      expose: {
+        dependencies: ['number'],
+        compute: ({ number }) => number * 2
+      }
+    }
+  });
+
+  t.plan(1);
+  obj.number = 5;
+  t.equal(obj.timesTwo, 10);
+});
+
+t.test(`CacheableObject basic cache behavior`, t => {
+  let computeCount = 0;
+
+  const obj = newCacheableObject({
+    string: {
+      flags: {
+        update: true
+      }
+    },
+
+    karkat: {
+      flags: {
+        expose: true
+      },
+
+      expose: {
+        dependencies: ['string'],
+        compute: ({ string }) => {
+          computeCount++;
+          return string.toUpperCase();
+        }
+      }
+    }
+  });
+
+  t.plan(8);
+
+  t.equal(computeCount, 0);
+
+  obj.string = 'hello world';
+  t.equal(computeCount, 0);
+
+  obj.karkat;
+  t.equal(computeCount, 1);
+
+  obj.karkat;
+  t.equal(computeCount, 1);
+
+  obj.string = 'testing once again';
+  t.equal(computeCount, 1);
+
+  obj.karkat;
+  t.equal(computeCount, 2);
+
+  obj.string = 'testing once again';
+  t.equal(computeCount, 2);
+
+  obj.karkat;
+  t.equal(computeCount, 2);
+});
+
+t.test(`CacheableObject combined update & expose (no transform)`, t => {
+  const obj = newCacheableObject({
+    directory: {
+      flags: {
+        update: true,
+        expose: true
+      }
+    }
+  });
+
+  t.plan(2);
+
+  obj.directory = 'the-world-revolving';
+  t.equal(obj.directory, 'the-world-revolving');
+
+  obj.directory = 'chaos-king';
+  t.equal(obj.directory, 'chaos-king');
+});
+
+t.test(`CacheableObject combined update & expose (basic transform)`, t => {
+  const obj = newCacheableObject({
+    getsRepeated: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      expose: {
+        transform: value => value.repeat(2)
+      }
+    }
+  });
+
+  t.plan(1);
+
+  obj.getsRepeated = 'dog';
+  t.equal(obj.getsRepeated, 'dogdog');
+});
+
+t.test(`CacheableObject combined update & expose (transform with dependency)`, t => {
+  const obj = newCacheableObject({
+    customRepeat: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      expose: {
+        dependencies: ['times'],
+        transform: (value, { times }) => value.repeat(times)
+      }
+    },
+
+    times: {
+      flags: {
+        update: true
+      }
+    }
+  });
+
+  t.plan(3);
+
+  obj.customRepeat = 'dog';
+  obj.times = 1;
+  t.equal(obj.customRepeat, 'dog');
+
+  obj.times = 5;
+  t.equal(obj.customRepeat, 'dogdogdogdogdog');
+
+  obj.customRepeat = 'cat';
+  t.equal(obj.customRepeat, 'catcatcatcatcat');
+});
+
+t.test(`CacheableObject validate on update`, t => {
+  const mockError = new TypeError(`Expected a string, not ${typeof value}`);
+
+  const obj = newCacheableObject({
+    directory: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      update: {
+        validate: value => {
+          if (typeof value !== 'string') {
+            throw mockError;
+          }
+          return true;
+        }
+      }
+    },
+
+    date: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      update: {
+        validate: value => (value instanceof Date)
+      }
+    }
+  });
+
+  let thrownError;
+  t.plan(6);
+
+  obj.directory = 'megalovania';
+  t.equal(obj.directory, 'megalovania');
+
+  try {
+    obj.directory = 25;
+  } catch (err) {
+    thrownError = err;
+  }
+
+  t.equal(thrownError, mockError);
+  t.equal(obj.directory, 'megalovania');
+
+  const date = new Date(`25 December 2009`);
+
+  obj.date = date;
+  t.equal(obj.date, date);
+
+  try {
+    obj.date = `TWELFTH PERIGEE'S EVE`;
+  } catch (err) {
+    thrownError = err;
+  }
+
+  t.equal(thrownError?.constructor, TypeError);
+  t.equal(obj.date, date);
+});
+
+t.test(`CacheableObject default update property value`, t => {
+  const obj = newCacheableObject({
+    fruit: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      update: {
+        default: 'potassium'
+      }
+    }
+  });
+
+  t.plan(1);
+  t.equal(obj.fruit, 'potassium');
+});
+
+t.test(`CacheableObject default property throws if invalid`, t => {
+  const mockError = new TypeError(`Expected a string, not ${typeof value}`);
+
+  t.plan(1);
+
+  let thrownError;
+
+  try {
+    newCacheableObject({
+      string: {
+        flags: {
+          update: true
+        },
+
+        update: {
+          default: 123,
+          validate: value => {
+            if (typeof value !== 'string') {
+              throw mockError;
+            }
+            return true;
+          }
+        }
+      }
+    });
+  } catch (err) {
+    thrownError = err;
+  }
+
+  t.equal(thrownError, mockError);
+});
diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js
new file mode 100644
index 00000000..0dad0e62
--- /dev/null
+++ b/test/unit/data/things/track.js
@@ -0,0 +1,75 @@
+import t from 'tap';
+
+import thingConstructors from '../../../../src/data/things/index.js';
+
+const {
+  Album,
+  Thing,
+  Track,
+  TrackGroup,
+} = thingConstructors;
+
+function stubAlbum(tracks) {
+  const album = new Album();
+  album.trackSections = [
+    {
+      tracksByRef: tracks.map(t => Thing.getReference(t)),
+    },
+  ];
+  album.trackData = tracks;
+  return album;
+}
+
+t.test(`Track.coverArtDate`, t => {
+  t.plan(5);
+
+  // Priority order is as follows, with the last (trackCoverArtDate) being
+  // greatest priority.
+  const albumDate = new Date('2010-10-10');
+  const albumTrackArtDate = new Date('2012-12-12');
+  const trackDateFirstReleased = new Date('2008-08-08');
+  const trackCoverArtDate = new Date('2009-09-09');
+
+  const track = new Track();
+  track.directory = 'foo';
+
+  const album = stubAlbum([track]);
+
+  track.albumData = [album];
+
+  // 1. coverArtDate defaults to null
+
+  t.equal(track.coverArtDate, null);
+
+  // 2. coverArtDate inherits album release date
+
+  album.date = albumDate;
+
+  // XXX clear cache so change in album's property is reflected
+  track.albumData = [];
+  track.albumData = [album];
+
+  t.equal(track.coverArtDate, albumDate);
+
+  // 3. coverArtDate inherits album trackArtDate
+
+  album.trackArtDate = albumTrackArtDate;
+
+  // XXX clear cache again
+  track.albumData = [];
+  track.albumData = [album];
+
+  t.equal(track.coverArtDate, albumTrackArtDate);
+
+  // 4. coverArtDate is overridden dateFirstReleased
+
+  track.dateFirstReleased = trackDateFirstReleased;
+
+  t.equal(track.coverArtDate, trackDateFirstReleased);
+
+  // 5. coverArtDate is overridden coverArtDate
+
+  track.coverArtDate = trackCoverArtDate;
+
+  t.equal(track.coverArtDate, trackCoverArtDate);
+});
diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js
new file mode 100644
index 00000000..53cba063
--- /dev/null
+++ b/test/unit/data/things/validators.js
@@ -0,0 +1,318 @@
+import t from 'tap';
+import { showAggregate } from '../../../../src/util/sugar.js';
+
+import {
+  // Basic types
+  isBoolean,
+  isCountingNumber,
+  isDate,
+  isNumber,
+  isString,
+  isStringNonEmpty,
+
+  // Complex types
+  isArray,
+  isObject,
+  validateArrayItems,
+
+  // Wiki data
+  isColor,
+  isCommentary,
+  isContribution,
+  isContributionList,
+  isDimensions,
+  isDirectory,
+  isDuration,
+  isFileExtension,
+  isName,
+  isURL,
+  validateReference,
+  validateReferenceList,
+
+  // Compositional utilities
+  oneOf,
+} from '../../../../src/data/things/validators.js';
+
+function test(t, msg, fn) {
+  t.test(msg, t => {
+    try {
+      fn(t);
+    } catch (error) {
+      if (error instanceof AggregateError) {
+        showAggregate(error);
+      }
+      throw error;
+    }
+  });
+}
+
+// Basic types
+
+test(t, 'isBoolean', t => {
+  t.plan(4);
+  t.ok(isBoolean(true));
+  t.ok(isBoolean(false));
+  t.throws(() => isBoolean(1), TypeError);
+  t.throws(() => isBoolean('yes'), TypeError);
+});
+
+test(t, 'isNumber', t => {
+  t.plan(6);
+  t.ok(isNumber(123));
+  t.ok(isNumber(0.05));
+  t.ok(isNumber(0));
+  t.ok(isNumber(-10));
+  t.throws(() => isNumber('413'), TypeError);
+  t.throws(() => isNumber(true), TypeError);
+});
+
+test(t, 'isCountingNumber', t => {
+  t.plan(6);
+  t.ok(isCountingNumber(3));
+  t.ok(isCountingNumber(1));
+  t.throws(() => isCountingNumber(1.75), TypeError);
+  t.throws(() => isCountingNumber(0), TypeError);
+  t.throws(() => isCountingNumber(-1), TypeError);
+  t.throws(() => isCountingNumber('612'), TypeError);
+});
+
+test(t, 'isString', t => {
+  t.plan(3);
+  t.ok(isString('hello!'));
+  t.ok(isString(''));
+  t.throws(() => isString(100), TypeError);
+});
+
+test(t, 'isStringNonEmpty', t => {
+  t.plan(4);
+  t.ok(isStringNonEmpty('hello!'));
+  t.throws(() => isStringNonEmpty(''), TypeError);
+  t.throws(() => isStringNonEmpty('     '), TypeError);
+  t.throws(() => isStringNonEmpty(100), TypeError);
+});
+
+// Complex types
+
+test(t, 'isArray', t => {
+  t.plan(3);
+  t.ok(isArray([]));
+  t.throws(() => isArray({}), TypeError);
+  t.throws(() => isArray('1, 2, 3'), TypeError);
+});
+
+test(t, 'isDate', t => {
+  t.plan(3);
+  t.ok(isDate(new Date('2023-03-27 09:24:15')));
+  t.throws(() => isDate(new Date(Infinity)), TypeError);
+  t.throws(() => isDimensions('2023-03-27 09:24:15'), TypeError);
+});
+
+test(t, 'isObject', t => {
+  t.plan(3);
+  t.ok(isObject({}));
+  t.ok(isObject([]));
+  t.throws(() => isObject(null), TypeError);
+});
+
+test(t, 'validateArrayItems', t => {
+  t.plan(6);
+
+  t.ok(validateArrayItems(isNumber)([3, 4, 5]));
+  t.ok(validateArrayItems(validateArrayItems(isNumber))([[3, 4], [4, 5], [6, 7]]));
+
+  let caughtError = null;
+  try {
+    validateArrayItems(isNumber)([10, 20, 'one hundred million consorts', 30]);
+  } catch (err) {
+    caughtError = err;
+  }
+
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 1);
+  t.ok(caughtError.errors[0] instanceof TypeError);
+});
+
+// Wiki data
+
+t.test('isColor', t => {
+  t.plan(9);
+  t.ok(isColor('#123'));
+  t.ok(isColor('#1234'));
+  t.ok(isColor('#112233'));
+  t.ok(isColor('#11223344'));
+  t.ok(isColor('#abcdef00'));
+  t.ok(isColor('#ABCDEF'));
+  t.throws(() => isColor('#ggg'), TypeError);
+  t.throws(() => isColor('red'), TypeError);
+  t.throws(() => isColor('hsl(150deg 30% 60%)'), TypeError);
+});
+
+t.test('isCommentary', t => {
+  t.plan(6);
+  t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`));
+  t.ok(isCommentary(`Technically, this works:</i>`));
+  t.ok(isCommentary(`<i><b>Whodunnit:</b></i>`));
+  t.throws(() => isCommentary(123), TypeError);
+  t.throws(() => isCommentary(``), TypeError);
+  t.throws(() => isCommentary(`<i><u>Toby Fox:</u></i>`));
+});
+
+t.test('isContribution', t => {
+  t.plan(4);
+  t.ok(isContribution({who: 'artist:toby-fox', what: 'Music'}));
+  t.ok(isContribution({who: 'Toby Fox'}));
+  t.throws(() => isContribution(({who: 'group:umspaf', what: 'Organizing'})),
+    {errors: /who/});
+  t.throws(() => isContribution(({who: 'artist:toby-fox', what: 123})),
+    {errors: /what/});
+});
+
+t.test('isContributionList', t => {
+  t.plan(4);
+  t.ok(isContributionList([{who: 'Beavis'}, {who: 'Butthead', what: 'Wrangling'}]));
+  t.ok(isContributionList([]));
+  t.throws(() => isContributionList(2));
+  t.throws(() => isContributionList(['Charlie', 'Woodstock']));
+});
+
+test(t, 'isDimensions', t => {
+  t.plan(6);
+  t.ok(isDimensions([1, 1]));
+  t.ok(isDimensions([50, 50]));
+  t.ok(isDimensions([5000, 1]));
+  t.throws(() => isDimensions([1]), TypeError);
+  t.throws(() => isDimensions([413, 612, 1025]), TypeError);
+  t.throws(() => isDimensions('800x200'), TypeError);
+});
+
+test(t, 'isDirectory', t => {
+  t.plan(6);
+  t.ok(isDirectory('savior-of-the-waking-world'));
+  t.ok(isDirectory('MeGaLoVania'));
+  t.ok(isDirectory('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'));
+  t.throws(() => isDirectory(123), TypeError);
+  t.throws(() => isDirectory(''), TypeError);
+  t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError);
+});
+
+test(t, 'isDuration', t => {
+  t.plan(5);
+  t.ok(isDuration(60));
+  t.ok(isDuration(0.02));
+  t.ok(isDuration(0));
+  t.throws(() => isDuration(-1), TypeError);
+  t.throws(() => isDuration('10:25'), TypeError);
+});
+
+test(t, 'isFileExtension', t => {
+  t.plan(6);
+  t.ok(isFileExtension('png'));
+  t.ok(isFileExtension('jpg'));
+  t.ok(isFileExtension('sub_loc'));
+  t.throws(() => isFileExtension(''), TypeError);
+  t.throws(() => isFileExtension('.jpg'), TypeError);
+  t.throws(() => isFileExtension('just an image bro!!!!'), TypeError);
+});
+
+t.test('isName', t => {
+  t.plan(4);
+  t.ok(isName('Dogz 2.0'));
+  t.ok(isName('album:this-track-is-only-named-thusly-to-give-niklink-a-headache'));
+  t.ok(isName(''));
+  t.throws(() => isName(612));
+});
+
+t.test('isURL', t => {
+  t.plan(4);
+  t.ok(isURL(`https://hsmusic.wiki/foo/bar/hi?baz=25#hash`));
+  t.throws(() => isURL(`/the/dog/zone/`));
+  t.throws(() => isURL(25));
+  t.throws(() => isURL(new URL(`https://hsmusic.wiki/perfectly/reasonable/`)));
+});
+
+test(t, 'validateReference', t => {
+  t.plan(16);
+
+  const typeless = validateReference();
+  const track = validateReference('track');
+  const album = validateReference('album');
+
+  t.ok(track('track:doctor'));
+  t.ok(track('track:MeGaLoVania'));
+  t.ok(track('Showtime (Imp Strife Mix)'));
+  t.throws(() => track('track:troll saint nic'), TypeError);
+  t.throws(() => track('track:'), TypeError);
+  t.throws(() => track('album:homestuck-vol-1'), TypeError);
+
+  t.ok(album('album:sburb'));
+  t.ok(album('album:the-wanderers'));
+  t.ok(album('Homestuck Vol. 8'));
+  t.throws(() => album('album:Hiveswap Friendsim'), TypeError);
+  t.throws(() => album('album:'), TypeError);
+  t.throws(() => album('track:showtime-piano-refrain'), TypeError);
+
+  t.ok(typeless('Hopes and Dreams'));
+  t.ok(typeless('track:snowdin-town'));
+  t.throws(() => typeless(''), TypeError);
+  t.throws(() => typeless('album:undertale-soundtrack'));
+});
+
+test(t, 'validateReferenceList', t => {
+  const track = validateReferenceList('track');
+  const artist = validateReferenceList('artist');
+
+  t.plan(9);
+
+  t.ok(track(['track:fallen-down', 'Once Upon a Time']));
+  t.ok(artist(['artist:toby-fox', 'Mark Hadley']));
+  t.ok(track(['track:amalgam']));
+  t.ok(track([]));
+
+  let caughtError = null;
+  try {
+    track(['Dog', 'album:vaporwave-2016', 'Cat', 'artist:john-madden']);
+  } catch (err) {
+    caughtError = err;
+  }
+
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 2);
+  t.ok(caughtError.errors[0] instanceof TypeError);
+  t.ok(caughtError.errors[1] instanceof TypeError);
+});
+
+test(t, 'oneOf', t => {
+  t.plan(11);
+
+  const isStringOrNumber = oneOf(isString, isNumber);
+
+  t.ok(isStringOrNumber('hello world'));
+  t.ok(isStringOrNumber(42));
+  t.throws(() => isStringOrNumber(false));
+
+  const mockError = new Error();
+  const neverSucceeds = () => {
+    throw mockError;
+  };
+
+  const isStringOrGetRekt = oneOf(isString, neverSucceeds);
+
+  t.ok(isStringOrGetRekt('phew!'));
+
+  let caughtError = null;
+  try {
+    isStringOrGetRekt(0xdeadbeef);
+  } catch (err) {
+    caughtError = err;
+  }
+
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 2);
+  t.ok(caughtError.errors[0] instanceof TypeError);
+  t.equal(caughtError.errors[0].check, isString);
+  t.equal(caughtError.errors[1], mockError);
+  t.equal(caughtError.errors[1].check, neverSucceeds);
+});
diff --git a/test/unit/util/html.js b/test/unit/util/html.js
new file mode 100644
index 00000000..c26d53b2
--- /dev/null
+++ b/test/unit/util/html.js
@@ -0,0 +1,502 @@
+import t from 'tap';
+
+import * as html from '../../../src/util/html.js';
+const {Tag, Attributes, Template, Slot} = html;
+
+t.test(`html.tag`, t => {
+  t.plan(16);
+
+  const tag1 =
+    html.tag('div',
+      {[html.onlyIfContent]: true, foo: 'bar'},
+      'child');
+
+  // 1-5: basic behavior when passing attributes
+  t.ok(tag1 instanceof Tag);
+  t.ok(tag1.onlyIfContent);
+  t.equal(tag1.attributes.get('foo'), 'bar');
+  t.equal(tag1.content.length, 1);
+  t.equal(tag1.content[0], 'child');
+
+  const tag2 = html.tag('div', ['two', 'children']);
+
+  // 6-8: basic behavior when not passing attributes
+  t.equal(tag2.content.length, 2);
+  t.equal(tag2.content[0], 'two');
+  t.equal(tag2.content[1], 'children');
+
+  const genericTag = html.tag('div');
+  let genericSlot;
+  const genericTemplate = html.template(slot => {
+    genericSlot = slot('title');
+    return [];
+  });
+
+  // 9-10: tag treated as content, not attributes
+  const tag3 = html.tag('div', genericTag);
+  t.equal(tag3.content.length, 1);
+  t.equal(tag3.content[0], genericTag);
+
+  // 11-12: template treated as content, not attributes
+  const tag4 = html.tag('div', genericTemplate);
+  t.equal(tag4.content.length, 1);
+  t.equal(tag4.content[0], genericTemplate);
+
+  // 13-14: slot treated as content, not attributes
+  const tag5 = html.tag('div', genericSlot);
+  t.equal(tag5.content.length, 1);
+  t.equal(tag5.content[0], genericSlot);
+
+  // 15-16: deep flattening support
+  const tag6 =
+    html.tag('div', [
+      true &&
+        [[[[[[
+          true &&
+            [[[[[`That's deep.`]]]]],
+        ]]]]]],
+    ]);
+  t.equal(tag6.content.length, 1);
+  t.equal(tag6.content[0], `That's deep.`);
+});
+
+t.test(`Tag (basic interface)`, t => {
+  t.plan(11);
+
+  const tag1 = new Tag();
+
+  // 1-5: essential properties & no arguments provided
+  t.equal(tag1.tagName, '');
+  t.ok(Array.isArray(tag1.content));
+  t.equal(tag1.content.length, 0);
+  t.ok(tag1.attributes instanceof Attributes);
+  t.equal(tag1.attributes.toString(), '');
+
+  const tag2 = new Tag('div', {id: 'banana'}, ['one', 'two', tag1]);
+
+  // 6-11: properties on basic usage
+  t.equal(tag2.tagName, 'div');
+  t.equal(tag2.content.length, 3);
+  t.equal(tag2.content[0], 'one');
+  t.equal(tag2.content[1], 'two');
+  t.equal(tag2.content[2], tag1);
+  t.equal(tag2.attributes.get('id'), 'banana');
+});
+
+t.test(`Tag (self-closing)`, t => {
+  t.plan(10);
+
+  const tag1 = new Tag('br');
+  const tag2 = new Tag('div');
+  const tag3 = new Tag('div');
+  tag3.tagName = 'br';
+
+  // 1-3: selfClosing depends on tagName
+  t.ok(tag1.selfClosing);
+  t.notOk(tag2.selfClosing);
+  t.ok(tag3.selfClosing);
+
+  // 4: constructing self-closing tag with content throws
+  t.throws(() => new Tag('br', null, 'bananas'), /self-closing/);
+
+  // 5: setting content on self-closing tag throws
+  t.throws(() => { tag1.content = ['suspicious']; }, /self-closing/);
+
+  // 6-9: setting empty content on self-closing tag doesn't throw
+  t.doesNotThrow(() => { tag1.content = null; });
+  t.doesNotThrow(() => { tag1.content = undefined; });
+  t.doesNotThrow(() => { tag1.content = ''; });
+  t.doesNotThrow(() => { tag1.content = [null, '', false]; });
+
+  const tag4 = new Tag('div', null, 'bananas');
+
+  // 10: changing tagName to self-closing when tag has content throws
+  t.throws(() => { tag4.tagName = 'br'; }, /self-closing/);
+});
+
+t.test(`Tag (properties from attributes - from constructor)`, t => {
+  t.plan(6);
+
+  const tag = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<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="&quot;To light a candle is to cast a shadow...&quot;" ` +
+    `tabindex="413">Content</div>`);
+
+  // 4: attributes match input order
+
+  const tag4 =
+    html.tag('div',
+      {class: ['foo', 'bar'], id: 'banana'},
+      'Content');
+
+  t.equal(tag4.toString(),
+    `<div class="foo bar" id="banana">Content</div>`);
+
+  // 5: multiline contented indented
+
+  const tag5 =
+    html.tag('div', 'foo\nbar');
+
+  t.equal(tag5.toString(),
+    `<div>\n` +
+    `    foo\n` +
+    `    bar\n` +
+    `</div>`);
+
+  // 6: nested multiline content double-indented
+
+  const tag6 =
+    html.tag('div', [
+      html.tag('p',
+        'foo\nbar'),
+      html.tag('span', `I'm on one line!`),
+    ]);
+
+  t.equal(tag6.toString(),
+    `<div>\n` +
+    `    <p>\n` +
+    `        foo\n` +
+    `        bar\n` +
+    `    </p>\n` +
+    `    <span>I'm on one line!</span>\n` +
+    `</div>`);
+
+  // 7: self-closing (with attributes)
+
+  const tag7 =
+    html.tag('article', [
+      html.tag('h1', `Title`),
+      html.tag('hr', {style: `color: magenta`}),
+      html.tag('p', `Shenanigans!`),
+    ]);
+
+  t.equal(tag7.toString(),
+    `<article>\n` +
+    `    <h1>Title</h1>\n` +
+    `    <hr style="color: magenta">\n` +
+    `    <p>Shenanigans!</p>\n` +
+    `</article>`);
+
+  // 8-9: empty tagName passes content through directly
+
+  const tag8 =
+    html.tag(null, [
+      html.tag('h1', `Foo`),
+      html.tag(`h2`, `Bar`),
+    ]);
+
+  t.equal(tag8.toString(),
+    `<h1>Foo</h1>\n` +
+    `<h2>Bar</h2>`);
+
+  const tag9 =
+    html.tag(null, {
+      [html.joinChildren]: html.tag('br'),
+    }, [
+      `Say it with me...`,
+      `Supercalifragilisticexpialidocious!`
+    ]);
+
+  t.equal(tag9.toString(),
+    `Say it with me...\n` +
+    `<br>\n` +
+    `Supercalifragilisticexpialidocious!`);
+});
+
+t.test(`Tag.toString (onlyIfContent)`, t => {
+  t.plan(4);
+
+  // 1-2: basic behavior
+
+  const tag1 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      `Hello!`);
+
+  t.equal(tag1.toString(),
+    `<div>Hello!</div>`);
+
+  const tag2 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      '');
+
+  t.equal(tag2.toString(),
+    '');
+
+  // 3-4: nested onlyIfContent with "more" content
+
+  const tag3 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      [
+        '',
+        0,
+        html.tag('h1',
+          {[html.onlyIfContent]: true},
+          html.tag('strong',
+            {[html.onlyIfContent]: true})),
+        null,
+        false,
+      ]);
+
+  t.equal(tag3.toString(),
+    '');
+
+  const tag4 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      [
+        '',
+        0,
+        html.tag('h1',
+          {[html.onlyIfContent]: true},
+          html.tag('strong')),
+        null,
+        false,
+      ]);
+
+  t.equal(tag4.toString(),
+    `<div><h1><strong></strong></h1></div>`);
+});
+
+t.test(`Tag.toString (joinChildren, noEdgeWhitespace)`, t => {
+  t.plan(6);
+
+  // 1: joinChildren: default (\n), noEdgeWhitespace: true
+
+  const tag1 =
+    html.tag('div',
+      {[html.noEdgeWhitespace]: true},
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag1.toString(),
+    `<div>Foo\n` +
+    `    Bar\n` +
+    `    Baz</div>`);
+
+  // 2: joinChildren: one-line string, noEdgeWhitespace: default (false)
+
+  const tag2 =
+    html.tag('div',
+      {
+        [html.joinChildren]:
+          html.tag('br', {location: '🍍'}),
+      },
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag2.toString(),
+    `<div>\n` +
+    `    Foo\n` +
+    `    <br location="🍍">\n` +
+    `    Bar\n` +
+    `    <br location="🍍">\n` +
+    `    Baz\n` +
+    `</div>`);
+
+  // 3-4: joinChildren: blank string, noEdgeWhitespace: default (false)
+
+  const tag3 =
+    html.tag('div',
+      {[html.joinChildren]: ''},
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag3.toString(),
+    `<div>FooBarBaz</div>`);
+
+  const tag4 =
+    html.tag('div',
+      {[html.joinChildren]: ''},
+      [
+        `Ain't I\na cute one?`,
+        `~`
+      ]);
+
+  t.equal(tag4.toString(),
+    `<div>\n` +
+    `    Ain't I\n` +
+    `    a cute one?~\n` +
+    `</div>`);
+
+  // 5: joinChildren: one-line string, noEdgeWhitespace: true
+
+  const tag5 =
+    html.tag('div',
+      {
+        [html.joinChildren]: html.tag('br'),
+        [html.noEdgeWhitespace]: true,
+      },
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag5.toString(),
+    `<div>Foo\n` +
+    `    <br>\n` +
+    `    Bar\n` +
+    `    <br>\n` +
+    `    Baz</div>`);
+
+  // 6: joinChildren: empty string, noEdgeWhitespace: true
+
+  const tag6 =
+    html.tag('span',
+      {
+        [html.joinChildren]: '',
+        [html.noEdgeWhitespace]: true,
+      },
+      [
+        html.tag('i', `Oh yes~ `),
+        `You're a cute one`,
+        html.tag('sup', `💕`),
+      ]);
+
+  t.equal(tag6.toString(),
+    `<span><i>Oh yes~ </i>You're a cute one<sup>💕</sup></span>`);
+});
+
+t.test(`Tag.toString (custom attributes)`, t => {
+  t.plan(1);
+
+  t.test(`Tag.toString (custom attribute: href)`, t => {
+    t.plan(2);
+
+    const tag1 = html.tag('a', {href: `https://hsmusic.wiki/`});
+    t.equal(tag1.toString(), `<a href="https://hsmusic.wiki/"></a>`);
+
+    const tag2 = html.tag('a', {href: `https://hsmusic.wiki/media/Album Booklet.pdf`});
+    t.equal(tag2.toString(), `<a href="https://hsmusic.wiki/media/Album%20Booklet.pdf"></a>`);
+  });
+});