Files
immich/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts
midzelis 928b69f415 refactor(web): rename DayGroup/MonthGroup to TimelineDay/TimelineMonth
- Rename classes: DayGroup → TimelineDay, MonthGroup → TimelineMonth
- Use short variable names: dayGroup → day, monthGroup → month
- Update all method names and properties for consistency
- Convert relative imports to $lib alias convention

No functional changes.
2025-11-04 00:40:48 +00:00

818 lines
32 KiB
TypeScript

import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getMonthByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { setTestHooks } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { tick } from 'svelte';
import type { MockInstance } from 'vitest';
async function getAssets(timelineManager: TimelineManager) {
const assets = [];
for await (const asset of timelineManager.assetsIterator()) {
assets.push(asset);
}
return assets;
}
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
return {
...arg,
localDateTime: arg.fileCreatedAt,
};
}
describe('TimelineManager', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('init', () => {
let timelineManager: TimelineManager;
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
}),
),
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(100).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
}),
),
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
}),
),
};
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
);
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01' },
{ count: 100, timeBucket: '2024-02-01' },
{ count: 3, timeBucket: '2024-01-01' },
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
await timelineManager.updateViewport({ width: 1588, height: 1000 });
await tick();
});
it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
});
it('calculates month height', () => {
const plainMonths = timelineManager.months.map((month) => ({
year: month.yearMonth.year,
month: month.yearMonth.month,
height: month.height,
}));
expect(plainMonths).toEqual(
expect.arrayContaining([
expect.objectContaining({ year: 2024, month: 3, height: 283 }),
expect.objectContaining({ year: 2024, month: 2, height: 7711 }),
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
]),
);
});
it('calculates timeline height', () => {
expect(timelineManager.totalViewerHeight).toBe(8340);
});
});
describe('loadMonth', () => {
let timelineManager: TimelineManager;
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
}),
),
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
}),
),
};
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
);
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]);
sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => {
await new Promise((resolve) => setTimeout(resolve, 0));
if (signal?.aborted) {
throw new AbortError();
}
return bucketAssetsResponse[timeBucket];
});
await timelineManager.updateViewport({ width: 1588, height: 0 });
});
it('loads a month', async () => {
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('ignores invalid months', async () => {
await timelineManager.loadMonth({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
});
it('cancels month loading', async () => {
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
void timelineManager.loadMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('prevents loading months multiple times', async () => {
await Promise.all([
timelineManager.loadMonth({ year: 2024, month: 1 }),
timelineManager.loadMonth({ year: 2024, month: 1 }),
]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
});
it('allows loading a canceled month', async () => {
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
const loadPromise = timelineManager.loadMonth({ year: 2024, month: 1 });
month.cancel();
await loadPromise;
expect(month?.getAssets().length).toEqual(0);
await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(month!.getAssets().length).toEqual(3);
});
});
describe('upsertAssets', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
it('is empty initially', () => {
expect(timelineManager.months.length).toEqual(0);
expect(timelineManager.assetCount).toEqual(0);
});
it('adds assets to new month', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
});
it('adds assets to existing month', () => {
const [assetOne, assetTwo] = timelineAssetFactory
.buildList(2, {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.upsertAssets([assetOne]);
timelineManager.upsertAssets([assetTwo]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months[0].getAssets().length).toEqual(2);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
});
it('orders assets in months by descending date', () => {
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
}),
);
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
expect(month).not.toBeNull();
expect(month?.getAssets().length).toEqual(3);
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
});
it('orders months by descending date', () => {
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-04-20T12:00:00.000Z'),
}),
);
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
expect(timelineManager.months.length).toEqual(3);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(4);
expect(timelineManager.months[1].yearMonth.year).toEqual(2024);
expect(timelineManager.months[1].yearMonth.month).toEqual(1);
expect(timelineManager.months[2].yearMonth.year).toEqual(2023);
expect(timelineManager.months[2].yearMonth.month).toEqual(1);
});
it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
// disabled due to the wasm Justified Layout import
it('ignores trashed assets when isTrashed is true', async () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));
const trashedAsset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: true }));
const timelineManager = new TimelineManager();
await timelineManager.updateOptions({ isTrashed: true });
timelineManager.upsertAssets([asset, trashedAsset]);
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
});
});
describe('ensure efficient timeline operations', () => {
let timelineManager: TimelineManager;
let month1day1asset1: TimelineAsset,
month1day2asset1: TimelineAsset,
month1day2asset2: TimelineAsset,
month1day3asset1: TimelineAsset,
month2day1asset1: TimelineAsset,
month2day2asset1: TimelineAsset,
month2day2asset2: TimelineAsset;
type DayMocks = {
layoutFn: MockInstance;
sortAssetsFn: MockInstance;
};
type MonthMocks = {
sortDaysFn: MockInstance;
};
const days = new Map<TimelineDay, DayMocks>();
const months = new Map<TimelineMonth, MonthMocks>();
beforeEach(async () => {
timelineManager = new TimelineManager();
setTestHooks({
onCreateDay: (day: TimelineDay) => {
days.set(day, {
layoutFn: vi.spyOn(day, 'layout'),
sortAssetsFn: vi.spyOn(day, 'sortAssets'),
});
},
onCreateMonth: (month: TimelineMonth) => {
months.set(month, {
sortDaysFn: vi.spyOn(month, 'sortDays'),
});
},
});
sdkMock.getTimeBuckets.mockResolvedValue([]);
month1day1asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
month1day2asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
}),
);
month1day2asset2 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T13:00:00.000Z'),
}),
);
month1day3asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
}),
);
month2day1asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-16T12:00:00.000Z'),
}),
);
month2day2asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-18T12:00:00.000Z'),
}),
);
month2day2asset2 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-18T13:00:00.000Z'),
}),
);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
timelineManager.upsertAssets([
month1day1asset1,
month1day2asset1,
month1day2asset2,
month1day3asset1,
month2day1asset1,
month2day2asset1,
month2day2asset2,
]);
vitest.resetAllMocks();
});
it.skip('Not Ready Yet - optimizations not complete: moving asset between months only sorts/layout the affected months once', () => {
// move from 2024-01-15 to 2024-01-16
timelineManager.updateAssetOperation([month1day2asset1.id], (asset) => {
asset.localDateTime.day = asset.localDateTime.day + 1;
});
for (const [day, mocks] of days) {
if (day.day === 15 && day.month.yearMonth.month === 1) {
// source - should be layout once
expect.soft(mocks.layoutFn).toBeCalledTimes(1);
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1);
}
if (day.day === 16 && day.month.yearMonth.month === 1) {
// target - should be layout once
expect.soft(mocks.layoutFn).toBeCalledTimes(1);
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1);
}
// everything else - should not be layed-out
expect.soft(mocks.layoutFn).toBeCalledTimes(0);
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(0);
}
for (const [_, mocks] of months) {
// if the day itself did not change, probably no need to sort it
// in the timeline manager, the day-group identity is immutable - you will never
// "move" a whole day to another day - only the assets inside will be moved from
// one to the other.
expect.soft(mocks.sortDaysFn).toBeCalledTimes(0);
}
});
});
describe('upsertAssets', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
it('upserts an asset', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
const updatedAsset = { ...asset, isFavorite: true };
timelineManager.upsertAssets([asset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
});
it('asset moves months when asset date changes', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const updatedAsset = deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
});
timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.months.length).toEqual(2);
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if visibility changes', async () => {
await timelineManager.updateOptions({
visibility: AssetVisibility.Archive,
});
const fixture = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
visibility: AssetVisibility.Archive,
}),
);
timelineManager.upsertAssets([fixture]);
expect(timelineManager.assetCount).toEqual(1);
const updated = Object.freeze({ ...fixture, visibility: AssetVisibility.Timeline });
timelineManager.upsertAssets([updated]);
expect(timelineManager.assetCount).toEqual(0);
timelineManager.upsertAssets([{ ...fixture, visibility: AssetVisibility.Archive }]);
expect(timelineManager.assetCount).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if isFavorite changes', async () => {
await timelineManager.updateOptions({
isFavorite: true,
});
const fixture = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
isFavorite: true,
}),
);
timelineManager.upsertAssets([fixture]);
expect(timelineManager.assetCount).toEqual(1);
const updated = Object.freeze({ ...fixture, isFavorite: false });
timelineManager.upsertAssets([updated]);
expect(timelineManager.assetCount).toEqual(0);
timelineManager.upsertAssets([{ ...fixture, isFavorite: true }]);
expect(timelineManager.assetCount).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if isTrashed changes', async () => {
await timelineManager.updateOptions({
isTrashed: true,
});
const fixture = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
isTrashed: true,
}),
);
timelineManager.upsertAssets([fixture]);
expect(timelineManager.assetCount).toEqual(1);
const updated = Object.freeze({ ...fixture, isTrashed: false });
timelineManager.upsertAssets([updated]);
expect(timelineManager.assetCount).toEqual(0);
timelineManager.upsertAssets([{ ...fixture, isTrashed: true }]);
expect(timelineManager.assetCount).toEqual(1);
});
});
describe('removeAssets', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
it('ignores invalid IDs', () => {
timelineManager.upsertAssets(
timelineAssetFactory
.buildList(2, {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)),
);
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(2);
});
it('removes asset from month', () => {
const [assetOne, assetTwo] = timelineAssetFactory
.buildList(2, {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetOne.id]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1);
});
it('does not remove month when empty', () => {
const assets = timelineAssetFactory
.buildList(2, {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.upsertAssets(assets);
timelineManager.removeAssets(assets.map((asset) => asset.id));
expect(timelineManager.assetCount).toEqual(0);
expect(timelineManager.months.length).toEqual(1);
});
});
describe('firstAsset', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 0, height: 0 });
});
it('empty store returns null', () => {
expect(timelineManager.getFirstAsset()).toBeUndefined();
});
it('populated store returns first asset', () => {
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
});
});
describe('getLaterAsset', () => {
let timelineManager: TimelineManager;
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
}),
),
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(6).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
}),
),
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
}),
),
};
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
);
beforeEach(async () => {
timelineManager = new TimelineManager();
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(bucketAssetsResponse[timeBucket]));
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
it('returns null for invalid assetId', async () => {
expect(() => timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
expect(await timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
});
it('returns previous assetId', async () => {
await timelineManager.loadMonth({ year: 2024, month: 1 });
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
const a = month!.getAssets()[0];
const b = month!.getAssets()[1];
const previous = await timelineManager.getLaterAsset(b);
expect(previous).toEqual(a);
});
it('returns previous assetId spanning multiple months', async () => {
await timelineManager.loadMonth({ year: 2024, month: 2 });
await timelineManager.loadMonth({ year: 2024, month: 3 });
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getAssets()[0];
const b = previousMonth!.getAssets()[0];
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
});
it('loads previous month', async () => {
await timelineManager.loadMonth({ year: 2024, month: 2 });
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getFirstAsset();
const b = previousMonth!.getFirstAsset();
const loadMonthSpy = vi.spyOn(month!.loader!, 'execute');
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {
await timelineManager.loadMonth({ year: 2024, month: 1 });
await timelineManager.loadMonth({ year: 2024, month: 2 });
await timelineManager.loadMonth({ year: 2024, month: 3 });
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
timelineManager.removeAssets([assetTwo.id]);
expect(await timelineManager.getLaterAsset(assetThree)).toEqual(assetOne);
});
it('returns null when no more assets', async () => {
await timelineManager.loadMonth({ year: 2024, month: 3 });
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
});
});
describe('getMonthIndexByAssetId', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 0, height: 0 });
});
it('returns null for invalid months', () => {
expect(getMonthByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined();
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined();
});
it('returns the month index', () => {
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
});
it('ignores removed months', () => {
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]);
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
});
});
describe('getRandomAsset', () => {
let timelineManager: TimelineManager;
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
}),
),
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(10).map((asset, idx) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
// here we make sure that not all assets are on the first day of the month
fileCreatedAt: fromISODateTimeUTCToObject(`2024-02-0${idx < 7 ? 1 : 2}T00:00:00.000Z`),
}),
),
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
deriveLocalDateTimeFromFileCreatedAt({
...asset,
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
}),
),
};
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
);
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01' },
{ count: 10, timeBucket: '2024-02-01' },
{ count: 3, timeBucket: '2024-01-01' },
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
await timelineManager.updateViewport({ width: 1588, height: 0 });
});
it('gets all assets once', async () => {
const assetCount = timelineManager.assetCount;
expect(assetCount).toBe(14);
const discoveredAssets: Set<string> = new Set();
for (let idx = 0; idx < assetCount; idx++) {
const asset = await timelineManager.getRandomAsset(idx);
expect(asset).toBeDefined();
const id = asset!.id;
expect(discoveredAssets.has(id)).toBeFalsy();
discoveredAssets.add(id);
}
expect(discoveredAssets.size).toBe(assetCount);
});
});
});