212 lines
6.7 KiB
TypeScript
212 lines
6.7 KiB
TypeScript
|
|
import { describe, it, expect } from 'vitest';
|
||
|
|
|
||
|
|
import {
|
||
|
|
ALBUM_SORT_MODES,
|
||
|
|
isAlbumSortMode,
|
||
|
|
sortAlbumAssets,
|
||
|
|
type AlbumSortMode,
|
||
|
|
type SortableAsset,
|
||
|
|
} from '@/lib/albumSort';
|
||
|
|
|
||
|
|
function asset(id: string, overrides: Partial<SortableAsset> = {}): SortableAsset {
|
||
|
|
return {
|
||
|
|
id,
|
||
|
|
originalFileName: `${id}.jpg`,
|
||
|
|
fileCreatedAt: '2024-01-01T00:00:00.000Z',
|
||
|
|
localDateTime: '2024-01-01T00:00:00.000Z',
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const ids = (assets: SortableAsset[]) => assets.map((a) => a.id);
|
||
|
|
|
||
|
|
describe('sortAlbumAssets is pure', () => {
|
||
|
|
it('returns a new array and leaves the input untouched', () => {
|
||
|
|
const input = [
|
||
|
|
asset('b', { localDateTime: '2024-02-01T00:00:00.000Z' }),
|
||
|
|
asset('a', { localDateTime: '2024-01-01T00:00:00.000Z' }),
|
||
|
|
];
|
||
|
|
const before = ids(input);
|
||
|
|
|
||
|
|
const out = sortAlbumAssets(input, { mode: 'oldest' });
|
||
|
|
|
||
|
|
expect(out).not.toBe(input);
|
||
|
|
expect(ids(input)).toEqual(before);
|
||
|
|
});
|
||
|
|
|
||
|
|
// The cached album is shared, so any mode that dropped or duplicated an asset
|
||
|
|
// would corrupt what every later request sees. This is the guard that any
|
||
|
|
// future rewrite of the manual branch has to survive.
|
||
|
|
it.each(ALBUM_SORT_MODES)('preserves the exact set of assets under %s', (mode) => {
|
||
|
|
const input = [asset('a'), asset('b'), asset('c'), asset('d')];
|
||
|
|
|
||
|
|
const out = sortAlbumAssets(input, {
|
||
|
|
mode: mode as AlbumSortMode,
|
||
|
|
immichOrder: 'asc',
|
||
|
|
manualOrder: ['c', 'zzz'],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(out).toHaveLength(input.length);
|
||
|
|
expect(ids(out).sort()).toEqual(['a', 'b', 'c', 'd']);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('capture-time modes', () => {
|
||
|
|
const jan = asset('jan', { localDateTime: '2024-01-01T00:00:00.000Z' });
|
||
|
|
const feb = asset('feb', { localDateTime: '2024-02-01T00:00:00.000Z' });
|
||
|
|
const mar = asset('mar', { localDateTime: '2024-03-01T00:00:00.000Z' });
|
||
|
|
|
||
|
|
it('orders newest first', () => {
|
||
|
|
expect(ids(sortAlbumAssets([jan, mar, feb], { mode: 'newest' }))).toEqual([
|
||
|
|
'mar',
|
||
|
|
'feb',
|
||
|
|
'jan',
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('orders oldest first, exactly reversing newest', () => {
|
||
|
|
const input = [jan, mar, feb];
|
||
|
|
expect(ids(sortAlbumAssets(input, { mode: 'oldest' }))).toEqual(
|
||
|
|
ids(sortAlbumAssets(input, { mode: 'newest' })).reverse(),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('falls back to fileCreatedAt when localDateTime ties', () => {
|
||
|
|
const early = asset('early', {
|
||
|
|
localDateTime: '2024-01-01T00:00:00.000Z',
|
||
|
|
fileCreatedAt: '2024-01-01T08:00:00.000Z',
|
||
|
|
});
|
||
|
|
const late = asset('late', {
|
||
|
|
localDateTime: '2024-01-01T00:00:00.000Z',
|
||
|
|
fileCreatedAt: '2024-01-01T20:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(ids(sortAlbumAssets([late, early], { mode: 'oldest' }))).toEqual(['early', 'late']);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Date.parse() on garbage yields NaN, and a comparator returning NaN makes
|
||
|
|
// sort() incoherent — the result depends on the engine's algorithm, not the data.
|
||
|
|
it('treats an unparseable date as epoch rather than producing NaN', () => {
|
||
|
|
const broken = asset('broken', { localDateTime: 'not-a-date', fileCreatedAt: 'also-not' });
|
||
|
|
|
||
|
|
const out = sortAlbumAssets([feb, broken, jan], { mode: 'oldest' });
|
||
|
|
|
||
|
|
expect(ids(out)).toEqual(['broken', 'jan', 'feb']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('immich follows the album direction', () => {
|
||
|
|
const input = [feb, jan, mar];
|
||
|
|
expect(ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'asc' }))).toEqual([
|
||
|
|
'jan',
|
||
|
|
'feb',
|
||
|
|
'mar',
|
||
|
|
]);
|
||
|
|
expect(ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'desc' }))).toEqual([
|
||
|
|
'mar',
|
||
|
|
'feb',
|
||
|
|
'jan',
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('filename mode', () => {
|
||
|
|
it('collates numerically, so IMG_2 precedes IMG_10', () => {
|
||
|
|
const input = [
|
||
|
|
asset('c', { originalFileName: 'IMG_10.jpg' }),
|
||
|
|
asset('a', { originalFileName: 'IMG_2.jpg' }),
|
||
|
|
asset('b', { originalFileName: 'IMG_9.jpg' }),
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(ids(sortAlbumAssets(input, { mode: 'filename' }))).toEqual(['a', 'b', 'c']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('is case-insensitive', () => {
|
||
|
|
const input = [
|
||
|
|
asset('b', { originalFileName: 'beta.jpg' }),
|
||
|
|
asset('a', { originalFileName: 'Alpha.jpg' }),
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(ids(sortAlbumAssets(input, { mode: 'filename' }))).toEqual(['a', 'b']);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Duplicate filenames across import batches are common; without the id
|
||
|
|
// tiebreak the order of a tie would follow the input and drift per request.
|
||
|
|
it('breaks ties on id so the order is stable', () => {
|
||
|
|
const one = asset('aaa', { originalFileName: 'DSC.jpg' });
|
||
|
|
const two = asset('bbb', { originalFileName: 'dsc.jpg' });
|
||
|
|
|
||
|
|
expect(ids(sortAlbumAssets([two, one], { mode: 'filename' }))).toEqual(['aaa', 'bbb']);
|
||
|
|
expect(ids(sortAlbumAssets([one, two], { mode: 'filename' }))).toEqual(['aaa', 'bbb']);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('manual mode is a pinned prefix', () => {
|
||
|
|
const a = asset('a', { localDateTime: '2024-01-01T00:00:00.000Z' });
|
||
|
|
const b = asset('b', { localDateTime: '2024-02-01T00:00:00.000Z' });
|
||
|
|
const c = asset('c', { localDateTime: '2024-03-01T00:00:00.000Z' });
|
||
|
|
|
||
|
|
it('puts pinned assets first, in the pinned order', () => {
|
||
|
|
const out = sortAlbumAssets([a, b, c], {
|
||
|
|
mode: 'manual',
|
||
|
|
immichOrder: 'asc',
|
||
|
|
manualOrder: ['c', 'a'],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(ids(out)).toEqual(['c', 'a', 'b']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('appends unpinned assets in the album Immich order', () => {
|
||
|
|
const desc = sortAlbumAssets([a, b, c], {
|
||
|
|
mode: 'manual',
|
||
|
|
immichOrder: 'desc',
|
||
|
|
manualOrder: ['a'],
|
||
|
|
});
|
||
|
|
const asc = sortAlbumAssets([a, b, c], {
|
||
|
|
mode: 'manual',
|
||
|
|
immichOrder: 'asc',
|
||
|
|
manualOrder: ['a'],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(ids(desc)).toEqual(['a', 'c', 'b']);
|
||
|
|
expect(ids(asc)).toEqual(['a', 'b', 'c']);
|
||
|
|
});
|
||
|
|
|
||
|
|
// The album can change in Immich after the order was saved; a stale id must
|
||
|
|
// not shift or drop anything.
|
||
|
|
it('ignores pinned ids that are no longer in the album', () => {
|
||
|
|
const out = sortAlbumAssets([a, b], {
|
||
|
|
mode: 'manual',
|
||
|
|
immichOrder: 'asc',
|
||
|
|
manualOrder: ['gone', 'b'],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(ids(out)).toEqual(['b', 'a']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('pins a duplicated id once, at its first position', () => {
|
||
|
|
const out = sortAlbumAssets([a, b, c], {
|
||
|
|
mode: 'manual',
|
||
|
|
immichOrder: 'asc',
|
||
|
|
manualOrder: ['c', 'a', 'c'],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(ids(out)).toEqual(['c', 'a', 'b']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('falls back to the album order when nothing is pinned', () => {
|
||
|
|
const input = [c, a, b];
|
||
|
|
expect(ids(sortAlbumAssets(input, { mode: 'manual', immichOrder: 'asc' }))).toEqual(
|
||
|
|
ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'asc' })),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('isAlbumSortMode', () => {
|
||
|
|
it('accepts every declared mode and nothing else', () => {
|
||
|
|
for (const mode of ALBUM_SORT_MODES) expect(isAlbumSortMode(mode)).toBe(true);
|
||
|
|
expect(isAlbumSortMode('bogus')).toBe(false);
|
||
|
|
expect(isAlbumSortMode(undefined)).toBe(false);
|
||
|
|
expect(isAlbumSortMode(null)).toBe(false);
|
||
|
|
});
|
||
|
|
});
|