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

2 Commits

Author SHA1 Message Date
c268eb564d Show warning for profiles when user is not logged in 2026-06-21 21:54:20 +04:00
cfb4c71f58 Capture and store last seen user flags from the booru
Main use case for these flags is to notify when something they're trying
to use will not be usable due to authorization or their role on the
site. Specifically, it will be also used later to detect when user
is a part of staff and show them staff-specific features.
2026-06-21 21:53:50 +04:00
12 changed files with 180 additions and 726 deletions

View File

@@ -28,7 +28,8 @@
"*://*.furbooru.org/*"
],
"js": [
"src/content/deps/amd.ts"
"src/content/deps/amd.ts",
"src/content/user-details.ts"
]
},
{

View File

@@ -0,0 +1,14 @@
import { UserDetails } from "$lib/extension/preferences/UserDetails";
(async () => {
const userDetails = new UserDetails();
const userDataStore = document.querySelector<HTMLElement>('.js-datastore');
if (!userDataStore) {
return;
}
await userDetails.isAuthorized.set(userDataStore.dataset.userIsSignedIn === 'true');
})();

View File

@@ -2,15 +2,7 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
import type StorageEntity from "$lib/extension/base/StorageEntity";
export default class EntitiesController {
/**
* 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;
static #storageHelper = new StorageHelper(chrome.storage.local);
/**
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
@@ -22,11 +14,7 @@ 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[]> {
if (!this.storage) {
throw new Error('Missing storage!');
}
const rawEntities = await this.storage.read(entityName, {});
const rawEntities = await this.#storageHelper.read(entityName, {});
if (!rawEntities || Object.keys(rawEntities).length === 0) {
return [];
@@ -44,14 +32,10 @@ export default class EntitiesController {
* @param entity Entity to update.
*/
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
if (!this.storage) {
throw new Error('Missing storage!');
}
this.storage.write(
this.#storageHelper.write(
entityName,
Object.assign(
await this.storage.read(
await this.#storageHelper.read(
entityName, {}
),
{
@@ -68,13 +52,9 @@ export default class EntitiesController {
* @param entityId ID of the entity to delete.
*/
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
if (!this.storage) {
throw new Error('Missing storage!');
}
const entities = await this.storage.read(entityName, {});
const entities = await this.#storageHelper.read(entityName, {});
delete entities[entityId];
this.storage.write(entityName, entities);
this.#storageHelper.write(entityName, entities);
}
/**
@@ -88,12 +68,6 @@ 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.
*/
@@ -106,8 +80,8 @@ export default class EntitiesController {
.then(callback);
}
storage.subscribe(subscriber);
this.#storageHelper.subscribe(subscriber);
return () => storage.unsubscribe(subscriber);
return () => this.#storageHelper.unsubscribe(subscriber);
}
}

View File

@@ -0,0 +1,16 @@
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
export interface UserDetailsFields {
isAuthorized: boolean;
}
export class UserDetails extends CacheablePreferences<UserDetailsFields> implements WithFields<UserDetailsFields> {
constructor() {
super('userDetails');
}
isAuthorized = new PreferenceField(this, {
field: 'isAuthorized',
defaultValue: false,
});
}

View File

@@ -5,6 +5,8 @@
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
import { user } from "$stores/preferences/user";
import Notice from "$components/ui/Notice.svelte";
$popupTitle = 'Tagging Profiles';
@@ -28,6 +30,18 @@
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/profiles/new/edit" icon="plus">Create New</MenuItem>
</Menu>
{#if $user?.isAuthorized === false}
<Menu>
<hr>
</Menu>
<Notice level="warning">
<strong>Tagging profiles will only work when you're signed in!</strong>
<br><br>
Unauthorized users have to solve the captcha before sending any submissions to the site.
</Notice>
{/if}
<Menu>
{#if profiles.length}
<hr>
{/if}

View File

@@ -0,0 +1,18 @@
import { UserDetails, type UserDetailsFields } from "$lib/extension/preferences/UserDetails";
import { readable } from "svelte/store";
const userDetails = new UserDetails();
export const user = readable<UserDetailsFields | null>(null, set => {
userDetails.subscribe(settings => {
set({
isAuthorized: Boolean(settings.isAuthorized)
});
});
userDetails.isAuthorized.get().then(isAuthorized => {
set({
isAuthorized
});
});
});

View File

@@ -1,261 +0,0 @@
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,6 +1,50 @@
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";
import { TestedPreferences } from "$tests/stubs/Preferences";
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,
});
}
}
describe('CachablePreferences', () => {
let preferences: TestedPreferences;

View File

@@ -1,239 +0,0 @@
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,141 +1,76 @@
import { URL } from 'url';
import {
buildTagsAndAliasesMap,
resolveTagCategoryFromTagName,
resolveTagNameFromLink,
slugEncodedCharacters
} from '$lib/philomena/tag-utils';
import { randomString } from "$tests/utils";
import { namespaceCategories } from "$config/tags";
import { resolveTagNameFromLink, slugEncodedCharacters } from '$lib/philomena/tag-utils';
const origin = 'https://furbooru.org';
describe('tag-utils', () => {
const origin = 'https://furbooru.org';
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'];
expect(buildTagsAndAliasesMap(tagsAndAliases, tagsAndAliases)).toMatchInlineSnapshot(`
Map {
"avali" => "avali",
"experiment (casualties unknown)" => "experiment (casualties unknown)",
"fictional species" => "fictional species",
}
`);
});
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'];
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 ignore any non-real tags coming before first tag is found', () => {
const outOfOrderTag = randomString();
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
const realAndAliasesTags = [outOfOrderTag, 'avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
const warn = vi.spyOn(console, 'warn');
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));
describe('resolveTagNameFromLink', () => {
function resolveFromSearchQuery(encodedQuery: string): string | null {
return resolveTagNameFromLink(new URL(`/search?q=${encodedQuery}`, origin));
}
it('should resolve a single tag', () => {
expect(resolveFromTagLink('safe')).toBe('safe');
});
describe('Parsing from /search/?q=tag links', () => {
it('should resolve a single tag from /search URLs', () => {
expect(resolveFromSearchQuery('safe')).toBe('safe');
});
it('should only read the tag page even if query is provided', () => {
expect(resolveFromTagLink('grotesque?q=explicit')).toBe('grotesque');
});
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 properly resolve links with encoded characters', () => {
expect(resolveFromTagLink('pok%C3%A9mon')).toBe('pokémon');
});
it('should return null if query is empty or not a term', () => {
expect(resolveFromSearchQuery('')).toBe(null);
expect(resolveFromSearchQuery('!')).toBe(null);
});
it('should decoded slug-encoded characters', () => {
// More common example where tag is.
expect(resolveFromTagLink(`namespace-colon-tag+name`)).toBe('namespace:tag name');
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)');
});
// 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 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);
});
});
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();
});
});

View File

@@ -1,17 +0,0 @@
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

@@ -1,45 +0,0 @@
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,
});
}
}