Skip to content

Commit

Permalink
🧪 test: add unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
ShayanTheNerd committed Jul 30, 2024
1 parent 3459564 commit e3b9250
Show file tree
Hide file tree
Showing 24 changed files with 854 additions and 151 deletions.
12 changes: 6 additions & 6 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
/* prettier-ignore */
"recommendations": [
"csstools.postcss",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"astro-build.astro-vscode"
]
"vitest.explorer",
"csstools.postcss",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"astro-build.astro-vscode"
]
}
10 changes: 7 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ const vitestRules = {
'vitest/prefer-to-be-truthy': 'error',
'vitest/prefer-to-be-object': 'error',
'vitest/no-standalone-expect': 'error',
'vitest/no-conditional-expect': 'error',
'vitest/no-conditional-in-test': 'error',
'vitest/prefer-to-have-length': 'error',
'vitest/prefer-hooks-in-order': 'error',
Expand Down Expand Up @@ -299,7 +298,7 @@ export default eslintAntfuConfig(
rules: { ...jsRules, ...tsRules, ...stylisticRules },
},
{
files: ['src/tests/**'],
files: ['tests/unit/**/*.test.ts'],
plugins: { eslintPluginVitest },
settings: {
vitest: {
Expand All @@ -311,7 +310,12 @@ export default eslintAntfuConfig(
...eslintPluginVitest.environments.env.globals,
},
},
rules: { ...vitestRules },
rules: {
...vitestRules,
'ts/no-unsafe-call': 'off',
'no-magic-numbers': 'off',
'ts/no-unsafe-member-access': 'off',
},
},
{ rules: generalRules },
{ ignores: ['**/*.json', 'src/env.d.ts'] },
Expand Down
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
},
"scripts": {
"dev": "astro dev",
"format": "npx prettier '**/*.{json,css,astro}' --config prettier.config.js --write --cache",
"lint": "eslint --fix --config eslint.config.js --ignore-pattern '.vscode/*.json' --cache-location '/node_modules/.eslintcache/",
"format": "npx prettier --write '**/*.{json,css,astro}' --config=prettier.config.js --cache",
"lint": "eslint --fix --ignore-pattern='.vscode/*.json' --config=eslint.config.js --cache-location='/node_modules/.eslintcache/",
"test:unit": "vitest --config=vitest.config.ts",
"build": "astro build",
"removeEnvTypesFile": "node scripts/removeEnvTypesFile.js",
"preview": "astro preview",
"all": "pnpm format && pnpm lint && pnpm build && pnpm removeEnvTypesFile"
"all": "pnpm format && pnpm lint && pnpm test:unit --run && pnpm build && pnpm removeEnvTypesFile"
},
"dependencies": {
"@formkit/auto-animate": "^0.8.1",
Expand All @@ -42,6 +43,7 @@
"eslint-plugin-vitest": "^0.5.4",
"lightningcss": "^1.24.0",
"prettier": "^3.2.5",
"prettier-plugin-astro": "^0.14.0"
"prettier-plugin-astro": "^0.14.0",
"vitest": "^2.0.4"
}
}
704 changes: 607 additions & 97 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

59 changes: 32 additions & 27 deletions src/assets/ts/imgStore.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { State, Filter, SpinMode, FilterName } from '@types';

import { deepClone } from '@ts/utils/deepClone.ts';
import { spinIsRotation } from '@ts/utils/spinIsRotation.ts';
import { resetRotationDeg } from '@ts/utils/resetRotationDeg.ts';
import { rotationDegs, splitFromLastDotRegExp } from '@ts/constants.ts';

const { left: leftRotationDeg, right: rightRotationDeg, half: halfRotationDeg, full: fullRotationDeg } = rotationDegs;

function deepCopy<TObj = object>(obj: TObj) {
return structuredClone(obj) || JSON.parse(JSON.stringify(obj)) as TObj;
}

class ImgStore {
/*** State ***/
state: State = {
name: null,
extension: null,
Expand Down Expand Up @@ -74,32 +74,47 @@ class ImgStore {
isActive: false,
},
],
};
}; /* “satisfies” vs. type annotation: “satisfies” fixes “verticalFlip” and “horizontalFlip” to 1, causing problem in createImgCanvas. */

#defaultState = deepCopy(this.state);
#defaultState = deepClone(this.state);

/*** Methods ***/
updateSpinValue(spinMode: SpinMode) {
if (spinIsRotation(spinMode)) {
const rotationDeg = spinMode === 'Rotate Right' ? rightRotationDeg : leftRotationDeg;
this.state.rotationDeg += rotationDeg;
} else {
const flipMode = spinMode === 'Vertical Flip' ? 'verticalFlip' : 'horizontalFlip';
this.state[flipMode] *= -1;
}
}

updateCSSFilters() {
const BLUR_REDUCTION_FACTOR = 2.5;

this.state.CSSFilters = this.state.filters.reduce((acc, { name, value, unit }) => {
const filtersString = this.state.filters.reduce((acc, { name, value, unit }) => {
if (name === 'blur') {
value /= BLUR_REDUCTION_FACTOR; // Visually reduce blur effect
}

return `${acc}${name}(${value}${unit}) `;
return `${acc}${name}(${value}${unit})`;
}, '');

this.state.CSSFilters = filtersString;

return filtersString;
}

reset() {
const initialState = deepClone(this.#defaultState);
const { name, extension, rotationDeg } = this.state;
const normalizedRotationDeg = this.#isRotated ? Math.round(rotationDeg / fullRotationDeg) * fullRotationDeg : rotationDeg;
const adjustedRotationDeg = this.#isRotated ? resetRotationDeg(rotationDeg) : rotationDeg;

this.state = {
...deepCopy(this.#defaultState),
...initialState,
name,
extension,
rotationDeg: normalizedRotationDeg,
rotationDeg: adjustedRotationDeg,
};
}

Expand Down Expand Up @@ -144,27 +159,17 @@ class ImgStore {
}

/*** Setters ***/
/* Type “any” is required when getter and setter don't share the same type. */
set activeFilter(newFilterName: any | FilterName) {
this.activeFilter.isActive = false;
const newFilter = this.state.filters.find((filter: Filter) => filter.name === newFilterName) as Filter;
newFilter.isActive = true;
}

set title(imgFileName: string) {
const [imgName, imgExtension] = imgFileName.split(splitFromLastDotRegExp);

Object.assign(this.state, { name: imgName, extension: imgExtension?.toLowerCase() });
}

// eslint-disable-next-line accessor-pairs -- “rotationDeg” and “verticalFlip”/“horizontalFlip” are accessed separately.
set spin(spinMode: SpinMode) {
if (spinMode.startsWith('Rotate')) {
const rotationDeg = spinMode === 'Rotate Right' ? rightRotationDeg : leftRotationDeg;
this.state.rotationDeg += rotationDeg;
} else {
const flipMode = spinMode === 'Vertical Flip' ? 'verticalFlip' : 'horizontalFlip';
this.state[flipMode] *= -1;
}
/* Type “any” is required when getter and setter don't share the same type. */
set activeFilter(newFilterName: any | FilterName) {
this.activeFilter.isActive = false;
const newFilter = this.state.filters.find((filter: Filter) => filter.name === newFilterName) as Filter;
newFilter.isActive = true;
}
}

Expand Down
1 change: 1 addition & 0 deletions src/assets/ts/modules/resetFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function resetFilters() {
const { filtersContainer } = DOMElements;
const activeFilterBtn = getActiveFilterBtn();
const firstFilterBtn = filtersContainer.children[0] as HTMLButtonElement;

activeFilterBtn.classList.remove(activeFilterClass);
firstFilterBtn.classList.add(activeFilterClass);
filtersContainer.scrollTo({ left: 0, behavior: 'smooth' });
Expand Down
12 changes: 2 additions & 10 deletions src/assets/ts/modules/spinImg.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
import type { SpinMode } from '@types';

import { imgStore } from '@ts/imgStore.ts';
import { DOMElements, getImgElement } from '@ts/domElements.ts';

const { width: imgContainerWidth, height: imgContainerHeight } = DOMElements.imgDropZone.getBoundingClientRect();

export function spinImg(event?: Event) {
if (event && event.target !== event.currentTarget) {
const target = event.target as HTMLElement;
const spinBtn = target.closest('button') as HTMLButtonElement;
const spinMode = spinBtn.title.trim() as SpinMode;
imgStore.spin = spinMode;
}

export function spinImg() {
const imgElement = getImgElement();
const { verticalFlip, horizontalFlip, rotationDeg } = imgStore.state;

imgElement.style.width = imgStore.isLandscape ? `${imgContainerHeight}px` : '100%';
imgElement.style.height = imgStore.isLandscape ? `${imgContainerWidth}px` : '100%';
imgElement.style.transform = `scale(${verticalFlip}, ${horizontalFlip}) rotate(${rotationDeg}deg)`;
Expand Down
5 changes: 5 additions & 0 deletions src/assets/ts/utils/deepClone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function deepClone<T = object>(obj: T) {
const clone = structuredClone(obj) || JSON.parse(JSON.stringify(obj)) as T;

return clone;
}
9 changes: 9 additions & 0 deletions src/assets/ts/utils/resetRotationDeg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { rotationDegs } from '@ts/constants.ts';

const fullRotationDeg = rotationDegs.full;

export function resetRotationDeg(rotationDeg: number) {
const rotationQuotient = Math.round(rotationDeg / fullRotationDeg);

return Math.abs(rotationQuotient * fullRotationDeg);
}
5 changes: 5 additions & 0 deletions src/assets/ts/utils/spinIsRotation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { SpinMode } from '@types';

export function spinIsRotation(spinMode: SpinMode) {
return spinMode.toLowerCase().includes('rotate');
}
4 changes: 2 additions & 2 deletions src/components/SpinButton.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ type Props = {
};
const { spinMode } = Astro.props as Props;
const icon = spinMode.replace(' ', '_').toLowerCase();
const spinIcon = spinMode.replaceAll(' ', '_').toLowerCase();
---

<button type="button" title={spinMode} class="btn">
<svg>
<use href={`${import.meta.env.BASE_URL}/icons.svg#${icon}`}></use>
<use href={`${import.meta.env.BASE_URL}/icons.svg#${spinIcon}`}></use>
</svg>
</button>

Expand Down
14 changes: 13 additions & 1 deletion src/components/TheSpins.astro
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,20 @@ import { spinModes } from '@ts/constants.ts';
</style>

<script>
import type { SpinMode } from '@types';

import { imgStore } from '@ts/imgStore.ts';
import { spinImg } from '@ts/modules/spinImg.ts';
import { DOMElements } from '@ts/domElements.ts';

DOMElements.spinsContainer.addEventListener('click', spinImg);
DOMElements.spinsContainer.addEventListener('click', ({ target, currentTarget }) => {
if (target === currentTarget) return;

const targetElement = target as HTMLElement;
const spinBtn = targetElement.closest('button') as HTMLButtonElement;
const spinMode = spinBtn.title.trim() as SpinMode;
imgStore.updateSpinValue(spinMode);

spinImg();
});
</script>
11 changes: 11 additions & 0 deletions tests/unit/imgStore/activeFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { imgStore } from '@ts/imgStore.ts';
import { it, expect, describe } from 'vitest';

describe('imgStore.activeFilter', () => {
it('should update the active filter based on the given name, and allow access to its properties', () => {
const activeFilterName = 'grayscale';
imgStore.activeFilter = activeFilterName;

expect(imgStore.activeFilter.name).toBe(activeFilterName);
});
});
14 changes: 14 additions & 0 deletions tests/unit/imgStore/isEdited.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { imgStore } from '@ts/imgStore.ts';
import { it, expect, describe } from 'vitest';

describe('imgStore.isEdited', () => {
it('should return “true” if the image has been rotated', () => {
const rotationDegs = [-450, -270, -180, -90, 90, 180, 270, 450] as const;

rotationDegs.forEach((rotationDeg) => {
imgStore.state.rotationDeg = rotationDeg;

expect(imgStore.isEdited).toBeTruthy();
});
});
});
14 changes: 14 additions & 0 deletions tests/unit/imgStore/isLandscape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { imgStore } from '@ts/imgStore.ts';
import { it, expect, describe } from 'vitest';

describe('imgStore.isLandscape', () => {
it('should return “true” if the image has been rotated only by multiples of 90 degrees and not 180 degrees', () => {
const rotationDegs = [-450, -270, -90, 90, 270, 450] as const;

rotationDegs.forEach((rotationDeg) => {
imgStore.state.rotationDeg = rotationDeg;

expect(imgStore.isLandscape).toBeTruthy();
});
});
});
17 changes: 17 additions & 0 deletions tests/unit/imgStore/reset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { imgStore } from '@ts/imgStore.ts';
import { it, expect, describe } from 'vitest';
import { deepClone } from '@ts/utils/deepClone.ts';
import { resetRotationDeg } from '@ts/utils/resetRotationDeg.ts';

describe('imgStore.reset', () => {
it('should correctly reset the state', () => {
const initialState = deepClone(imgStore.state);
const [name, extension, rotationDeg] = ['New Image', 'png', 180];
const adjustedRotationDeg = resetRotationDeg(rotationDeg);

Object.assign(imgStore.state, { name, extension, rotationDeg, verticalFlip: -1 });
imgStore.reset();

expect(imgStore.state).toStrictEqual({ ...initialState, name, extension, rotationDeg: adjustedRotationDeg });
});
});
10 changes: 10 additions & 0 deletions tests/unit/imgStore/title.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { imgStore } from '@ts/imgStore.ts';
import { it, expect, describe } from 'vitest';

describe('imgStore.title', () => {
it('should set the name and extension of the image file, and return its full name (name.extension)', () => {
Object.assign(imgStore.state, { name: 'New Image', extension: 'png' });

expect(imgStore.title).toMatchInlineSnapshot(`"New Image.png"`);
});
});
12 changes: 12 additions & 0 deletions tests/unit/imgStore/updateCSSFilters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { imgStore } from '@ts/imgStore.ts';
import { it, expect, describe } from 'vitest';

describe('imgStore.updateCSSFilters', () => {
it('should create and/or update the CSS filters string corresponding with filter values', () => {
imgStore.state.filters.find(({ name }) => name === 'grayscale')!.value = 50;
imgStore.updateCSSFilters();
const { CSSFilters } = imgStore.state;

expect(CSSFilters).toMatchInlineSnapshot(`"brightness(100%)grayscale(50%)blur(0px)hue-rotate(0deg)opacity(100%)contrast(100%)saturate(100%)sepia(0%)"`);
});
});
21 changes: 21 additions & 0 deletions tests/unit/imgStore/updateSpinValue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { imgStore } from '@ts/imgStore.ts';
import { spinModes } from '@ts/constants.ts';
import { it, expect, describe } from 'vitest';
import { spinIsRotation } from '@ts/utils/spinIsRotation.ts';

describe('imgStore.updateSpinValue', () => {
it('should update the rotation degree and vertical/horizontal flip values', () => {
spinModes.forEach((spinMode) => {
imgStore.updateSpinValue(spinMode);

const { rotationDeg, verticalFlip, horizontalFlip } = imgStore.state;

if (spinIsRotation(spinMode)) {
expect(rotationDeg === 0 || rotationDeg % 90 === 0).toBeTruthy();
} else {
expect([1, -1]).toContain(verticalFlip);
expect([1, -1]).toContain(horizontalFlip);
}
});
});
});
18 changes: 18 additions & 0 deletions tests/unit/utils/deepClone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { it, expect, describe } from 'vitest';
import { deepClone } from '@ts/utils/deepClone.ts';

describe('deepClone', () => {
it('should create a deep clone of the given object', () => {
const obj = {
a: 1,
b: true,
c: 'value',
d: { e: 'f' },
g: [1, '2', { h: '3' }],
};
const clone = deepClone(obj);

expect(clone).not.toBe(obj);
expect(clone).toStrictEqual(obj);
});
});
Loading

0 comments on commit e3b9250

Please sign in to comment.