mirror of https://github.com/immich-app/immich.git
Merge
parent
2a3c1c6e15
99b1073143
ab5c031cbf
2cb97c3cee
c291c4c522
7818477bd3
74c107284b
1109c32891
6d499c782a
commit
e1a6f6ac34
@ -0,0 +1,52 @@
|
||||
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
class AsyncCache<V> {
|
||||
#cache = new Map<string, V>();
|
||||
|
||||
async getOrFetch<K>(
|
||||
params: K,
|
||||
fetcher: (params: K) => Promise<V>,
|
||||
keySerializer: (params: K) => string = (params) => JSON.stringify(params),
|
||||
): Promise<V> {
|
||||
const cacheKey = keySerializer(params);
|
||||
|
||||
const cached = this.#cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const value = await fetcher(params);
|
||||
if (value) {
|
||||
this.#cache.set(cacheKey, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class AssetCacheManager {
|
||||
#assetCache = new AsyncCache<AssetResponseDto>();
|
||||
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
|
||||
|
||||
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }) {
|
||||
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo);
|
||||
}
|
||||
|
||||
async getAssetOcr(id: string) {
|
||||
return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id);
|
||||
}
|
||||
|
||||
clearAssetCache() {
|
||||
this.#assetCache.clear();
|
||||
}
|
||||
|
||||
clearOcrCache() {
|
||||
this.#ocrCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const assetCacheManager = new AssetCacheManager();
|
||||
@ -0,0 +1,37 @@
|
||||
import { getAssetUrl } from '$lib/utils';
|
||||
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
class PreloadManager {
|
||||
preload(asset: AssetResponseDto | undefined) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
if (globalThis.isSecureContext) {
|
||||
preloadImageUrl(getAssetUrl({ asset }));
|
||||
return;
|
||||
}
|
||||
if (asset.type === AssetTypeEnum.Image) {
|
||||
const img = new Image();
|
||||
img.src = getAssetUrl({ asset });
|
||||
}
|
||||
}
|
||||
|
||||
cancel(asset: AssetResponseDto | undefined) {
|
||||
if (!globalThis.isSecureContext || !asset) {
|
||||
return;
|
||||
}
|
||||
const url = getAssetUrl({ asset });
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
|
||||
cancelPreloadUrl(url: string) {
|
||||
if (!globalThis.isSecureContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const preloadManager = new PreloadManager();
|
||||
@ -0,0 +1,225 @@
|
||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { getAssetOcr } from '@immich/sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock the SDK
|
||||
vi.mock('@immich/sdk', () => ({
|
||||
getAssetOcr: vi.fn(),
|
||||
}));
|
||||
|
||||
const createMockOcrData = (overrides?: Partial<OcrBoundingBox>): OcrBoundingBox[] => [
|
||||
{
|
||||
id: '1',
|
||||
assetId: 'asset-123',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 100,
|
||||
y2: 0,
|
||||
x3: 100,
|
||||
y3: 50,
|
||||
x4: 0,
|
||||
y4: 50,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.98,
|
||||
text: 'Hello World',
|
||||
...overrides,
|
||||
},
|
||||
];
|
||||
|
||||
describe('OcrManager', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the singleton state before each test
|
||||
ocrManager.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with empty data', () => {
|
||||
expect(ocrManager.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize with showOverlay as false', () => {
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with hasOcrData as false', () => {
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAssetOcr', () => {
|
||||
it('should load OCR data for an asset', async () => {
|
||||
const mockData = createMockOcrData();
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
expect(getAssetOcr).toHaveBeenCalledWith({ id: 'asset-123' });
|
||||
expect(ocrManager.data).toEqual(mockData);
|
||||
expect(ocrManager.hasOcrData).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty OCR data', async () => {
|
||||
vi.mocked(getAssetOcr).mockResolvedValue([]);
|
||||
|
||||
await ocrManager.getAssetOcr('asset-456');
|
||||
|
||||
expect(ocrManager.data).toEqual([]);
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset the loader when previously cleared', async () => {
|
||||
const mockData = createMockOcrData();
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
|
||||
// First clear
|
||||
ocrManager.clear();
|
||||
expect(ocrManager.data).toEqual([]);
|
||||
|
||||
// Then load new data
|
||||
await ocrManager.getAssetOcr('asset-789');
|
||||
|
||||
expect(ocrManager.data).toEqual(mockData);
|
||||
expect(ocrManager.hasOcrData).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle concurrent requests safely', async () => {
|
||||
const firstData = createMockOcrData({ id: '1', text: 'First' });
|
||||
const secondData = createMockOcrData({ id: '2', text: 'Second' });
|
||||
|
||||
vi.mocked(getAssetOcr)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve(firstData), 100);
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(secondData);
|
||||
|
||||
// Start first request
|
||||
const promise1 = ocrManager.getAssetOcr('asset-1');
|
||||
// Start second request immediately (should wait for first to complete)
|
||||
const promise2 = ocrManager.getAssetOcr('asset-2');
|
||||
|
||||
await Promise.all([promise1, promise2]);
|
||||
|
||||
// CancellableTask waits for first request, so second request is ignored
|
||||
// The data should be from the first request that completed
|
||||
expect(ocrManager.data).toEqual(firstData);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Network error');
|
||||
vi.mocked(getAssetOcr).mockRejectedValue(error);
|
||||
|
||||
// The error should be handled by CancellableTask
|
||||
await expect(ocrManager.getAssetOcr('asset-error')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear OCR data', async () => {
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
ocrManager.clear();
|
||||
|
||||
expect(ocrManager.data).toEqual([]);
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset showOverlay to false', () => {
|
||||
ocrManager.showOverlay = true;
|
||||
|
||||
ocrManager.clear();
|
||||
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark as cleared for next load', async () => {
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
|
||||
ocrManager.clear();
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
// Should successfully load after clear
|
||||
expect(ocrManager.data).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleOcrBoundingBox', () => {
|
||||
it('should toggle showOverlay from false to true', () => {
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
|
||||
expect(ocrManager.showOverlay).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle showOverlay from true to false', () => {
|
||||
ocrManager.showOverlay = true;
|
||||
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle multiple times', () => {
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
expect(ocrManager.showOverlay).toBe(true);
|
||||
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
expect(ocrManager.showOverlay).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasOcrData derived state', () => {
|
||||
it('should be false when data is empty', () => {
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when data is present', async () => {
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
expect(ocrManager.hasOcrData).toBe(true);
|
||||
});
|
||||
|
||||
it('should update when data is cleared', async () => {
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
expect(ocrManager.hasOcrData).toBe(true);
|
||||
|
||||
ocrManager.clear();
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data immutability', () => {
|
||||
it('should return the same reference when data does not change', () => {
|
||||
const firstReference = ocrManager.data;
|
||||
const secondReference = ocrManager.data;
|
||||
|
||||
expect(firstReference).toBe(secondReference);
|
||||
});
|
||||
|
||||
it('should return a new reference when data changes', async () => {
|
||||
const firstReference = ocrManager.data;
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
const secondReference = ocrManager.data;
|
||||
|
||||
expect(firstReference).not.toBe(secondReference);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,540 @@
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
|
||||
describe('CancellableTask', () => {
|
||||
describe('execute', () => {
|
||||
it('should execute task successfully and return LOADED', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async (_: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(task.loading).toBe(false);
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call loadedCallback when task completes successfully', async () => {
|
||||
const loadedCallback = vi.fn();
|
||||
const task = new CancellableTask(loadedCallback);
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(loadedCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should wait if task is already running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, true);
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
|
||||
expect(task.loading).toBe(true);
|
||||
resolveTask!();
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(result1).toBe('LOADED');
|
||||
expect(result2).toBe('WAITED');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass AbortSignal to task function', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await Promise.resolve();
|
||||
capturedSignal = signal;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('should set cancellable flag correctly', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
expect(task.cancellable).toBe(true);
|
||||
const promise = task.execute(taskFn, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should not allow transition from prevent cancel to allow cancel when task is running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
resolveTask!();
|
||||
await Promise.all([promise1, promise2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should cancel a running task', async () => {
|
||||
const task = new CancellableTask();
|
||||
let taskStarted = false;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
taskStarted = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(taskStarted).toBe(true);
|
||||
|
||||
task.cancel();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should call canceledCallback when task is canceled', async () => {
|
||||
const canceledCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, canceledCallback);
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
|
||||
expect(canceledCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cancel if task is not cancellable', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
const promise = task.execute(taskFn, false);
|
||||
task.cancel();
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
|
||||
it('should not cancel if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
|
||||
task.cancel();
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset task to initial state', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
|
||||
await task.reset();
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.cancelToken).toBe(null);
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should cancel running task before resetting', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const resetPromise = task.reset();
|
||||
|
||||
await promise;
|
||||
await resetPromise;
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after reset', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
await task.reset();
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(taskFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilCompletion', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.waitUntilCompletion();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
});
|
||||
|
||||
it('should return WAITED if task completes while waiting', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
resolveTask!();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('WAITED');
|
||||
});
|
||||
|
||||
it('should return CANCELED if task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilExecution', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.waitUntilExecution();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
});
|
||||
|
||||
it('should return WAITED if task completes successfully', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
|
||||
resolveTask!();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('WAITED');
|
||||
});
|
||||
|
||||
it('should retry if task is canceled and wait for next execution', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const task = new CancellableTask();
|
||||
let attempt = 0;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
attempt++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted && attempt === 1) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
// Start first execution
|
||||
const executePromise1 = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
|
||||
// Cancel the first execution
|
||||
vi.advanceTimersByTime(10);
|
||||
task.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
await executePromise1;
|
||||
|
||||
// Start second execution
|
||||
const executePromise2 = task.execute(taskFn, true);
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
|
||||
|
||||
expect(executeResult).toBe('LOADED');
|
||||
expect(waitResult).toBe('WAITED');
|
||||
expect(attempt).toBe(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return ERRORED when task throws non-abort error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('ERRORED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should call errorCallback when task throws non-abort error', async () => {
|
||||
const errorCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, undefined, errorCallback);
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(errorCallback).toHaveBeenCalledTimes(1);
|
||||
expect(errorCallback).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should return CANCELED when task throws AbortError', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn1 = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
const taskFn2 = vi.fn(async () => {});
|
||||
|
||||
const result1 = await task.execute(taskFn1, true);
|
||||
expect(result1).toBe('ERRORED');
|
||||
|
||||
const result2 = await task.execute(taskFn2, true);
|
||||
expect(result2).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading property', () => {
|
||||
it('should return true when task is running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
expect(task.loading).toBe(true);
|
||||
|
||||
resolveTask!();
|
||||
await promise;
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete promise', () => {
|
||||
it('should resolve when task completes successfully', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
await expect(completePromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
|
||||
await expect(completePromise).rejects.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject when task errors', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
await expect(completePromise).rejects.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort signal handling', () => {
|
||||
it('should automatically call abort() on signal when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
capturedSignal = signal;
|
||||
// Simulate a long-running task
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(capturedSignal).not.toBeNull();
|
||||
expect(capturedSignal!.aborted).toBe(false);
|
||||
|
||||
// Cancel the task
|
||||
task.cancel();
|
||||
|
||||
// Verify the signal was aborted
|
||||
expect(capturedSignal!.aborted).toBe(true);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
});
|
||||
|
||||
it('should detect if signal was aborted after task completes', async () => {
|
||||
const task = new CancellableTask();
|
||||
let controller: AbortController | null = null;
|
||||
const taskFn = async (_: AbortSignal) => {
|
||||
// Capture the controller to abort it externally
|
||||
controller = task.cancelToken;
|
||||
// Simulate some work
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Now abort before the function returns
|
||||
controller?.abort();
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle abort signal in async operations', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
// Simulate listening to abort signal during async operation
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,39 @@
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
|
||||
let isLoadedReplyListeners: ((url: string, isUrlCached: boolean) => void)[] = [];
|
||||
broadcast.addEventListener('message', (event) => {
|
||||
if (event.data.type == 'isImageUrlCachedReply') {
|
||||
for (const listener of isLoadedReplyListeners) {
|
||||
listener(event.data.url, event.data.isImageUrlCached);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function cancelImageUrl(url: string) {
|
||||
broadcast.postMessage({ type: 'cancel', url });
|
||||
}
|
||||
|
||||
export function preloadImageUrl(url: string) {
|
||||
broadcast.postMessage({ type: 'preload', url });
|
||||
}
|
||||
|
||||
export function isImageUrlCached(url: string) {
|
||||
if (!globalThis.isSecureContext) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const listener = (urlReply: string, isUrlCached: boolean) => {
|
||||
if (urlReply === url) {
|
||||
cleanup(isUrlCached);
|
||||
}
|
||||
};
|
||||
const cleanup = (isUrlCached: boolean) => {
|
||||
isLoadedReplyListeners = isLoadedReplyListeners.filter((element) => element !== listener);
|
||||
resolve(isUrlCached);
|
||||
};
|
||||
isLoadedReplyListeners.push(listener);
|
||||
broadcast.postMessage({ type: 'isImageUrlCached', url });
|
||||
|
||||
setTimeout(() => cleanup(false), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue