1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2025-12-23 23:02:58 +00:00

Merge pull request #104 from koloml/feature/testing-configuration-controller

Added tests for ConfigurationController class
This commit is contained in:
2025-02-28 02:01:00 +04:00
committed by GitHub
10 changed files with 284 additions and 25 deletions

18
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@sveltejs/kit": "^2.17.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"@types/node": "^22.13.5",
"@vitest/coverage-v8": "^3.0.6",
"cheerio": "^1.0.0",
"jsdom": "^26.0.0",
@@ -814,6 +815,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.6.tgz",
@@ -2972,6 +2983,13 @@
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz",

View File

@@ -17,6 +17,7 @@
"@sveltejs/kit": "^2.17.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"@types/node": "^22.13.5",
"@vitest/coverage-v8": "^3.0.6",
"cheerio": "^1.0.0",
"jsdom": "^26.0.0",

View File

@@ -2,12 +2,16 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
export default class ConfigurationController {
readonly #configurationName: string;
readonly #storage: StorageHelper;
/**
* @param {string} configurationName Name of the configuration to work with.
* @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage
* is used.
*/
constructor(configurationName: string) {
constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) {
this.#configurationName = configurationName;
this.#storage = storage;
}
/**
@@ -19,7 +23,7 @@ export default class ConfigurationController {
* @return The setting value or the default value if the setting does not exist.
*/
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
return settings[settingName] ?? defaultValue;
}
@@ -32,11 +36,11 @@ export default class ConfigurationController {
* @return {Promise<void>}
*/
async writeSetting(settingName: string, value: any): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
settings[settingName] = value;
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -45,11 +49,11 @@ export default class ConfigurationController {
* @param {string} settingName Setting name to delete.
*/
async deleteSetting(settingName: string): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
delete settings[settingName];
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -69,10 +73,8 @@ export default class ConfigurationController {
callback(changes[this.#configurationName].newValue);
}
ConfigurationController.#storageHelper.subscribe(subscriber);
this.#storage.subscribe(subscriber);
return () => ConfigurationController.#storageHelper.unsubscribe(subscriber);
return () => this.#storage.unsubscribe(subscriber);
}
static #storageHelper = new StorageHelper(chrome.storage.local);
}

View File

@@ -1,9 +1,6 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
function randomString() {
return crypto.randomUUID();
}
import { randomString } from "$tests/utils";
describe('BaseComponent', () => {
it('should bind the component to the element', () => {

View File

@@ -0,0 +1,186 @@
import ConfigurationController from "$lib/extension/ConfigurationController";
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import { randomString } from "$tests/utils";
describe('ConfigurationController', () => {
const mockedStorageArea = new ChromeStorageArea();
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
beforeEach(() => {
mockedStorageArea.clear();
});
it('should read setting from the field inside the configuration object', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[field]: value
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
const returnedValue = await controller.readSetting(field);
expect(returnedValue).toBe(value);
});
it('should return fallback value if configuration field does not exist', async () => {
const controller = new ConfigurationController(randomString(), mockedStorageHelper);
const fallbackValue = randomString();
const returnedValue = await controller.readSetting(randomString(), fallbackValue);
expect(returnedValue).toBe(fallbackValue);
});
it('should treat existing falsy values as existing values', async () => {
const name = randomString();
const falsyValuesStorage = [0, false, ''].reduce((record, value) => {
record[randomString()] = value;
return record;
}, {} as Record<string, any>);
mockedStorageArea.insertMockedData({
[name]: falsyValuesStorage
});
const controller = new ConfigurationController(name, mockedStorageHelper);
for (const fieldName of Object.keys(falsyValuesStorage)) {
const returnedValue = await controller.readSetting(fieldName, randomString());
expect(returnedValue).toBe(falsyValuesStorage[fieldName]);
}
});
it('should write data to storage', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.writeSetting(field, value);
const expectedStructure = {
[name]: {
[field]: value,
}
};
expect(mockedStorageArea.mockedData).toEqual(expectedStructure);
});
it('should update existing object without touching other entries', async () => {
const name = randomString();
const existingField = randomString();
const existingValue = randomString();
const addedField = randomString();
const addedValue = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[existingField]: existingValue,
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.writeSetting(addedField, addedValue);
const expectedStructure = {
[name]: {
[existingField]: existingValue,
[addedField]: addedValue,
}
}
expect(mockedStorageArea.mockedData).toEqual(expectedStructure);
});
it('should delete setting from storage', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[field]: value
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.deleteSetting(field);
expect(mockedStorageArea.mockedData).toEqual({
[name]: {},
});
});
it('should return updated settings contents on changes', async () => {
const name = randomString();
const initialField = randomString();
const initialValue = randomString();
const addedField = randomString();
const addedValue = randomString();
const updatedInitialValue = randomString();
const receivedData: Record<string, string>[] = [];
mockedStorageArea.insertMockedData({
[name]: {
[initialField]: initialValue,
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
const subscriber = vi.fn((storageState: Record<string, string>) => {
receivedData.push(JSON.parse(JSON.stringify(storageState)));
});
controller.subscribeToChanges(subscriber);
await controller.writeSetting(addedField, addedValue);
await controller.writeSetting(initialField, updatedInitialValue);
await controller.deleteSetting(initialField);
expect(subscriber).toBeCalledTimes(3);
const expectedData: Record<string, string>[] = [
// First, initial data and added field are present
{
[initialField]: initialValue,
[addedField]: addedValue,
},
// Then we get new value on initial field
{
[initialField]: updatedInitialValue,
[addedField]: addedValue,
},
// And then the initial value is dropped
{
[addedField]: addedValue,
}
];
expect(receivedData).toEqual(expectedData);
});
it('should stop listening once unsubscribe called', async () => {
const controller = new ConfigurationController(randomString(), mockedStorageHelper);
const subscriber = vi.fn();
const unsubscribe = controller.subscribeToChanges(subscriber);
await controller.writeSetting(randomString(), randomString());
expect(subscriber).toBeCalledTimes(1);
unsubscribe();
subscriber.mockReset();
await controller.writeSetting(randomString(), randomString())
expect(subscriber).not.toBeCalled();
});
});

View File

@@ -1,9 +1,9 @@
export default class ChromeEvent<T extends Function> implements chrome.events.Event<T> {
addListener = vi.fn();
getRules = vi.fn();
hasListener = vi.fn();
removeRules = vi.fn();
addRules = vi.fn();
removeListener = vi.fn();
hasListeners = vi.fn();
addListener = vi.fn();
getRules = vi.fn();
hasListener = vi.fn();
removeRules = vi.fn();
addRules = vi.fn();
removeListener = vi.fn();
hasListeners = vi.fn();
}

View File

@@ -1,5 +1,5 @@
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
export class ChromeLocalStorageArea extends ChromeStorageArea implements chrome.storage.LocalStorageArea {
QUOTA_BYTES = 100000;
QUOTA_BYTES = 100000;
}

View File

@@ -1,4 +1,4 @@
import ChromeEvent from "./ChromeEvent";
import ChromeStorageChangeEvent from "$tests/mocks/ChromeStorageChangeEvent";
type ChangedEventCallback = (changes: chrome.storage.StorageChange) => void
@@ -13,8 +13,20 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
})
});
set = vi.fn((...args: any[]): Promise<void> => {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
const change: Record<string, chrome.storage.StorageChange> = {};
const setter = args[0];
for (let targetKey of Object.keys(setter)) {
change[targetKey] = {
oldValue: this.#mockedData[targetKey] ?? undefined,
newValue: setter[targetKey],
};
}
this.#mockedData = Object.assign(this.#mockedData, args[0]);
this.onChanged.mockEmitStorageChange(change);
resolve();
})
});
@@ -23,7 +35,16 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
const key = args[0];
if (typeof key === 'string') {
const change: chrome.storage.StorageChange = {
oldValue: this.#mockedData[key],
};
delete this.#mockedData[key];
this.onChanged.mockEmitStorageChange({
[key]: change
});
resolve();
}
@@ -58,7 +79,7 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
});
});
setAccessLevel = vi.fn();
onChanged = new ChromeEvent<ChangedEventCallback>();
onChanged = new ChromeStorageChangeEvent();
getKeys = vi.fn();
insertMockedData(data: Record<string, any>) {

View File

@@ -0,0 +1,27 @@
import ChromeEvent from "$tests/mocks/ChromeEvent";
import { EventEmitter } from "node:events";
type MockedStorageChanges = Record<string, chrome.storage.StorageChange>;
type IncomingStorageChangeListener = (changes: MockedStorageChanges) => void;
const storageChangeEvent = Symbol();
interface StorageChangeEventMap {
[storageChangeEvent]: [MockedStorageChanges];
}
export default class ChromeStorageChangeEvent extends ChromeEvent<IncomingStorageChangeListener> {
#emitter = new EventEmitter<StorageChangeEventMap>();
addListener = vi.fn((actualListener: IncomingStorageChangeListener) => {
this.#emitter.addListener(storageChangeEvent, actualListener);
});
removeListener = vi.fn((actualListener: IncomingStorageChangeListener) => {
this.#emitter.removeListener(storageChangeEvent, actualListener);
});
mockEmitStorageChange(changes: MockedStorageChanges) {
this.#emitter.emit(storageChangeEvent, changes);
}
}

7
tests/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
export function randomString(): string {
return crypto.randomUUID();
}
export function copyValue<T>(object: T): T {
return JSON.parse(JSON.stringify(object));
}