diff --git a/.eslintrc.json b/.eslintrc.json index 2ff44ead..663e9003 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -108,6 +108,7 @@ "deferPromise": "readonly", "clone": "readonly", "generateId": "readonly", + "DynamicProperty": "readonly", "EventDispatcher": "readonly", "EventListenerCollection": "readonly" } diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 9142a846..5bee4670 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -260,7 +260,7 @@ function promiseTimeout(delay, resolveValue) { /* - * Common events + * Common classes */ class EventDispatcher { @@ -348,3 +348,106 @@ class EventListenerCollection { this._eventListeners = []; } } + +/** + * Class representing a generic value with an override stack. + * Changes can be observed by listening to the 'change' event. + */ +class DynamicProperty extends EventDispatcher { + /** + * Creates a new instance with the specified value. + * @param value The value to assign. + */ + constructor(value) { + super(); + this._value = value; + this._defaultValue = value; + this._overrides = []; + } + + /** + * Gets the default value for the property, which is assigned to the + * public value property when no overrides are present. + */ + get defaultValue() { + return this._defaultValue; + } + + /** + * Assigns the default value for the property. If no overrides are present + * and if the value is different than the current default value, + * the 'change' event will be triggered. + * @param value The value to assign. + */ + set defaultValue(value) { + this._defaultValue = value; + if (this._overrides.length === 0) { this._updateValue(); } + } + + /** + * Gets the current value for the property, taking any overrides into account. + */ + get value() { + return this._value; + } + + /** + * Gets the number of overrides added to the property. + */ + get overrideCount() { + return this._overrides.length; + } + + /** + * Adds an override value with the specified priority to the override stack. + * Values with higher priority will take precedence over those with lower. + * For tie breaks, the override value added first will take precedence. + * If the newly added override has the highest priority of all overrides + * and if the override value is different from the current value, + * the 'change' event will be fired. + * @param value The override value to assign. + * @param priority The priority value to use, as a number. + * @returns A string token which can be passed to the clearOverride function + * to remove the override. + */ + setOverride(value, priority=0) { + const overridesCount = this._overrides.length; + let i = 0; + for (; i < overridesCount; ++i) { + if (priority > this._overrides[i].priority) { break; } + } + const token = generateId(16); + this._overrides.splice(i, 0, {value, priority, token}); + if (i === 0) { this._updateValue(); } + return token; + } + + /** + * Removes a specific override value. If the removed override + * had the highest priority, and the new value is different from + * the previous value, the 'change' event will be fired. + * @param token The token for the corresponding override which is to be removed. + * @returns true if an override was returned, false otherwise. + */ + clearOverride(token) { + for (let i = 0, ii = this._overrides.length; i < ii; ++i) { + if (this._overrides[i].token === token) { + this._overrides.splice(i, 1); + if (i === 0) { this._updateValue(); } + return true; + } + } + return false; + } + + /** + * Updates the current value using the current overrides and default value. + * If the new value differs from the previous value, the 'change' event will be fired. + */ + _updateValue() { + const value = this._overrides.length > 0 ? this._overrides[0].value : this._defaultValue; + if (this._value === value) { return; } + this._value = value; + this.trigger('change', {value}); + } +} diff --git a/package.json b/package.json index 8bbf883a..d276d701 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "node ./dev/build.js", "test": "npm run test-lint && npm run test-code && npm run test-manifest", "test-lint": "eslint . && node ./test/lint/global-declarations.js", - "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document-util.js && node ./test/test-object-property-accessor.js && node ./test/test-japanese.js && node ./test/test-text-source-map.js && node ./test/test-dom-text-scanner.js && node ./test/test-cache-map.js && node ./test/test-profile-conditions.js", + "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document-util.js && node ./test/test-object-property-accessor.js && node ./test/test-japanese.js && node ./test/test-text-source-map.js && node ./test/test-dom-text-scanner.js && node ./test/test-cache-map.js && node ./test/test-profile-conditions.js && node ./test/test-core.js", "test-manifest": "node ./test/test-manifest.js" }, "repository": { diff --git a/test/test-core.js b/test/test-core.js new file mode 100644 index 00000000..d4a880d1 --- /dev/null +++ b/test/test-core.js @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +const assert = require('assert'); +const crypto = require('crypto'); +const {VM} = require('./yomichan-vm'); + +const vm = new VM({ + crypto: { + getRandomValues: (array) => { + const buffer = crypto.randomBytes(array.byteLength); + buffer.copy(array); + return array; + } + } +}); +vm.execute([ + 'mixed/js/core.js' +]); +const [DynamicProperty] = vm.get(['DynamicProperty']); + + +function testDynamicProperty() { + const data = [ + { + initialValue: 0, + operations: [ + { + operation: null, + expectedDefaultValue: 0, + expectedValue: 0, + expectedOverrideCount: 0, + expeectedEventOccurred: false + }, + { + operation: 'set.defaultValue', + args: [1], + expectedDefaultValue: 1, + expectedValue: 1, + expectedOverrideCount: 0, + expeectedEventOccurred: true + }, + { + operation: 'set.defaultValue', + args: [1], + expectedDefaultValue: 1, + expectedValue: 1, + expectedOverrideCount: 0, + expeectedEventOccurred: false + }, + { + operation: 'set.defaultValue', + args: [0], + expectedDefaultValue: 0, + expectedValue: 0, + expectedOverrideCount: 0, + expeectedEventOccurred: true + }, + { + operation: 'setOverride', + args: [8], + expectedDefaultValue: 0, + expectedValue: 8, + expectedOverrideCount: 1, + expeectedEventOccurred: true + }, + { + operation: 'setOverride', + args: [16], + expectedDefaultValue: 0, + expectedValue: 8, + expectedOverrideCount: 2, + expeectedEventOccurred: false + }, + { + operation: 'setOverride', + args: [32, 1], + expectedDefaultValue: 0, + expectedValue: 32, + expectedOverrideCount: 3, + expeectedEventOccurred: true + }, + { + operation: 'setOverride', + args: [64, -1], + expectedDefaultValue: 0, + expectedValue: 32, + expectedOverrideCount: 4, + expeectedEventOccurred: false + }, + { + operation: 'clearOverride', + args: [-4], + expectedDefaultValue: 0, + expectedValue: 32, + expectedOverrideCount: 3, + expeectedEventOccurred: false + }, + { + operation: 'clearOverride', + args: [-3], + expectedDefaultValue: 0, + expectedValue: 32, + expectedOverrideCount: 2, + expeectedEventOccurred: false + }, + { + operation: 'clearOverride', + args: [-2], + expectedDefaultValue: 0, + expectedValue: 64, + expectedOverrideCount: 1, + expeectedEventOccurred: true + }, + { + operation: 'clearOverride', + args: [-1], + expectedDefaultValue: 0, + expectedValue: 0, + expectedOverrideCount: 0, + expeectedEventOccurred: true + } + ] + } + ]; + + for (const {initialValue, operations} of data) { + const property = new DynamicProperty(initialValue); + const overrideTokens = []; + let eventOccurred = false; + const onChange = () => { eventOccurred = true; }; + property.on('change', onChange); + for (const {operation, args, expectedDefaultValue, expectedValue, expectedOverrideCount, expeectedEventOccurred} of operations) { + eventOccurred = false; + switch (operation) { + case 'set.defaultValue': property.defaultValue = args[0]; break; + case 'setOverride': overrideTokens.push(property.setOverride(...args)); break; + case 'clearOverride': property.clearOverride(overrideTokens[overrideTokens.length + args[0]]); break; + } + assert.strictEqual(eventOccurred, expeectedEventOccurred); + assert.strictEqual(property.defaultValue, expectedDefaultValue); + assert.strictEqual(property.value, expectedValue); + assert.strictEqual(property.overrideCount, expectedOverrideCount); + } + property.off('change', onChange); + } +} + + +function main() { + testDynamicProperty(); +} + + +if (require.main === module) { main(); }