From 01db549c7cddb9a4e2ebe4477ff440abbd5940b8 Mon Sep 17 00:00:00 2001 From: Felipe Cabrera Date: Wed, 5 Aug 2026 20:53:30 -0300 Subject: [PATCH] fix(web): flag metadata fields missing from only some duplicates --- web/src/lib/utils/duplicate-utils.spec.ts | 89 +++++++++++++++++++++++ web/src/lib/utils/duplicate-utils.ts | 9 ++- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/utils/duplicate-utils.spec.ts diff --git a/web/src/lib/utils/duplicate-utils.spec.ts b/web/src/lib/utils/duplicate-utils.spec.ts new file mode 100644 index 0000000000..846b22f6e2 --- /dev/null +++ b/web/src/lib/utils/duplicate-utils.spec.ts @@ -0,0 +1,89 @@ +import type { AssetResponseDto, ExifResponseDto } from '@immich/sdk'; +import { computeDifferingMetadataFields, countDifferingMetadataItems } from '$lib/utils/duplicate-utils'; +import { assetFactory } from '@test-data/factories/asset-factory'; + +// Derives both assets from a single base so that everything except `exifInfo` is identical, +// leaving the field under test as the only possible difference. +const buildPair = (first: ExifResponseDto, second: ExifResponseDto): AssetResponseDto[] => { + const base = assetFactory.build(); + return [ + { ...base, id: 'first-asset', exifInfo: first }, + { ...base, id: 'second-asset', exifInfo: second }, + ]; +}; + +describe('computeDifferingMetadataFields', () => { + it('flags coordinates when only one asset has them', () => { + const assets = buildPair({ latitude: 48.8583, longitude: 2.2944 }, {}); + + const diffs = computeDifferingMetadataFields(assets); + + expect(diffs.latitude).toBe(true); + expect(diffs.longitude).toBe(true); + }); + + it('does not flag coordinates when neither asset has them', () => { + const assets = buildPair({}, {}); + + const diffs = computeDifferingMetadataFields(assets); + + expect(diffs.latitude).toBe(false); + expect(diffs.longitude).toBe(false); + }); + + it('does not flag coordinates when both assets share them', () => { + const coordinates = { latitude: 48.8583, longitude: 2.2944 }; + const assets = buildPair(coordinates, { ...coordinates }); + + const diffs = computeDifferingMetadataFields(assets); + + expect(diffs.latitude).toBe(false); + expect(diffs.longitude).toBe(false); + }); + + it('flags coordinates when the assets disagree', () => { + const assets = buildPair({ latitude: 48.8583, longitude: 2.2944 }, { latitude: 41.8902, longitude: 12.4922 }); + + const diffs = computeDifferingMetadataFields(assets); + + expect(diffs.latitude).toBe(true); + expect(diffs.longitude).toBe(true); + }); + + it('treats an explicit null the same as an absent field', () => { + const assets = buildPair({ description: null }, {}); + + const diffs = computeDifferingMetadataFields(assets); + + expect(diffs.description).toBe(false); + }); + + it('treats an empty string the same as an absent field', () => { + const assets = buildPair({ description: '' }, { description: null }); + + const diffs = computeDifferingMetadataFields(assets); + + expect(diffs.description).toBe(false); + }); + + it('flags other nullable fields present on only one asset', () => { + const assets = buildPair({ rating: 4, lensModel: 'EF24-70mm f/2.8L II USM', make: 'Canon' }, {}); + + const diffs = computeDifferingMetadataFields(assets); + + expect(diffs.rating).toBe(true); + expect(diffs.lensModel).toBe(true); + expect(diffs.make).toBe(true); + }); +}); + +describe('countDifferingMetadataItems', () => { + it('counts a field that only one asset has', () => { + const assets = buildPair({ latitude: 48.8583, longitude: 2.2944 }, {}); + + const diffs = computeDifferingMetadataFields(assets); + + // latitude and longitude belong to the single "gps" field + expect(countDifferingMetadataItems(diffs)).toBe(1); + }); +}); diff --git a/web/src/lib/utils/duplicate-utils.ts b/web/src/lib/utils/duplicate-utils.ts index 86b221906f..fb6dd44b11 100644 --- a/web/src/lib/utils/duplicate-utils.ts +++ b/web/src/lib/utils/duplicate-utils.ts @@ -27,6 +27,11 @@ import type { MessageFormatter } from 'svelte-i18n'; import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils'; import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util'; +// Stands in for a value that is absent, so that "present on one asset, absent on another" counts as a difference. +// Every value below collapses to it, since the metadata renderers fall back to "unknown" for each of them. +const MISSING = Symbol('missing'); +const MISSING_VALUES = new Set([undefined, null, '']); + const truncateMiddle = (path: string, maxLength = 50): string => { if (path.length <= maxLength) { return path; @@ -289,9 +294,7 @@ export const computeDifferingMetadataFields = (assets: AssetResponseDto[]): Diff for (const asset of assets) { const value = getValueForAsset(asset, key); - if (value !== undefined && value !== null) { - uniqueValues.add(normalizeForComparison(key, value)); - } + uniqueValues.add(MISSING_VALUES.has(value) ? MISSING : normalizeForComparison(key, value)); } diffs[key] = uniqueValues.size > 1;