Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(web): add asset store unit tests #8077

Merged
merged 1 commit into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions web/src/lib/__mocks__/sdk.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import sdk from '@immich/sdk';
import type { Mock, MockedObject } from 'vitest';

vi.mock('@immich/sdk', async (originalImport) => {
const module = await originalImport<typeof import('@immich/sdk')>();

const mocks: Record<string, Mock> = {};
for (const [key, value] of Object.entries(module)) {
if (typeof value === 'function') {
mocks[key] = vi.fn();
}
}

const mock = { ...module, ...mocks };
return { ...mock, default: mock };
});

export const sdkMock = sdk as MockedObject<typeof sdk>;
11 changes: 2 additions & 9 deletions web/src/lib/components/album-page/__tests__/album-card.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
import sdk, { ThumbnailFormat } from '@immich/sdk';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { ThumbnailFormat } from '@immich/sdk';
import { albumFactory } from '@test-data';
import '@testing-library/jest-dom';
import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte';
import type { MockedObject } from 'vitest';
import AlbumCard from '../album-card.svelte';

vi.mock('@immich/sdk', async (originalImport) => {
const module = await originalImport<typeof import('@immich/sdk')>();
const mock = { ...module, getAssetThumbnail: vi.fn() };
return { ...mock, default: mock };
});

const sdkMock: MockedObject<typeof sdk> = sdk as MockedObject<typeof sdk>;
const onShowContextMenu = vi.fn();

describe('AlbumCard component', () => {
Expand Down
357 changes: 357 additions & 0 deletions web/src/lib/stores/asset.store.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,357 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { AssetStore, BucketPosition } from './assets.store';

describe('AssetStore', () => {
beforeEach(() => {
vi.resetAllMocks();
});

describe('init', () => {
let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = {
'2024-03-01T00:00:00.000Z': assetFactory.buildList(1),
'2024-02-01T00:00:00.000Z': assetFactory.buildList(100),
'2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
};

beforeEach(async () => {
assetStore = new AssetStore({});
sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));

await assetStore.init({ width: 1588, height: 1000 });
});

it('should load buckets in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month });
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
});

it('calculates bucket height', () => {
expect(assetStore.buckets).toEqual(
expect.arrayContaining([
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 235 }),
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3760 }),
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }),
]),
);
});

it('calculates timeline height', () => {
expect(assetStore.timelineHeight).toBe(4230);
});
});

describe('loadBucket', () => {
let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = {
'2024-01-03T00:00:00.000Z': assetFactory.buildList(1),
'2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
};

beforeEach(async () => {
assetStore = new AssetStore({});
sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-01-03T00:00:00.000Z' },
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
await assetStore.init({ width: 0, height: 0 });
});

it('loads a bucket', async () => {
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3);
});

it('ignores invalid buckets', async () => {
await assetStore.loadBucket('2023-01-01T00:00:00.000Z', BucketPosition.Visible);
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
});

it('only updates the position of loaded buckets', async () => {
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown);
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Unknown);

await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Visible);
});

it('cancels bucket loading', async () => {
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const loadPromise = assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown);

const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
expect(bucket).not.toBeNull();

assetStore.cancelBucket(bucket!);
expect(abortSpy).toBeCalledTimes(1);
await loadPromise;
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
});
});

describe('addAssets', () => {
let assetStore: AssetStore;

beforeEach(async () => {
assetStore = new AssetStore({});
sdkMock.getTimeBuckets.mockResolvedValue([]);
await assetStore.init({ width: 1588, height: 1000 });
});

it('is empty initially', () => {
expect(assetStore.buckets.length).toEqual(0);
expect(assetStore.assets.length).toEqual(0);
});

it('adds assets to new bucket', () => {
const asset = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
assetStore.addAssets([asset]);

expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.assets.length).toEqual(1);
expect(assetStore.buckets[0].assets.length).toEqual(1);
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
expect(assetStore.assets[0].id).toEqual(asset.id);
});

it('adds assets to existing bucket', () => {
const [assetOne, assetTwo] = assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' });
assetStore.addAssets([assetOne]);
assetStore.addAssets([assetTwo]);

expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.assets.length).toEqual(2);
expect(assetStore.buckets[0].assets.length).toEqual(2);
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
});

it('orders assets in buckets by descending date', () => {
const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ fileCreatedAt: '2024-01-15T12:00:00.000Z' });
const assetThree = assetFactory.build({ fileCreatedAt: '2024-01-16T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo, assetThree]);

const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
expect(bucket).not.toBeNull();
expect(bucket?.assets.length).toEqual(3);
expect(bucket?.assets[0].id).toEqual(assetOne.id);
expect(bucket?.assets[1].id).toEqual(assetThree.id);
expect(bucket?.assets[2].id).toEqual(assetTwo.id);
});

it('orders buckets by descending date', () => {
const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ fileCreatedAt: '2024-04-20T12:00:00.000Z' });
const assetThree = assetFactory.build({ fileCreatedAt: '2023-01-20T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo, assetThree]);

expect(assetStore.buckets.length).toEqual(3);
expect(assetStore.buckets[0].bucketDate).toEqual('2024-04-01T00:00:00.000Z');
expect(assetStore.buckets[1].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
expect(assetStore.buckets[2].bucketDate).toEqual('2023-01-01T00:00:00.000Z');
});

it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
const asset = assetFactory.build();
assetStore.addAssets([asset]);

assetStore.addAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(assetStore.assets.length).toEqual(1);
});
});

describe('updateAssets', () => {
let assetStore: AssetStore;

beforeEach(async () => {
assetStore = new AssetStore({});
sdkMock.getTimeBuckets.mockResolvedValue([]);
await assetStore.init({ width: 1588, height: 1000 });
});

it('ignores non-existing assets', () => {
assetStore.updateAssets([assetFactory.build()]);

expect(assetStore.buckets.length).toEqual(0);
expect(assetStore.assets.length).toEqual(0);
});

it('updates an asset', () => {
const asset = assetFactory.build({ isFavorite: false });
const updatedAsset = { ...asset, isFavorite: true };

assetStore.addAssets([asset]);
expect(assetStore.assets.length).toEqual(1);
expect(assetStore.assets[0].isFavorite).toEqual(false);

assetStore.updateAssets([updatedAsset]);
expect(assetStore.assets.length).toEqual(1);
expect(assetStore.assets[0].isFavorite).toEqual(true);
});

it('replaces bucket date when asset date changes', () => {
const asset = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
const updatedAsset = { ...asset, fileCreatedAt: '2024-03-20T12:00:00.000Z' };

assetStore.addAssets([asset]);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).not.toBeNull();

assetStore.updateAssets([updatedAsset]);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).toBeNull();
expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).not.toBeNull();
});
});

describe('removeAssets', () => {
let assetStore: AssetStore;

beforeEach(async () => {
assetStore = new AssetStore({});
sdkMock.getTimeBuckets.mockResolvedValue([]);
await assetStore.init({ width: 1588, height: 1000 });
});

it('ignores invalid IDs', () => {
assetStore.addAssets(assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' }));
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);

expect(assetStore.assets.length).toEqual(2);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.buckets[0].assets.length).toEqual(2);
});

it('removes asset from bucket', () => {
const [assetOne, assetTwo] = assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetOne.id]);

expect(assetStore.assets.length).toEqual(1);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.buckets[0].assets.length).toEqual(1);
});

it('removes bucket when empty', () => {
const assets = assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' });
assetStore.addAssets(assets);
assetStore.removeAssets(assets.map((asset) => asset.id));

expect(assetStore.assets.length).toEqual(0);
expect(assetStore.buckets.length).toEqual(0);
});
});

describe('getPreviousAssetId', () => {
let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = {
'2024-03-01T00:00:00.000Z': assetFactory.buildList(1),
'2024-02-01T00:00:00.000Z': assetFactory.buildList(6),
'2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
};

beforeEach(async () => {
assetStore = new AssetStore({});
sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));

await assetStore.init({ width: 0, height: 0 });
});

it('returns null for invalid assetId', async () => {
expect(() => assetStore.getPreviousAssetId('invalid')).not.toThrow();
expect(await assetStore.getPreviousAssetId('invalid')).toBeNull();
});

it('returns previous assetId', async () => {
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');

expect(await assetStore.getPreviousAssetId(bucket!.assets[1].id)).toEqual(bucket!.assets[0].id);
});

it('returns previous assetId spanning multiple buckets', async () => {
await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);

const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
expect(await assetStore.getPreviousAssetId(bucket!.assets[0].id)).toEqual(previousBucket!.assets[0].id);
});

it('loads previous bucket', async () => {
await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);

const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
expect(await assetStore.getPreviousAssetId(bucket!.assets[0].id)).toEqual(previousBucket!.assets[0].id);
expect(loadBucketSpy).toBeCalledTimes(1);
});

it('skips removed assets', async () => {
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);

const [assetOne, assetTwo, assetThree] = assetStore.assets;
assetStore.removeAssets([assetTwo.id]);
expect(await assetStore.getPreviousAssetId(assetThree.id)).toEqual(assetOne.id);
});

it('returns null when no more assets', async () => {
await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
expect(await assetStore.getPreviousAssetId(assetStore.assets[0].id)).toBeNull();
});
});

describe('getBucketIndexByAssetId', () => {
let assetStore: AssetStore;

beforeEach(async () => {
assetStore = new AssetStore({});
sdkMock.getTimeBuckets.mockResolvedValue([]);
await assetStore.init({ width: 0, height: 0 });
});

it('returns null for invalid buckets', () => {
expect(assetStore.getBucketByDate('invalid')).toBeNull();
expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).toBeNull();
});

it('returns the bucket index', () => {
const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ fileCreatedAt: '2024-02-15T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo]);

expect(assetStore.getBucketIndexByAssetId(assetTwo.id)).toEqual(0);
expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(1);
});

it('ignores removed buckets', () => {
const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ fileCreatedAt: '2024-02-15T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo]);

assetStore.removeAssets([assetTwo.id]);
expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(0);
});
});
});
Loading
Loading