immich/server/src/utils/duplicate.spec.ts
Daniel Dietzler 8ee5d3039a
chore!: remove deviceId and deviceAssetId (#27818)
chore: remove deviceId and deviceAssetId
2026-04-15 15:00:33 -04:00

183 lines
6.3 KiB
TypeScript

import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ExifResponseSchema } from 'src/dtos/exif.dto';
import { AssetType, AssetVisibility } from 'src/enum';
import { getExifCount, suggestDuplicate, suggestDuplicateKeepAssetIds } from 'src/utils/duplicate';
import { describe, expect, it } from 'vitest';
import type { z } from 'zod';
type ExifInfoInput = Partial<z.infer<typeof ExifResponseSchema>>;
const createAsset = (
id: string,
fileSizeInByte: number | null = null,
exifFields: ExifInfoInput = {},
): AssetResponseDto => ({
id,
type: AssetType.Image,
thumbhash: null,
localDateTime: new Date().toISOString(),
duration: '0:00:00.00000',
hasMetadata: true,
width: 1920,
height: 1080,
createdAt: new Date().toISOString(),
ownerId: 'owner-1',
originalPath: '/path/to/asset',
originalFileName: 'asset.jpg',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isFavorite: false,
isArchived: false,
isTrashed: false,
isOffline: false,
isEdited: false,
visibility: AssetVisibility.Timeline,
checksum: 'checksum',
exifInfo:
fileSizeInByte !== null || Object.keys(exifFields).length > 0
? ExifResponseSchema.parse({ fileSizeInByte, ...exifFields })
: undefined,
});
describe('duplicate utils', () => {
describe('getExifCount', () => {
it('should return 0 for asset without exifInfo', () => {
const asset = createAsset('asset-1');
asset.exifInfo = undefined;
expect(getExifCount(asset)).toBe(0);
});
it('should return 0 for empty exifInfo', () => {
const asset = createAsset('asset-1');
asset.exifInfo = ExifResponseSchema.parse({});
expect(getExifCount(asset)).toBe(0);
});
it('should count all truthy values in exifInfo', () => {
const asset = createAsset('asset-1', 1000, {
make: 'Canon',
model: 'EOS 5D',
dateTimeOriginal: new Date().toISOString(),
timeZone: 'UTC',
latitude: 40.7128,
longitude: -74.006,
city: 'New York',
state: 'NY',
country: 'USA',
description: 'A photo',
rating: 5,
});
// fileSizeInByte (1000) + 11 other truthy fields = 12
expect(getExifCount(asset)).toBe(12);
});
it('should not count null or undefined values', () => {
const asset = createAsset('asset-1', 1000, {
make: 'Canon',
model: null,
latitude: undefined,
city: '',
rating: 0,
});
// fileSizeInByte (1000) + make ('Canon') = 2 truthy values
// model (null), latitude (undefined), city (''), rating (0) are all falsy
expect(getExifCount(asset)).toBe(2);
});
});
describe('suggestDuplicate', () => {
it('should return undefined for empty list', () => {
expect(suggestDuplicate([])).toBeUndefined();
});
it('should return the single asset for list with one asset', () => {
const asset = createAsset('asset-1', 1000);
expect(suggestDuplicate([asset])).toEqual(asset);
});
it('should return asset with largest file size', () => {
const small = createAsset('small', 1000);
const large = createAsset('large', 5000);
const medium = createAsset('medium', 3000);
expect(suggestDuplicate([small, large, medium])?.id).toBe('large');
expect(suggestDuplicate([large, small, medium])?.id).toBe('large');
expect(suggestDuplicate([medium, small, large])?.id).toBe('large');
});
it('should use EXIF count as tie-breaker when file sizes are equal', () => {
const lessExif = createAsset('less-exif', 1000, { make: 'Canon' });
const moreExif = createAsset('more-exif', 1000, {
make: 'Canon',
model: 'EOS 5D',
dateTimeOriginal: new Date().toISOString(),
city: 'New York',
});
expect(suggestDuplicate([lessExif, moreExif])?.id).toBe('more-exif');
expect(suggestDuplicate([moreExif, lessExif])?.id).toBe('more-exif');
});
it('should handle assets with no exifInfo (treat as 0 file size)', () => {
const noExif = createAsset('no-exif');
noExif.exifInfo = undefined;
const withExif = createAsset('with-exif', 1000);
expect(suggestDuplicate([noExif, withExif])?.id).toBe('with-exif');
});
it('should handle assets with exifInfo but no fileSizeInByte', () => {
const noFileSize = createAsset('no-file-size');
noFileSize.exifInfo = ExifResponseSchema.parse({ make: 'Canon', model: 'EOS 5D' });
const withFileSize = createAsset('with-file-size', 1000);
expect(suggestDuplicate([noFileSize, withFileSize])?.id).toBe('with-file-size');
});
it('should return last asset when all have same file size and EXIF count', () => {
const asset1 = createAsset('asset-1', 1000, { make: 'Canon' });
const asset2 = createAsset('asset-2', 1000, { make: 'Nikon' });
// Both have same file size (1000) and same EXIF count (2: fileSizeInByte + make)
// Should return the last one in the sorted array
const result = suggestDuplicate([asset1, asset2]);
// Since they're equal, the last one after sorting should be returned
expect(result).toBeDefined();
expect(['asset-1', 'asset-2']).toContain(result?.id);
});
it('should prioritize file size over EXIF count', () => {
const largeWithLessExif = createAsset('large-less-exif', 5000, { make: 'Canon' });
const smallWithMoreExif = createAsset('small-more-exif', 1000, {
make: 'Canon',
model: 'EOS 5D',
dateTimeOriginal: new Date().toISOString(),
city: 'New York',
state: 'NY',
country: 'USA',
});
expect(suggestDuplicate([largeWithLessExif, smallWithMoreExif])?.id).toBe('large-less-exif');
});
});
describe('suggestDuplicateKeepAssetIds', () => {
it('should return empty array for empty list', () => {
expect(suggestDuplicateKeepAssetIds([])).toEqual([]);
});
it('should return array with single asset ID', () => {
const asset = createAsset('asset-1', 1000);
expect(suggestDuplicateKeepAssetIds([asset])).toEqual(['asset-1']);
});
it('should return array with best asset ID', () => {
const small = createAsset('small', 1000);
const large = createAsset('large', 5000);
expect(suggestDuplicateKeepAssetIds([small, large])).toEqual(['large']);
});
});
});