mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 20:18:00 +01:00
Add JS utility unit tests with Jest (#144)
This commit is contained in:
parent
5b422a8089
commit
997d6e0cb5
33 changed files with 8496 additions and 180 deletions
12
.github/workflows/elixir.yml
vendored
12
.github/workflows/elixir.yml
vendored
|
@ -4,6 +4,7 @@ on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
name: 'Build Elixir app'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
@ -26,7 +27,8 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
docker-compose run app mix sobelow --config
|
docker-compose run app mix sobelow --config
|
||||||
docker-compose run app mix deps.audit
|
docker-compose run app mix deps.audit
|
||||||
lint:
|
lint-and-test:
|
||||||
|
name: 'JavaScript Linting and Unit Tests'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
@ -34,7 +36,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '14'
|
node-version: '16'
|
||||||
|
|
||||||
- name: Cache node_modules
|
- name: Cache node_modules
|
||||||
id: cache-node-modules
|
id: cache-node-modules
|
||||||
|
@ -48,6 +50,8 @@ jobs:
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci --ignore-scripts
|
||||||
working-directory: ./assets
|
working-directory: ./assets
|
||||||
|
|
||||||
- name: Run ESLint
|
- run: npm run lint
|
||||||
run: npm run lint
|
working-directory: ./assets
|
||||||
|
|
||||||
|
- run: npm run test
|
||||||
working-directory: ./assets
|
working-directory: ./assets
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -57,3 +57,6 @@ npm-debug.log
|
||||||
# Rust binaries
|
# Rust binaries
|
||||||
/native/**/target
|
/native/**/target
|
||||||
/.cargo
|
/.cargo
|
||||||
|
|
||||||
|
# Jest coverage
|
||||||
|
/assets/coverage
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
js/vendor/*
|
js/vendor/*
|
||||||
webpack.config.js
|
webpack.config.js
|
||||||
|
jest.config.js
|
||||||
|
|
|
@ -10,6 +10,7 @@ parserOptions:
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
- '@typescript-eslint'
|
- '@typescript-eslint'
|
||||||
|
- jest
|
||||||
|
|
||||||
globals:
|
globals:
|
||||||
ga: false
|
ga: false
|
||||||
|
@ -223,7 +224,7 @@ rules:
|
||||||
prefer-rest-params: 2
|
prefer-rest-params: 2
|
||||||
prefer-spread: 0
|
prefer-spread: 0
|
||||||
prefer-template: 2
|
prefer-template: 2
|
||||||
quote-props: [2, 'as-needed', ]
|
quote-props: [2, 'as-needed']
|
||||||
quotes: [2, 'single']
|
quotes: [2, 'single']
|
||||||
radix: 2
|
radix: 2
|
||||||
require-jsdoc: 0
|
require-jsdoc: 0
|
||||||
|
@ -253,9 +254,34 @@ rules:
|
||||||
yield-star-spacing: 2
|
yield-star-spacing: 2
|
||||||
yoda: [2, 'never']
|
yoda: [2, 'never']
|
||||||
|
|
||||||
# Disable rules which are impossible to satisfy (types require .ts extension)
|
|
||||||
overrides:
|
overrides:
|
||||||
|
# JavaScript Files
|
||||||
|
# Disable rules which are impossible to satisfy (types require .ts extension)
|
||||||
- files:
|
- files:
|
||||||
- '*.js'
|
- '*.js'
|
||||||
rules:
|
rules:
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 0
|
'@typescript-eslint/explicit-module-boundary-types': 0
|
||||||
|
# TypeScript Files
|
||||||
|
# Some ESLint rules report false errors due to lacking type information. Replacement rules are provided for these, and the originals need to be disabled
|
||||||
|
- files:
|
||||||
|
- '*.ts'
|
||||||
|
rules:
|
||||||
|
# https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/FAQ.md#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
|
no-undef: 0
|
||||||
|
no-unused-vars: 0
|
||||||
|
'@typescript-eslint/no-unused-vars': [2, {vars: 'all', args: 'after-used'}]
|
||||||
|
no-redeclare: 0
|
||||||
|
'@typescript-eslint/no-redeclare': 2
|
||||||
|
no-extra-parens: 0
|
||||||
|
'@typescript-eslint/no-extra-parens': 2
|
||||||
|
no-shadow: 0
|
||||||
|
'@typescript-eslint/no-shadow': 2
|
||||||
|
# Jest Tests (also written in TypeScript)
|
||||||
|
# Disable rules that do not make sense in test files (e.g. testing for undefined input values should be allowed)
|
||||||
|
- files:
|
||||||
|
- '*.spec.ts'
|
||||||
|
- 'test/*.ts'
|
||||||
|
extends:
|
||||||
|
- 'plugin:jest/recommended'
|
||||||
|
rules:
|
||||||
|
no-undefined: 0
|
||||||
|
|
41
assets/jest.config.js
Normal file
41
assets/jest.config.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
export default {
|
||||||
|
collectCoverage: true,
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'js/**/*.{js,ts}',
|
||||||
|
],
|
||||||
|
coveragePathIgnorePatterns: [
|
||||||
|
'/node_modules/',
|
||||||
|
'/.*\\.test\\.ts$',
|
||||||
|
'.*\\.d\\.ts$',
|
||||||
|
],
|
||||||
|
coverageDirectory: '<rootDir>/coverage/',
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 0,
|
||||||
|
branches: 0,
|
||||||
|
functions: 0,
|
||||||
|
lines: 0,
|
||||||
|
},
|
||||||
|
'./js/utils/**/*.js': {
|
||||||
|
statements: 100,
|
||||||
|
branches: 100,
|
||||||
|
functions: 100,
|
||||||
|
lines: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preset: 'ts-jest/presets/js-with-ts-esm',
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/test/jest-setup.ts'],
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'./js/(.*)': '<rootDir>/js/$1',
|
||||||
|
},
|
||||||
|
transform: {},
|
||||||
|
globals: {
|
||||||
|
extensionsToTreatAsEsm: ['.ts', '.js'],
|
||||||
|
'ts-jest': {
|
||||||
|
tsconfig: '<rootDir>/tsconfig.json',
|
||||||
|
useESM: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -874,4 +874,7 @@ SearchAST.prototype.dumpTree = function() {
|
||||||
return retStrArr.join('\n');
|
return retStrArr.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Force module handling for Jest, can be removed after TypeScript migration
|
||||||
|
export {};
|
||||||
|
|
||||||
export default parseSearch;
|
export default parseSearch;
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import { fetchJson } from './utils/requests';
|
import { fetchJson } from './utils/requests';
|
||||||
import { filterNode } from './imagesclientside';
|
import { filterNode } from './imagesclientside';
|
||||||
import { hideEl, showEl } from './utils/dom.js';
|
import { hideEl, showEl } from './utils/dom';
|
||||||
|
|
||||||
function handleError(response) {
|
function handleError(response) {
|
||||||
const errorMessage = '<div>Preview failed to load!</div>';
|
const errorMessage = '<div>Preview failed to load!</div>';
|
||||||
|
|
191
assets/js/utils/__tests__/array.spec.ts
Normal file
191
assets/js/utils/__tests__/array.spec.ts
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
import { arraysEqual, moveElement } from '../array';
|
||||||
|
|
||||||
|
describe('Array Utilities', () => {
|
||||||
|
describe('moveElement', () => {
|
||||||
|
describe('empty array', () => {
|
||||||
|
it('should preserve unexpected behavior', () => {
|
||||||
|
const input: undefined[] = [];
|
||||||
|
moveElement(input, 1, 0);
|
||||||
|
expect(input).toEqual([undefined]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('swap two items in a 2-item array', () => {
|
||||||
|
it('should work with descending index parameters', () => {
|
||||||
|
const input = [true, false];
|
||||||
|
moveElement(input, 1, 0);
|
||||||
|
expect(input).toEqual([false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with ascending index parameters', () => {
|
||||||
|
const input = [true, false];
|
||||||
|
moveElement(input, 0, 1);
|
||||||
|
expect(input).toEqual([false, true]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('swap first and last item in a 3-item array', () => {
|
||||||
|
it('should work with descending index parameters', () => {
|
||||||
|
const input = ['a', 'b', 'c'];
|
||||||
|
moveElement(input, 2, 0);
|
||||||
|
expect(input).toEqual(['c', 'a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with ascending index parameters', () => {
|
||||||
|
const input = ['a', 'b', 'c'];
|
||||||
|
moveElement(input, 0, 2);
|
||||||
|
expect(input).toEqual(['b', 'c', 'a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('swap items in the middle of a 4-item array', () => {
|
||||||
|
it('should work with descending index parameters', () => {
|
||||||
|
const input = ['a', 'b', 'c', 'd'];
|
||||||
|
moveElement(input, 2, 1);
|
||||||
|
expect(input).toEqual(['a', 'c', 'b', 'd']);
|
||||||
|
});
|
||||||
|
it('should work with ascending index parameters', () => {
|
||||||
|
const input = ['a', 'b', 'c', 'd'];
|
||||||
|
moveElement(input, 1, 2);
|
||||||
|
expect(input).toEqual(['a', 'c', 'b', 'd']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arraysEqual', () => {
|
||||||
|
describe('positive cases', () => {
|
||||||
|
it('should return true for empty arrays', () => {
|
||||||
|
expect(arraysEqual([], [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for matching arrays', () => {
|
||||||
|
// Numbers
|
||||||
|
expect(arraysEqual([0], [0])).toBe(true);
|
||||||
|
expect(arraysEqual([4e3], [4000])).toBe(true);
|
||||||
|
expect(arraysEqual([0, 1], [0, 1])).toBe(true);
|
||||||
|
expect(arraysEqual([1_000_000, 30_000_000], [1_000_000, 30_000_000])).toBe(true);
|
||||||
|
expect(arraysEqual([0, 1, 2], [0, 1, 2])).toBe(true);
|
||||||
|
expect(arraysEqual([0, 1, 2, 3], [0, 1, 2, 3])).toBe(true);
|
||||||
|
const randomNumber = Math.random();
|
||||||
|
expect(arraysEqual([randomNumber], [randomNumber])).toBe(true);
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
expect(arraysEqual(['a'], ['a'])).toBe(true);
|
||||||
|
expect(arraysEqual(['abcdef'], ['abcdef'])).toBe(true);
|
||||||
|
expect(arraysEqual(['a', 'b'], ['a', 'b'])).toBe(true);
|
||||||
|
expect(arraysEqual(['aaaaa', 'bbbbb'], ['aaaaa', 'bbbbb'])).toBe(true);
|
||||||
|
expect(arraysEqual(['a', 'b', 'c'], ['a', 'b', 'c'])).toBe(true);
|
||||||
|
expect(arraysEqual(['a', 'b', 'c', 'd'], ['a', 'b', 'c', 'd'])).toBe(true);
|
||||||
|
|
||||||
|
// Object by reference
|
||||||
|
const uniqueValue = Symbol('item');
|
||||||
|
expect(arraysEqual([uniqueValue], [uniqueValue])).toBe(true);
|
||||||
|
|
||||||
|
// Mixed parameters
|
||||||
|
const mockObject = { value: Math.random() };
|
||||||
|
expect(arraysEqual(
|
||||||
|
['', null, false, uniqueValue, mockObject, Infinity, undefined],
|
||||||
|
['', null, false, uniqueValue, mockObject, Infinity, undefined]
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for matching up to the first array\'s length', () => {
|
||||||
|
// Numbers
|
||||||
|
expect(arraysEqual([0], [0, 1])).toBe(true);
|
||||||
|
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(true);
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
expect(arraysEqual(['a'], ['a', 'b'])).toBe(true);
|
||||||
|
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(true);
|
||||||
|
|
||||||
|
// Object by reference
|
||||||
|
const uniqueValue1 = Symbol('item1');
|
||||||
|
const uniqueValue2 = Symbol('item2');
|
||||||
|
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(true);
|
||||||
|
|
||||||
|
// Mixed parameters
|
||||||
|
const mockObject = { value: Math.random() };
|
||||||
|
expect(arraysEqual(
|
||||||
|
[''],
|
||||||
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
|
)).toBe(true);
|
||||||
|
expect(arraysEqual(
|
||||||
|
['', null],
|
||||||
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
|
)).toBe(true);
|
||||||
|
expect(arraysEqual(
|
||||||
|
['', null, false],
|
||||||
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
|
)).toBe(true);
|
||||||
|
expect(arraysEqual(
|
||||||
|
['', null, false, mockObject],
|
||||||
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
|
)).toBe(true);
|
||||||
|
expect(arraysEqual(
|
||||||
|
['', null, false, mockObject, Infinity],
|
||||||
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('negative cases', () => {
|
||||||
|
// FIXME This case should be handled
|
||||||
|
// eslint-disable-next-line jest/no-disabled-tests
|
||||||
|
it.skip('should return false for arrays of different length', () => {
|
||||||
|
// Numbers
|
||||||
|
expect(arraysEqual([], [0])).toBe(false);
|
||||||
|
expect(arraysEqual([0], [])).toBe(false);
|
||||||
|
expect(arraysEqual([0], [0, 0])).toBe(false);
|
||||||
|
expect(arraysEqual([0, 0], [0])).toBe(false);
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
expect(arraysEqual([], ['a'])).toBe(false);
|
||||||
|
expect(arraysEqual(['a'], [])).toBe(false);
|
||||||
|
expect(arraysEqual(['a'], ['a', 'a'])).toBe(false);
|
||||||
|
expect(arraysEqual(['a', 'a'], ['a'])).toBe(false);
|
||||||
|
|
||||||
|
// Mixed parameters
|
||||||
|
const mockObject = { value: Math.random() };
|
||||||
|
expect(arraysEqual([], [mockObject])).toBe(false);
|
||||||
|
expect(arraysEqual([mockObject], [])).toBe(false);
|
||||||
|
expect(arraysEqual([mockObject, mockObject], [mockObject])).toBe(false);
|
||||||
|
expect(arraysEqual([mockObject], [mockObject, mockObject])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if items up to the first array\'s length differ', () => {
|
||||||
|
// Numbers
|
||||||
|
expect(arraysEqual([0], [1])).toBe(false);
|
||||||
|
expect(arraysEqual([0, 1], [1, 2])).toBe(false);
|
||||||
|
expect(arraysEqual([0, 1, 2], [1, 2, 3])).toBe(false);
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
expect(arraysEqual(['a'], ['b'])).toBe(false);
|
||||||
|
expect(arraysEqual(['a', 'b'], ['b', 'c'])).toBe(false);
|
||||||
|
expect(arraysEqual(['a', 'b', 'c'], ['b', 'c', 'd'])).toBe(false);
|
||||||
|
|
||||||
|
// Object by reference
|
||||||
|
const mockObject1 = { value1: Math.random() };
|
||||||
|
const mockObject2 = { value2: Math.random() };
|
||||||
|
expect(arraysEqual([mockObject1], [mockObject2])).toBe(false);
|
||||||
|
|
||||||
|
// Mixed parameters
|
||||||
|
expect(arraysEqual(
|
||||||
|
['a'],
|
||||||
|
['b', null, false, mockObject2, Infinity]
|
||||||
|
)).toBe(false);
|
||||||
|
expect(arraysEqual(
|
||||||
|
['a', null, true],
|
||||||
|
['b', null, false, mockObject2, Infinity]
|
||||||
|
)).toBe(false);
|
||||||
|
expect(arraysEqual(
|
||||||
|
['a', null, true, mockObject1],
|
||||||
|
['b', null, false, mockObject2, Infinity]
|
||||||
|
)).toBe(false);
|
||||||
|
expect(arraysEqual(
|
||||||
|
['a', null, true, mockObject1, -Infinity],
|
||||||
|
['b', null, false, mockObject2, Infinity]
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
BIN
assets/js/utils/__tests__/autocomplete-compiled-v2.bin
Normal file
BIN
assets/js/utils/__tests__/autocomplete-compiled-v2.bin
Normal file
Binary file not shown.
434
assets/js/utils/__tests__/dom.spec.ts
Normal file
434
assets/js/utils/__tests__/dom.spec.ts
Normal file
|
@ -0,0 +1,434 @@
|
||||||
|
import {
|
||||||
|
$,
|
||||||
|
$$,
|
||||||
|
clearEl,
|
||||||
|
escapeCss,
|
||||||
|
escapeHtml,
|
||||||
|
hideEl,
|
||||||
|
insertBefore,
|
||||||
|
makeEl,
|
||||||
|
onLeftClick,
|
||||||
|
removeEl,
|
||||||
|
showEl,
|
||||||
|
toggleEl,
|
||||||
|
whenReady,
|
||||||
|
findFirstTextNode,
|
||||||
|
} from '../dom';
|
||||||
|
import { getRandomArrayItem, getRandomIntBetween } from '../../../test/randomness';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
|
||||||
|
describe('DOM Utilities', () => {
|
||||||
|
const mockSelectors = ['#id', '.class', 'div', '#a .complex--selector:not(:hover)'];
|
||||||
|
const hiddenClass = 'hidden';
|
||||||
|
const createHiddenElement: Document['createElement'] = (...params: Parameters<Document['createElement']>) => {
|
||||||
|
const el = document.createElement(...params);
|
||||||
|
el.classList.add(hiddenClass);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('$', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the native querySelector method on document by default', () => {
|
||||||
|
const spy = jest.spyOn(document, 'querySelector');
|
||||||
|
|
||||||
|
mockSelectors.forEach((selector, nthCall) => {
|
||||||
|
$(selector);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the native querySelector method on the passed element', () => {
|
||||||
|
const mockElement = document.createElement('br');
|
||||||
|
const spy = jest.spyOn(mockElement, 'querySelector');
|
||||||
|
|
||||||
|
mockSelectors.forEach((selector, nthCall) => {
|
||||||
|
// FIXME This will not be necessary once the file is properly typed
|
||||||
|
$(selector, mockElement as unknown as Document);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('$$', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the native querySelectorAll method on document by default', () => {
|
||||||
|
const spy = jest.spyOn(document, 'querySelectorAll');
|
||||||
|
|
||||||
|
mockSelectors.forEach((selector, nthCall) => {
|
||||||
|
$$(selector);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the native querySelectorAll method on the passed element', () => {
|
||||||
|
const mockElement = document.createElement('br');
|
||||||
|
const spy = jest.spyOn(mockElement, 'querySelectorAll');
|
||||||
|
|
||||||
|
mockSelectors.forEach((selector, nthCall) => {
|
||||||
|
// FIXME This will not be necessary once the file is properly typed
|
||||||
|
$$(selector, mockElement as unknown as Document);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showEl', () => {
|
||||||
|
it(`should remove the ${hiddenClass} class from the provided element`, () => {
|
||||||
|
const mockElement = createHiddenElement('div');
|
||||||
|
showEl(mockElement);
|
||||||
|
expect(mockElement).not.toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should remove the ${hiddenClass} class from all provided elements`, () => {
|
||||||
|
const mockElements = [
|
||||||
|
createHiddenElement('div'),
|
||||||
|
createHiddenElement('a'),
|
||||||
|
createHiddenElement('strong'),
|
||||||
|
];
|
||||||
|
showEl(mockElements);
|
||||||
|
expect(mockElements[0]).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements[1]).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements[2]).not.toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should remove the ${hiddenClass} class from elements provided in multiple arrays`, () => {
|
||||||
|
const mockElements1 = [
|
||||||
|
createHiddenElement('div'),
|
||||||
|
createHiddenElement('a'),
|
||||||
|
];
|
||||||
|
const mockElements2 = [
|
||||||
|
createHiddenElement('strong'),
|
||||||
|
createHiddenElement('em'),
|
||||||
|
];
|
||||||
|
showEl(mockElements1, mockElements2);
|
||||||
|
expect(mockElements1[0]).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements1[1]).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements2[0]).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements2[1]).not.toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hideEl', () => {
|
||||||
|
it(`should add the ${hiddenClass} class to the provided element`, () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
hideEl(mockElement);
|
||||||
|
expect(mockElement).toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should add the ${hiddenClass} class to all provided elements`, () => {
|
||||||
|
const mockElements = [
|
||||||
|
document.createElement('div'),
|
||||||
|
document.createElement('a'),
|
||||||
|
document.createElement('strong'),
|
||||||
|
];
|
||||||
|
hideEl(mockElements);
|
||||||
|
expect(mockElements[0]).toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements[1]).toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements[2]).toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should add the ${hiddenClass} class to elements provided in multiple arrays`, () => {
|
||||||
|
const mockElements1 = [
|
||||||
|
document.createElement('div'),
|
||||||
|
document.createElement('a'),
|
||||||
|
];
|
||||||
|
const mockElements2 = [
|
||||||
|
document.createElement('strong'),
|
||||||
|
document.createElement('em'),
|
||||||
|
];
|
||||||
|
hideEl(mockElements1, mockElements2);
|
||||||
|
expect(mockElements1[0]).toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements1[1]).toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements2[0]).toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements2[1]).toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleEl', () => {
|
||||||
|
it(`should toggle the ${hiddenClass} class on the provided element`, () => {
|
||||||
|
const mockVisibleElement = document.createElement('div');
|
||||||
|
toggleEl(mockVisibleElement);
|
||||||
|
expect(mockVisibleElement).toHaveClass(hiddenClass);
|
||||||
|
|
||||||
|
const mockHiddenElement = createHiddenElement('div');
|
||||||
|
toggleEl(mockHiddenElement);
|
||||||
|
expect(mockHiddenElement).not.toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should toggle the ${hiddenClass} class on all provided elements`, () => {
|
||||||
|
const mockElements = [
|
||||||
|
document.createElement('div'),
|
||||||
|
createHiddenElement('a'),
|
||||||
|
document.createElement('strong'),
|
||||||
|
createHiddenElement('em'),
|
||||||
|
];
|
||||||
|
toggleEl(mockElements);
|
||||||
|
expect(mockElements[0]).toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements[1]).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements[2]).toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements[3]).not.toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should toggle the ${hiddenClass} class on elements provided in multiple arrays`, () => {
|
||||||
|
const mockElements1 = [
|
||||||
|
createHiddenElement('div'),
|
||||||
|
document.createElement('a'),
|
||||||
|
];
|
||||||
|
const mockElements2 = [
|
||||||
|
createHiddenElement('strong'),
|
||||||
|
document.createElement('em'),
|
||||||
|
];
|
||||||
|
toggleEl(mockElements1, mockElements2);
|
||||||
|
expect(mockElements1[0]).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements1[1]).toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements2[0]).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockElements2[1]).toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearEl', () => {
|
||||||
|
it('should not throw an exception for empty element', () => {
|
||||||
|
const emptyElement = document.createElement('br');
|
||||||
|
expect(emptyElement.children).toHaveLength(0);
|
||||||
|
expect(() => clearEl(emptyElement)).not.toThrow();
|
||||||
|
expect(emptyElement.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a single child node', () => {
|
||||||
|
const baseElement = document.createElement('p');
|
||||||
|
baseElement.appendChild(document.createElement('br'));
|
||||||
|
expect(baseElement.children).toHaveLength(1);
|
||||||
|
clearEl(baseElement);
|
||||||
|
expect(baseElement.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a multiple child nodes', () => {
|
||||||
|
const baseElement = document.createElement('p');
|
||||||
|
const elementsToAdd = getRandomIntBetween(5, 10);
|
||||||
|
for (let i = 0; i < elementsToAdd; ++i) {
|
||||||
|
baseElement.appendChild(document.createElement('br'));
|
||||||
|
}
|
||||||
|
expect(baseElement.children).toHaveLength(elementsToAdd);
|
||||||
|
clearEl(baseElement);
|
||||||
|
expect(baseElement.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove child nodes of elements provided in multiple arrays', () => {
|
||||||
|
const baseElement1 = document.createElement('p');
|
||||||
|
const elementsToAdd1 = getRandomIntBetween(5, 10);
|
||||||
|
for (let i = 0; i < elementsToAdd1; ++i) {
|
||||||
|
baseElement1.appendChild(document.createElement('br'));
|
||||||
|
}
|
||||||
|
expect(baseElement1.children).toHaveLength(elementsToAdd1);
|
||||||
|
|
||||||
|
const baseElement2 = document.createElement('p');
|
||||||
|
const elementsToAdd2 = getRandomIntBetween(5, 10);
|
||||||
|
for (let i = 0; i < elementsToAdd2; ++i) {
|
||||||
|
baseElement2.appendChild(document.createElement('br'));
|
||||||
|
}
|
||||||
|
expect(baseElement2.children).toHaveLength(elementsToAdd2);
|
||||||
|
|
||||||
|
clearEl([baseElement1], [baseElement2]);
|
||||||
|
expect(baseElement1.children).toHaveLength(0);
|
||||||
|
expect(baseElement2.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeEl', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if element has no parent', () => {
|
||||||
|
const detachedElement = document.createElement('div');
|
||||||
|
expect(() => removeEl(detachedElement)).toThrow(/propert(y|ies).*null/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the native removeElement method on parent', () => {
|
||||||
|
const parentNode = document.createElement('div');
|
||||||
|
const childNode = document.createElement('p');
|
||||||
|
parentNode.appendChild(childNode);
|
||||||
|
|
||||||
|
const spy = jest.spyOn(parentNode, 'removeChild');
|
||||||
|
|
||||||
|
removeEl(childNode);
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(1, childNode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeEl', () => {
|
||||||
|
it('should create br tag', () => {
|
||||||
|
const el = makeEl('br');
|
||||||
|
expect(el.nodeName).toEqual('BR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a script tag', () => {
|
||||||
|
const mockSource = 'https://example.com/';
|
||||||
|
const el = makeEl('script', { src: mockSource, async: true, defer: true });
|
||||||
|
expect(el.nodeName).toEqual('SCRIPT');
|
||||||
|
expect(el.src).toEqual(mockSource);
|
||||||
|
expect(el.async).toEqual(true);
|
||||||
|
expect(el.defer).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a link tag', () => {
|
||||||
|
const mockHref = 'https://example.com/';
|
||||||
|
const mockTarget = '_blank';
|
||||||
|
const el = makeEl('a', { href: mockHref, target: mockTarget });
|
||||||
|
expect(el.nodeName).toEqual('A');
|
||||||
|
expect(el.href).toEqual(mockHref);
|
||||||
|
expect(el.target).toEqual(mockTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create paragraph tag', () => {
|
||||||
|
const mockClassOne = 'class-one';
|
||||||
|
const mockClassTwo = 'class-two';
|
||||||
|
const el = makeEl('p', { className: `${mockClassOne} ${mockClassTwo}` });
|
||||||
|
expect(el.nodeName).toEqual('P');
|
||||||
|
expect(el).toHaveClass(mockClassOne);
|
||||||
|
expect(el).toHaveClass(mockClassTwo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertBefore', () => {
|
||||||
|
it('should insert the new element before the existing element', () => {
|
||||||
|
const mockParent = document.createElement('p');
|
||||||
|
const mockExisingElement = document.createElement('span');
|
||||||
|
mockParent.appendChild(mockExisingElement);
|
||||||
|
const mockNewElement = document.createElement('strong');
|
||||||
|
|
||||||
|
insertBefore(mockExisingElement, mockNewElement);
|
||||||
|
|
||||||
|
expect(mockParent.children).toHaveLength(2);
|
||||||
|
expect(mockParent.children[0].tagName).toBe('STRONG');
|
||||||
|
expect(mockParent.children[1].tagName).toBe('SPAN');
|
||||||
|
});
|
||||||
|
it('should insert between two elements', () => {
|
||||||
|
const mockParent = document.createElement('p');
|
||||||
|
const mockFirstExisingElement = document.createElement('span');
|
||||||
|
const mockSecondExisingElement = document.createElement('em');
|
||||||
|
mockParent.appendChild(mockFirstExisingElement);
|
||||||
|
mockParent.appendChild(mockSecondExisingElement);
|
||||||
|
const mockNewElement = document.createElement('strong');
|
||||||
|
|
||||||
|
insertBefore(mockSecondExisingElement, mockNewElement);
|
||||||
|
|
||||||
|
expect(mockParent.children).toHaveLength(3);
|
||||||
|
expect(mockParent.children[0].tagName).toBe('SPAN');
|
||||||
|
expect(mockParent.children[1].tagName).toBe('STRONG');
|
||||||
|
expect(mockParent.children[2].tagName).toBe('EM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if there is no parent', () => {
|
||||||
|
const mockParent = document.createElement('p');
|
||||||
|
const mockNewElement = document.createElement('em');
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
insertBefore(mockParent, mockNewElement);
|
||||||
|
}).toThrow(/propert(y|ies).*null/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onLeftClick', () => {
|
||||||
|
it('should call callback on left click', () => {
|
||||||
|
const mockCallback = jest.fn();
|
||||||
|
const element = document.createElement('div');
|
||||||
|
onLeftClick(mockCallback, element as unknown as Document);
|
||||||
|
|
||||||
|
fireEvent.click(element, { button: 0 });
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT call callback on non-left click', () => {
|
||||||
|
const mockCallback = jest.fn();
|
||||||
|
const element = document.createElement('div');
|
||||||
|
onLeftClick(mockCallback, element as unknown as Document);
|
||||||
|
|
||||||
|
const mockButton = getRandomArrayItem([1, 2, 3, 4, 5]);
|
||||||
|
fireEvent.click(element, { button: mockButton });
|
||||||
|
|
||||||
|
expect(mockCallback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add click event listener to the document by default', () => {
|
||||||
|
const mockCallback = jest.fn();
|
||||||
|
onLeftClick(mockCallback);
|
||||||
|
|
||||||
|
fireEvent.click(document.body);
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('whenReady', () => {
|
||||||
|
it('should call callback immediately if document ready state is not loading', () => {
|
||||||
|
const mockReadyStateValue = getRandomArrayItem<DocumentReadyState>(['complete', 'interactive']);
|
||||||
|
const readyStateSpy = jest.spyOn(document, 'readyState', 'get').mockReturnValue(mockReadyStateValue);
|
||||||
|
const mockCallback = jest.fn();
|
||||||
|
|
||||||
|
try {
|
||||||
|
whenReady(mockCallback);
|
||||||
|
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
readyStateSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add event listener with callback if document ready state is loading', () => {
|
||||||
|
const readyStateSpy = jest.spyOn(document, 'readyState', 'get').mockReturnValue('loading');
|
||||||
|
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
|
||||||
|
const mockCallback = jest.fn();
|
||||||
|
|
||||||
|
try {
|
||||||
|
whenReady(mockCallback);
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'DOMContentLoaded', mockCallback);
|
||||||
|
expect(mockCallback).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
readyStateSpy.mockRestore();
|
||||||
|
addEventListenerSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('escapeHtml', () => {
|
||||||
|
it('should replace only the expected characters with their HTML entity equivalents', () => {
|
||||||
|
expect(escapeHtml('<script src="http://example.com/?a=1&b=2"></script>')).toBe('<script src="http://example.com/?a=1&b=2"></script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('escapeCss', () => {
|
||||||
|
it('should replace only the expected characters with their escaped equivalents', () => {
|
||||||
|
expect(escapeCss('url("https://example.com")')).toBe('url(\\"https://example.com\\")');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findFirstTextNode', () => {
|
||||||
|
it('should return the first text node child', () => {
|
||||||
|
const mockText = `expected text ${Math.random()}`;
|
||||||
|
const mockNode = document.createElement('div');
|
||||||
|
mockNode.innerHTML = `<strong>bold</strong>${mockText}<em>italic</em>`;
|
||||||
|
|
||||||
|
const result: Node = findFirstTextNode(mockNode);
|
||||||
|
expect(result.nodeValue).toBe(mockText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined if there is no text node child', () => {
|
||||||
|
const mockNode = document.createElement('div');
|
||||||
|
mockNode.innerHTML = '<strong>bold</strong><em>italic</em>';
|
||||||
|
|
||||||
|
const result: Node = findFirstTextNode(mockNode);
|
||||||
|
expect(result).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
285
assets/js/utils/__tests__/draggable.spec.ts
Normal file
285
assets/js/utils/__tests__/draggable.spec.ts
Normal file
|
@ -0,0 +1,285 @@
|
||||||
|
import { initDraggables } from '../draggable';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { getRandomArrayItem } from '../../../test/randomness';
|
||||||
|
|
||||||
|
describe('Draggable Utilities', () => {
|
||||||
|
// jsdom lacks proper support for window.DragEvent so this is an attempt at a minimal recreation
|
||||||
|
const createDragEvent = (name: string, init?: DragEventInit): DragEvent => {
|
||||||
|
const mockEvent = new Event(name, { bubbles: true, cancelable: true }) as unknown as DragEvent;
|
||||||
|
let dataTransfer = init?.dataTransfer;
|
||||||
|
if (!dataTransfer) {
|
||||||
|
const items: Pick<DataTransferItem, 'type' | 'getAsString'>[] = [];
|
||||||
|
dataTransfer = {
|
||||||
|
items: items as unknown as DataTransferItemList,
|
||||||
|
setData(format: string, data: string) {
|
||||||
|
items.push({ type: format, getAsString: (callback: FunctionStringCallback) => callback(data) });
|
||||||
|
}
|
||||||
|
} as unknown as DataTransfer;
|
||||||
|
}
|
||||||
|
Object.assign(mockEvent, { dataTransfer });
|
||||||
|
return mockEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDraggableElement = (): HTMLDivElement => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.setAttribute('draggable', 'true');
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('initDraggables', () => {
|
||||||
|
const draggingClass = 'dragging';
|
||||||
|
const dragContainerClass = 'drag-container';
|
||||||
|
const dragOverClass = 'over';
|
||||||
|
let documentEventListenerSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
let mockDragContainer: HTMLDivElement;
|
||||||
|
let mockDraggable: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDragContainer = document.createElement('div');
|
||||||
|
mockDragContainer.classList.add(dragContainerClass);
|
||||||
|
document.body.appendChild(mockDragContainer);
|
||||||
|
|
||||||
|
mockDraggable = createDraggableElement();
|
||||||
|
mockDragContainer.appendChild(mockDraggable);
|
||||||
|
|
||||||
|
|
||||||
|
// Redirect all document event listeners to this element for easier cleanup
|
||||||
|
documentEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation((...params) => {
|
||||||
|
mockDragContainer.addEventListener(...params);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.removeChild(mockDragContainer);
|
||||||
|
documentEventListenerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragStart', () => {
|
||||||
|
it('should add the dragging class to the element that starts moving', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockEvent = createDragEvent('dragstart');
|
||||||
|
|
||||||
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
|
||||||
|
expect(mockDraggable).toHaveClass(draggingClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add dummy data to the dragstart event if it\'s empty', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockEvent = createDragEvent('dragstart');
|
||||||
|
expect(mockEvent.dataTransfer?.items).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
|
||||||
|
expect(mockEvent.dataTransfer?.items).toHaveLength(1);
|
||||||
|
|
||||||
|
const dataTransferItem = (mockEvent.dataTransfer as DataTransfer).items[0];
|
||||||
|
expect(dataTransferItem.type).toEqual('text/plain');
|
||||||
|
|
||||||
|
let stringValue: string | undefined;
|
||||||
|
dataTransferItem.getAsString(value => {
|
||||||
|
stringValue = value;
|
||||||
|
});
|
||||||
|
expect(stringValue).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep data in the dragstart event if it\'s present', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockTransferItemType = getRandomArrayItem(['text/javascript', 'image/jpg', 'application/json']);
|
||||||
|
const mockDataTransferItem: DataTransferItem = {
|
||||||
|
type: mockTransferItemType,
|
||||||
|
} as unknown as DataTransferItem;
|
||||||
|
|
||||||
|
const mockEvent = createDragEvent('dragstart', { dataTransfer: { items: [mockDataTransferItem] as unknown as DataTransferItemList } } as DragEventInit);
|
||||||
|
expect(mockEvent.dataTransfer?.items).toHaveLength(1);
|
||||||
|
|
||||||
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
|
||||||
|
expect(mockEvent.dataTransfer?.items).toHaveLength(1);
|
||||||
|
|
||||||
|
const dataTransferItem = (mockEvent.dataTransfer as DataTransfer).items[0];
|
||||||
|
expect(dataTransferItem.type).toEqual(mockTransferItemType);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the allowed effect to move on the data transfer', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockEvent = createDragEvent('dragstart');
|
||||||
|
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
|
||||||
|
|
||||||
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
|
||||||
|
expect(mockEvent.dataTransfer?.effectAllowed).toEqual('move');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragOver', () => {
|
||||||
|
it('should cancel event and set the drop effect to move on the data transfer', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockEvent = createDragEvent('dragover');
|
||||||
|
|
||||||
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
|
||||||
|
expect(mockEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(mockEvent.dataTransfer?.dropEffect).toEqual('move');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragEnter', () => {
|
||||||
|
it('should add the over class to the target', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockEvent = createDragEvent('dragenter');
|
||||||
|
|
||||||
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
|
||||||
|
expect(mockDraggable).toHaveClass(dragOverClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragLeave', () => {
|
||||||
|
it('should remove the over class from the target', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
mockDraggable.classList.add(dragOverClass);
|
||||||
|
const mockEvent = createDragEvent('dragleave');
|
||||||
|
|
||||||
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
|
||||||
|
expect(mockDraggable).not.toHaveClass(dragOverClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('drop', () => {
|
||||||
|
it('should cancel the event and remove dragging class if dropped on same element', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockStartEvent = createDragEvent('dragstart');
|
||||||
|
fireEvent(mockDraggable, mockStartEvent);
|
||||||
|
|
||||||
|
expect(mockDraggable).toHaveClass(draggingClass);
|
||||||
|
|
||||||
|
const mockDropEvent = createDragEvent('drop');
|
||||||
|
fireEvent(mockDraggable, mockDropEvent);
|
||||||
|
|
||||||
|
expect(mockDropEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(mockDraggable).not.toHaveClass(draggingClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel the event and insert source before target if dropped on left side', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockSecondDraggable = createDraggableElement();
|
||||||
|
mockDragContainer.appendChild(mockSecondDraggable);
|
||||||
|
|
||||||
|
const mockStartEvent = createDragEvent('dragstart');
|
||||||
|
fireEvent(mockSecondDraggable, mockStartEvent);
|
||||||
|
|
||||||
|
expect(mockSecondDraggable).toHaveClass(draggingClass);
|
||||||
|
|
||||||
|
const mockDropEvent = createDragEvent('drop');
|
||||||
|
Object.assign(mockDropEvent, { clientX: 124 });
|
||||||
|
const boundingBoxSpy = jest.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
left: 100,
|
||||||
|
width: 50,
|
||||||
|
} as unknown as DOMRect);
|
||||||
|
fireEvent(mockDraggable, mockDropEvent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(mockDropEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(mockSecondDraggable).not.toHaveClass(draggingClass);
|
||||||
|
expect(mockSecondDraggable.nextElementSibling).toBe(mockDraggable);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
boundingBoxSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel the event and insert source after target if dropped on right side', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockSecondDraggable = createDraggableElement();
|
||||||
|
mockDragContainer.appendChild(mockSecondDraggable);
|
||||||
|
|
||||||
|
const mockStartEvent = createDragEvent('dragstart');
|
||||||
|
fireEvent(mockSecondDraggable, mockStartEvent);
|
||||||
|
|
||||||
|
expect(mockSecondDraggable).toHaveClass(draggingClass);
|
||||||
|
|
||||||
|
const mockDropEvent = createDragEvent('drop');
|
||||||
|
Object.assign(mockDropEvent, { clientX: 125 });
|
||||||
|
const boundingBoxSpy = jest.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
left: 100,
|
||||||
|
width: 50,
|
||||||
|
} as unknown as DOMRect);
|
||||||
|
fireEvent(mockDraggable, mockDropEvent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(mockDropEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(mockSecondDraggable).not.toHaveClass(draggingClass);
|
||||||
|
expect(mockDraggable.nextElementSibling).toBe(mockSecondDraggable);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
boundingBoxSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragEnd', () => {
|
||||||
|
it('should remove dragging class from source and over class from target\'s descendants', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockStartEvent = createDragEvent('dragstart');
|
||||||
|
fireEvent(mockDraggable, mockStartEvent);
|
||||||
|
|
||||||
|
expect(mockDraggable).toHaveClass(draggingClass);
|
||||||
|
|
||||||
|
const mockOverElement = createDraggableElement();
|
||||||
|
mockOverElement.classList.add(dragOverClass);
|
||||||
|
mockDraggable.parentNode?.appendChild(mockOverElement);
|
||||||
|
const mockOverEvent = createDragEvent('dragend');
|
||||||
|
fireEvent(mockOverElement, mockOverEvent);
|
||||||
|
|
||||||
|
const mockDropEvent = createDragEvent('dragend');
|
||||||
|
fireEvent(mockDraggable, mockDropEvent);
|
||||||
|
|
||||||
|
expect(mockDraggable).not.toHaveClass(draggingClass);
|
||||||
|
expect(mockOverElement).not.toHaveClass(dragOverClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wrapper', () => {
|
||||||
|
it('should do nothing when event target has no closest method', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockEvent = createDragEvent('dragstart');
|
||||||
|
Object.assign(mockDraggable, { closest: undefined });
|
||||||
|
|
||||||
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
|
||||||
|
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when event target does not have a parent matching the predefined selector', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockEvent = createDragEvent('dragstart');
|
||||||
|
const documentClosestSpy = jest.spyOn(mockDraggable, 'closest').mockReturnValue(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
|
||||||
|
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
documentClosestSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
143
assets/js/utils/__tests__/events.spec.ts
Normal file
143
assets/js/utils/__tests__/events.spec.ts
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
import { delegate, fire, leftClick, on } from '../events';
|
||||||
|
import { getRandomArrayItem } from '../../../test/randomness';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
|
||||||
|
describe('Event utils', () => {
|
||||||
|
const mockEvent = getRandomArrayItem(['click', 'blur', 'mouseleave']);
|
||||||
|
|
||||||
|
describe('fire', () => {
|
||||||
|
it('should call the native dispatchEvent method on the element', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const dispatchEventSpy = jest.spyOn(mockElement, 'dispatchEvent');
|
||||||
|
const mockDetail = getRandomArrayItem([0, 'test', null]);
|
||||||
|
|
||||||
|
fire(mockElement, mockEvent, mockDetail);
|
||||||
|
|
||||||
|
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [customEvent] = dispatchEventSpy.mock.calls[0] as (Event | CustomEvent)[];
|
||||||
|
|
||||||
|
expect(customEvent).toBeInstanceOf(CustomEvent);
|
||||||
|
expect(customEvent.type).toBe(mockEvent);
|
||||||
|
expect(customEvent.bubbles).toBe(true);
|
||||||
|
expect(customEvent.cancelable).toBe(true);
|
||||||
|
expect((customEvent as CustomEvent).detail).toBe(mockDetail);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('on', () => {
|
||||||
|
it('should fire handler on descendant click', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
|
||||||
|
const mockWrapperElement = document.createElement('div');
|
||||||
|
mockWrapperElement.classList.add('wrapper');
|
||||||
|
mockElement.appendChild(mockWrapperElement);
|
||||||
|
|
||||||
|
const mockInnerElement = document.createElement('div');
|
||||||
|
const innerClass = 'inner';
|
||||||
|
mockInnerElement.classList.add(innerClass);
|
||||||
|
mockWrapperElement.appendChild(mockInnerElement);
|
||||||
|
|
||||||
|
const mockButton = document.createElement('button');
|
||||||
|
mockButton.classList.add('mock-button');
|
||||||
|
mockInnerElement.appendChild(mockButton);
|
||||||
|
|
||||||
|
const mockHandler = jest.fn();
|
||||||
|
on(mockElement, 'click', `.${innerClass}`, mockHandler);
|
||||||
|
|
||||||
|
fireEvent(mockButton, new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(mockHandler).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
const [event, target] = mockHandler.mock.calls[0];
|
||||||
|
expect(event).toBeInstanceOf(Event);
|
||||||
|
expect(target).toBe(mockInnerElement);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leftClick', () => {
|
||||||
|
it('should fire on left click', () => {
|
||||||
|
const mockButton = document.createElement('button');
|
||||||
|
const mockHandler = jest.fn();
|
||||||
|
|
||||||
|
mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton));
|
||||||
|
|
||||||
|
fireEvent.click(mockButton, { button: 0 });
|
||||||
|
|
||||||
|
expect(mockHandler).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT fire on any other click', () => {
|
||||||
|
const mockButton = document.createElement('button');
|
||||||
|
const mockHandler = jest.fn();
|
||||||
|
const mockButtonNumber = getRandomArrayItem([1, 2, 3, 4, 5]);
|
||||||
|
|
||||||
|
mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton));
|
||||||
|
|
||||||
|
fireEvent.click(mockButton, { button: mockButtonNumber });
|
||||||
|
|
||||||
|
expect(mockHandler).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delegate', () => {
|
||||||
|
it('should call the native addEventListener method on the element', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const addEventListenerSpy = jest.spyOn(mockElement, 'addEventListener');
|
||||||
|
|
||||||
|
delegate(mockElement, mockEvent, {});
|
||||||
|
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [event, handler] = addEventListenerSpy.mock.calls[0];
|
||||||
|
expect(event).toBe(mockEvent);
|
||||||
|
expect(typeof handler).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the function for the selector', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const parentClass = 'parent';
|
||||||
|
mockElement.classList.add(parentClass);
|
||||||
|
|
||||||
|
const mockButton = document.createElement('button');
|
||||||
|
mockElement.appendChild(mockButton);
|
||||||
|
|
||||||
|
const mockHandler = jest.fn();
|
||||||
|
delegate(mockElement, 'click', { [`.${parentClass}`]: mockHandler });
|
||||||
|
|
||||||
|
fireEvent(mockButton, new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(mockHandler).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
const [event, target] = mockHandler.mock.calls[0];
|
||||||
|
expect(event).toBeInstanceOf(Event);
|
||||||
|
expect(target).toBe(mockElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop executing handlers after one returns with false', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const parentClass = 'parent';
|
||||||
|
mockElement.classList.add(parentClass);
|
||||||
|
|
||||||
|
const mockWrapperElement = document.createElement('div');
|
||||||
|
const wrapperClass = 'wrapper';
|
||||||
|
mockWrapperElement.classList.add(wrapperClass);
|
||||||
|
mockElement.appendChild(mockWrapperElement);
|
||||||
|
|
||||||
|
const mockButton = document.createElement('button');
|
||||||
|
mockWrapperElement.appendChild(mockButton);
|
||||||
|
|
||||||
|
const mockParentHandler = jest.fn();
|
||||||
|
const mockWrapperHandler = jest.fn().mockReturnValue(false);
|
||||||
|
delegate(mockElement, 'click', {
|
||||||
|
[`.${wrapperClass}`]: mockWrapperHandler,
|
||||||
|
[`.${parentClass}`]: mockParentHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent(mockButton, new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(mockWrapperHandler).toBeCalledTimes(1);
|
||||||
|
expect(mockParentHandler).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
569
assets/js/utils/__tests__/image.spec.ts
Normal file
569
assets/js/utils/__tests__/image.spec.ts
Normal file
|
@ -0,0 +1,569 @@
|
||||||
|
import { hideThumb, showBlock, showThumb, spoilerBlock, spoilerThumb } from '../image';
|
||||||
|
import { getRandomArrayItem } from '../../../test/randomness';
|
||||||
|
import { mockStorage } from '../../../test/mock-storage';
|
||||||
|
import { createEvent, fireEvent } from '@testing-library/dom';
|
||||||
|
import { EventType } from '@testing-library/dom/types/events';
|
||||||
|
|
||||||
|
describe('Image utils', () => {
|
||||||
|
const hiddenClass = 'hidden';
|
||||||
|
const spoilerOverlayClass = 'js-spoiler-info-overlay';
|
||||||
|
const serveHidpiStorageKey = 'serve_hidpi';
|
||||||
|
const mockSpoilerReason = 'Mock reason';
|
||||||
|
const mockSpoilerUri = '/images/tagblocked.svg';
|
||||||
|
const mockImageUri = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||||
|
const getMockImageSizeUrls = (extension: string) => ({
|
||||||
|
thumb: `https://example.com/thumb.${extension}`,
|
||||||
|
small: `https://example.com/small.${extension}`,
|
||||||
|
medium: `https://example.com/medium.${extension}`,
|
||||||
|
large: `https://example.com/large.${extension}`,
|
||||||
|
});
|
||||||
|
type ImageSize = keyof ReturnType<typeof getMockImageSizeUrls>;
|
||||||
|
const PossibleImageSizes: ImageSize[] = ['thumb', 'small', 'medium', 'large'];
|
||||||
|
|
||||||
|
const applyMockDataAttributes = (element: HTMLElement, extension: string, size?: ImageSize) => {
|
||||||
|
const mockSize = size || getRandomArrayItem(PossibleImageSizes);
|
||||||
|
const mockSizeUrls = getMockImageSizeUrls(extension);
|
||||||
|
element.setAttribute('data-size', mockSize);
|
||||||
|
element.setAttribute('data-uris', JSON.stringify(mockSizeUrls));
|
||||||
|
return { mockSize, mockSizeUrls };
|
||||||
|
};
|
||||||
|
const createMockSpoilerOverlay = () => {
|
||||||
|
const mockSpoilerOverlay = document.createElement('div');
|
||||||
|
mockSpoilerOverlay.classList.add(spoilerOverlayClass);
|
||||||
|
return mockSpoilerOverlay;
|
||||||
|
};
|
||||||
|
const createMockElementWithPicture = (extension: string, size?: ImageSize) => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const { mockSizeUrls, mockSize } = applyMockDataAttributes(mockElement, extension, size);
|
||||||
|
|
||||||
|
const mockPicture = document.createElement('picture');
|
||||||
|
mockElement.appendChild(mockPicture);
|
||||||
|
|
||||||
|
const mockSizeImage = new Image();
|
||||||
|
mockPicture.appendChild(mockSizeImage);
|
||||||
|
|
||||||
|
const mockSpoilerOverlay = createMockSpoilerOverlay();
|
||||||
|
mockElement.appendChild(mockSpoilerOverlay);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mockElement,
|
||||||
|
mockPicture,
|
||||||
|
mockSize,
|
||||||
|
mockSizeImage,
|
||||||
|
mockSizeUrls,
|
||||||
|
mockSpoilerOverlay,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('showThumb', () => {
|
||||||
|
let mockServeHidpiValue: string | null = null;
|
||||||
|
mockStorage({
|
||||||
|
getItem(key: string) {
|
||||||
|
if (key !== serveHidpiStorageKey) return null;
|
||||||
|
|
||||||
|
return mockServeHidpiValue;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('video thumbnail', () => {
|
||||||
|
type CreateMockElementsOptions = {
|
||||||
|
extension: string;
|
||||||
|
videoClasses?: string[];
|
||||||
|
imgClasses?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMockElements = ({ videoClasses, imgClasses, extension }: CreateMockElementsOptions) => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const { mockSize, mockSizeUrls } = applyMockDataAttributes(mockElement, extension);
|
||||||
|
|
||||||
|
const mockImage = new Image();
|
||||||
|
mockImage.src = mockImageUri;
|
||||||
|
if (imgClasses) {
|
||||||
|
imgClasses.forEach(videoClass => {
|
||||||
|
mockImage.classList.add(videoClass);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mockElement.appendChild(mockImage);
|
||||||
|
|
||||||
|
const mockVideo = document.createElement('video');
|
||||||
|
if (videoClasses) {
|
||||||
|
videoClasses.forEach(videoClass => {
|
||||||
|
mockVideo.classList.add(videoClass);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mockElement.appendChild(mockVideo);
|
||||||
|
const playSpy = jest.spyOn(mockVideo, 'play').mockReturnValue(Promise.resolve());
|
||||||
|
|
||||||
|
const mockSpoilerOverlay = createMockSpoilerOverlay();
|
||||||
|
mockElement.appendChild(mockSpoilerOverlay);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mockElement,
|
||||||
|
mockImage,
|
||||||
|
mockSize,
|
||||||
|
mockSizeUrls,
|
||||||
|
mockSpoilerOverlay,
|
||||||
|
mockVideo,
|
||||||
|
playSpy,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should hide the img element and show the video instead if no picture element is present', () => {
|
||||||
|
const {
|
||||||
|
mockElement,
|
||||||
|
mockImage,
|
||||||
|
playSpy,
|
||||||
|
mockVideo,
|
||||||
|
mockSize,
|
||||||
|
mockSizeUrls,
|
||||||
|
mockSpoilerOverlay,
|
||||||
|
} = createMockElements({
|
||||||
|
extension: 'webm',
|
||||||
|
videoClasses: ['hidden'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
|
||||||
|
expect(mockImage).toHaveClass(hiddenClass);
|
||||||
|
expect(mockVideo.children).toHaveLength(2);
|
||||||
|
|
||||||
|
const webmSourceElement = mockVideo.children[0];
|
||||||
|
const webmSource = mockSizeUrls[mockSize];
|
||||||
|
expect(webmSourceElement.nodeName).toEqual('SOURCE');
|
||||||
|
expect(webmSourceElement.getAttribute('type')).toEqual('video/webm');
|
||||||
|
expect(webmSourceElement.getAttribute('src')).toEqual(webmSource);
|
||||||
|
|
||||||
|
const mp4SourceElement = mockVideo.children[1];
|
||||||
|
expect(mp4SourceElement.nodeName).toEqual('SOURCE');
|
||||||
|
expect(mp4SourceElement.getAttribute('type')).toEqual('video/mp4');
|
||||||
|
expect(mp4SourceElement.getAttribute('src')).toEqual(webmSource.replace('webm', 'mp4'));
|
||||||
|
|
||||||
|
expect(mockVideo).not.toHaveClass(hiddenClass);
|
||||||
|
expect(playSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return early if there is no video element', () => {
|
||||||
|
const { mockElement, mockVideo, playSpy } = createMockElements({
|
||||||
|
extension: 'webm',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockElement.removeChild(mockVideo);
|
||||||
|
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(playSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return early if img element is missing', () => {
|
||||||
|
const { mockElement, mockImage, playSpy } = createMockElements({
|
||||||
|
extension: 'webm',
|
||||||
|
imgClasses: ['hidden'],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockElement.removeChild(mockImage);
|
||||||
|
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(playSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return early if img element already has the hidden class', () => {
|
||||||
|
const { mockElement, playSpy } = createMockElements({
|
||||||
|
extension: 'webm',
|
||||||
|
imgClasses: ['hidden'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(playSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the correct thumbnail image for jpg extension', () => {
|
||||||
|
const {
|
||||||
|
mockElement,
|
||||||
|
mockSizeImage,
|
||||||
|
mockSizeUrls,
|
||||||
|
mockSize,
|
||||||
|
mockSpoilerOverlay,
|
||||||
|
} = createMockElementWithPicture('jpg');
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
|
||||||
|
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
|
||||||
|
expect(mockSizeImage.srcset).toBe('');
|
||||||
|
|
||||||
|
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the correct thumbnail image for gif extension', () => {
|
||||||
|
const {
|
||||||
|
mockElement,
|
||||||
|
mockSizeImage,
|
||||||
|
mockSizeUrls,
|
||||||
|
mockSize,
|
||||||
|
mockSpoilerOverlay,
|
||||||
|
} = createMockElementWithPicture('gif');
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
|
||||||
|
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
|
||||||
|
expect(mockSizeImage.srcset).toBe('');
|
||||||
|
|
||||||
|
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the correct thumbnail image for webm extension', () => {
|
||||||
|
const {
|
||||||
|
mockElement,
|
||||||
|
mockSpoilerOverlay,
|
||||||
|
mockSizeImage,
|
||||||
|
mockSizeUrls,
|
||||||
|
mockSize,
|
||||||
|
} = createMockElementWithPicture('webm');
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
|
||||||
|
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize].replace('webm', 'gif'));
|
||||||
|
expect(mockSizeImage.srcset).toBe('');
|
||||||
|
|
||||||
|
expect(mockSpoilerOverlay).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockSpoilerOverlay).toHaveTextContent('WebM');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('high DPI srcset handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockServeHidpiValue = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkSrcsetAttribute = (size: ImageSize, x2size: ImageSize) => {
|
||||||
|
const {
|
||||||
|
mockElement,
|
||||||
|
mockSizeImage,
|
||||||
|
mockSizeUrls,
|
||||||
|
mockSpoilerOverlay,
|
||||||
|
} = createMockElementWithPicture('jpg', size);
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
|
||||||
|
expect(mockSizeImage.src).toBe(mockSizeUrls[size]);
|
||||||
|
expect(mockSizeImage.srcset).toContain(`${mockSizeUrls[size]} 1x`);
|
||||||
|
expect(mockSizeImage.srcset).toContain(`${mockSizeUrls[x2size]} 2x`);
|
||||||
|
|
||||||
|
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should set correct srcset on img if thumbUri is NOT a gif at small size', () => {
|
||||||
|
const result = checkSrcsetAttribute('small', 'medium');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set correct srcset on img if thumbUri is NOT a gif at medium size', () => {
|
||||||
|
const result = checkSrcsetAttribute('medium', 'large');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT set srcset on img if thumbUri is a gif at small size', () => {
|
||||||
|
const mockSize = 'small';
|
||||||
|
const {
|
||||||
|
mockElement,
|
||||||
|
mockSizeImage,
|
||||||
|
mockSizeUrls,
|
||||||
|
mockSpoilerOverlay,
|
||||||
|
} = createMockElementWithPicture('gif', mockSize);
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
|
||||||
|
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
|
||||||
|
expect(mockSizeImage.srcset).toBe('');
|
||||||
|
|
||||||
|
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if img cannot be found', () => {
|
||||||
|
const { mockElement, mockPicture, mockSizeImage } = createMockElementWithPicture('jpg');
|
||||||
|
mockPicture.removeChild(mockSizeImage);
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if img source already matches thumbUri', () => {
|
||||||
|
const {
|
||||||
|
mockElement,
|
||||||
|
mockSizeImage,
|
||||||
|
mockSizeUrls,
|
||||||
|
mockSize,
|
||||||
|
} = createMockElementWithPicture('jpg');
|
||||||
|
mockSizeImage.src = mockSizeUrls[mockSize];
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showBlock', () => {
|
||||||
|
const imageFilteredClass = 'image-filtered';
|
||||||
|
const imageShowClass = 'image-show';
|
||||||
|
const spoilerPendingClass = 'spoiler-pending';
|
||||||
|
|
||||||
|
it('should hide the filtered image element and show the image', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
|
||||||
|
const mockFilteredImageElement = document.createElement('div');
|
||||||
|
mockFilteredImageElement.classList.add(imageFilteredClass);
|
||||||
|
mockElement.appendChild(mockFilteredImageElement);
|
||||||
|
|
||||||
|
const mockShowElement = document.createElement('div');
|
||||||
|
mockShowElement.classList.add(imageShowClass);
|
||||||
|
mockShowElement.classList.add(hiddenClass);
|
||||||
|
mockElement.appendChild(mockShowElement);
|
||||||
|
|
||||||
|
showBlock(mockElement);
|
||||||
|
|
||||||
|
expect(mockFilteredImageElement).toHaveClass(hiddenClass);
|
||||||
|
expect(mockShowElement).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockShowElement).toHaveClass(spoilerPendingClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hideThumb', () => {
|
||||||
|
describe('hideVideoThumb', () => {
|
||||||
|
it('should return early if picture AND video elements are missing', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
|
||||||
|
const querySelectorSpy = jest.spyOn(mockElement, 'querySelector');
|
||||||
|
|
||||||
|
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(querySelectorSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
|
||||||
|
expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
querySelectorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('should return early if picture and img elements are missing BUT video element is present', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const mockVideo = document.createElement('video');
|
||||||
|
mockElement.appendChild(mockVideo);
|
||||||
|
const pauseSpy = jest.spyOn(mockVideo, 'pause').mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const querySelectorSpy = jest.spyOn(mockElement, 'querySelector');
|
||||||
|
|
||||||
|
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(querySelectorSpy).toHaveBeenCalledTimes(4);
|
||||||
|
expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
|
||||||
|
expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video');
|
||||||
|
expect(querySelectorSpy).toHaveBeenNthCalledWith(3, 'img');
|
||||||
|
expect(querySelectorSpy).toHaveBeenNthCalledWith(4, `.${spoilerOverlayClass}`);
|
||||||
|
expect(mockVideo).not.toHaveClass(hiddenClass);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
querySelectorSpy.mockRestore();
|
||||||
|
pauseSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide video thumbnail if picture element is missing BUT video element is present', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const mockVideo = document.createElement('video');
|
||||||
|
mockElement.appendChild(mockVideo);
|
||||||
|
const pauseSpy = jest.spyOn(mockVideo, 'pause').mockReturnValue(undefined);
|
||||||
|
const mockImage = document.createElement('img');
|
||||||
|
mockImage.classList.add(hiddenClass);
|
||||||
|
mockElement.appendChild(mockImage);
|
||||||
|
const mockOverlay = document.createElement('span');
|
||||||
|
mockOverlay.classList.add(spoilerOverlayClass, hiddenClass);
|
||||||
|
mockElement.appendChild(mockOverlay);
|
||||||
|
|
||||||
|
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(mockImage).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockImage).toHaveAttribute('src', mockSpoilerUri);
|
||||||
|
expect(mockOverlay).toHaveTextContent(mockSpoilerReason);
|
||||||
|
expect(mockVideo).toBeEmptyDOMElement();
|
||||||
|
expect(mockVideo).toHaveClass(hiddenClass);
|
||||||
|
expect(pauseSpy).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
pauseSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return early if picture element is present AND img element is missing', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const mockPicture = document.createElement('picture');
|
||||||
|
mockElement.appendChild(mockPicture);
|
||||||
|
|
||||||
|
const imgQuerySelectorSpy = jest.spyOn(mockElement, 'querySelector');
|
||||||
|
const pictureQuerySelectorSpy = jest.spyOn(mockPicture, 'querySelector');
|
||||||
|
|
||||||
|
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(imgQuerySelectorSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(pictureQuerySelectorSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
|
||||||
|
expect(pictureQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'img');
|
||||||
|
expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(2, `.${spoilerOverlayClass}`);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
imgQuerySelectorSpy.mockRestore();
|
||||||
|
pictureQuerySelectorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide img thumbnail if picture element is present AND img element is present', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const mockPicture = document.createElement('picture');
|
||||||
|
mockElement.appendChild(mockPicture);
|
||||||
|
const mockImage = document.createElement('img');
|
||||||
|
mockPicture.appendChild(mockImage);
|
||||||
|
const mockOverlay = document.createElement('span');
|
||||||
|
mockOverlay.classList.add(spoilerOverlayClass, hiddenClass);
|
||||||
|
mockElement.appendChild(mockOverlay);
|
||||||
|
|
||||||
|
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
|
||||||
|
|
||||||
|
expect(mockImage).toHaveAttribute('srcset', '');
|
||||||
|
expect(mockImage).toHaveAttribute('src', mockSpoilerUri);
|
||||||
|
expect(mockOverlay).toContainHTML(mockSpoilerReason);
|
||||||
|
expect(mockOverlay).not.toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spoilerThumb', () => {
|
||||||
|
const testSpoilerThumb = (handlers?: [EventType, EventType]) => {
|
||||||
|
const { mockElement, mockSpoilerOverlay, mockSizeImage } = createMockElementWithPicture('jpg');
|
||||||
|
const addEventListenerSpy = jest.spyOn(mockElement, 'addEventListener');
|
||||||
|
|
||||||
|
spoilerThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
|
||||||
|
|
||||||
|
// Element should be hidden by the call
|
||||||
|
expect(mockSizeImage).toHaveAttribute('src', mockSpoilerUri);
|
||||||
|
expect(mockSpoilerOverlay).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockSpoilerOverlay).toContainHTML(mockSpoilerReason);
|
||||||
|
|
||||||
|
// If addEventListener calls are not expected, bail
|
||||||
|
if (!handlers) {
|
||||||
|
expect(addEventListenerSpy).not.toHaveBeenCalled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [firstHandler, secondHandler] = handlers;
|
||||||
|
|
||||||
|
// Event listeners should be attached to correct events
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(addEventListenerSpy.mock.calls[0][0]).toBe(firstHandler.toLowerCase());
|
||||||
|
expect(addEventListenerSpy.mock.calls[1][0]).toBe(secondHandler.toLowerCase());
|
||||||
|
|
||||||
|
// Clicking once should reveal the image and hide spoiler elements
|
||||||
|
let clickEvent = createEvent[firstHandler](mockElement);
|
||||||
|
fireEvent(mockElement, clickEvent);
|
||||||
|
if (firstHandler === 'click') {
|
||||||
|
expect(clickEvent.defaultPrevented).toBe(true);
|
||||||
|
}
|
||||||
|
expect(mockSizeImage).not.toHaveAttribute('src', mockSpoilerUri);
|
||||||
|
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
|
||||||
|
|
||||||
|
if (firstHandler === 'click') {
|
||||||
|
// Second attempt to click a shown spoiler should not cause default prevention
|
||||||
|
clickEvent = createEvent.click(mockElement);
|
||||||
|
fireEvent(mockElement, clickEvent);
|
||||||
|
expect(clickEvent.defaultPrevented).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moving the mouse away should hide the image and show the overlay again
|
||||||
|
const mouseLeaveEvent = createEvent.mouseLeave(mockElement);
|
||||||
|
fireEvent(mockElement, mouseLeaveEvent);
|
||||||
|
expect(mockSizeImage).toHaveAttribute('src', mockSpoilerUri);
|
||||||
|
expect(mockSpoilerOverlay).not.toHaveClass(hiddenClass);
|
||||||
|
expect(mockSpoilerOverlay).toContainHTML(mockSpoilerReason);
|
||||||
|
};
|
||||||
|
let lastSpoilerType: SpoilerType;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
lastSpoilerType = window.booru.spoilerType;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.booru.spoilerType = lastSpoilerType;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add click and mouseleave handlers for click spoiler type', () => {
|
||||||
|
window.booru.spoilerType = 'click';
|
||||||
|
expect.hasAssertions();
|
||||||
|
testSpoilerThumb(['click', 'mouseLeave']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add mouseenter and mouseleave handlers for hover spoiler type', () => {
|
||||||
|
window.booru.spoilerType = 'hover';
|
||||||
|
expect.hasAssertions();
|
||||||
|
testSpoilerThumb(['mouseEnter', 'mouseLeave']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add event handlers for off spoiler type', () => {
|
||||||
|
window.booru.spoilerType = 'off';
|
||||||
|
expect.hasAssertions();
|
||||||
|
testSpoilerThumb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add event handlers for static spoiler type', () => {
|
||||||
|
window.booru.spoilerType = 'static';
|
||||||
|
expect.hasAssertions();
|
||||||
|
testSpoilerThumb();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spoilerBlock', () => {
|
||||||
|
const imageFilteredClass = 'image-filtered';
|
||||||
|
const filterExplanationClass = 'filter-explanation';
|
||||||
|
const imageShowClass = 'image-show';
|
||||||
|
const createFilteredImageElement = () => {
|
||||||
|
const mockImageFiltered = document.createElement('div');
|
||||||
|
mockImageFiltered.classList.add(imageFilteredClass, hiddenClass);
|
||||||
|
|
||||||
|
const mockImage = new Image();
|
||||||
|
mockImage.src = mockImageUri;
|
||||||
|
mockImageFiltered.appendChild(mockImage);
|
||||||
|
|
||||||
|
return { mockImageFiltered, mockImage };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should do nothing if image element is missing', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the elements with the parameters and set classes if image element is found', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const { mockImageFiltered, mockImage } = createFilteredImageElement();
|
||||||
|
mockElement.appendChild(mockImageFiltered);
|
||||||
|
const mockExplanation = document.createElement('span');
|
||||||
|
mockExplanation.classList.add(filterExplanationClass);
|
||||||
|
mockElement.appendChild(mockExplanation);
|
||||||
|
const mockImageShow = document.createElement('div');
|
||||||
|
mockImageShow.classList.add(imageShowClass);
|
||||||
|
mockElement.appendChild(mockImageShow);
|
||||||
|
|
||||||
|
spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason);
|
||||||
|
|
||||||
|
expect(mockImage).toHaveAttribute('src', mockSpoilerUri);
|
||||||
|
expect(mockExplanation).toContainHTML(mockSpoilerReason);
|
||||||
|
expect(mockImageShow).toHaveClass(hiddenClass);
|
||||||
|
expect(mockImageFiltered).not.toHaveClass(hiddenClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
100
assets/js/utils/__tests__/local-autocompleter.spec.ts
Normal file
100
assets/js/utils/__tests__/local-autocompleter.spec.ts
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import { LocalAutocompleter } from '../local-autocompleter';
|
||||||
|
import { promises } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { TextDecoder } from 'util';
|
||||||
|
|
||||||
|
describe('Local Autocompleter', () => {
|
||||||
|
let mockData: ArrayBuffer;
|
||||||
|
const defaultK = 5;
|
||||||
|
|
||||||
|
beforeAll(async() => {
|
||||||
|
const mockDataPath = join(__dirname, 'autocomplete-compiled-v2.bin');
|
||||||
|
/**
|
||||||
|
* Read pre-generated binary autocomplete data
|
||||||
|
*
|
||||||
|
* Contains the tags: safe (6), forest (3), flower (1), flowers -> flower, fog (1),
|
||||||
|
* force field (1), artist:test (1), explicit (0), grimdark (0),
|
||||||
|
* grotesque (0), questionable (0), semi-grimdark (0), suggestive (0)
|
||||||
|
*/
|
||||||
|
mockData = (await promises.readFile(mockDataPath, { encoding: null })).buffer;
|
||||||
|
|
||||||
|
// Polyfills for jsdom
|
||||||
|
global.TextDecoder = TextDecoder as unknown as typeof global.TextDecoder;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete (global as Partial<typeof global>).TextEncoder;
|
||||||
|
delete (global as Partial<typeof global>).TextDecoder;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('instantiation', () => {
|
||||||
|
it('should be constructable with compatible data', () => {
|
||||||
|
const result = new LocalAutocompleter(mockData);
|
||||||
|
expect(result).toBeInstanceOf(LocalAutocompleter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT be constructable with incompatible data', () => {
|
||||||
|
const versionDataOffset = 12;
|
||||||
|
const mockIncompatibleDataArray = new Array(versionDataOffset).fill(0);
|
||||||
|
// Set data version to 1
|
||||||
|
mockIncompatibleDataArray[mockIncompatibleDataArray.length - versionDataOffset] = 1;
|
||||||
|
const mockIncompatibleData = new Uint32Array(mockIncompatibleDataArray).buffer;
|
||||||
|
|
||||||
|
expect(() => new LocalAutocompleter(mockIncompatibleData)).toThrow('Incompatible autocomplete format version');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('topK', () => {
|
||||||
|
let localAc: LocalAutocompleter;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
localAc = new LocalAutocompleter(mockData);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.booru.hiddenTagList = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return suggestions for exact tag name match', () => {
|
||||||
|
const result = localAc.topK('safe', defaultK);
|
||||||
|
expect(result).toEqual([expect.objectContaining({ name: 'safe', imageCount: 6 })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return suggestion for original tag when passed an alias', () => {
|
||||||
|
const result = localAc.topK('flowers', defaultK);
|
||||||
|
expect(result).toEqual([expect.objectContaining({ name: 'flower', imageCount: 1 })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return suggestions sorted by image count', () => {
|
||||||
|
const result = localAc.topK('fo', defaultK);
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ name: 'forest', imageCount: 3 }),
|
||||||
|
expect.objectContaining({ name: 'fog', imageCount: 1 }),
|
||||||
|
expect.objectContaining({ name: 'force field', imageCount: 1 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return namespaced suggestions without including namespace', () => {
|
||||||
|
const result = localAc.topK('test', defaultK);
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ name: 'artist:test', imageCount: 1 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return only the required number of suggestions', () => {
|
||||||
|
const result = localAc.topK('fo', 1);
|
||||||
|
expect(result).toEqual([expect.objectContaining({ name: 'forest', imageCount: 3 })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT return suggestions associated with hidden tags', () => {
|
||||||
|
window.booru.hiddenTagList = [1];
|
||||||
|
const result = localAc.topK('fo', defaultK);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for empty prefix', () => {
|
||||||
|
const result = localAc.topK('', defaultK);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
84
assets/js/utils/__tests__/requests.spec.ts
Normal file
84
assets/js/utils/__tests__/requests.spec.ts
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import { fetchHtml, fetchJson, handleError } from '../requests';
|
||||||
|
import fetchMock from 'jest-fetch-mock';
|
||||||
|
|
||||||
|
describe('Request utils', () => {
|
||||||
|
const mockEndpoint = '/endpoint';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fetchMock.enableMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fetchMock.disableMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.booru.csrfToken = Math.random().toString();
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchJson', () => {
|
||||||
|
it('should call native fetch with the correct parameters (without body)', () => {
|
||||||
|
const mockVerb = 'GET';
|
||||||
|
|
||||||
|
fetchJson(mockVerb, mockEndpoint);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
|
||||||
|
method: mockVerb,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-csrf-token': window.booru.csrfToken,
|
||||||
|
'x-requested-with': 'xmlhttprequest'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call native fetch with the correct parameters (with body)', () => {
|
||||||
|
const mockVerb = 'POST';
|
||||||
|
const mockBody = { mockField: Math.random() };
|
||||||
|
|
||||||
|
fetchJson(mockVerb, mockEndpoint, mockBody);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
|
||||||
|
method: mockVerb,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-csrf-token': window.booru.csrfToken,
|
||||||
|
'x-requested-with': 'xmlhttprequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...mockBody,
|
||||||
|
_method: mockVerb
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchHtml', () => {
|
||||||
|
it('should call native fetch with the correct parameters', () => {
|
||||||
|
fetchHtml(mockEndpoint);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'x-csrf-token': window.booru.csrfToken,
|
||||||
|
'x-requested-with': 'xmlhttprequest'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleError', () => {
|
||||||
|
it('should throw if ok property is false', () => {
|
||||||
|
const mockResponse = { ok: false } as unknown as Response;
|
||||||
|
expect(() => handleError(mockResponse)).toThrow('Received error from server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return response if ok property is true', () => {
|
||||||
|
const mockResponse = { ok: true } as unknown as Response;
|
||||||
|
expect(handleError(mockResponse)).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
216
assets/js/utils/__tests__/store.spec.ts
Normal file
216
assets/js/utils/__tests__/store.spec.ts
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
import store, { lastUpdatedSuffix } from '../store';
|
||||||
|
import { mockStorageImpl } from '../../../test/mock-storage';
|
||||||
|
import { getRandomIntBetween } from '../../../test/randomness';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { mockDateNow } from '../../../test/mock-date-now';
|
||||||
|
|
||||||
|
describe('Store utilities', () => {
|
||||||
|
const { setItemSpy, getItemSpy, removeItemSpy, forceStorageError, setStorageValue } = mockStorageImpl();
|
||||||
|
const initialDateNow = 1640645432942;
|
||||||
|
|
||||||
|
describe('set', () => {
|
||||||
|
it('should be able to set various types of items correctly', () => {
|
||||||
|
const mockKey = `mock-set-key-${getRandomIntBetween(1, 10)}`;
|
||||||
|
const mockValues = [
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
Math.random(),
|
||||||
|
'some string\n value\twith trailing whitespace ',
|
||||||
|
{ complex: { value: true, key: 'string' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
mockValues.forEach((mockValue, i) => {
|
||||||
|
const result = store.set(mockKey, mockValue);
|
||||||
|
|
||||||
|
expect(setItemSpy).toHaveBeenNthCalledWith(i + 1, mockKey, JSON.stringify(mockValue));
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
expect(setItemSpy).toHaveBeenCalledTimes(mockValues.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should gracefully handle failure to set key', () => {
|
||||||
|
const mockKey = 'mock-set-key';
|
||||||
|
const mockValue = Math.random();
|
||||||
|
let result: boolean | undefined;
|
||||||
|
forceStorageError(() => {
|
||||||
|
result = store.set(mockKey, mockValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get', () => {
|
||||||
|
it('should be able to get various types of items correctly', () => {
|
||||||
|
const initialValues = {
|
||||||
|
int: 1,
|
||||||
|
boolean: false,
|
||||||
|
null: null,
|
||||||
|
float: Math.random(),
|
||||||
|
string: '\t\t\thello\nthere\n ',
|
||||||
|
object: {
|
||||||
|
rolling: {
|
||||||
|
in: {
|
||||||
|
the: {
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const initialValueKeys = Object.keys(initialValues) as (keyof typeof initialValues)[];
|
||||||
|
setStorageValue(initialValueKeys.reduce((acc, key) => {
|
||||||
|
return { ...acc, [key]: JSON.stringify(initialValues[key]) };
|
||||||
|
}, {}));
|
||||||
|
|
||||||
|
initialValueKeys.forEach((key, i) => {
|
||||||
|
const result = store.get(key);
|
||||||
|
|
||||||
|
expect(getItemSpy).toHaveBeenNthCalledWith(i + 1, key);
|
||||||
|
expect(result).toEqual(initialValues[key]);
|
||||||
|
});
|
||||||
|
expect(getItemSpy).toHaveBeenCalledTimes(initialValueKeys.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original value if item cannot be parsed', () => {
|
||||||
|
const mockKey = 'mock-get-key';
|
||||||
|
const malformedValue = '({[+:"`';
|
||||||
|
setStorageValue({
|
||||||
|
[mockKey]: malformedValue,
|
||||||
|
});
|
||||||
|
const result = store.get(mockKey);
|
||||||
|
expect(getItemSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getItemSpy).toHaveBeenNthCalledWith(1, mockKey);
|
||||||
|
expect(result).toBe(malformedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if item is not set', () => {
|
||||||
|
const mockKey = `mock-get-key-${getRandomIntBetween(1, 10)}`;
|
||||||
|
const result = store.get(mockKey);
|
||||||
|
expect(getItemSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getItemSpy).toHaveBeenNthCalledWith(1, mockKey);
|
||||||
|
expect(result).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
it('should remove the provided key', () => {
|
||||||
|
const mockKey = `mock-remove-key-${getRandomIntBetween(1, 10)}`;
|
||||||
|
const result = store.remove(mockKey);
|
||||||
|
expect(removeItemSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(removeItemSpy).toHaveBeenNthCalledWith(1, mockKey);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should gracefully handle failure to remove key', () => {
|
||||||
|
const mockKey = `mock-remove-key-${getRandomIntBetween(1, 10)}`;
|
||||||
|
let result: boolean | undefined;
|
||||||
|
forceStorageError(() => {
|
||||||
|
result = store.remove(mockKey);
|
||||||
|
});
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('watch', () => {
|
||||||
|
it('should attach a storage event listener and fire when the provide key changes', () => {
|
||||||
|
const mockKey = `mock-watch-key-${getRandomIntBetween(1, 10)}`;
|
||||||
|
const mockValue = Math.random();
|
||||||
|
const mockCallback = jest.fn();
|
||||||
|
setStorageValue({
|
||||||
|
[mockKey]: JSON.stringify(mockValue),
|
||||||
|
});
|
||||||
|
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||||
|
|
||||||
|
const cleanup = store.watch(mockKey, mockCallback);
|
||||||
|
|
||||||
|
// Should not get the item just yet, only register the event handler
|
||||||
|
expect(getItemSpy).not.toHaveBeenCalled();
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addEventListenerSpy.mock.calls[0][0]).toEqual('storage');
|
||||||
|
|
||||||
|
// Should not call callback for unknown key
|
||||||
|
let storageEvent = new StorageEvent('storage', { key: 'unknown-key' });
|
||||||
|
fireEvent(window, storageEvent);
|
||||||
|
expect(getItemSpy).not.toHaveBeenCalled();
|
||||||
|
expect(mockCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should call callback with the value from the store
|
||||||
|
storageEvent = new StorageEvent('storage', { key: mockKey });
|
||||||
|
fireEvent(window, storageEvent);
|
||||||
|
expect(getItemSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCallback).toHaveBeenNthCalledWith(1, mockValue);
|
||||||
|
|
||||||
|
// Remove the listener
|
||||||
|
cleanup();
|
||||||
|
storageEvent = new StorageEvent('storage', { key: mockKey });
|
||||||
|
fireEvent(window, storageEvent);
|
||||||
|
|
||||||
|
// Expect unchanged call counts due to removed handler
|
||||||
|
expect(getItemSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCallback).toHaveBeenNthCalledWith(1, mockValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setWithExpireTime', () => {
|
||||||
|
mockDateNow(initialDateNow);
|
||||||
|
|
||||||
|
it('should set both original and last update key', () => {
|
||||||
|
const mockKey = `mock-setWithExpireTime-key-${getRandomIntBetween(1, 10)}`;
|
||||||
|
const mockValue = 'mock value';
|
||||||
|
const mockMaxAge = 3600;
|
||||||
|
store.setWithExpireTime(mockKey, mockValue, mockMaxAge);
|
||||||
|
|
||||||
|
expect(setItemSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(setItemSpy).toHaveBeenNthCalledWith(1, mockKey, JSON.stringify(mockValue));
|
||||||
|
expect(setItemSpy).toHaveBeenNthCalledWith(2, mockKey + lastUpdatedSuffix, JSON.stringify(initialDateNow + mockMaxAge));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasExpired', () => {
|
||||||
|
mockDateNow(initialDateNow);
|
||||||
|
const mockKey = `mock-hasExpired-key-${getRandomIntBetween(1, 10)}`;
|
||||||
|
const mockLastUpdatedKey = mockKey + lastUpdatedSuffix;
|
||||||
|
|
||||||
|
it('should return true for values that have no expiration key', () => {
|
||||||
|
const result = store.hasExpired('undefined-key');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for keys with last update timestamp smaller than the current time', () => {
|
||||||
|
setStorageValue({
|
||||||
|
[mockLastUpdatedKey]: JSON.stringify(initialDateNow - 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = store.hasExpired(mockKey);
|
||||||
|
|
||||||
|
expect(getItemSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for keys with last update timestamp equal to the current time', () => {
|
||||||
|
setStorageValue({
|
||||||
|
[mockLastUpdatedKey]: JSON.stringify(initialDateNow),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = store.hasExpired(mockKey);
|
||||||
|
|
||||||
|
expect(getItemSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for keys with last update timestamp greater than the current time', () => {
|
||||||
|
setStorageValue({
|
||||||
|
[mockLastUpdatedKey]: JSON.stringify(initialDateNow + 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = store.hasExpired(mockKey);
|
||||||
|
|
||||||
|
expect(getItemSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
186
assets/js/utils/__tests__/tag.spec.ts
Normal file
186
assets/js/utils/__tests__/tag.spec.ts
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags } from '../tag';
|
||||||
|
import { mockStorage } from '../../../test/mock-storage';
|
||||||
|
import { getRandomArrayItem } from '../../../test/randomness';
|
||||||
|
import parseSearch from '../../match_query';
|
||||||
|
|
||||||
|
// TODO Move to source file when rewriting in TypeScript
|
||||||
|
interface StorageTagInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
images: number;
|
||||||
|
spoiler_image_uri: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Tag utilities', () => {
|
||||||
|
const tagStorageKeyPrefix = 'bor_tags_';
|
||||||
|
const mockTagInfo: Record<string, StorageTagInfo> = {
|
||||||
|
1: {
|
||||||
|
id: 1,
|
||||||
|
name: 'safe',
|
||||||
|
images: 69,
|
||||||
|
spoiler_image_uri: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
id: 2,
|
||||||
|
name: 'fox',
|
||||||
|
images: 1,
|
||||||
|
spoiler_image_uri: '/mock-fox-spoiler-image.svg',
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
id: 3,
|
||||||
|
name: 'paw pads',
|
||||||
|
images: 42,
|
||||||
|
spoiler_image_uri: '/mock-paw-pads-spoiler-image.svg',
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
id: 4,
|
||||||
|
name: 'whiskers',
|
||||||
|
images: 42,
|
||||||
|
spoiler_image_uri: null,
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
id: 5,
|
||||||
|
name: 'lilo & stitch',
|
||||||
|
images: 6,
|
||||||
|
spoiler_image_uri: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const getEnabledSpoilerType = () => getRandomArrayItem<SpoilerType>(['click', 'hover', 'static']);
|
||||||
|
|
||||||
|
mockStorage({
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
if (key.startsWith(tagStorageKeyPrefix)) {
|
||||||
|
const tagId = key.substring(tagStorageKeyPrefix.length);
|
||||||
|
const tagInfo = mockTagInfo[tagId];
|
||||||
|
return tagInfo ? JSON.stringify(tagInfo) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getHiddenTags', () => {
|
||||||
|
it('should get a single hidden tag\'s information', () => {
|
||||||
|
window.booru.hiddenTagList = [1, 1];
|
||||||
|
|
||||||
|
const result = getHiddenTags();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result).toEqual([mockTagInfo[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get the list of multiple hidden tags in the correct order', () => {
|
||||||
|
window.booru.hiddenTagList = [1, 2, 2, 2, 3, 4, 4];
|
||||||
|
|
||||||
|
const result = getHiddenTags();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result).toEqual([
|
||||||
|
mockTagInfo[3],
|
||||||
|
mockTagInfo[2],
|
||||||
|
mockTagInfo[1],
|
||||||
|
mockTagInfo[4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSpoileredTags', () => {
|
||||||
|
it('should return an empty array if spoilers are off', () => {
|
||||||
|
window.booru.spoileredTagList = [1, 2, 3, 4];
|
||||||
|
window.booru.spoilerType = 'off';
|
||||||
|
|
||||||
|
const result = getSpoileredTags();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a single spoilered tag\'s information', () => {
|
||||||
|
window.booru.spoileredTagList = [1, 1];
|
||||||
|
window.booru.ignoredTagList = [];
|
||||||
|
window.booru.spoilerType = getEnabledSpoilerType();
|
||||||
|
|
||||||
|
const result = getSpoileredTags();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result).toEqual([mockTagInfo[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get the list of multiple spoilered tags in the correct order', () => {
|
||||||
|
window.booru.spoileredTagList = [1, 1, 2, 2, 3, 4, 4];
|
||||||
|
window.booru.ignoredTagList = [];
|
||||||
|
window.booru.spoilerType = getEnabledSpoilerType();
|
||||||
|
|
||||||
|
const result = getSpoileredTags();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result).toEqual([
|
||||||
|
mockTagInfo[2],
|
||||||
|
mockTagInfo[3],
|
||||||
|
mockTagInfo[1],
|
||||||
|
mockTagInfo[4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should omit ignored tags from the list', () => {
|
||||||
|
window.booru.spoileredTagList = [1, 2, 2, 3, 4, 4, 4];
|
||||||
|
window.booru.ignoredTagList = [2, 3];
|
||||||
|
window.booru.spoilerType = getEnabledSpoilerType();
|
||||||
|
const result = getSpoileredTags();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result).toEqual([
|
||||||
|
mockTagInfo[1],
|
||||||
|
mockTagInfo[4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imageHitsTags', () => {
|
||||||
|
it('should return the list of tags that apply to the image', () => {
|
||||||
|
const mockImageTags = [1, 4];
|
||||||
|
const mockImage = new Image();
|
||||||
|
mockImage.dataset.imageTags = JSON.stringify(mockImageTags);
|
||||||
|
|
||||||
|
const result = imageHitsTags(mockImage, [mockTagInfo[1], mockTagInfo[2], mockTagInfo[3], mockTagInfo[4]]);
|
||||||
|
expect(result).toHaveLength(mockImageTags.length);
|
||||||
|
expect(result).toEqual([
|
||||||
|
mockTagInfo[1],
|
||||||
|
mockTagInfo[4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imageHitsComplex', () => {
|
||||||
|
it('should return true if image matches the complex filter', () => {
|
||||||
|
const mockSearchAST = parseSearch('safe || solo');
|
||||||
|
|
||||||
|
const mockImageTagAliases = mockTagInfo[1].name;
|
||||||
|
const mockImage = new Image();
|
||||||
|
mockImage.dataset.imageTagAliases = mockImageTagAliases;
|
||||||
|
|
||||||
|
const result = imageHitsComplex(mockImage, mockSearchAST);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('displayTags', () => {
|
||||||
|
it('should return the correct value for a single tag', () => {
|
||||||
|
const result = displayTags([mockTagInfo[1]]);
|
||||||
|
expect(result).toEqual(mockTagInfo[1].name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct value for two tags', () => {
|
||||||
|
const result = displayTags([mockTagInfo[1], mockTagInfo[4]]);
|
||||||
|
expect(result).toEqual(`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}">, ${mockTagInfo[4].name}</span>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct value for three tags', () => {
|
||||||
|
const result = displayTags([mockTagInfo[1], mockTagInfo[4], mockTagInfo[3]]);
|
||||||
|
expect(result).toEqual(`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}, ${mockTagInfo[3].name}">, ${mockTagInfo[4].name}, ${mockTagInfo[3].name}</span>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape HTML in the tag name', () => {
|
||||||
|
const result = displayTags([mockTagInfo[5]]);
|
||||||
|
expect(result).toEqual('lilo & stitch');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,13 +1,11 @@
|
||||||
// http://stackoverflow.com/a/5306832/1726690
|
// http://stackoverflow.com/a/5306832/1726690
|
||||||
function moveElement(array, from, to) {
|
export function moveElement(array, from, to) {
|
||||||
array.splice(to, 0, array.splice(from, 1)[0]);
|
array.splice(to, 0, array.splice(from, 1)[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function arraysEqual(array1, array2) {
|
export function arraysEqual(array1, array2) {
|
||||||
for (let i = 0; i < array1.length; ++i) {
|
for (let i = 0; i < array1.length; ++i) {
|
||||||
if (array1[i] !== array2[i]) return false;
|
if (array1[i] !== array2[i]) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { moveElement, arraysEqual };
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ function showVideoThumb(img) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showThumb(img) {
|
export function showThumb(img) {
|
||||||
const size = img.dataset.size;
|
const size = img.dataset.size;
|
||||||
const uris = JSON.parse(img.dataset.uris);
|
const uris = JSON.parse(img.dataset.uris);
|
||||||
const thumbUri = uris[size].replace(/webm$/, 'gif');
|
const thumbUri = uris[size].replace(/webm$/, 'gif');
|
||||||
|
@ -57,7 +57,7 @@ function showThumb(img) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showBlock(img) {
|
export function showBlock(img) {
|
||||||
img.querySelector('.image-filtered').classList.add('hidden');
|
img.querySelector('.image-filtered').classList.add('hidden');
|
||||||
const imageShowClasses = img.querySelector('.image-show').classList;
|
const imageShowClasses = img.querySelector('.image-show').classList;
|
||||||
imageShowClasses.remove('hidden');
|
imageShowClasses.remove('hidden');
|
||||||
|
@ -82,7 +82,7 @@ function hideVideoThumb(img, spoilerUri, reason) {
|
||||||
vidEl.pause();
|
vidEl.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideThumb(img, spoilerUri, reason) {
|
export function hideThumb(img, spoilerUri, reason) {
|
||||||
const picEl = img.querySelector('picture');
|
const picEl = img.querySelector('picture');
|
||||||
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
|
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
|
||||||
|
|
||||||
|
@ -97,7 +97,7 @@ function hideThumb(img, spoilerUri, reason) {
|
||||||
imgOverlay.classList.remove('hidden');
|
imgOverlay.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
function spoilerThumb(img, spoilerUri, reason) {
|
export function spoilerThumb(img, spoilerUri, reason) {
|
||||||
hideThumb(img, spoilerUri, reason);
|
hideThumb(img, spoilerUri, reason);
|
||||||
|
|
||||||
switch (window.booru.spoilerType) {
|
switch (window.booru.spoilerType) {
|
||||||
|
@ -114,7 +114,7 @@ function spoilerThumb(img, spoilerUri, reason) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function spoilerBlock(img, spoilerUri, reason) {
|
export function spoilerBlock(img, spoilerUri, reason) {
|
||||||
const imgEl = img.querySelector('.image-filtered img');
|
const imgEl = img.querySelector('.image-filtered img');
|
||||||
const imgReason = img.querySelector('.filter-explanation');
|
const imgReason = img.querySelector('.filter-explanation');
|
||||||
|
|
||||||
|
@ -126,5 +126,3 @@ function spoilerBlock(img, spoilerUri, reason) {
|
||||||
img.querySelector('.image-show').classList.add('hidden');
|
img.querySelector('.image-show').classList.add('hidden');
|
||||||
img.querySelector('.image-filtered').classList.remove('hidden');
|
img.querySelector('.image-filtered').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
export { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb };
|
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
//@ts-check
|
// Client-side tag completion.
|
||||||
/*
|
|
||||||
* Client-side tag completion.
|
|
||||||
*/
|
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -132,8 +129,6 @@ export class LocalAutocompleter {
|
||||||
let min = 0;
|
let min = 0;
|
||||||
let max = this.numTags;
|
let max = this.numTags;
|
||||||
|
|
||||||
/** @type {number[]} */
|
|
||||||
//@ts-expect-error No type for window.booru yet
|
|
||||||
const hiddenTags = window.booru.hiddenTagList;
|
const hiddenTags = window.booru.hiddenTagList;
|
||||||
|
|
||||||
while (min < max - 1) {
|
while (min < max - 1) {
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
/**
|
// Request Utils
|
||||||
* Request Utils
|
|
||||||
*/
|
|
||||||
|
|
||||||
function fetchJson(verb, endpoint, body) {
|
export function fetchJson(verb, endpoint, body) {
|
||||||
const data = {
|
const data = {
|
||||||
method: verb,
|
method: verb,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
|
@ -21,7 +19,7 @@ function fetchJson(verb, endpoint, body) {
|
||||||
return fetch(endpoint, data);
|
return fetch(endpoint, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchHtml(endpoint) {
|
export function fetchHtml(endpoint) {
|
||||||
return fetch(endpoint, {
|
return fetch(endpoint, {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -31,11 +29,9 @@ function fetchHtml(endpoint) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleError(response) {
|
export function handleError(response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Received error from server');
|
throw new Error('Received error from server');
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { fetchJson, fetchHtml, handleError };
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
* localStorage utils
|
* localStorage utils
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const lastUpdatedSuffix = '__lastUpdated';
|
export const lastUpdatedSuffix = '__lastUpdated';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
||||||
|
@ -38,9 +38,11 @@ export default {
|
||||||
|
|
||||||
// Watch changes to a specified key - returns value on change
|
// Watch changes to a specified key - returns value on change
|
||||||
watch(key, callback) {
|
watch(key, callback) {
|
||||||
window.addEventListener('storage', event => {
|
const handler = event => {
|
||||||
if (event.key === key) callback(this.get(key));
|
if (event.key === key) callback(this.get(key));
|
||||||
});
|
};
|
||||||
|
window.addEventListener('storage', handler);
|
||||||
|
return () => window.removeEventListener('storage', handler);
|
||||||
},
|
},
|
||||||
|
|
||||||
// set() with an additional key containing the current time + expiration time
|
// set() with an additional key containing the current time + expiration time
|
||||||
|
@ -56,11 +58,7 @@ export default {
|
||||||
const lastUpdatedKey = key + lastUpdatedSuffix;
|
const lastUpdatedKey = key + lastUpdatedSuffix;
|
||||||
const lastUpdatedTime = this.get(lastUpdatedKey);
|
const lastUpdatedTime = this.get(lastUpdatedKey);
|
||||||
|
|
||||||
if (Date.now() > lastUpdatedTime) {
|
return Date.now() > lastUpdatedTime;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,13 +19,13 @@ function sortTags(hidden, a, b) {
|
||||||
return a.spoiler_image_uri ? -1 : 1;
|
return a.spoiler_image_uri ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHiddenTags() {
|
export function getHiddenTags() {
|
||||||
return unique(window.booru.hiddenTagList)
|
return unique(window.booru.hiddenTagList)
|
||||||
.map(tagId => getTag(tagId))
|
.map(tagId => getTag(tagId))
|
||||||
.sort(sortTags.bind(null, true));
|
.sort(sortTags.bind(null, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpoileredTags() {
|
export function getSpoileredTags() {
|
||||||
if (window.booru.spoilerType === 'off') return [];
|
if (window.booru.spoilerType === 'off') return [];
|
||||||
|
|
||||||
return unique(window.booru.spoileredTagList)
|
return unique(window.booru.spoileredTagList)
|
||||||
|
@ -34,16 +34,16 @@ function getSpoileredTags() {
|
||||||
.sort(sortTags.bind(null, false));
|
.sort(sortTags.bind(null, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageHitsTags(img, matchTags) {
|
export function imageHitsTags(img, matchTags) {
|
||||||
const imageTags = JSON.parse(img.dataset.imageTags);
|
const imageTags = JSON.parse(img.dataset.imageTags);
|
||||||
return matchTags.filter(t => imageTags.indexOf(t.id) !== -1);
|
return matchTags.filter(t => imageTags.indexOf(t.id) !== -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageHitsComplex(img, matchComplex) {
|
export function imageHitsComplex(img, matchComplex) {
|
||||||
return matchComplex.hitsImage(img);
|
return matchComplex.hitsImage(img);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayTags(tags) {
|
export function displayTags(tags) {
|
||||||
const mainTag = tags[0], otherTags = tags.slice(1);
|
const mainTag = tags[0], otherTags = tags.slice(1);
|
||||||
let list = escapeHtml(mainTag.name), extras;
|
let list = escapeHtml(mainTag.name), extras;
|
||||||
|
|
||||||
|
@ -54,5 +54,3 @@ function displayTags(tags) {
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags };
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { registerEvents } from './boorujs';
|
||||||
import { setupBurgerMenu } from './burger';
|
import { setupBurgerMenu } from './burger';
|
||||||
import { bindCaptchaLinks } from './captcha';
|
import { bindCaptchaLinks } from './captcha';
|
||||||
import { setupComments } from './comment';
|
import { setupComments } from './comment';
|
||||||
import { setupDupeReports } from './duplicate_reports.js';
|
import { setupDupeReports } from './duplicate_reports';
|
||||||
import { setFingerprintCookie } from './fingerprint';
|
import { setFingerprintCookie } from './fingerprint';
|
||||||
import { setupGalleryEditing } from './galleries';
|
import { setupGalleryEditing } from './galleries';
|
||||||
import { initImagesClientside } from './imagesclientside';
|
import { initImagesClientside } from './imagesclientside';
|
||||||
|
@ -31,7 +31,7 @@ import { setupTagEvents } from './tagsmisc';
|
||||||
import { setupTimestamps } from './timeago';
|
import { setupTimestamps } from './timeago';
|
||||||
import { setupImageUpload } from './upload';
|
import { setupImageUpload } from './upload';
|
||||||
import { setupSearch } from './search';
|
import { setupSearch } from './search';
|
||||||
import { setupToolbar } from './markdowntoolbar.js';
|
import { setupToolbar } from './markdowntoolbar';
|
||||||
import { hideStaffTools } from './staffhider';
|
import { hideStaffTools } from './staffhider';
|
||||||
import { pollOptionCreator } from './poll';
|
import { pollOptionCreator } from './poll';
|
||||||
|
|
||||||
|
|
6046
assets/package-lock.json
generated
6046
assets/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,7 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"deploy": "cross-env NODE_ENV=production webpack",
|
"deploy": "cross-env NODE_ENV=production webpack",
|
||||||
"lint": "eslint . --ext .js,.ts",
|
"lint": "eslint . --ext .js,.ts",
|
||||||
|
"test": "jest --ci",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
"watch": "webpack --watch"
|
"watch": "webpack --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -13,8 +16,12 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-typescript": "^8.2.5",
|
"@rollup/plugin-typescript": "^8.2.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.32.0",
|
"@testing-library/dom": "^8.7.2",
|
||||||
"@typescript-eslint/parser": "^4.32.0",
|
"@testing-library/jest-dom": "^5.14.1",
|
||||||
|
"@types/jest": "^27.0.3",
|
||||||
|
"@types/web": "^0.0.40",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.8.0",
|
||||||
|
"@typescript-eslint/parser": "^5.8.0",
|
||||||
"acorn": "^7.4.1",
|
"acorn": "^7.4.1",
|
||||||
"autoprefixer": "^10.3.5",
|
"autoprefixer": "^10.3.5",
|
||||||
"copy-webpack-plugin": "^6.4.1",
|
"copy-webpack-plugin": "^6.4.1",
|
||||||
|
@ -22,9 +29,13 @@
|
||||||
"css-loader": "^5.2.7",
|
"css-loader": "^5.2.7",
|
||||||
"css-minimizer-webpack-plugin": "^2.0.0",
|
"css-minimizer-webpack-plugin": "^2.0.0",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
|
"eslint-plugin-jest": "^25.7.0",
|
||||||
|
"eslint-plugin-jest-dom": "^4.0.1",
|
||||||
"eslint-webpack-plugin": "^3.0.1",
|
"eslint-webpack-plugin": "^3.0.1",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"ignore-emit-webpack-plugin": "^2.0.6",
|
"ignore-emit-webpack-plugin": "^2.0.6",
|
||||||
|
"jest": "^27.4.7",
|
||||||
|
"jest-fetch-mock": "^3.0.3",
|
||||||
"mini-css-extract-plugin": "^2.3.0",
|
"mini-css-extract-plugin": "^2.3.0",
|
||||||
"normalize-scss": "^7.0.1",
|
"normalize-scss": "^7.0.1",
|
||||||
"postcss-loader": "^5.3.0",
|
"postcss-loader": "^5.3.0",
|
||||||
|
@ -40,6 +51,7 @@
|
||||||
"source-map-support": "^0.5.20",
|
"source-map-support": "^0.5.20",
|
||||||
"style-loader": "^1.3.0",
|
"style-loader": "^1.3.0",
|
||||||
"terser-webpack-plugin": "^3.1.0",
|
"terser-webpack-plugin": "^3.1.0",
|
||||||
|
"ts-jest": "^27.0.4",
|
||||||
"typescript": "^4.4",
|
"typescript": "^4.4",
|
||||||
"webpack": "^5.53.0",
|
"webpack": "^5.53.0",
|
||||||
"webpack-cli": "^4.8.0",
|
"webpack-cli": "^4.8.0",
|
||||||
|
|
23
assets/test/jest-setup.ts
Normal file
23
assets/test/jest-setup.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
const blankFilter = {
|
||||||
|
leftOperand: null,
|
||||||
|
negate: false,
|
||||||
|
op: null,
|
||||||
|
rightOperand: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.booru = {
|
||||||
|
csrfToken: 'mockCsrfToken',
|
||||||
|
hiddenTag: '/mock-tagblocked.svg',
|
||||||
|
hiddenTagList: [],
|
||||||
|
ignoredTagList: [],
|
||||||
|
imagesWithDownvotingDisabled: [],
|
||||||
|
spoilerType: 'off',
|
||||||
|
spoileredTagList: [],
|
||||||
|
userCanEditFilter: false,
|
||||||
|
userIsSignedIn: false,
|
||||||
|
watchedTagList: [],
|
||||||
|
hiddenFilter: blankFilter,
|
||||||
|
spoileredFilter: blankFilter,
|
||||||
|
};
|
9
assets/test/mock-date-now.ts
Normal file
9
assets/test/mock-date-now.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export function mockDateNow(initialDateNow: number): void {
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.useFakeTimers().setSystemTime(initialDateNow);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
}
|
89
assets/test/mock-storage.ts
Normal file
89
assets/test/mock-storage.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
type MockStorageKeys = 'getItem' | 'setItem' | 'removeItem';
|
||||||
|
|
||||||
|
export function mockStorage<Keys extends MockStorageKeys>(options: Pick<Storage, Keys>): { [k in `${Keys}Spy`]: jest.SpyInstance } {
|
||||||
|
const getItemSpy = 'getItem' in options ? jest.spyOn(Storage.prototype, 'getItem') : undefined;
|
||||||
|
const setItemSpy = 'setItem' in options ? jest.spyOn(Storage.prototype, 'setItem') : undefined;
|
||||||
|
const removeItemSpy = 'removeItem' in options ? jest.spyOn(Storage.prototype, 'removeItem') : undefined;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
getItemSpy && getItemSpy.mockImplementation((options as Storage).getItem);
|
||||||
|
setItemSpy && setItemSpy.mockImplementation((options as Storage).setItem);
|
||||||
|
removeItemSpy && removeItemSpy.mockImplementation((options as Storage).removeItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
getItemSpy && getItemSpy.mockClear();
|
||||||
|
setItemSpy && setItemSpy.mockClear();
|
||||||
|
removeItemSpy && removeItemSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
getItemSpy && getItemSpy.mockRestore();
|
||||||
|
setItemSpy && setItemSpy.mockRestore();
|
||||||
|
removeItemSpy && removeItemSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { getItemSpy, setItemSpy, removeItemSpy } as ReturnType<typeof mockStorage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockStorageImplApi = { [k in `${MockStorageKeys}Spy`]: jest.SpyInstance } & {
|
||||||
|
/**
|
||||||
|
* Forces the mock storage back to its default (empty) state
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
clearStorage: VoidFunction,
|
||||||
|
/**
|
||||||
|
* Forces the mock storage to be in the specific state provided as the parameter
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
setStorageValue: (value: Record<string, string>) => void,
|
||||||
|
/**
|
||||||
|
* Forces the mock storage to throw an error for the duration of the provided function's execution,
|
||||||
|
* or in case a promise is returned by the function, until that promise is resolved.
|
||||||
|
*/
|
||||||
|
forceStorageError: <Args, Return>(func: (...args: Args[]) => Return | Promise<Return>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockStorageImpl(): MockStorageImplApi {
|
||||||
|
let shouldThrow = false;
|
||||||
|
let tempStorage: Record<string, string> = {};
|
||||||
|
const mockStorageSpies = mockStorage({
|
||||||
|
setItem(key, value) {
|
||||||
|
if (shouldThrow) throw new Error('Mock error thrown by mockStorageImpl.setItem');
|
||||||
|
|
||||||
|
tempStorage[key] = String(value);
|
||||||
|
},
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
if (shouldThrow) throw new Error('Mock error thrown by mockStorageImpl.getItem');
|
||||||
|
|
||||||
|
return key in tempStorage ? tempStorage[key] : null;
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
if (shouldThrow) throw new Error('Mock error thrown by mockStorageImpl.removeItem');
|
||||||
|
|
||||||
|
delete tempStorage[key];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const forceStorageError: MockStorageImplApi['forceStorageError'] = func => {
|
||||||
|
shouldThrow = true;
|
||||||
|
const value = func();
|
||||||
|
if (!(value instanceof Promise)) {
|
||||||
|
shouldThrow = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
value.then(() => {
|
||||||
|
shouldThrow = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setStorageValue: MockStorageImplApi['setStorageValue'] = value => {
|
||||||
|
tempStorage = value;
|
||||||
|
};
|
||||||
|
const clearStorage = () => setStorageValue({});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...mockStorageSpies, clearStorage, forceStorageError, setStorageValue };
|
||||||
|
}
|
27
assets/test/randomness.ts
Normal file
27
assets/test/randomness.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* @fileOverview Utilities to aid in unit testing where multiple potential values need to be considered.
|
||||||
|
* Picking from a pool of limited data randomly can add further resilience.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Randomly returns either `true` or `false`
|
||||||
|
*/
|
||||||
|
export const getRandomBoolean = (): boolean => Math.random() > 0.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a random integer between `min` and `max`, inclusive
|
||||||
|
*/
|
||||||
|
export function getRandomIntBetween(min: number, max: number): number {
|
||||||
|
const intMin = Math.ceil(min);
|
||||||
|
const intMax = Math.floor(max);
|
||||||
|
return Math.floor(Math.random() * (intMax - intMin + 1) + intMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a random item from the provided `array`, throws an error if `array is empty
|
||||||
|
*/
|
||||||
|
export const getRandomArrayItem = <T>(array: T[]): T => {
|
||||||
|
if (array.length === 0) throw new Error('Attempt to pick random item from empty array');
|
||||||
|
|
||||||
|
return array[Math.floor(Math.random() * array.length)];
|
||||||
|
};
|
|
@ -3,7 +3,13 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"baseUrl": "./js",
|
"baseUrl": "./js",
|
||||||
"target": "ES2018",
|
"target": "ES2018",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "Node",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2018",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
"strict": true
|
"strict": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
58
assets/types/booru-object.d.ts
vendored
Normal file
58
assets/types/booru-object.d.ts
vendored
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
type SpoilerType = 'click' | 'hover' | 'static' | 'off';
|
||||||
|
|
||||||
|
interface BooruObject {
|
||||||
|
csrfToken: string;
|
||||||
|
/**
|
||||||
|
* One of the specified values, based on user setting
|
||||||
|
*/
|
||||||
|
spoilerType: SpoilerType;
|
||||||
|
/**
|
||||||
|
* List of numeric image IDs as strings
|
||||||
|
*/
|
||||||
|
imagesWithDownvotingDisabled: string[];
|
||||||
|
/**
|
||||||
|
* Array of watched tag IDs as numbers
|
||||||
|
*/
|
||||||
|
watchedTagList: number[];
|
||||||
|
/**
|
||||||
|
* Array of spoilered tag IDs as numbers
|
||||||
|
*/
|
||||||
|
spoileredTagList: number[];
|
||||||
|
/**
|
||||||
|
* Array of ignored tag IDs as numbers
|
||||||
|
*/
|
||||||
|
ignoredTagList: number[];
|
||||||
|
/**
|
||||||
|
* Array of hidden tag IDs as numbers
|
||||||
|
*/
|
||||||
|
hiddenTagList: number[];
|
||||||
|
/**
|
||||||
|
* Stores the URL of the default "tag blocked" image
|
||||||
|
*/
|
||||||
|
hiddenTag: string;
|
||||||
|
userIsSignedIn: boolean;
|
||||||
|
/**
|
||||||
|
* Indicates if the current user has edit rights to the currently selected filter
|
||||||
|
*/
|
||||||
|
userCanEditFilter: boolean;
|
||||||
|
/**
|
||||||
|
* SearchAST instance for hidden tags, converted from raw AST data in {@see import('../js/booru.js')}
|
||||||
|
*
|
||||||
|
* TODO Properly type after TypeScript migration
|
||||||
|
*
|
||||||
|
* @type {import('../js/match_query.js').SearchAST}
|
||||||
|
*/
|
||||||
|
hiddenFilter: unknown;
|
||||||
|
/**
|
||||||
|
* SearchAST instance for spoilered tags, converted from raw AST data in {@see import('../js/booru.js')}
|
||||||
|
*
|
||||||
|
* TODO Properly type after TypeScript migration
|
||||||
|
*
|
||||||
|
* @type {import('../js/match_query.js').SearchAST}
|
||||||
|
*/
|
||||||
|
spoileredFilter: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
booru: BooruObject;
|
||||||
|
}
|
|
@ -1,18 +1,25 @@
|
||||||
const fs = require('fs');
|
import fs from 'fs';
|
||||||
const path = require('path');
|
import path from 'path';
|
||||||
const TerserPlugin = require('terser-webpack-plugin');
|
import url from 'url';
|
||||||
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
|
import TerserPlugin from 'terser-webpack-plugin';
|
||||||
const CopyPlugin = require('copy-webpack-plugin');
|
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
|
||||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
import CopyPlugin from 'copy-webpack-plugin';
|
||||||
const IgnoreEmitPlugin = require('ignore-emit-webpack-plugin');
|
import MiniCssExtractPlugin from "mini-css-extract-plugin";
|
||||||
const ESLintPlugin = require('eslint-webpack-plugin');
|
import IgnoreEmitPlugin from 'ignore-emit-webpack-plugin';
|
||||||
|
import ESLintPlugin from 'eslint-webpack-plugin';
|
||||||
|
import autoprefixer from 'autoprefixer';
|
||||||
|
import rollupPluginIncludepaths from 'rollup-plugin-includepaths';
|
||||||
|
import rollupPluginMultiEntry from 'rollup-plugin-multi-entry';
|
||||||
|
import rollupPluginBuble from 'rollup-plugin-buble';
|
||||||
|
import rollupPluginTypescript from '@rollup/plugin-typescript';
|
||||||
|
|
||||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||||
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const includePaths = require('rollup-plugin-includepaths')();
|
const includePaths = rollupPluginIncludepaths();
|
||||||
const multiEntry = require('rollup-plugin-multi-entry')();
|
const multiEntry = rollupPluginMultiEntry();
|
||||||
const buble = require('rollup-plugin-buble')({ transforms: { dangerousForOf: true } });
|
const buble = rollupPluginBuble({ transforms: { dangerousForOf: true } });
|
||||||
const typescript = require('@rollup/plugin-typescript')();
|
const typescript = rollupPluginTypescript();
|
||||||
|
|
||||||
let plugins = [
|
let plugins = [
|
||||||
new IgnoreEmitPlugin(/css\/.*(?<!css)$/),
|
new IgnoreEmitPlugin(/css\/.*(?<!css)$/),
|
||||||
|
@ -56,7 +63,7 @@ for (const name of themeNames) {
|
||||||
themes[`css/${name}`] = `./css/themes/${name}.scss`;
|
themes[`css/${name}`] = `./css/themes/${name}.scss`;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
export default {
|
||||||
mode: isDevelopment ? 'development' : 'production',
|
mode: isDevelopment ? 'development' : 'production',
|
||||||
entry: {
|
entry: {
|
||||||
'js/app.js': './js/app.js',
|
'js/app.js': './js/app.js',
|
||||||
|
@ -126,7 +133,7 @@ module.exports = {
|
||||||
ident: 'postcss',
|
ident: 'postcss',
|
||||||
syntax: 'postcss-scss',
|
syntax: 'postcss-scss',
|
||||||
plugins: [
|
plugins: [
|
||||||
require('autoprefixer')(),
|
autoprefixer(),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue