1
0
mirror of https://github.com/koloml/philomena-tagging-assistant.git synced 2026-06-24 02:32:21 +00:00

7 Commits

Author SHA1 Message Date
7af6124278 Cover 2 more functions in tag-utils module 2026-06-21 19:19:34 +04:00
51ea806ddc Shorten waiting timeout & interval to reduce test time 2026-06-21 17:54:08 +04:00
234db4f147 Testing internal EntitiesController class used by the entities 2026-06-21 17:52:08 +04:00
978918735d Extracted preferences & entity stubs to make them reusable 2026-06-21 16:18:11 +04:00
a6eae657c7 Added comment for the storage helper instance in EntitiesController 2026-06-21 15:36:14 +04:00
9a245ed0f5 Testing StorageEntity class behavior 2026-06-21 15:31:03 +04:00
3e5266ca7b Expose storage in EntitiesController for testing purposes
- Storage can now be mocked
- Class can be loaded even if chrome storage is not present globally
- It will not throw an error if storage not found in runtime
2026-06-21 15:29:42 +04:00
7 changed files with 725 additions and 116 deletions

View File

@@ -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);
}
}

View 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});
})
});
});

View File

@@ -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;

View 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);
});
});
});

View File

@@ -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
View 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);
}
}

View 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,
});
}
}