您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Base library usable any time.
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-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,
- };
- }());