diff --git a/src/lib/extension/EntitiesController.ts b/src/lib/extension/EntitiesController.ts index 8a680e5..0df9dc9 100644 --- a/src/lib/extension/EntitiesController.ts +++ b/src/lib/extension/EntitiesController.ts @@ -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>(entityName: string, entityClass: new (...any: any[]) => Type): Promise { - 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): Promise { - 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 { - 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>(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); } } diff --git a/tests/lib/extension/EntitiesController.spec.ts b/tests/lib/extension/EntitiesController.spec.ts new file mode 100644 index 0000000..e3f44d0 --- /dev/null +++ b/tests/lib/extension/EntitiesController.spec.ts @@ -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>> = { + [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}); + }) + }); +}); diff --git a/tests/lib/extension/base/CachablePreferences.spec.ts b/tests/lib/extension/base/CachablePreferences.spec.ts index 93a719e..ca91d51 100644 --- a/tests/lib/extension/base/CachablePreferences.spec.ts +++ b/tests/lib/extension/base/CachablePreferences.spec.ts @@ -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 implements WithFields { - 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; diff --git a/tests/lib/extension/base/StorageEntity.spec.ts b/tests/lib/extension/base/StorageEntity.spec.ts new file mode 100644 index 0000000..e213799 --- /dev/null +++ b/tests/lib/extension/base/StorageEntity.spec.ts @@ -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); + }); + }); +}); diff --git a/tests/lib/philomena/tag-utils.spec.ts b/tests/lib/philomena/tag-utils.spec.ts index a322a07..e04221e 100644 --- a/tests/lib/philomena/tag-utils.spec.ts +++ b/tests/lib/philomena/tag-utils.spec.ts @@ -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(); }); }); diff --git a/tests/stubs/Entity.ts b/tests/stubs/Entity.ts new file mode 100644 index 0000000..6e65521 --- /dev/null +++ b/tests/stubs/Entity.ts @@ -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 { + static readonly _entityName = "entity"; + + constructor(id: string, settings: TestedSettings) { + super(id, settings); + } +} diff --git a/tests/stubs/Preferences.ts b/tests/stubs/Preferences.ts new file mode 100644 index 0000000..d21c4ea --- /dev/null +++ b/tests/stubs/Preferences.ts @@ -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 implements WithFields { + 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, + }); + } +}