mirror of
https://github.com/koloml/philomena-tagging-assistant.git
synced 2026-06-23 18:22:20 +00:00
Compare commits
7 Commits
0.7.2
...
feature/te
| Author | SHA1 | Date | |
|---|---|---|---|
| 7af6124278 | |||
| 51ea806ddc | |||
| 234db4f147 | |||
| 978918735d | |||
| a6eae657c7 | |||
| 9a245ed0f5 | |||
| 3e5266ca7b |
@@ -2,7 +2,15 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export default class EntitiesController {
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
/**
|
||||
* Instance of storage helper used to store/read/subscribe to storage changes.
|
||||
*
|
||||
* Mainly exposed for the testing purposes. When class is loaded outside of extension context, will hold `null`
|
||||
* instead. Any operations of entities will throw an error in this case.
|
||||
*/
|
||||
static storage: StorageHelper | null = typeof chrome !== 'undefined'
|
||||
? new StorageHelper(chrome.storage.local)
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
|
||||
@@ -14,7 +22,11 @@ export default class EntitiesController {
|
||||
* @return List of entities of the given type.
|
||||
*/
|
||||
static async readAllEntities<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type): Promise<Type[]> {
|
||||
const rawEntities = await this.#storageHelper.read(entityName, {});
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const rawEntities = await this.storage.read(entityName, {});
|
||||
|
||||
if (!rawEntities || Object.keys(rawEntities).length === 0) {
|
||||
return [];
|
||||
@@ -32,10 +44,14 @@ export default class EntitiesController {
|
||||
* @param entity Entity to update.
|
||||
*/
|
||||
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
|
||||
this.#storageHelper.write(
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
this.storage.write(
|
||||
entityName,
|
||||
Object.assign(
|
||||
await this.#storageHelper.read(
|
||||
await this.storage.read(
|
||||
entityName, {}
|
||||
),
|
||||
{
|
||||
@@ -52,9 +68,13 @@ export default class EntitiesController {
|
||||
* @param entityId ID of the entity to delete.
|
||||
*/
|
||||
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
|
||||
const entities = await this.#storageHelper.read(entityName, {});
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const entities = await this.storage.read(entityName, {});
|
||||
delete entities[entityId];
|
||||
this.#storageHelper.write(entityName, entities);
|
||||
this.storage.write(entityName, entities);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +88,12 @@ export default class EntitiesController {
|
||||
* @return Unsubscribe function.
|
||||
*/
|
||||
static subscribeToEntity<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type, callback: (entities: Type[]) => void): () => void {
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const storage = this.storage;
|
||||
|
||||
/**
|
||||
* Watch the changes made to the storage and call the callback when the entity changes.
|
||||
*/
|
||||
@@ -80,8 +106,8 @@ export default class EntitiesController {
|
||||
.then(callback);
|
||||
}
|
||||
|
||||
this.#storageHelper.subscribe(subscriber);
|
||||
storage.subscribe(subscriber);
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(subscriber);
|
||||
return () => storage.unsubscribe(subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
261
tests/lib/extension/EntitiesController.spec.ts
Normal file
261
tests/lib/extension/EntitiesController.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { TestedEntity, type TestedSettings } from "$tests/stubs/Entity";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
|
||||
describe('EntitiesController', () => {
|
||||
let mockedStorage: ChromeStorageArea;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedStorage = new ChromeStorageArea();
|
||||
EntitiesController.storage = new StorageHelper(mockedStorage);
|
||||
});
|
||||
|
||||
it('should throw when storage is not present', async () => {
|
||||
EntitiesController.storage = null;
|
||||
|
||||
const readPromise = EntitiesController.readAllEntities(
|
||||
TestedEntity._entityName,
|
||||
TestedEntity,
|
||||
);
|
||||
|
||||
const deletePromise = EntitiesController.deleteEntity(
|
||||
TestedEntity._entityName,
|
||||
randomString(),
|
||||
);
|
||||
|
||||
const updatePromise = EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
numberField: randomInt(1000),
|
||||
stringField: randomString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const subscribe = () => {
|
||||
EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, vi.fn());
|
||||
}
|
||||
|
||||
await expect(readPromise).rejects.toThrow(Error);
|
||||
await expect(deletePromise).rejects.toThrow(Error);
|
||||
await expect(updatePromise).rejects.toThrow(Error);
|
||||
|
||||
expect(subscribe).toThrow(Error);
|
||||
});
|
||||
|
||||
describe('readAllEntities', () => {
|
||||
it('should return empty array when nothing in the storage yet', async () => {
|
||||
const entities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should properly capture different entities from storage', async () => {
|
||||
const storageWithEntities: Record<string, Record<string, Partial<TestedSettings>>> = {
|
||||
[TestedEntity._entityName]: {
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData(storageWithEntities);
|
||||
|
||||
const loadedEntities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
|
||||
expect(loadedEntities).toHaveLength(2);
|
||||
|
||||
for (const entity of loadedEntities) {
|
||||
const rawStorageEntry = storageWithEntities[TestedEntity._entityName][entity.id];
|
||||
|
||||
expect(entity.settings.stringField).toBe(rawStorageEntry.stringField);
|
||||
expect(entity.settings.numberField).toBe(rawStorageEntry.numberField);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEntity', () => {
|
||||
it('should create a storage structure if it is not created yet', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[entity.id]: {
|
||||
stringField: entity.settings.stringField,
|
||||
numberField: entity.settings.numberField,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update entity inside the existing', async () => {
|
||||
const id = randomString();
|
||||
const initialStringValue = randomString();
|
||||
const updatedStringValue = randomString();
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: {
|
||||
stringField: initialStringValue,
|
||||
numberField: randomInt(100_000),
|
||||
} as TestedSettings,
|
||||
}
|
||||
});
|
||||
|
||||
const [entity] = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entity.settings.stringField).toBe(initialStringValue);
|
||||
|
||||
entity.settings.stringField = updatedStringValue;
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
const entityInsideStorage = mockedStorage.mockedData[TestedEntity._entityName][id];
|
||||
expect(entityInsideStorage.stringField).toBe(updatedStringValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEntity', () => {
|
||||
it('should initialize the storage structure if delete called', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete entity and keep the storage object empty', async () => {
|
||||
const id = randomString();
|
||||
const settings: TestedSettings = {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
// Doesn't touch existing instance if ID is not found in the storage
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, id);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToEntity', () => {
|
||||
it('should notify about changes and return new entities', async () => {
|
||||
let receivedEntities: TestedEntity[] | null = null;
|
||||
|
||||
const subscriber = vi.fn((entities: TestedEntity[]) => {
|
||||
receivedEntities = entities;
|
||||
});
|
||||
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, subscriber);
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
|
||||
const createdEntity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, createdEntity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
const [firstReceivedEntity] = receivedEntities || [];
|
||||
|
||||
expect(firstReceivedEntity).toBeInstanceOf(TestedEntity);
|
||||
expect(firstReceivedEntity).not.toBe(createdEntity);
|
||||
expect(firstReceivedEntity).toEqual(createdEntity);
|
||||
});
|
||||
|
||||
it('should stop receiving updates once unsubscribed', async () => {
|
||||
const firstSubscriber = vi.fn();
|
||||
const unsubscribeFirst = EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, firstSubscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(firstSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
firstSubscriber.mockReset();
|
||||
unsubscribeFirst();
|
||||
|
||||
const secondSubscriber = vi.fn();
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, secondSubscriber);
|
||||
|
||||
entity.settings.stringField = randomString();
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(secondSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
expect(firstSubscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not notify when something else was changed in the storage', async () => {
|
||||
const rawStorageSubscriber = vi.fn();
|
||||
const entitiesSubscriber = vi.fn();
|
||||
|
||||
void EntitiesController.storage?.subscribe(rawStorageSubscriber);
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, entitiesSubscriber);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
}),
|
||||
);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(entitiesSubscriber).toHaveBeenCalledOnce();
|
||||
expect(rawStorageSubscriber).toHaveBeenCalledTimes(3);
|
||||
}, {timeout: 10, interval: 1});
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,6 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
|
||||
interface TestedFields {
|
||||
numberField: number;
|
||||
stringField: string;
|
||||
}
|
||||
|
||||
class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
|
||||
readonly defaults: TestedFields;
|
||||
readonly mockedSettingsNamespace: string;
|
||||
readonly mockedStorageArea: ChromeStorageArea;
|
||||
readonly mockedStorageHelper: StorageHelper;
|
||||
|
||||
numberField;
|
||||
stringField;
|
||||
|
||||
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
|
||||
const mockedStorageArea = new ChromeStorageArea();
|
||||
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
|
||||
const mockedConfigurationController = new ConfigurationController(
|
||||
settingsNamespace,
|
||||
mockedStorageHelper,
|
||||
);
|
||||
|
||||
super(settingsNamespace, mockedConfigurationController);
|
||||
|
||||
this.mockedSettingsNamespace = settingsNamespace;
|
||||
this.mockedStorageArea = mockedStorageArea;
|
||||
this.mockedStorageHelper = mockedStorageHelper;
|
||||
this.defaults = mockedDefaults;
|
||||
|
||||
this.numberField = new PreferenceField(this, {
|
||||
field: 'numberField',
|
||||
defaultValue: this.defaults.numberField,
|
||||
});
|
||||
|
||||
this.stringField = new PreferenceField(this, {
|
||||
field: 'stringField',
|
||||
defaultValue: this.defaults.stringField,
|
||||
});
|
||||
}
|
||||
}
|
||||
import { TestedPreferences } from "$tests/stubs/Preferences";
|
||||
|
||||
describe('CachablePreferences', () => {
|
||||
let preferences: TestedPreferences;
|
||||
|
||||
239
tests/lib/extension/base/StorageEntity.spec.ts
Normal file
239
tests/lib/extension/base/StorageEntity.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
import { TestedEntity } from "$tests/stubs/Entity";
|
||||
|
||||
describe("StorageEntity", () => {
|
||||
let mockedStorageArea: ChromeStorageArea;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedStorageArea = new ChromeStorageArea();
|
||||
EntitiesController.storage = new StorageHelper(mockedStorageArea);
|
||||
});
|
||||
|
||||
describe("readAll", () => {
|
||||
it("should return empty array if no entities stored", async () => {
|
||||
const entities = await TestedEntity.readAll();
|
||||
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should read all entities from storage", async () => {
|
||||
const entity1 = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
const entity2 = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity1.save();
|
||||
await entity2.save();
|
||||
|
||||
const entities = await TestedEntity.readAll();
|
||||
|
||||
expect(entities).toHaveLength(2);
|
||||
expect(entities[0].id).toBe(entity1.id);
|
||||
expect(entities[0].settings).toEqual(entity1.settings);
|
||||
expect(entities[1].id).toBe(entity2.id);
|
||||
expect(entities[1].settings).toEqual(entity2.settings);
|
||||
});
|
||||
|
||||
it("should build entities with correct class", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
const [savedEntity] = await TestedEntity.readAll();
|
||||
|
||||
expect(savedEntity).toBeInstanceOf(TestedEntity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save", () => {
|
||||
it("should save entity to storage", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
expect(mockedStorageArea.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[entity.id]: entity.settings,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should overwrite existing entity with same ID", async () => {
|
||||
const id = randomString();
|
||||
const originalSettings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity1 = new TestedEntity(id, originalSettings);
|
||||
await entity1.save();
|
||||
|
||||
const updatedSettings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity2 = new TestedEntity(id, updatedSettings);
|
||||
await entity2.save();
|
||||
|
||||
expect(mockedStorageArea.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: updatedSettings,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete entity from storage", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).not.toEqual({});
|
||||
|
||||
await entity.delete();
|
||||
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).toEqual({});
|
||||
});
|
||||
|
||||
it("should not fail if entity does not exist", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await expect(entity.delete()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("should notify about new entities", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
// Saving is not notified about immediately.
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
}, {timeout: 10, interval: 1});
|
||||
});
|
||||
|
||||
it("should notify about entity updates", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
subscriber.mockReset();
|
||||
|
||||
entity.settings.stringField = randomString();
|
||||
await entity.save();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
}, {interval: 1, timeout: 10});
|
||||
});
|
||||
|
||||
it("should notify about entity deletion", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
await entity.delete();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledTimes(2);
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
expect(subscriber).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("should stop notifications after unsubscribe", async () => {
|
||||
const subscriber = vi.fn();
|
||||
const unsubscribe = TestedEntity.subscribe(subscriber);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("properties", () => {
|
||||
it("should expose id", () => {
|
||||
const id = randomString();
|
||||
const entity = new TestedEntity(id, {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
expect(entity.id).toBe(id);
|
||||
});
|
||||
|
||||
it("should expose settings", () => {
|
||||
const settings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity = new TestedEntity(randomString(), settings);
|
||||
|
||||
expect(entity.settings).toEqual(settings);
|
||||
});
|
||||
|
||||
it("should expose type from _entityName", () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
expect(entity.type).toBe(TestedEntity._entityName);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +1,141 @@
|
||||
import { URL } from 'url';
|
||||
import { resolveTagNameFromLink, slugEncodedCharacters } from '$lib/philomena/tag-utils';
|
||||
import {
|
||||
buildTagsAndAliasesMap,
|
||||
resolveTagCategoryFromTagName,
|
||||
resolveTagNameFromLink,
|
||||
slugEncodedCharacters
|
||||
} from '$lib/philomena/tag-utils';
|
||||
import { randomString } from "$tests/utils";
|
||||
import { namespaceCategories } from "$config/tags";
|
||||
|
||||
describe('tag-utils', () => {
|
||||
const origin = 'https://furbooru.org';
|
||||
const origin = 'https://furbooru.org';
|
||||
|
||||
describe('resolveTagNameFromLink', () => {
|
||||
function resolveFromSearchQuery(encodedQuery: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/search?q=${encodedQuery}`, origin));
|
||||
}
|
||||
describe('buildTagsAndAliasesMap', () => {
|
||||
it('should return regular tags if both real and real+alias tags are the same', () => {
|
||||
const tagsAndAliases = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
|
||||
describe('Parsing from /search/?q=tag links', () => {
|
||||
it('should resolve a single tag from /search URLs', () => {
|
||||
expect(resolveFromSearchQuery('safe')).toBe('safe');
|
||||
});
|
||||
|
||||
it('should return null for queries with multiple comma-separated tags', () => {
|
||||
// Comma acts as a separator in the query, resulting in multiple tokens
|
||||
expect(resolveFromSearchQuery('safe, suggestive')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null if query is empty or not a term', () => {
|
||||
expect(resolveFromSearchQuery('')).toBe(null);
|
||||
expect(resolveFromSearchQuery('!')).toBe(null);
|
||||
});
|
||||
|
||||
it('should properly treat parentheses in the query with single tag', () => {
|
||||
// Parentheses are operators in the query language, but when inside the tag name, they should still be properly
|
||||
// working.
|
||||
expect(resolveFromSearchQuery('experiment (casualties unknown)')).toBe('experiment (casualties unknown)');
|
||||
});
|
||||
|
||||
it('should properly resolve queries with encoded characters', () => {
|
||||
expect(resolveFromSearchQuery('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
|
||||
it('should unquote quoted term', () => {
|
||||
expect(resolveFromSearchQuery('"experiment (casualties unknown)"')).toBe('experiment (casualties unknown)')
|
||||
expect(resolveFromSearchQuery('"single tag, really"')).toBe('single tag, really');
|
||||
});
|
||||
})
|
||||
|
||||
describe('Parsing from /tags/name links', () => {
|
||||
function resolveFromTagLink(encodedTagName: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/tags/${encodedTagName}`, origin));
|
||||
expect(buildTagsAndAliasesMap(tagsAndAliases, tagsAndAliases)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should resolve a single tag', () => {
|
||||
expect(resolveFromTagLink('safe')).toBe('safe');
|
||||
});
|
||||
it('should identify any aliases going after the real tag', () => {
|
||||
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
const realAndAliasesTags = ['avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
|
||||
|
||||
it('should only read the tag page even if query is provided', () => {
|
||||
expect(resolveFromTagLink('grotesque?q=explicit')).toBe('grotesque');
|
||||
});
|
||||
expect(buildTagsAndAliasesMap(realAndAliasesTags, realTags)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
"experiment (gunsaw)" => "experiment (casualties unknown)",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should properly resolve links with encoded characters', () => {
|
||||
expect(resolveFromTagLink('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
it('should ignore any non-real tags coming before first tag is found', () => {
|
||||
const outOfOrderTag = randomString();
|
||||
|
||||
it('should decoded slug-encoded characters', () => {
|
||||
// More common example where tag is.
|
||||
expect(resolveFromTagLink(`namespace-colon-tag+name`)).toBe('namespace:tag name');
|
||||
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
const realAndAliasesTags = [outOfOrderTag, 'avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
|
||||
|
||||
// Testing the whole list of encoded characters.
|
||||
for (const [encodedCharacter, decodedCharacter] of slugEncodedCharacters.entries()) {
|
||||
expect(resolveFromTagLink(`test+symbol${encodedCharacter}without+spaces`)).toBe(`test symbol${decodedCharacter}without spaces`);
|
||||
expect(resolveFromTagLink(`test+symbol+${encodedCharacter}+with+spaces`)).toBe(`test symbol ${decodedCharacter} with spaces`);
|
||||
}
|
||||
});
|
||||
});
|
||||
const warn = vi.spyOn(console, 'warn');
|
||||
|
||||
it('should return null for unsupported URLs', () => {
|
||||
expect(resolveTagNameFromLink(new URL('/pages/example', origin))).toBe(null);
|
||||
});
|
||||
expect(buildTagsAndAliasesMap(realAndAliasesTags, realTags)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
"experiment (gunsaw)" => "experiment (casualties unknown)",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(`No real tag found for the alias:`, outOfOrderTag);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTagNameFromLink', () => {
|
||||
function resolveFromSearchQuery(encodedQuery: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/search?q=${encodedQuery}`, origin));
|
||||
}
|
||||
|
||||
describe('Parsing from /search/?q=tag links', () => {
|
||||
it('should resolve a single tag from /search URLs', () => {
|
||||
expect(resolveFromSearchQuery('safe')).toBe('safe');
|
||||
});
|
||||
|
||||
it('should return null for queries with multiple comma-separated tags', () => {
|
||||
// Comma acts as a separator in the query, resulting in multiple tokens
|
||||
expect(resolveFromSearchQuery('safe, suggestive')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null if query is empty or not a term', () => {
|
||||
expect(resolveFromSearchQuery('')).toBe(null);
|
||||
expect(resolveFromSearchQuery('!')).toBe(null);
|
||||
});
|
||||
|
||||
it('should properly treat parentheses in the query with single tag', () => {
|
||||
// Parentheses are operators in the query language, but when inside the tag name, they should still be properly
|
||||
// working.
|
||||
expect(resolveFromSearchQuery('experiment (casualties unknown)')).toBe('experiment (casualties unknown)');
|
||||
});
|
||||
|
||||
it('should properly resolve queries with encoded characters', () => {
|
||||
expect(resolveFromSearchQuery('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
|
||||
it('should unquote quoted term', () => {
|
||||
expect(resolveFromSearchQuery('"experiment (casualties unknown)"')).toBe('experiment (casualties unknown)')
|
||||
expect(resolveFromSearchQuery('"single tag, really"')).toBe('single tag, really');
|
||||
});
|
||||
})
|
||||
|
||||
describe('Parsing from /tags/name links', () => {
|
||||
function resolveFromTagLink(encodedTagName: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/tags/${encodedTagName}`, origin));
|
||||
}
|
||||
|
||||
it('should resolve a single tag', () => {
|
||||
expect(resolveFromTagLink('safe')).toBe('safe');
|
||||
});
|
||||
|
||||
it('should only read the tag page even if query is provided', () => {
|
||||
expect(resolveFromTagLink('grotesque?q=explicit')).toBe('grotesque');
|
||||
});
|
||||
|
||||
it('should properly resolve links with encoded characters', () => {
|
||||
expect(resolveFromTagLink('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
|
||||
it('should decoded slug-encoded characters', () => {
|
||||
// More common example where tag is.
|
||||
expect(resolveFromTagLink(`namespace-colon-tag+name`)).toBe('namespace:tag name');
|
||||
|
||||
// Testing the whole list of encoded characters.
|
||||
for (const [encodedCharacter, decodedCharacter] of slugEncodedCharacters.entries()) {
|
||||
expect(resolveFromTagLink(`test+symbol${encodedCharacter}without+spaces`)).toBe(`test symbol${decodedCharacter}without spaces`);
|
||||
expect(resolveFromTagLink(`test+symbol+${encodedCharacter}+with+spaces`)).toBe(`test symbol ${decodedCharacter} with spaces`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for unsupported URLs', () => {
|
||||
expect(resolveTagNameFromLink(new URL('/pages/example', origin))).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTagCategoryFromTagName', () => {
|
||||
it('should resolve any known namespace into its known category', () => {
|
||||
for (const [namespace, category] of namespaceCategories) {
|
||||
expect(resolveTagCategoryFromTagName(`${namespace}:${randomString()}`)).toBe(category);
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore any namespace not listed in config', () => {
|
||||
expect(resolveTagCategoryFromTagName(`${randomString()}:${randomString()}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
17
tests/stubs/Entity.ts
Normal file
17
tests/stubs/Entity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface TestedSettings {
|
||||
stringField: string;
|
||||
numberField: number;
|
||||
nested?: {
|
||||
field: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class TestedEntity extends StorageEntity<TestedSettings> {
|
||||
static readonly _entityName = "entity";
|
||||
|
||||
constructor(id: string, settings: TestedSettings) {
|
||||
super(id, settings);
|
||||
}
|
||||
}
|
||||
45
tests/stubs/Preferences.ts
Normal file
45
tests/stubs/Preferences.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
export interface TestedFields {
|
||||
numberField: number;
|
||||
stringField: string;
|
||||
}
|
||||
|
||||
export class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
|
||||
readonly defaults: TestedFields;
|
||||
readonly mockedSettingsNamespace: string;
|
||||
readonly mockedStorageArea: ChromeStorageArea;
|
||||
readonly mockedStorageHelper: StorageHelper;
|
||||
|
||||
numberField;
|
||||
stringField;
|
||||
|
||||
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
|
||||
const mockedStorageArea = new ChromeStorageArea();
|
||||
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
|
||||
const mockedConfigurationController = new ConfigurationController(
|
||||
settingsNamespace,
|
||||
mockedStorageHelper,
|
||||
);
|
||||
|
||||
super(settingsNamespace, mockedConfigurationController);
|
||||
|
||||
this.mockedSettingsNamespace = settingsNamespace;
|
||||
this.mockedStorageArea = mockedStorageArea;
|
||||
this.mockedStorageHelper = mockedStorageHelper;
|
||||
this.defaults = mockedDefaults;
|
||||
|
||||
this.numberField = new PreferenceField(this, {
|
||||
field: 'numberField',
|
||||
defaultValue: this.defaults.numberField,
|
||||
});
|
||||
|
||||
this.stringField = new PreferenceField(this, {
|
||||
field: 'stringField',
|
||||
defaultValue: this.defaults.stringField,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user