mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 13:47:58 +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:
|
||||
build:
|
||||
name: 'Build Elixir app'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
@ -26,7 +27,8 @@ jobs:
|
|||
run: |
|
||||
docker-compose run app mix sobelow --config
|
||||
docker-compose run app mix deps.audit
|
||||
lint:
|
||||
lint-and-test:
|
||||
name: 'JavaScript Linting and Unit Tests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
@ -34,7 +36,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '16'
|
||||
|
||||
- name: Cache node_modules
|
||||
id: cache-node-modules
|
||||
|
@ -48,6 +50,8 @@ jobs:
|
|||
run: npm ci --ignore-scripts
|
||||
working-directory: ./assets
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
- run: npm run lint
|
||||
working-directory: ./assets
|
||||
|
||||
- run: npm run test
|
||||
working-directory: ./assets
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -57,3 +57,6 @@ npm-debug.log
|
|||
# Rust binaries
|
||||
/native/**/target
|
||||
/.cargo
|
||||
|
||||
# Jest coverage
|
||||
/assets/coverage
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
js/vendor/*
|
||||
webpack.config.js
|
||||
jest.config.js
|
||||
|
|
|
@ -10,6 +10,7 @@ parserOptions:
|
|||
|
||||
plugins:
|
||||
- '@typescript-eslint'
|
||||
- jest
|
||||
|
||||
globals:
|
||||
ga: false
|
||||
|
@ -223,7 +224,7 @@ rules:
|
|||
prefer-rest-params: 2
|
||||
prefer-spread: 0
|
||||
prefer-template: 2
|
||||
quote-props: [2, 'as-needed', ]
|
||||
quote-props: [2, 'as-needed']
|
||||
quotes: [2, 'single']
|
||||
radix: 2
|
||||
require-jsdoc: 0
|
||||
|
@ -253,9 +254,34 @@ rules:
|
|||
yield-star-spacing: 2
|
||||
yoda: [2, 'never']
|
||||
|
||||
# Disable rules which are impossible to satisfy (types require .ts extension)
|
||||
overrides:
|
||||
# JavaScript Files
|
||||
# Disable rules which are impossible to satisfy (types require .ts extension)
|
||||
- files:
|
||||
- '*.js'
|
||||
rules:
|
||||
'@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');
|
||||
};
|
||||
|
||||
// Force module handling for Jest, can be removed after TypeScript migration
|
||||
export {};
|
||||
|
||||
export default parseSearch;
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { fetchJson } from './utils/requests';
|
||||
import { filterNode } from './imagesclientside';
|
||||
import { hideEl, showEl } from './utils/dom.js';
|
||||
import { hideEl, showEl } from './utils/dom';
|
||||
|
||||
function handleError(response) {
|
||||
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
|
||||
function moveElement(array, from, to) {
|
||||
export function moveElement(array, from, to) {
|
||||
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) {
|
||||
if (array1[i] !== array2[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export { moveElement, arraysEqual };
|
||||
|
|
|
@ -26,7 +26,7 @@ function showVideoThumb(img) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function showThumb(img) {
|
||||
export function showThumb(img) {
|
||||
const size = img.dataset.size;
|
||||
const uris = JSON.parse(img.dataset.uris);
|
||||
const thumbUri = uris[size].replace(/webm$/, 'gif');
|
||||
|
@ -57,7 +57,7 @@ function showThumb(img) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function showBlock(img) {
|
||||
export function showBlock(img) {
|
||||
img.querySelector('.image-filtered').classList.add('hidden');
|
||||
const imageShowClasses = img.querySelector('.image-show').classList;
|
||||
imageShowClasses.remove('hidden');
|
||||
|
@ -82,7 +82,7 @@ function hideVideoThumb(img, spoilerUri, reason) {
|
|||
vidEl.pause();
|
||||
}
|
||||
|
||||
function hideThumb(img, spoilerUri, reason) {
|
||||
export function hideThumb(img, spoilerUri, reason) {
|
||||
const picEl = img.querySelector('picture');
|
||||
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
|
||||
|
||||
|
@ -97,7 +97,7 @@ function hideThumb(img, spoilerUri, reason) {
|
|||
imgOverlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function spoilerThumb(img, spoilerUri, reason) {
|
||||
export function spoilerThumb(img, spoilerUri, reason) {
|
||||
hideThumb(img, spoilerUri, reason);
|
||||
|
||||
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 imgReason = img.querySelector('.filter-explanation');
|
||||
|
||||
|
@ -126,5 +126,3 @@ function spoilerBlock(img, spoilerUri, reason) {
|
|||
img.querySelector('.image-show').classList.add('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';
|
||||
|
||||
/**
|
||||
|
@ -132,8 +129,6 @@ export class LocalAutocompleter {
|
|||
let min = 0;
|
||||
let max = this.numTags;
|
||||
|
||||
/** @type {number[]} */
|
||||
//@ts-expect-error No type for window.booru yet
|
||||
const hiddenTags = window.booru.hiddenTagList;
|
||||
|
||||
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 = {
|
||||
method: verb,
|
||||
credentials: 'same-origin',
|
||||
|
@ -21,7 +19,7 @@ function fetchJson(verb, endpoint, body) {
|
|||
return fetch(endpoint, data);
|
||||
}
|
||||
|
||||
function fetchHtml(endpoint) {
|
||||
export function fetchHtml(endpoint) {
|
||||
return fetch(endpoint, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
@ -31,11 +29,9 @@ function fetchHtml(endpoint) {
|
|||
});
|
||||
}
|
||||
|
||||
function handleError(response) {
|
||||
export function handleError(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Received error from server');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export { fetchJson, fetchHtml, handleError };
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
* localStorage utils
|
||||
*/
|
||||
|
||||
const lastUpdatedSuffix = '__lastUpdated';
|
||||
export const lastUpdatedSuffix = '__lastUpdated';
|
||||
|
||||
export default {
|
||||
|
||||
|
@ -38,9 +38,11 @@ export default {
|
|||
|
||||
// Watch changes to a specified key - returns value on change
|
||||
watch(key, callback) {
|
||||
window.addEventListener('storage', event => {
|
||||
const handler = event => {
|
||||
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
|
||||
|
@ -56,11 +58,7 @@ export default {
|
|||
const lastUpdatedKey = key + lastUpdatedSuffix;
|
||||
const lastUpdatedTime = this.get(lastUpdatedKey);
|
||||
|
||||
if (Date.now() > lastUpdatedTime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return Date.now() > lastUpdatedTime;
|
||||
},
|
||||
|
||||
};
|
||||
|
|
|
@ -19,13 +19,13 @@ function sortTags(hidden, a, b) {
|
|||
return a.spoiler_image_uri ? -1 : 1;
|
||||
}
|
||||
|
||||
function getHiddenTags() {
|
||||
export function getHiddenTags() {
|
||||
return unique(window.booru.hiddenTagList)
|
||||
.map(tagId => getTag(tagId))
|
||||
.sort(sortTags.bind(null, true));
|
||||
}
|
||||
|
||||
function getSpoileredTags() {
|
||||
export function getSpoileredTags() {
|
||||
if (window.booru.spoilerType === 'off') return [];
|
||||
|
||||
return unique(window.booru.spoileredTagList)
|
||||
|
@ -34,16 +34,16 @@ function getSpoileredTags() {
|
|||
.sort(sortTags.bind(null, false));
|
||||
}
|
||||
|
||||
function imageHitsTags(img, matchTags) {
|
||||
export function imageHitsTags(img, matchTags) {
|
||||
const imageTags = JSON.parse(img.dataset.imageTags);
|
||||
return matchTags.filter(t => imageTags.indexOf(t.id) !== -1);
|
||||
}
|
||||
|
||||
function imageHitsComplex(img, matchComplex) {
|
||||
export function imageHitsComplex(img, matchComplex) {
|
||||
return matchComplex.hitsImage(img);
|
||||
}
|
||||
|
||||
function displayTags(tags) {
|
||||
export function displayTags(tags) {
|
||||
const mainTag = tags[0], otherTags = tags.slice(1);
|
||||
let list = escapeHtml(mainTag.name), extras;
|
||||
|
||||
|
@ -54,5 +54,3 @@ function displayTags(tags) {
|
|||
|
||||
return list;
|
||||
}
|
||||
|
||||
export { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags };
|
||||
|
|
|
@ -13,7 +13,7 @@ import { registerEvents } from './boorujs';
|
|||
import { setupBurgerMenu } from './burger';
|
||||
import { bindCaptchaLinks } from './captcha';
|
||||
import { setupComments } from './comment';
|
||||
import { setupDupeReports } from './duplicate_reports.js';
|
||||
import { setupDupeReports } from './duplicate_reports';
|
||||
import { setFingerprintCookie } from './fingerprint';
|
||||
import { setupGalleryEditing } from './galleries';
|
||||
import { initImagesClientside } from './imagesclientside';
|
||||
|
@ -31,7 +31,7 @@ import { setupTagEvents } from './tagsmisc';
|
|||
import { setupTimestamps } from './timeago';
|
||||
import { setupImageUpload } from './upload';
|
||||
import { setupSearch } from './search';
|
||||
import { setupToolbar } from './markdowntoolbar.js';
|
||||
import { setupToolbar } from './markdowntoolbar';
|
||||
import { hideStaffTools } from './staffhider';
|
||||
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": {
|
||||
"deploy": "cross-env NODE_ENV=production webpack",
|
||||
"lint": "eslint . --ext .js,.ts",
|
||||
"test": "jest --ci",
|
||||
"test:watch": "jest --watch",
|
||||
"watch": "webpack --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -13,8 +16,12 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-typescript": "^8.2.5",
|
||||
"@typescript-eslint/eslint-plugin": "^4.32.0",
|
||||
"@typescript-eslint/parser": "^4.32.0",
|
||||
"@testing-library/dom": "^8.7.2",
|
||||
"@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",
|
||||
"autoprefixer": "^10.3.5",
|
||||
"copy-webpack-plugin": "^6.4.1",
|
||||
|
@ -22,9 +29,13 @@
|
|||
"css-loader": "^5.2.7",
|
||||
"css-minimizer-webpack-plugin": "^2.0.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-jest": "^25.7.0",
|
||||
"eslint-plugin-jest-dom": "^4.0.1",
|
||||
"eslint-webpack-plugin": "^3.0.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"ignore-emit-webpack-plugin": "^2.0.6",
|
||||
"jest": "^27.4.7",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"mini-css-extract-plugin": "^2.3.0",
|
||||
"normalize-scss": "^7.0.1",
|
||||
"postcss-loader": "^5.3.0",
|
||||
|
@ -40,6 +51,7 @@
|
|||
"source-map-support": "^0.5.20",
|
||||
"style-loader": "^1.3.0",
|
||||
"terser-webpack-plugin": "^3.1.0",
|
||||
"ts-jest": "^27.0.4",
|
||||
"typescript": "^4.4",
|
||||
"webpack": "^5.53.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,
|
||||
"baseUrl": "./js",
|
||||
"target": "ES2018",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "Node",
|
||||
"allowJs": true,
|
||||
"lib": [
|
||||
"ES2018",
|
||||
"DOM"
|
||||
],
|
||||
"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');
|
||||
const path = require('path');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const IgnoreEmitPlugin = require('ignore-emit-webpack-plugin');
|
||||
const ESLintPlugin = require('eslint-webpack-plugin');
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import url from 'url';
|
||||
import TerserPlugin from 'terser-webpack-plugin';
|
||||
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
|
||||
import CopyPlugin from 'copy-webpack-plugin';
|
||||
import MiniCssExtractPlugin from "mini-css-extract-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 __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
|
||||
const includePaths = require('rollup-plugin-includepaths')();
|
||||
const multiEntry = require('rollup-plugin-multi-entry')();
|
||||
const buble = require('rollup-plugin-buble')({ transforms: { dangerousForOf: true } });
|
||||
const typescript = require('@rollup/plugin-typescript')();
|
||||
const includePaths = rollupPluginIncludepaths();
|
||||
const multiEntry = rollupPluginMultiEntry();
|
||||
const buble = rollupPluginBuble({ transforms: { dangerousForOf: true } });
|
||||
const typescript = rollupPluginTypescript();
|
||||
|
||||
let plugins = [
|
||||
new IgnoreEmitPlugin(/css\/.*(?<!css)$/),
|
||||
|
@ -56,7 +63,7 @@ for (const name of themeNames) {
|
|||
themes[`css/${name}`] = `./css/themes/${name}.scss`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
mode: isDevelopment ? 'development' : 'production',
|
||||
entry: {
|
||||
'js/app.js': './js/app.js',
|
||||
|
@ -126,7 +133,7 @@ module.exports = {
|
|||
ident: 'postcss',
|
||||
syntax: 'postcss-scss',
|
||||
plugins: [
|
||||
require('autoprefixer')(),
|
||||
autoprefixer(),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue