NH_base

Base library usable any time.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greasyfork.org/scripts/477290/1333365/NH_base.js

// ==UserScript==
// ==UserLibrary==
// @name        NH_base
// @description Base library usable any time.
// @version     52
// @license     GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html
// @homepageURL https://github.com/nexushoratio/userscripts
// @supportURL  https://github.com/nexushoratio/userscripts/issues
// @match       https://www.example.com/*
// ==/UserLibrary==
// ==/UserScript==

window.NexusHoratio ??= {};

window.NexusHoratio.base = (function base() {
  'use strict';

  /** @type {number} - Bumped per release. */
  const version = 52;

  /**
   * @type {number} - Constant (to make eslint's `no-magic-numbers` setting
   * happy).
   */
  const NOT_FOUND = -1;

  /**
   * @type {number} - Constant useful for testing length of an array.
   */
  const ONE_ITEM = 1;

  /**
   * @typedef {object} NexusHoratioVersion
   * @property {string} name - Library name.
   * @property {number} [minVersion=0] - Minimal version needed.
   */

  /**
   * Ensures appropriate versions of NexusHoratio libraries are loaded.
   * @param {NexusHoratioVersion[]} versions - Versions required.
   * @returns {object} - Namespace with only ensured libraries present.
   * @throws {Error} - When requirements not met.
   */
  function ensure(versions) {
    let msg = 'Forgot to set a message';
    const namespace = {};
    for (const ver of versions) {
      const {
        name,
        minVersion = 0,
      } = ver;
      const lib = window.NexusHoratio[name];
      if (!lib) {
        msg = `Library "${name}" is not loaded`;
        throw new Error(`Not Loaded: ${msg}`);
      }
      if (minVersion > lib.version) {
        msg = `At least version ${minVersion} of library "${name}" ` +
          `required; version ${lib.version} present.`;
        throw new Error(`Min Version: ${msg}`);
      }
      namespace[name] = lib;
    }
    return namespace;
  }

  const NH = ensure([{name: 'xunit', minVersion: 49}]);

  /* eslint-disable require-jsdoc */
  class EnsureTestCase extends NH.xunit.TestCase {

    testEmpty() {
      const actual = ensure([]);
      const expected = {};
      this.assertEqual(actual, expected);
    }

    testNameOnly() {
      const ns = ensure([{name: 'base'}]);
      this.assertTrue(ns.base);
    }

    testMinVersion() {
      this.assertRaisesRegExp(
        Error, /^Min Version:.*required.*present.$/u, () => {
          ensure([{name: 'base', minVersion: Number.MAX_VALUE}]);
        }
      );
    }

    testMissing() {
      this.assertRaisesRegExp(
        Error, /^Not Loaded: /u, () => {
          ensure([{name: 'missing'}]);
        }
      );
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(EnsureTestCase);

  /** Base exception that uses the name of the class. */
  class Exception extends Error {

    /** @type {string} */
    get name() {
      return this.constructor.name;
    }

  }

  /* eslint-disable require-jsdoc */
  class ExceptionTestCase extends NH.xunit.TestCase {

    testBase() {
      // Assemble/Act
      const e = new Exception(this.id);

      // Assert
      this.assertEqual(e.name, 'Exception', 'name');
      this.assertEqual(
        e.toString(), 'Exception: ExceptionTestCase.testBase', 'toString'
      );
      this.assertTrue(e instanceof Exception, 'is exception');
      this.assertTrue(e instanceof Error, 'is error');
      this.assertFalse(e instanceof TypeError, 'is NOT type-error');
    }

    testInheritance() {
      // Assemble
      class TestException extends Exception {}
      class DifferentException extends Exception {}

      // Act
      const te = new TestException('silly message');

      // Assert
      this.assertEqual(te.name, 'TestException', 'name');
      this.assertEqual(
        te.toString(), 'TestException: silly message', 'toString'
      );
      this.assertTrue(te instanceof TestException, 'is test-exception');
      this.assertTrue(te instanceof Exception, 'is exception');
      this.assertTrue(te instanceof Error, 'is error');
      this.assertFalse(te instanceof TypeError, 'is NOT type-error');
      this.assertFalse(te instanceof DifferentException,
        'is NOT different-exception');
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(ExceptionTestCase);

  /**
   * Simple dispatcher (event bus).
   *
   * It takes a fixed list of event types upon construction and attempts to
   * use an unknown event will throw an error.
   */
  class Dispatcher {

    /**
     * @callback Handler
     * @param {string} eventType - Event type.
     * @param {*} data - Event data.
     */

    /**
     * @param {...string} eventTypes - Event types this instance can handle.
     */
    constructor(...eventTypes) {
      for (const eventType of eventTypes) {
        this.#handlers.set(eventType, []);
      }
    }

    /**
     * Attach a function to an eventType.
     * @param {string} eventType - Event type to connect with.
     * @param {Handler} func - Single argument function to call.
     * @returns {Dispatcher} - This instance, for chaining.
     */
    on(eventType, func) {
      const handlers = this.#getHandlers(eventType);
      handlers.push(func);
      return this;
    }

    /**
     * Remove all instances of a function registered to an eventType.
     * @param {string} eventType - Event type to disconnect from.
     * @param {Handler} func - Function to remove.
     * @returns {Dispatcher} - This instance, for chaining.
     */
    off(eventType, func) {
      const handlers = this.#getHandlers(eventType);
      let index = 0;
      while ((index = handlers.indexOf(func)) !== NOT_FOUND) {
        handlers.splice(index, 1);
      }
      return this;
    }

    /**
     * Calls all registered functions for the given eventType.
     * @param {string} eventType - Event type to use.
     * @param {object} data - Data to pass to each function.
     * @returns {Dispatcher} - This instance, for chaining.
     */
    fire(eventType, data) {
      const handlers = this.#getHandlers(eventType);
      for (const handler of handlers) {
        handler(eventType, data);
      }
      return this;
    }

    #handlers = new Map();

    /**
     * Look up array of handlers by event type.
     * @param {string} eventType - Event type to look up.
     * @throws {Error} - When eventType was not registered during
     * instantiation.
     * @returns {Handler[]} - Handlers currently registered for this
     * eventType.
     */
    #getHandlers = (eventType) => {
      const handlers = this.#handlers.get(eventType);
      if (!handlers) {
        const eventTypes = Array.from(this.#handlers.keys())
          .join(', ');
        throw new Error(
          `Unknown event type: ${eventType}, must be one of: ${eventTypes}`
        );
      }
      return handlers;
    }

  }

  /* eslint-disable max-lines-per-function */
  /* eslint-disable max-statements */
  /* eslint-disable no-new */
  /* eslint-disable no-empty-function */
  /* eslint-disable require-jsdoc */
  class DispatcherTestCase extends NH.xunit.TestCase {

    testConstruction() {
      this.assertNoRaises(() => {
        new Dispatcher();
      }, 'empty');

      this.assertNoRaises(() => {
        new Dispatcher('one');
      }, 'single');

      this.assertNoRaises(() => {
        new Dispatcher('one', 'two', 'three');
      }, 'multiple');
    }

    testOnOff() {
      const dispatcher = new Dispatcher('boo');
      const handler = () => {};

      this.assertNoRaises(() => {
        dispatcher.on('boo', handler);
        dispatcher.on('boo', handler);
      }, 'on twice');

      this.assertNoRaises(() => {
        dispatcher.off('boo', handler);
        dispatcher.off('boo', handler);
      }, 'off twice');

      this.assertNoRaises(() => {
        dispatcher.on('boo', handler)
          .off('boo', handler)
          .on('boo', handler)
          .off('boo', handler);
      }, 'chaining works');

      this.assertRaisesRegExp(
        Error,
        /Unknown event type: hoo, must be one of: boo/u,
        () => {
          dispatcher.on('hoo', handler);
        },
        'on, bad event type'
      );

      this.assertRaisesRegExp(
        Error,
        /Unknown event type: hoo, must be one of: boo/u,
        () => {
          dispatcher.off('hoo', handler);
        },
        'on, bad event type'
      );
    }

    testFire() {
      const dispatcher = new Dispatcher('boo', 'ya');
      const calls1 = [];
      const calls2 = new Map();
      const handler1 = (...args) => {
        calls1.push(args);
      };
      const handler2 = (type, data) => {
        calls2.set(type, data);
      };

      this.assertNoRaises(() => {
        dispatcher.fire('boo', 'random data')
          .fire('ya', 'other stuff');
      });
      this.assertEqual(calls1, [], 'calls1 empty');
      this.assertEqual(calls2, new Map(), 'calls2 empty');

      dispatcher.on('boo', handler1)
        .on('ya', handler2)
        .fire('boo', 'more random data');
      this.assertEqual(
        calls1, [['boo', 'more random data']], 'single handler1 registered'
      );
      this.assertEqual(calls2, new Map(), 'calls2 still empty');

      calls1.length = 0;
      calls2.clear();
      dispatcher.on('boo', handler1)
        .on('boo', handler2)
        .on('ya', handler2)
        .fire('boo', {an: 'object'})
        .fire('ya', 'ya stuff');
      this.assertEqual(
        calls1,
        [['boo', {an: 'object'}], ['boo', {an: 'object'}]],
        'handler1 registered twice'
      );
      this.assertEqual(
        calls2,
        new Map([['boo', {an: 'object'}], ['ya', 'ya stuff']]),
        'calls2 registered once'
      );

      calls1.length = 0;
      calls2.clear();
      dispatcher.off('boo', handler1)
        .fire('boo', {a: 'different object'});
      this.assertEqual(calls1, [], 'single off got rid of all handler1');
      this.assertEqual(
        calls2,
        new Map([['boo', {a: 'different object'}]]),
        'handler2 still there'
      );

      calls1.length = 0;
      calls2.clear();
      this.assertRaisesRegExp(
        Error,
        /Unknown event type: hoo, must be one of: boo, ya/u,
        () => {
          dispatcher.fire('hoo', 'oops');
        },
        'bad eventType'
      );
      this.assertEqual(calls1, [], 'calls1 should be empty');
      this.assertEqual(calls2, new Map(), 'calls2 should be empty');
    }

    testBadHandler() {
      const dispatcher = new Dispatcher('oops');

      this.assertNoRaises(() => {
        dispatcher.on('oops', null);
      }, 'happily sets silly handler');

      this.assertRaises(
        TypeError,
        () => {
          dispatcher.fire('oops', 'this will not end well');
        },
        'and then it crashes'
      );
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(DispatcherTestCase);

  /**
   * A simple message system that will queue messages to be delivered.
   *
   * This is similar to the WEB API's `MessageChannel`.
   */
  class MessageQueue {

    /** @type {number} - Number of messages currently queued. */
    get count() {
      return this.#messages.length;
    }

    /**
     * @param {...*} items - Whatever to add to the queue.
     * @returns {MessageQueue} - This instance, for chaining.
     */
    post(...items) {
      this.#messages.push(items);
      this.#dispatcher.fire('post');
      return this;
    }

    /**
     * @param {?function(...*)} func - Function that receives the messages.
     * If falsy, listener is removed.
     * @returns {MessageQueue} - This instance, for chaining.
     */
    listen(func) {
      if (func) {
        this.#listener = func;
        this.#dispatcher.on('post', this.#handler);
        this.#handler();
      } else {
        this.#listener = null;
        this.#dispatcher.off('post', this.#handler);
      }
      return this;
    }

    #dispatcher = new Dispatcher('post');
    #listener
    #messages = [];

    #handler = () => {
      while (this.#messages.length && this.#listener) {
        this.#listener(...this.#messages.shift());
      }
    }

  }

  /* eslint-disable no-magic-numbers */
  /* eslint-disable require-jsdoc */
  class MessageQueueTestCase extends NH.xunit.TestCase {

    testCount() {
      // Assemble
      const mq = new MessageQueue();

      // Act
      for (let i = 0; i < 20; i += 1) {
        mq.post(i);
      }

      // Assert
      this.assertEqual(mq.count, 20);
    }

    testListener() {
      // Assemble
      const mq = new MessageQueue();
      const messages = [];
      const cb = (message) => {
        messages.push(message);
      };
      mq.post('a')
        .post('b')
        .post('c');

      // Act
      mq.listen(cb)
        .post(1);
      mq.post(2)
        .post(3);

      // Assert
      this.assertEqual(messages, ['a', 'b', 'c', 1, 2, 3], 'received');
      this.assertEqual(mq.count, 0, 'final count');
    }

    testDisconnect() {
      // Assemble
      const mq = new MessageQueue();
      const messages = [];
      const cb = (message) => {
        messages.push(message);
        mq.listen();
      };
      mq.post('a')
        .post('b')
        .post('c');

      // Act
      mq.listen(cb);
      mq.post(1)
        .post(2);

      // Assert
      this.assertEqual(messages, ['a'], 'received');
      this.assertEqual(mq.count, 4, 'final count');
    }

    testListenerChange() {
      // Assemble
      const mq = new MessageQueue();
      const newMessages = [];
      const origMessages = [];
      const newCallback = (message) => {
        newMessages.push(message);
      };
      const origCallback = (message) => {
        origMessages.push(message);
        mq.listen(newCallback);
      };
      mq.post('a')
        .post('b')
        .post('c');

      // Act
      mq.listen(origCallback)
        .post(1)
        .post(2);

      // Assert
      this.assertEqual(origMessages, ['a'], 'orig messages');
      this.assertEqual(newMessages, ['b', 'c', 1, 2], 'new messages');
      this.assertEqual(mq.count, 0, 'final count');
    }

    testFancyMessages() {
      // Assemble
      const mq = new MessageQueue();
      const messages = [];
      const cb = (...items) => {
        messages.push(...items);
        messages.push('---');
      };
      mq.listen(cb);
      const obj = {z: 26};

      mq.post('line 1', 'line 2', 'line 3');
      mq.post(1)
        .post(obj, [4, 'd']);

      this.assertEqual(
        messages,
        ['line 1', 'line 2', 'line 3', '---', 1, '---', obj, [4, 'd'], '---']
      );
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(MessageQueueTestCase);

  /**
   * NexusHoratio libraries and apps should log issues here.
   *
   * They should be logged in the form of multiple strings:
   * NH.base.issues.post('Something bad', 'detail 1', 'detail 2');
   *
   * An eventual listener should do something like:
   * listen(...issues) {
   *   for (const issue of issues) {
   *     displayIssueMessage(issue);
   *   }
   *   displayIssueSeparator();
   * }
   */
  const issues = new MessageQueue();

  /**
   * A Number like class that supports operations.
   *
   * For lack of any other standard, methods will be named like those in
   * Python's operator module.
   *
   * All operations should return `this` to allow chaining.
   *
   * The existence of the valueOf(), toString() and toJSON() methods will
   * probably allow this class to work in many situations through type
   * coercion.
   */
  class NumberOp {

    /** @param {number} value - Initial value, parsed by Number(). */
    constructor(value) {
      this.assign(value);
    }

    /** @returns {number} - Current value. */
    valueOf() {
      return this.#value;
    }

    /** @returns {string} - Current value. */
    toString() {
      return `${this.valueOf()}`;
    }

    /** @returns {number} - Current value. */
    toJSON() {
      return this.valueOf();
    }

    /**
     * @param {number} value - Number to assign.
     * @returns {NumberOp} - This instance.
     */
    assign(value = 0) {
      this.#value = Number(value);
      return this;
    }

    /**
     * @param {number} value - Number to add.
     * @returns {NumberOp} - This instance.
     */
    add(value) {
      this.#value += Number(value);
      return this;
    }

    #value

  }

  /* eslint-disable newline-per-chained-call */
  /* eslint-disable no-magic-numbers */
  /* eslint-disable no-undefined */
  /* eslint-disable require-jsdoc */
  class NumberOpTestCase extends NH.xunit.TestCase {

    testValueOf() {
      this.assertEqual(new NumberOp().valueOf(), 0, 'default');
      this.assertEqual(new NumberOp(null).valueOf(), 0, 'null');
      this.assertEqual(new NumberOp(undefined).valueOf(), 0, 'undefined');
      this.assertEqual(new NumberOp(42).valueOf(), 42, 'number');
      this.assertEqual(new NumberOp('52').valueOf(), 52, 'string');
    }

    testToString() {
      this.assertEqual(new NumberOp(123).toString(), '123', 'number');
      this.assertEqual(new NumberOp(null).toString(), '0', 'null');
      this.assertEqual(new NumberOp(undefined).toString(), '0', 'undefined');
    }

    testTemplateLiteral() {
      const val = new NumberOp(456);
      this.assertEqual(`abc${val}xyz`, 'abc456xyz');
    }

    testBasicMath() {
      this.assertEqual(new NumberOp(124) + 6, 130, 'NO + x');
      this.assertEqual(3 + new NumberOp(5), 8, 'x + NO');
    }

    testStringManipulation() {
      const a = 'abc';
      const x = 'xyz';
      const n = new NumberOp('654');

      this.assertEqual(a + n, 'abc654', 's + NO');
      this.assertEqual(n + x, '654xyz', 'NO + s');
    }

    testAssignOp() {
      const n = new NumberOp(123);
      n.assign(42);
      this.assertEqual(n.valueOf(), 42, 'number');

      n.assign(null);
      this.assertEqual(n.valueOf(), 0, 'null');

      n.assign(789);
      this.assertEqual(n.valueOf(), 789, 'number, reset');

      n.assign(undefined);
      this.assertEqual(n.valueOf(), 0, 'undefined');
    }

    testAddOp() {
      this.assertEqual(new NumberOp(3).add(1)
        .valueOf(), 4,
      'number');
      this.assertEqual(new NumberOp(1).add('5')
        .valueOf(), 6,
      'string');
      this.assertEqual(new NumberOp(3).add(new NumberOp(8))
        .valueOf(), 11,
      'NO.add(NO)');
      this.assertEqual(new NumberOp(9).add(-16)
        .valueOf(), -7,
      'negative');
    }

    testChaining() {
      this.assertEqual(new NumberOp().add(1)
        .add(2)
        .add('3')
        .valueOf(), 6,
      'adds');
      this.assertEqual(new NumberOp(3).assign(40)
        .add(2)
        .valueOf(), 42,
      'mixed');
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(NumberOpTestCase);

  /**
   * Subclass of {Map} similar to Python's defaultdict.
   *
   * First argument is a factory function that will create a new default value
   * for the key if not already present in the container.
   *
   * The factory function may take arguments.  If `.get()` is called with
   * extra arguments, those will be passed to the factory if it needed.
   */
  class DefaultMap extends Map {

    /**
     * @param {function(...args) : *} factory - Function that creates a new
     * default value if a requested key is not present.
     * @param {Iterable} [iterable] - Passed to {Map} super().
     */
    constructor(factory, iterable) {
      if (!(factory instanceof Function)) {
        throw new TypeError('The factory argument MUST be of ' +
                            `type Function, not ${typeof factory}.`);
      }
      super(iterable);

      this.#factory = factory;
    }

    /**
     * Enhanced version of `Map.prototype.get()`.
     * @param {*} key - The key of the element to return from this instance.
     * @param {...*} args - Extra arguments passed tot he factory function if
     * it is called.
     * @returns {*} - The value associated with the key, perhaps newly
     * created.
     */
    get(key, ...args) {
      if (!this.has(key)) {
        this.set(key, this.#factory(...args));
      }

      return super.get(key);
    }

    #factory

  }

  /* eslint-disable newline-per-chained-call */
  /* eslint-disable no-magic-numbers */
  /* eslint-disable no-new */
  /* eslint-disable require-jsdoc */
  class DefaultMapTestCase extends NH.xunit.TestCase {

    testNoFactory() {
      this.assertRaisesRegExp(TypeError, /MUST.*not undefined/u, () => {
        new DefaultMap();
      });
    }

    testBadFactory() {
      this.assertRaisesRegExp(TypeError, /MUST.*not string/u, () => {
        new DefaultMap('a');
      });
    }

    testFactorWithArgs() {
      // Assemble
      const dummy = new DefaultMap(x => new NumberOp(x));
      this.defaultEqual = this.equalValueOf;

      // Act
      dummy.get('a');
      dummy.get('b', 5);

      // Assert
      this.assertEqual(Array.from(dummy.entries()),
        [['a', 0], ['b', 5]]);
    }

    testWithIterable() {
      // Assemble
      const dummy = new DefaultMap(Number, [[1, 'one'], [2, 'two']]);

      // Act
      dummy.set(3, ['a', 'b']);
      dummy.get(4);

      // Assert
      this.assertEqual(Array.from(dummy.entries()),
        [[1, 'one'], [2, 'two'], [3, ['a', 'b']], [4, 0]]);
    }

    testCounter() {
      // Assemble
      const dummy = new DefaultMap(() => new NumberOp());
      this.defaultEqual = this.equalValueOf;

      // Act
      dummy.get('a');
      dummy.get('b').add(1);
      dummy.get('b').add(1);
      dummy.get('c');
      dummy.get(4).add(1);

      // Assert
      this.assertEqual(Array.from(dummy.entries()),
        [['a', 0], ['b', 2], ['c', 0], [4, 1]]);
    }

    testArray() {
      // Assemble
      const dummy = new DefaultMap(Array);

      // Act
      dummy.get('a').push(1, 2, 3);
      dummy.get('b').push(4, 5, 6);
      dummy.get('a').push('one', 'two', 'three');

      // Assert
      this.assertEqual(Array.from(dummy.entries()),
        [['a', [1, 2, 3, 'one', 'two', 'three']], ['b', [4, 5, 6]]]);
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(DefaultMapTestCase);

  /**
   * Fancy-ish log messages (likely over engineered).
   *
   * Console nested message groups can be started and ended using the special
   * method pairs, {@link Logger#entered}/{@link Logger#leaving} and {@link
   * Logger#starting}/{@link Logger#finished}.  By default, the former are
   * opened and the latter collapsed (documented here as closed).
   *
   * Individual Loggers can be enabled/disabled by setting the {@link
   * Logger##Config.enabled} boolean property.
   *
   * Each Logger will have also have a collection of {@link Logger##Group}s
   * associated with it.  These groups can have one of three modes: "opened",
   * "closed", "silenced".  The first two correspond to the browser console
   * nested message groups.  The intro and outro type of methods will handle
   * the nesting.  If a group is set as "silenced", no messages will be sent
   * to the console.
   *
   * All Logger instances register a configuration with a singleton Map keyed
   * by the instance name.  If more than one instance is created with the same
   * name, they all share the same configuration.
   *
   * Configurations can be exported as a plain object and reimported using the
   * {@link Logger.configs} property.  The object could be saved via the
   * userscript script manager.  Depending on which one, it may have to be
   * processed with the JSON.{stringify,parse} functions.  Once exported, the
   * object may be modified.  This could be used to provide a UI to edit the
   * object, though no schema is provided.
   *
   * Some values may be of interest to users for help in debugging a script.
   *
   * The {callCount} value is how many times a logger would have been used for
   * messages, even if the logger is disabled.  Similarly, each group
   * associated with a logger also has a {callCount}.  These values can be
   * used to determine which loggers and groups generate a lot of messages and
   * could be disabled or silenced.
   *
   * The {sequence} value is a rough indicator of how recently a logger or
   * group was actually used.  It is purposely not a timestamp, but rather,
   * more closely associated with how often configurations are restored,
   * e.g. during web page reloads.  A low sequence number, relative to the
   * others, may indicate a logger was renamed, groups removed, or simply
   * parts of an application that have not been visited recently.  Depending
   * on the situation, the could clean up old configs, or explore other parts
   * of the script.
   *
   * @example
   * const log = new Logger('Bob');
   * foo(x) {
   *  const me = 'foo';
   *  log.entered(me, x);
   *  ... do stuff ...
   *  log.starting('loop');
   *  for (const item in items) {
   *    log.log(`Processing ${item}`);
   *    ...
   *  }
   *  log.finished('loop');
   *  log.leaving(me, y);
   *  return y;
   * }
   *
   * Logger.config('Bob').enabled = true;
   * Logger.config('Bob').group('foo').mode = 'silenced');
   *
   * GM.setValue('Logger', Logger.configs);
   * ... restart browser ...
   * Logger.configs = GM.getValue('Logger');
   */
  class Logger {

    /** @param {string} name - Name for this logger. */
    constructor(name) {
      this.#mq.listen(this.#errMsgListener);
      this.#name = name;
      this.#config = Logger.config(name);
      Logger.#loggers.get(this.#name)
        .push(new WeakRef(this));
    }

    static sequence = 1;

    /** @type {object} - Logger configurations. */
    static get configs() {
      return Logger.#toPojo();
    }

    /** @param {object} val - Logger configurations. */
    static set configs(val) {
      Logger.#fromPojo(val);
      Logger.#resetLoggerConfigs();
    }

    /** @type {string[]} - Names of known loggers. */
    static get loggers() {
      return Array.from(this.#loggers.keys());
    }

    /**
     * Get configuration of a specific Logger.
     * @param {string} name - Logger configuration to get.
     * @returns {Logger.Config} - Current config for that Logger.
     */
    static config(name) {
      return this.#configs.get(name);
    }

    /** Reset all configs to an empty state. */
    static resetConfigs() {
      this.#configs.clear();
      this.sequence = 1;
    }

    /** Clear the console. */
    static clear() {
      this.#clear();
    }

    /** @type {boolean} - Whether logging is currently enabled. */
    get enabled() {
      return this.#config.enabled;
    }

    /** @type {boolean} - Indicates whether messages include a stack trace. */
    get includeStackTrace() {
      return this.#config.includeStackTrace;
    }

    /** @type {MessageQueue} */
    get mq() {
      return this.#mq;
    }

    /** @type {string} - Name for this logger. */
    get name() {
      return this.#name;
    }

    /** @type {boolean} - Indicates whether current group is silenced. */
    get silenced() {
      let ret = false;
      const group = this.#groupStack.at(-1);
      if (group) {
        const mode = this.#config.group(group).mode;
        ret = mode === Logger.#GroupMode.Silenced;
      }
      return ret;
    }

    /**
     * Log a specific message.
     * @param {string} msg - Message to send to console.debug.
     * @param {...*} rest - Arbitrary items to pass to console.debug.
     */
    log(msg, ...rest) {
      this.#log(msg, ...rest);
    }

    /**
     * Indicate entered a specific group.
     * @param {string} group - Group that was entered.
     * @param {...*} rest - Arbitrary items to pass to console.debug.
     */
    entered(group, ...rest) {
      this.#intro(group, Logger.#GroupMode.Opened, ...rest);
    }

    /**
     * Indicate leaving a specific group.
     * @param {string} group - Group leaving.
     * @param {...*} rest - Arbitrary items to pass to console.debug.
     */
    leaving(group, ...rest) {
      this.#outro(group, ...rest);
    }

    /**
     * Indicate starting a specific collapsed group.
     * @param {string} group - Group that is being started.
     * @param {...*} rest - Arbitrary items to pass to console.debug.
     */
    starting(group, ...rest) {
      this.#intro(group, Logger.#GroupMode.Closed, ...rest);
    }

    /**
     * Indicate finishe a specific collapsed group.
     * @param {string} group - Group that was entered.
     * @param {...*} rest - Arbitrary items to pass to console.debug.
     */
    finished(group, ...rest) {
      this.#outro(group, ...rest);
    }

    static #Config = class {

      sequence = 0;

      /** @type {NumberOp} */
      get callCount() {
        return this.#callCount;
      }

      /** @type {boolean} - Whether logging is currently enabled. */
      get enabled() {
        return this.#enabled;
      }

      /** @param {boolean} val - Set whether logging is currently enabled. */
      set enabled(val) {
        this.#enabled = Boolean(val);
      }

      /** @type {Map<string,Logger.#Group>} - Per group settings. */
      get groups() {
        return this.#groups;
      }

      /** @type {boolean} - Whether messages include a stack trace. */
      get includeStackTrace() {
        return this.#includeStackTrace;
      }

      /** @param {boolean} val - Set inclusion of stack traces. */
      set includeStackTrace(val) {
        this.#includeStackTrace = Boolean(val);
      }

      /**
       * @param {string} name - Name of the group to get.
       * @param {Logger.#GroupMode} mode - Default mode if not seen before.
       * @returns {Logger.#Group} - Requested group, perhaps newly made.
       */
      group(name, mode) {
        const sanitizedName = name ?? 'null';
        const defaultMode = mode ?? 'opened';
        return this.#groups.get(sanitizedName, defaultMode);
      }

      /**
       * Capture that the associated Logger was used.
       * @param {string} name - Which group was used.
       */
      used(name) {
        const grp = this.group(name);

        this.callCount.add(1);
        this.sequence = Logger.sequence;

        grp.callCount.add(1);
        grp.sequence = Logger.sequence;
      }

      /** @returns {object} - Config as a plain object. */
      toPojo() {
        const pojo = {
          callCount: this.callCount.valueOf(),
          sequence: this.sequence,
          enabled: this.enabled,
          includeStackTrace: this.includeStackTrace,
          groups: {},
        };

        for (const [k, v] of this.groups) {
          pojo.groups[k] = v.toPojo();
        }

        return pojo;
      }

      /** @param {object} pojo - Config as a plain object. */
      fromPojo(pojo) {
        if (Object.hasOwn(pojo, 'callCount')) {
          this.callCount.assign(pojo.callCount);
        }
        if (Object.hasOwn(pojo, 'sequence')) {
          this.sequence = pojo.sequence;
          Logger.sequence = Math.max(Logger.sequence, this.sequence);
        }
        if (Object.hasOwn(pojo, 'enabled')) {
          this.enabled = pojo.enabled;
        }
        if (Object.hasOwn(pojo, 'includeStackTrace')) {
          this.includeStackTrace = pojo.includeStackTrace;
        }
        if (Object.hasOwn(pojo, 'groups')) {
          for (const [k, v] of Object.entries(pojo.groups)) {
            const gm = Logger.#GroupMode.byName(v.mode);
            if (gm) {
              this.group(k)
                .fromPojo(v);
            }
          }
        }
      }

      #callCount = new NumberOp();
      #enabled = false;
      #groups = new DefaultMap(x => new Logger.#Group(x));
      #includeStackTrace = false;

    }

    static #Group = class {

      /** @param {Logger.#GroupMode} mode - Initial mode for this group. */
      constructor(mode) {
        this.mode = mode;
        this.sequence = 0;
      }

      /** @type {NumberOp} */
      get callCount() {
        return this.#callCount;
      }

      /** @type {Logger.#GroupMode} */
      get mode() {
        return this.#mode;
      }

      /** @param {Logger.#GroupMode} val - Mode to set this group. */
      set mode(val) {
        let newVal = val;
        if (!(newVal instanceof Logger.#GroupMode)) {
          newVal = Logger.#GroupMode.byName(newVal);
        }
        if (newVal) {
          this.#mode = newVal;
        }
      }

      /** @returns {object} - Group as a plain object. */
      toPojo() {
        const pojo = {
          mode: this.mode.name,
          callCount: this.callCount.valueOf(),
          sequence: this.sequence,
        };

        return pojo;
      }

      /** @param {object} pojo - Group as a plain object. */
      fromPojo(pojo) {
        this.mode = pojo.mode;
        this.callCount.assign(pojo.callCount);
        this.sequence = pojo.sequence ?? 0;
        Logger.sequence = Math.max(Logger.sequence, this.sequence);
      }

      #callCount = new NumberOp();
      #mode

    }

    /** Enum/helper for Logger groups. */
    static #GroupMode = class {

      /**
       * @param {string} name - Mode name.
       * @param {string} [greeting] - Greeting when opening group.
       * @param {string} [farewell] - Salutation when closing group.
       * @param {string} [func] - console.func to use for opening group.
       */
      constructor(name, greeting, farewell, func) {  // eslint-disable-line max-params
        this.#farewell = farewell;
        this.#func = func;
        this.#greeting = greeting;
        this.#name = name;

        Logger.#GroupMode.#known.set(name, this);

        Object.freeze(this);
      }

      /**
       * Find GroupMode by name.
       * @param {string} name - Mode name.
       * @returns {GroupMode} - Mode, if found.
       */
      static byName(name) {
        return this.#known.get(name);
      }

      /** @type {string} - Farewell when closing group. */
      get farewell() {
        return this.#farewell;
      }

      /** @type {string} - console.func to use for opening group. */
      get func() {
        return this.#func;
      }

      /** @type {string} - Greeting when opening group. */
      get greeting() {
        return this.#greeting;
      }

      /** @type {string} - Mode name. */
      get name() {
        return this.#name;
      }

      static #known = new Map();

      #farewell
      #func
      #greeting
      #name

    }

    static {
      Logger.#GroupMode.Silenced = new Logger.#GroupMode('silenced');
      Logger.#GroupMode.Opened = new Logger.#GroupMode(
        'opened', 'Entered', 'Leaving', 'group'
      );
      Logger.#GroupMode.Closed = new Logger.#GroupMode(
        'closed', 'Starting', 'Finished', 'groupCollapsed'
      );

      Object.freeze(Logger.#GroupMode);
    }

    static #configs = new DefaultMap(() => new Logger.#Config());
    static #loggers = new DefaultMap(Array);

    /**
     * Set Logger configs from a plain object.
     * @param {object} pojo - Created by {Logger.#toPojo}.
     */
    static #fromPojo = (pojo) => {
      if (pojo && pojo.type === 'LoggerConfigs') {
        this.resetConfigs();
        for (const [k, v] of Object.entries(pojo.entries)) {
          this.#configs.get(k)
            .fromPojo(v);
        }
        Logger.sequence += 1;
      }
    }

    /** @returns {object} - Logger.#configs as a plain object. */
    static #toPojo = () => {
      const pojo = {
        type: 'LoggerConfigs',
        entries: {},
      };
      for (const [k, v] of this.#configs.entries()) {
        pojo.entries[k] = v.toPojo();
      }
      return pojo;
    }

    /**
     * This only resets Logger instances that have know configs.
     *
     * That way, Loggers created during tests wrapped with a save/restore
     * sequence, will not have their configs regenerated.
     */
    static #resetLoggerConfigs = () => {
      for (const key of this.#configs.keys()) {
        // We do not want to accidentally create a key in this DefaultMap.
        if (this.#loggers.has(key)) {
          const loggerArrays = this.#loggers.get(key);
          for (const loggerRef of loggerArrays) {
            const logger = loggerRef.deref();
            if (logger) {
              logger.#config = Logger.config(key);
            }
          }
        }
      }
    }

    /* eslint-disable no-console */
    static #clear = () => {
      console.clear();
    }

    #config
    #groupStack = [];
    #mq = new MessageQueue();
    #name

    #errMsgListener = (...msgs) => {
      console.error(...msgs);
    }

    /**
     * Log a specific message.
     * @param {string} msg - Message to send to console.debug.
     * @param {...*} rest - Arbitrary items to pass to console.debug.
     */
    #log = (msg, ...rest) => {
      const group = this.#groupStack.at(-1);
      this.#config.used(group);
      if (this.enabled && !this.silenced) {
        if (this.includeStackTrace) {
          console.groupCollapsed(`${this.name} call stack`);
          console.trace();
          console.groupEnd();
        }
        console.debug(`${this.name}: ${msg}`, ...rest);
      }
    }

    /**
     * Introduces a specific group.
     * @param {string} group - Group being created.
     * @param {Logger.#GroupMode} defaultMode - Mode to use if new.
     * @param {...*} rest - Arbitrary items to pass to console.debug.
     */
    #intro = (group, defaultMode, ...rest) => {
      this.#groupStack.push(group);
      const mode = this.#config.group(group, defaultMode).mode;

      if (this.enabled && mode !== Logger.#GroupMode.Silenced) {
        console[mode.func](`${this.name}: ${group}`);
      }

      if (rest.length) {
        const msg = `${mode.greeting} ${group} with`;
        this.log(msg, ...rest);
      }
    }

    /**
     * Concludes a specific group.
     * @param {string} group - Group leaving.
     * @param {...*} rest - Arbitrary items to pass to console.debug.
     */
    #outro = (group, ...rest) => {
      const mode = this.#config.group(group).mode;

      let msg = `${mode.farewell} ${group}`;
      if (rest.length) {
        msg += ' with:';
      }
      this.log(msg, ...rest);

      const lastGroup = this.#groupStack.pop();
      if (group !== lastGroup) {
        this.#mq.post(`${this.name}: Logging group mismatch!  Received ` +
                      `"${group}", expected to see "${lastGroup}"`);
      }

      if (this.enabled && mode !== Logger.#GroupMode.Silenced) {
        console.groupEnd();
      }
    }
    /* eslint-enable */

    /* eslint-disable require-jsdoc */
    /* eslint-disable no-undefined */
    /** This must be nested due to accessing #private fields. */
    static GroupModeTestCase = class extends NH.xunit.TestCase {

      testClassIsFrozen() {
        this.assertRaisesRegExp(TypeError, /is not extensible/u, () => {
          Logger.#GroupMode.Bob = {};
        });
      }

      testInstanceIsFrozen() {
        this.assertRaisesRegExp(TypeError, /is not extensible/u, () => {
          Logger.#GroupMode.Silenced.newProp = 'data';
        });
      }

      testLookupByValidName() {
        // Act
        const gm = Logger.#GroupMode.byName('closed');

        // Assert
        this.assertEqual(gm, Logger.#GroupMode.Closed);
      }

      testLookupByInvalidName() {
        // Act
        const gm = Logger.#GroupMode.byName('nope');

        // Assert
        this.assertEqual(gm, undefined);
      }

    }
    /* eslint-enable */

  }

  NH.xunit.testing.testCases.push(Logger.GroupModeTestCase);

  /* eslint-disable class-methods-use-this */
  /* eslint-disable newline-per-chained-call */
  /* eslint-disable no-magic-numbers */
  /* eslint-disable require-jsdoc */
  class LoggerTestCase extends NH.xunit.TestCase {

    setUp() {
      this.addCleanup(this.restoreConfigs, Logger.configs);
      Logger.resetConfigs();
    }

    restoreConfigs(saved) {
      Logger.configs = saved;
    }

    testReset() {
      // Assemble
      Logger.config(this.id).enabled = true;

      // Act
      Logger.resetConfigs();

      // Assert
      this.assertEqual(Logger.configs.entries, {});
    }

    testInitialValues() {
      // Assemble
      const logger = new Logger(this.id);

      // Assert
      this.assertFalse(logger.enabled, 'enabled');
      this.assertFalse(logger.includeStackTrace, 'stack trace');
      this.assertEqual(Logger.config(this.id).groups.size, 0, 'no groups');
    }

    testGroupDefaults() {
      // Assemble
      const logger = new Logger(this.id);

      // Act
      logger.entered('func');
      logger.starting('loop');

      // Assert
      const groups = Logger.config(this.id).groups;
      this.assertEqual(groups.size, 2, 'we saw two groups');
      this.assertEqual(groups.get('func').mode.name, 'opened', 'func');
      this.assertEqual(groups.get('loop').mode.name, 'closed', 'loop');
    }

    testCountsCollected() {
      // Assemble
      Logger.sequence = 10;
      const logger = new Logger(this.id);

      // Act
      // Results in counts
      logger.log('one');
      logger.log('two');

      // Basic intros do not log a message
      logger.entered('ent1');

      // Intros with extra stuff do log
      logger.entered('ent2', 'extra');

      // Count is in a group
      logger.log('three');

      // Outros cause logs
      logger.leaving('ent2');
      logger.leaving('ent1', 'extra');

      // Assert

      // Some of these are {@link NumberOp}
      this.defaultEqual = this.equalValueOf;

      const config = Logger.config(this.id);
      this.assertEqual(config.callCount, 6, 'call count');
      this.assertEqual(config.sequence, 10, 'sequence');
      this.assertEqual(config.groups.get('null').callCount, 2, 'null count');
      this.assertEqual(config.groups.get('null').sequence, 10, 'null seq');
      this.assertEqual(config.groups.get('ent1').callCount, 1, '1 count');
      this.assertEqual(config.groups.get('ent1').sequence, 10, '1 seq');
      this.assertEqual(config.groups.get('ent2').callCount, 3, '2 count');
      this.assertEqual(config.groups.get('ent2').sequence, 10, '2 seq');
    }

    testExpectMismatchedGroup() {
      // Assemble
      const messages = [];
      const listener = (...msgs) => {
        messages.push(...msgs);
      };
      const logger = new Logger(this.id);
      logger.mq.listen(listener);

      // Act
      logger.entered('one');
      logger.leaving('two');

      // Assert
      this.assertEqual(messages, [
        'LoggerTestCase.testExpectMismatchedGroup: Logging group mismatch!' +
          '  Received "two", expected to see "one"',
      ]);
    }

    testUpdateGroupByString() {
      // Assemble
      const logger = new Logger(this.id);
      logger.entered('one');

      // Act
      Logger.config('updateGroupByString').group('one').mode = 'silenced';
      this.assertEqual(
        Logger.config('updateGroupByString').group('one').mode.name,
        'silenced'
      );
    }

    testSaveRestoreConfigsTopLevel() {
      // This test does not strictly follow Assemble/Act/Assert as it has
      // extra verifications during state changes.

      // Some of these are {@link NumberOp}
      this.defaultEqual = this.equalValueOf;

      // Initial
      Logger.config(this.id).includeStackTrace = true;
      const logger = new Logger(this.id);
      logger.log('bumping the call count');

      const savedConfigs = Logger.configs;

      this.assertTrue(Logger.config(this.id).includeStackTrace, 'init trace');
      this.assertEqual(Logger.config(this.id).callCount, 1, 'init count');

      // Reset
      Logger.resetConfigs();

      this.assertFalse(Logger.config(this.id).includeStackTrace,
        'reset trace');
      this.assertEqual(Logger.config(this.id).callCount, 0, 'reset count');

      // Bob was not present before saving the configs.  So, the following
      // tweak away from defaults should reset after restoration.
      Logger.config('Bob').enabled = true;

      // Restore
      Logger.configs = savedConfigs;

      this.assertTrue(Logger.config(this.id).includeStackTrace,
        'restore trace');
      this.assertEqual(Logger.config(this.id).callCount, 1, 'restore count');
      this.assertFalse(Logger.config('Bob').enabled, 'restore Bob');
    }

    testSaveRestoreConfigsGroups() {
      // This test does not strictly follow Assemble/Act/Assert as it has
      // extra verifications during state changes.

      // Some of these are {@link NumberOp}
      this.defaultEqual = this.equalValueOf;

      const grp = 'a-loop';

      // Initial
      const logger = new Logger(this.id);
      logger.starting(grp);
      logger.finished(grp, 'bumping the call count');

      this.assertEqual(Logger.config(this.id).group(grp).mode.name,
        'closed',
        'init mode');
      this.assertEqual(Logger.config(this.id).group(grp).callCount,
        1,
        'init count');

      const savedConfigs = Logger.configs;

      // Reset
      Logger.resetConfigs();

      this.assertEqual(Logger.config(this.id).group(grp).mode.name,
        'opened',
        'reset mode');
      this.assertEqual(Logger.config(this.id).group(grp).callCount,
        0,
        'reset count');

      // Restore
      Logger.configs = savedConfigs;

      this.assertEqual(Logger.config(this.id).group(grp).mode.name,
        'closed',
        'restore mode');
      this.assertEqual(Logger.config(this.id).group(grp).callCount,
        1,
        'restore count');
    }

    testSaveRestoreBumpsSequenceAboveHighest() {
      const grp = 'some-group';
      Logger.sequence = 23;
      const logger = new Logger(this.id);

      // Just generating a group so it can have a sequence
      logger.starting(grp);
      logger.finished(grp);

      const savedConfigs = Logger.configs;

      this.assertEqual(savedConfigs.entries[this.id].groups[grp].sequence,
        23,
        'just checking....');

      savedConfigs.entries[this.id].sequence = 34;
      savedConfigs.entries[this.id].groups[grp].sequence = 42;

      // Restore - sequence should be > max(34, 42) from above
      Logger.configs = savedConfigs;
      this.assertTrue(Logger.sequence > 42, 'better be bumped');
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(LoggerTestCase);

  /**
   * Execute TestCase tests.
   * @param {Logger} logger - Logger to use.
   * @returns {boolean} - Success status.
   */
  function doTestCases(logger) {
    const me = 'Running TestCases';
    logger.entered(me);

    const savedConfigs = Logger.configs;
    const result = NH.xunit.runTests();
    Logger.configs = savedConfigs;

    const summary = result.summary(true)
      .join('\n');
    logger.log(`summary:\n${summary}`);
    if (result.errors.length) {
      logger.starting('Errors');

      for (const error of result.errors) {
        logger.log('error:', error);
      }

      logger.finished('Errors');
    }

    if (result.failures.length) {
      logger.starting('Failures');

      for (const failure of result.failures) {
        logger.log('failure:', failure.name, failure.message);
      }

      logger.finished('Failures');
    }

    logger.leaving(me, result.wasSuccessful());
    return result.wasSuccessful();
  }

  /**
   * Basic test runner.
   *
   * This depends on {Logger}, hence the location in this file.
   */
  function runTests() {
    if (NH.xunit.testing.enabled) {
      const logger = new Logger('Testing');
      if (doTestCases(logger)) {
        logger.log('All TestCases passed.');
      } else {
        logger.log('At least one TestCase failed.');
      }
    }
  }

  NH.xunit.testing.run = runTests;

  /**
   * Create a UUID-like string with a base.
   * @param {string} strBase - Base value for the string.
   * @returns {string} - A unique string.
   */
  function uuId(strBase) {
    return `${strBase}-${crypto.randomUUID()}`;
  }

  /**
   * Normalizes a string to be safe to use as an HTML element id.
   * @param {string} input - The string to normalize.
   * @returns {string} - Normlized string.
   */
  function safeId(input) {
    let result = input
      .replaceAll(' ', '-')
      .replaceAll('.', '_')
      .replaceAll(',', '__comma__')
      .replaceAll(':', '__colon__');
    if (!(/^[a-z_]/iu).test(result)) {
      result = `a${result}`;
    }
    return result;
  }

  /* eslint-disable no-undefined */
  /* eslint-disable require-jsdoc */
  class SafeIdTestCase extends NH.xunit.TestCase {

    testNormalInputs() {
      const tests = [
        {text: 'Tabby Cat', expected: 'Tabby-Cat'},
        {text: '_', expected: '_'},
        {text: '', expected: 'a'},
        {text: '0', expected: 'a0'},
        {text: 'a.b.c', expected: 'a_b_c'},
        {text: 'a,b,c', expected: 'a__comma__b__comma__c'},
        {text: 'a:b::c', expected: 'a__colon__b__colon____colon__c'},
      ];
      for (const {text, expected} of tests) {
        this.assertEqual(safeId(text), expected, text);
      }
    }

    testBadInputs() {
      this.assertRaises(
        TypeError,
        () => {
          safeId(undefined);
        },
        'undefined'
      );

      this.assertRaises(
        TypeError,
        () => {
          safeId(null);
        },
        'null'
      );
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(SafeIdTestCase);

  /**
   * Equivalent (for now) Java's hashCode (do not store externally).
   *
   * Do not expect it to be stable across releases.
   *
   * Implements: s[0]*31(n-1) + s[1]*31(n-2) + ... + s[n-1]
   * @param {string} s - String to hash.
   * @returns {string} - Hash value.
   */
  function strHash(s) {
    let hash = 0;
    for (let i = 0; i < s.length; i += 1) {
      // eslint-disable-next-line no-magic-numbers
      hash = (hash * 31) + s.charCodeAt(i) | 0;
    }
    return `${hash}`;
  }

  /**
   * Separate a string of concatenated words along transitions.
   *
   * Transitions are:
   *   lower to upper (lowerUpper -> lower Upper)
   *   grouped upper to lower (ABCd -> AB Cd)
   *   underscores (snake_case -> snake case)
   *   spaces
   *   character/numbers (lower2Upper -> lower 2 Upper)
   * Likely only works with ASCII.
   * Empty strings return an empty array.
   * Extra separators are consolidated.
   * @param {string} text - Text to parse.
   * @returns {string[]} - Parsed text.
   */
  function simpleParseWords(text) {
    const results = [];

    const working = [text];
    const moreWork = [];

    while (working.length || moreWork.length) {
      if (working.length === 0) {
        working.push(...moreWork);
        moreWork.length = 0;
      }

      // Unicode categories used below:
      // L - Letter
      // Ll - Letter, lower
      // Lu - Letter, upper
      // N - Number
      let word = working.shift();
      if (word) {
        word = word.replace(
          /(?<lower>\p{Ll})(?<upper>\p{Lu})/u,
          '$<lower> $<upper>'
        );

        word = word.replace(
          /(?<upper>\p{Lu}+)(?<lower>\p{Lu}\p{Ll})/u,
          '$<upper> $<lower>'
        );

        word = word.replace(
          /(?<letter>\p{L})(?<number>\p{N})/u,
          '$<letter> $<number>'
        );

        word = word.replace(
          /(?<number>\p{N})(?<letter>\p{L})/u,
          '$<number> $<letter>'
        );

        const split = word.split(/[ _]/u);
        if (split.length > 1 || moreWork.length) {
          moreWork.push(...split);
        } else {
          results.push(word);
        }
      }
    }

    return results;
  }

  /* eslint-disable require-jsdoc */
  class SimpleParseWordsTestCase extends NH.xunit.TestCase {

    testEmpty() {
      // Act
      const actual = simpleParseWords('');

      // Assert
      this.assertEqual(actual, []);
    }

    testSeparatorsOnly() {
      // Act
      const actual = simpleParseWords(' _ __  _');

      // Assert
      this.assertEqual(actual, []);
    }

    testAllLower() {
      // Act
      const actual = simpleParseWords('lower');

      // Assert
      const expected = ['lower'];
      this.assertEqual(actual, expected);
    }

    testAllUpper() {
      // Act
      const actual = simpleParseWords('UPPER');

      // Assert
      const expected = ['UPPER'];
      this.assertEqual(actual, expected);
    }

    testMixed() {
      // Act
      const actual = simpleParseWords('Mixed');

      // Assert
      const expected = ['Mixed'];
      this.assertEqual(actual, expected);
    }

    testSimpleCamelCase() {
      // Act
      const actual = simpleParseWords('SimpleCamelCase');

      // Assert
      const expected = ['Simple', 'Camel', 'Case'];
      this.assertEqual(actual, expected);
    }

    testLongCamelCase() {
      // Act
      const actual = simpleParseWords('AnUPPERWord');

      // Assert
      const expected = ['An', 'UPPER', 'Word'];
      this.assertEqual(actual, expected);
    }

    testLowerCamelCase() {
      // Act
      const actual = simpleParseWords('lowerCamelCase');

      // Assert
      const expected = ['lower', 'Camel', 'Case'];
      this.assertEqual(actual, expected);
    }

    testSnakeCase() {
      // Act
      const actual = simpleParseWords('snake_case_Example');

      // Assert
      const expected = ['snake', 'case', 'Example'];
      this.assertEqual(actual, expected);
    }

    testDoubleSnakeCase() {
      // Act
      const actual = simpleParseWords('double__snake_Case_example');

      // Assert
      const expected = ['double', 'snake', 'Case', 'example'];
      this.assertEqual(actual, expected);
    }

    testWithNumbers() {
      // Act
      const actual = simpleParseWords('One23fourFive');

      // Assert
      const expected = ['One', '23', 'four', 'Five'];
      this.assertEqual(actual, expected);
    }

    testWithSpaces() {
      // Act
      const actual = simpleParseWords('ABCd EF  ghIj');

      // Assert
      const expected = ['AB', 'Cd', 'EF', 'gh', 'Ij'];
      this.assertEqual(actual, expected);
    }

    testComplicated() {
      // Act
      const actual = simpleParseWords(
        'A_VERYComplicated_Wordy __ _  Example'
      );

      // Assert
      const expected = ['A', 'VERY', 'Complicated', 'Wordy', 'Example'];
      this.assertEqual(actual, expected);
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(SimpleParseWordsTestCase);

  /**
   * Base class for building services that can be turned on and off.
   *
   * Subclasses should NOT override methods here, except for constructor().
   * Instead they should register listeners for appropriate events.
   *
   * Generally, methods will fire two event verbs.  The first, in present
   * tense, will instruct what should happen (activate, deactivate).  The
   * second, in past tense, will describe what should have happened
   * (activated, deactivated).  Typically, subclasses will act upon the
   * present tense, and users of the class may act upon the past tense.
   *
   * @example
   * class DummyService extends Service {
   *
   *   constructor(name, dummyArgs) {
   *     super(`The ${name}`);
   *     this.#args = dummyArgs
   *     this.on('activate', this.#onActivate)
   *       .on('deactivate', this.#onDeactivate);
   *   }
   *
   *   #onActivate = (event) => {
   *     ... do activate stuff with this.#args ...
   *   }
   *
   *   #onDeactivate = (event) => {
   *     ... do deactivate stuff with this.#args ...
   *   }
   *
   * }
   *
   * ... else where ...
   * function dummyEventCallback(event, svc) {
   *   console.info(`${svc.name}` was ${event}`);
   * }
   *
   * const service = new DummyService('Bob', bobInfo)
   *   .on('activated', dummyEventCallback)
   *   .on('deactivated', dummyEventCallback);
   * service.activate();
   * service.deactivate();
   *
   */
  class Service {

    /** @param {string} name - Custom portion of this instance. */
    constructor(name) {
      if (new.target === Service) {
        throw new TypeError('Abstract class; do not instantiate directly.');
      }
      this.#name = `${this.constructor.name}: ${name}`;
      this.#shortName = name;
      this.#dispatcher = new Dispatcher(...Service.#knownEvents);
      this.#logger = new Logger(this.#name);
    }

    /** @type {Logger} - Logger instance. */
    get logger() {
      return this.#logger;
    }

    /** @type {string} - Instance name. */
    get name() {
      return this.#name;
    }

    /** @type {string} - Shorter instance name. */
    get shortName() {
      return this.#shortName;
    }

    /**
     * Called each time service is activated.
     *
     * @fires 'activate' 'activated'
     */
    activate() {
      if (!this.#activated || this.#allowReactivation) {
        this.#dispatcher.fire('activate', this);
        this.#dispatcher.fire('activated', this);
      }
      this.#activated = true;
    }

    /**
     * Called each time service is deactivated.
     *
     * @fires 'deactivate' 'deactivated'
     */
    deactivate() {
      this.#dispatcher.fire('deactivate', this);
      this.#dispatcher.fire('deactivated', this);
      this.#activated = false;
    }

    /**
     * Attach a function to an eventType.
     * @param {string} eventType - Event type to connect with.
     * @param {Dispatcher~Handler} func - Single argument function to
     * call.
     * @returns {Service} - This instance, for chaining.
     */
    on(eventType, func) {
      this.#dispatcher.on(eventType, func);
      return this;
    }

    /**
     * Remove all instances of a function registered to an eventType.
     * @param {string} eventType - Event type to disconnect from.
     * @param {Dispatcher~Handler} func - Function to remove.
     * @returns {Service} - This instance, for chaining.
     */
    off(eventType, func) {
      this.#dispatcher.off(eventType, func);
      return this;
    }

    /**
     * @param {boolean} allow - Whether to allow this service to be activated
     * when already active.
     * @returns {ScrollerService} - This instance, for chaining.
     */
    allowReactivation(allow) {
      this.#allowReactivation = allow;
      return this;
    }

    static #knownEvents = [
      'activate',
      'activated',
      'deactivate',
      'deactivated',
    ];

    #activated = false
    #allowReactivation = true
    #dispatcher
    #logger
    #name
    #shortName

  }

  /* eslint-disable max-lines-per-function */
  /* eslint-disable max-statements */
  /* eslint-disable no-new */
  /* eslint-disable require-jsdoc */
  class ServiceTestCase extends NH.xunit.TestCase {

    static Test = class extends Service {

      constructor(name) {
        super(`The ${name}`);
        this.on('activate', this.#onEvent)
          .on('deactivated', this.#onEvent);
      }

      set mq(val) {
        this.#mq = val;
      }

      #mq

      #onEvent = (evt, data) => {
        this.#mq.post('via Service', evt, data.shortName);
      }

    }

    testAbstract() {
      this.assertRaises(TypeError, () => {
        new Service();
      });
    }

    testProperties() {
      // Assemble
      const s = new ServiceTestCase.Test(this.id);

      // Assert
      this.assertEqual(
        s.name, 'Test: The ServiceTestCase.testProperties', 'name'
      );
      this.assertEqual(
        s.shortName, 'The ServiceTestCase.testProperties', 'short name'
      );
    }

    testSimpleEvents() {
      // Assemble
      const s = new ServiceTestCase.Test(this.id);
      const mq = new MessageQueue();
      s.mq = mq;

      const messages = [];
      const capture = (...message) => {
        messages.push(message);
      };
      const cb = (evt, service) => {
        mq.post('via cb', evt, service.name);
      };

      const shortName = 'The ServiceTestCase.testSimpleEvents';
      const longName = 'Test: The ServiceTestCase.testSimpleEvents';

      // Act I - Basic captures
      s.on('activated', cb)
        .on('deactivate', cb);
      s.activate();
      s.deactivate();

      mq.listen(capture);

      // Assert
      this.assertEqual(
        messages,
        [
          ['via Service', 'activate', shortName],
          ['via cb', 'activated', longName],
          ['via cb', 'deactivate', longName],
          ['via Service', 'deactivated', shortName],
        ],
        'first run through'
      );

      messages.length = 0;
      // Act II - Make sure *off()* is wired in.
      s.off('deactivate', cb);

      s.activate();
      s.deactivate();

      // Assert
      this.assertEqual(
        messages,
        [
          ['via Service', 'activate', shortName],
          ['via cb', 'activated', longName],
          // No deactivate in this spot this time
          ['via Service', 'deactivated', shortName],
        ],
        'second run through'
      );
    }

    testReactivation() {
      // Assemble
      const messages = [];
      const capture = (...message) => {
        messages.push(message);
      };

      const s = new ServiceTestCase.Test(this.id);
      s.mq = new MessageQueue()
        .listen(capture);

      const shortName = `The ${this.id}`;

      // Act I - Allowed by default
      s.activate();
      s.activate();
      s.deactivate();

      // Assert
      this.assertEqual(
        messages,
        [
          ['via Service', 'activate', shortName],
          // Activation while active worked
          ['via Service', 'activate', shortName],
          ['via Service', 'deactivated', shortName],
        ],
        'allowed by default'
      );

      // Act II - Turning off works
      messages.length = 0;
      s.allowReactivation(false);

      s.activate();
      s.activate();
      s.deactivate();

      // Assert
      this.assertEqual(
        messages,
        [
          ['via Service', 'activate', shortName],
          // No reactivation here
          ['via Service', 'deactivated', shortName],
        ],
        'turning off works'
      );

      // Act III - Turning back on works
      messages.length = 0;
      s.allowReactivation(true);

      s.activate();
      s.activate();
      s.deactivate();

      // Assert
      this.assertEqual(
        messages,
        [
          ['via Service', 'activate', shortName],
          // Activation while active worked
          ['via Service', 'activate', shortName],
          ['via Service', 'deactivated', shortName],
        ],
        'turning back on works'
      );
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(ServiceTestCase);

  return {
    version: version,
    NOT_FOUND: NOT_FOUND,
    ONE_ITEM: ONE_ITEM,
    ensure: ensure,
    Exception: Exception,
    Dispatcher: Dispatcher,
    MessageQueue: MessageQueue,
    issues: issues,
    DefaultMap: DefaultMap,
    Logger: Logger,
    uuId: uuId,
    safeId: safeId,
    strHash: strHash,
    simpleParseWords: simpleParseWords,
    Service: Service,
  };

}());