release: v0.13.0 (#535)

Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.

Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.

Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ralksta
2026-08-25 10:08:12 +02:00
committed by GitHub
parent 10ec68f493
commit a813433891
73 changed files with 4697 additions and 453 deletions

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { albumNeighbours } from '@/lib/albumNav';
const album = (id: string, slug = id, albumName = id.toUpperCase()) => ({ id, slug, albumName });
const three = [album('a'), album('b'), album('c')];
describe('albumNeighbours', () => {
it('offers both neighbours in the middle of a list', () => {
expect(albumNeighbours(three, 'b')).toEqual({
prev: { href: '/a', name: 'A' },
next: { href: '/c', name: 'C' },
});
});
it('omits the previous link on the first album', () => {
expect(albumNeighbours(three, 'a')).toEqual({ next: { href: '/b', name: 'B' } });
});
it('omits the next link on the last album — no wrap-around', () => {
expect(albumNeighbours(three, 'c')).toEqual({ prev: { href: '/b', name: 'B' } });
});
it('prefixes hrefs with the subpage path when there is one', () => {
expect(albumNeighbours(three, 'b', '/travel')).toEqual({
prev: { href: '/travel/a', name: 'A' },
next: { href: '/travel/c', name: 'C' },
});
});
it('returns nothing for a single-album list', () => {
expect(albumNeighbours([album('a')], 'a')).toEqual({});
});
it('returns nothing for an empty list', () => {
expect(albumNeighbours([], 'a')).toEqual({});
});
it('returns nothing when the album is not in the list', () => {
expect(albumNeighbours(three, 'zzz')).toEqual({});
});
it('follows list order, not slug or name order', () => {
const shuffled = [album('c'), album('a'), album('b')];
expect(albumNeighbours(shuffled, 'a')).toEqual({
prev: { href: '/c', name: 'C' },
next: { href: '/b', name: 'B' },
});
});
it('uses the slug for the href and the display name for the label', () => {
const list = [album('id-1', 'harbour-at-dawn', 'Harbour at Dawn'), album('id-2')];
expect(albumNeighbours(list, 'id-2').prev).toEqual({
href: '/harbour-at-dawn',
name: 'Harbour at Dawn',
});
});
});

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { LocationPrecision } from '@/lib/mapPrecision';
/**
* The per-album `location:` setting governed the map and nothing else, so an
* album asking to be absent from it still had its city named in the lightbox
* info panel. These pin the join: an asset inherits the setting of the albums
* that carry it.
*/
const albumLocationPrecision: Record<string, LocationPrecision> = {};
vi.mock('@/lib/config', async () => {
const actual = await vi.importActual<typeof import('@/lib/config')>('@/lib/config');
return {
...actual,
getConfig: () => ({ albumLocationPrecision }),
};
});
const getAlbum = vi.fn();
vi.mock('@/lib/immich', () => ({
immich: { getAlbum: (id: string) => getAlbum(id) },
}));
import { assetLocationPrecision } from '@/lib/assetLocation';
const ASSET = 'asset-1';
const OTHER = 'asset-2';
/** An album whose assets are just ids. */
const album = (id: string, assetIds: string[]) => ({
id,
assets: assetIds.map((a) => ({ id: a })),
});
beforeEach(() => {
for (const key of Object.keys(albumLocationPrecision)) delete albumLocationPrecision[key];
getAlbum.mockReset();
});
describe('assetLocationPrecision', () => {
it('is exact when no album restricts anything, without asking Immich at all', async () => {
await expect(assetLocationPrecision(ASSET)).resolves.toBe('exact');
// The common case must not cost a single album fetch.
expect(getAlbum).not.toHaveBeenCalled();
});
it('takes the setting of an album that carries the asset', async () => {
albumLocationPrecision['album-a'] = 'country';
getAlbum.mockResolvedValue(album('album-a', [ASSET]));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('country');
});
it('ignores an album that restricts but does not carry the asset', async () => {
albumLocationPrecision['album-a'] = 'hidden';
getAlbum.mockResolvedValue(album('album-a', [OTHER]));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('exact');
});
// Same rule as a merged map marker: the cautious setting governs.
it('takes the strictest when several albums carry the asset', async () => {
albumLocationPrecision['album-a'] = 'city';
albumLocationPrecision['album-b'] = 'hidden';
getAlbum.mockImplementation(async (id: string) => album(id, [ASSET]));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('hidden');
});
it('only asks about albums that actually restrict something', async () => {
albumLocationPrecision['album-a'] = 'exact';
albumLocationPrecision['album-b'] = 'city';
getAlbum.mockImplementation(async (id: string) => album(id, [ASSET]));
await assetLocationPrecision(ASSET);
expect(getAlbum).toHaveBeenCalledTimes(1);
expect(getAlbum).toHaveBeenCalledWith('album-b');
});
// Failing open would publish the position the setting exists to withhold.
it('withholds the place when an album cannot be read', async () => {
albumLocationPrecision['album-a'] = 'city';
getAlbum.mockRejectedValue(new Error('Immich is down'));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('hidden');
});
it('withholds it when the album is gone from the allowlist', async () => {
albumLocationPrecision['album-a'] = 'city';
getAlbum.mockResolvedValue(null);
await expect(assetLocationPrecision(ASSET)).resolves.toBe('hidden');
});
});

View File

@@ -6,11 +6,17 @@ vi.mock('@/lib/config', () => ({
getConfig: () => ({
immich: { apiKey: 'test-api-key-for-auth-tests' },
authSecret: 'test-auth-secret-32-chars-long-min',
standaloneAlbums: ['00000000-0000-0000-0000-000000000004'],
albumPasswords: { '00000000-0000-0000-0000-000000000005': 'albumpass' },
subpages: [
{
name: 'Private',
slug: 'private',
albumIds: ['00000000-0000-0000-0000-000000000001'],
albumIds: [
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000004',
'00000000-0000-0000-0000-000000000005',
],
password: 'secret123',
},
{
@@ -32,7 +38,13 @@ vi.mock('@/lib/config', () => ({
}),
}));
import { isProtected, authenticate, isAuthenticated, findSubpageBySlug } from '@/lib/auth';
import {
isProtected,
authenticate,
isAuthenticated,
isAlbumReachable,
findSubpageBySlug,
} from '@/lib/auth';
describe('findSubpageBySlug', () => {
it('returns the subpage config for a known slug', () => {
@@ -197,3 +209,52 @@ describe('token expiry', () => {
expect(Math.abs(exp - (Date.now() + maxAge * 1000))).toBeLessThan(2000);
});
});
/**
* A route handler is reached without ever passing through the page that would
* have asked for the subpage password, so it has to ask the same question
* itself (#475).
*/
describe('isAlbumReachable', () => {
const IN_PRIVATE = '00000000-0000-0000-0000-000000000001';
const IN_PUBLIC = '00000000-0000-0000-0000-000000000002';
const ALSO_STANDALONE = '00000000-0000-0000-0000-000000000004';
const OWN_PASSWORD = '00000000-0000-0000-0000-000000000005';
const none = () => undefined;
async function cookieFor(key: string, password: string, type: 'subpage' | 'album' = 'subpage') {
const header = (await authenticate(key, password, type))!;
const token = header.split('=')[1].split(';')[0];
const name = header.split('=')[0];
return (asked: string) => (asked === name ? token : undefined);
}
it('allows an album in a subpage that asks for nothing', () => {
expect(isAlbumReachable(IN_PUBLIC, none)).toBe(true);
});
it('refuses an album whose only route is a protected subpage', () => {
expect(isAlbumReachable(IN_PRIVATE, none)).toBe(false);
});
it('allows it once that subpage has been unlocked', async () => {
const getCookie = await cookieFor('private', 'secret123');
expect(isAlbumReachable(IN_PRIVATE, getCookie)).toBe(true);
});
// Listing an album on the home page publishes it, whatever else it is in.
it('allows a standalone album even when a protected subpage also lists it', () => {
expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true);
});
it('still refuses an album that carries its own password', async () => {
const subpageOnly = await cookieFor('private', 'secret123');
expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false);
});
it('allows that one once its own password is given too', async () => {
const subpage = await cookieFor('private', 'secret123');
const album = await cookieFor(OWN_PASSWORD, 'albumpass', 'album');
expect(isAlbumReachable(OWN_PASSWORD, (n) => subpage(n) ?? album(n))).toBe(true);
});
});

View File

@@ -12,7 +12,13 @@ vi.mock('@/lib/env', () => ({
},
}));
import { slugify, buildSubpageGrid, sanitizeNavLinks } from '@/lib/config';
import {
slugify,
albumSlug,
normalizeSlug,
buildSubpageGrid,
sanitizeNavLinks,
} from '@/lib/config';
// EXPERIMENTAL: external navigation links — rendered as <a href> in the
// header, so only http(s) URLs may survive sanitisation.
@@ -81,6 +87,64 @@ describe('slugify', () => {
it('preserves numbers', () => {
expect(slugify('Album 2024')).toBe('album-2024');
});
// #522: stripping every non-ASCII character left CJK names with an empty
// slug, so their links pointed at "/" and clicking an album did nothing.
it('preserves CJK characters', () => {
expect(slugify('家族相册')).toBe('家族相册');
expect(slugify('写真')).toBe('写真');
expect(slugify('旅行 2024')).toBe('旅行-2024');
});
it('preserves Hangul in composed (NFC) form', () => {
const slug = slugify('가족 앨범');
expect(slug).toBe('가족-앨범');
expect(slug).toBe(slug.normalize('NFC'));
});
it('preserves other non-Latin scripts', () => {
expect(slugify('Москва 2024')).toBe('москва-2024');
});
it('still returns empty when nothing letter-like remains', () => {
expect(slugify('🎉🎉')).toBe('');
});
});
describe('albumSlug', () => {
it('uses the slugified name when it yields something', () => {
expect(albumSlug('Kloster Chorin', 'abc-123')).toBe('kloster-chorin');
expect(albumSlug('家族相册', 'abc-123')).toBe('家族相册');
});
// Without a fallback two emoji-only albums would both slugify to '' and
// collide on the same (unreachable) URL.
it('falls back to the album id when the name slugifies to nothing', () => {
expect(albumSlug('🎉', 'abc-123')).toBe('abc-123');
expect(albumSlug('', 'abc-123')).toBe('abc-123');
});
});
describe('normalizeSlug', () => {
// Next hands catch-all route segments over percent-encoded, so this is the
// half of #522 that fixing slugify() alone did not cure.
it('percent-decodes an encoded slug', () => {
expect(normalizeSlug('%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C')).toBe('家族相册');
});
it('leaves an already decoded slug alone', () => {
expect(normalizeSlug('家族相册')).toBe('家族相册');
expect(normalizeSlug('kloster-chorin')).toBe('kloster-chorin');
});
it('composes decomposed (NFD) input', () => {
expect(normalizeSlug('가족-앨범'.normalize('NFD'))).toBe('가족-앨범');
});
it('survives a malformed percent sequence instead of throwing', () => {
expect(() => normalizeSlug('%E5%AE')).not.toThrow();
expect(normalizeSlug('100%-real')).toBe('100%-real');
});
});
describe('buildSubpageGrid', () => {

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
}));
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
import { deriveGallery } from '@/lib/config';
import type { GalleryYaml } from '@/lib/config/schema';
const A = '11111111-1111-1111-1111-111111111111';
const B = '22222222-2222-2222-2222-222222222222';
describe('deriveGallery — location precision (#469)', () => {
it('leaves an album without a setting absent, meaning exact', () => {
const gallery: GalleryYaml = { albums: [A] };
expect(deriveGallery(gallery).albumLocationPrecision[A]).toBeUndefined();
});
it('reads a per-album setting', () => {
const gallery: GalleryYaml = { albums: [{ [A]: { location: 'city' } }] };
expect(deriveGallery(gallery).albumLocationPrecision[A]).toBe('city');
});
it('applies a subpage setting to every album on it', () => {
const gallery: GalleryYaml = {
subpages: [{ name: 'Gardens', location: 'country', albums: [A, B] }],
};
const derived = deriveGallery(gallery);
expect(derived.albumLocationPrecision[A]).toBe('country');
expect(derived.albumLocationPrecision[B]).toBe('country');
});
it("lets an album state its own, overriding the subpage's", () => {
const gallery: GalleryYaml = {
subpages: [
{ name: 'Gardens', location: 'city', albums: [A, { [B]: { location: 'hidden' } }] },
],
};
const derived = deriveGallery(gallery);
expect(derived.albumLocationPrecision[A]).toBe('city');
expect(derived.albumLocationPrecision[B]).toBe('hidden');
});
/**
* A typo in a privacy setting must not fall back to `exact` — that would
* publish the position it was meant to withhold.
*/
it('throws on an unknown album value rather than defaulting', () => {
const gallery: GalleryYaml = { albums: [{ [A]: { location: 'приблизительно' } }] };
expect(() => deriveGallery(gallery)).toThrow(/unknown location/i);
});
it('throws on an unknown subpage value', () => {
const gallery: GalleryYaml = {
subpages: [{ name: 'Gardens', location: 'vague', albums: [A] }],
};
expect(() => deriveGallery(gallery)).toThrow(/unknown location/i);
});
it('names the valid values in the error, so the log line is enough to fix it', () => {
const gallery: GalleryYaml = { albums: [{ [A]: { location: 'nope' } }] };
expect(() => deriveGallery(gallery)).toThrow(/exact.*city.*country.*hidden/);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { safeDownloadName, contentDisposition } from '@/lib/downloadName';
const LF = String.fromCharCode(10);
const CRLF = String.fromCharCode(13, 10);
const NUL = String.fromCharCode(0);
const QUOTE = String.fromCharCode(34);
/** True if the string holds any control character. */
const hasControlChar = (value: string) =>
Array.from(value).some((character) => {
const code = character.charCodeAt(0);
return code < 32 || code === 127;
});
describe('safeDownloadName', () => {
it('passes an ordinary camera filename through', () => {
expect(safeDownloadName('DSCF1234.JPG')).toBe('DSCF1234.JPG');
});
it('keeps non-ASCII letters, which are a legitimate part of a name', () => {
expect(safeDownloadName('Fr\u00fchling am Meer.jpg')).toBe('Fr\u00fchling am Meer.jpg');
});
/** The header injection this exists to prevent. */
it.each([
['a newline', `a${LF}X-Evil: 1.jpg`],
['a CRLF', `a${CRLF}X-Evil: 1.jpg`],
['a null byte', `a${NUL}b.jpg`],
])('strips %s', (_label, raw) => {
expect(hasControlChar(safeDownloadName(raw))).toBe(false);
});
it('neutralises path separators and traversal', () => {
expect(safeDownloadName('../../etc/passwd')).not.toContain('/');
expect(safeDownloadName('..\\..\\secret.jpg')).not.toContain('\\');
expect(safeDownloadName('../evil.jpg').startsWith('.')).toBe(false);
});
it('removes the quotes and semicolons that delimit header parameters', () => {
expect(safeDownloadName(`a${QUOTE};b.jpg`)).toBe('ab.jpg');
});
it.each([
['undefined', undefined],
['empty', ''],
['whitespace', ' '],
['only dots', '...'],
])('falls back to a generic name for %s', (_label, raw) => {
expect(safeDownloadName(raw)).toBe('photo');
});
it('never returns an empty name', () => {
for (const raw of ['///', QUOTE.repeat(3), '..', ' . ', NUL]) {
expect(safeDownloadName(raw).length).toBeGreaterThan(0);
}
});
it('bounds the length', () => {
expect(safeDownloadName('x'.repeat(5000)).length).toBeLessThanOrEqual(200);
});
});
describe('contentDisposition', () => {
it('marks the response as an attachment', () => {
expect(contentDisposition('DSCF1234.JPG')).toMatch(/^attachment; /);
});
it('carries both a plain and a percent-encoded filename', () => {
const header = contentDisposition('Fr\u00fchling.jpg');
expect(header).toContain('filename="Fr_hling.jpg"');
expect(header).toContain("filename*=UTF-8''Fr%C3%BChling.jpg");
});
it('never emits a control character, whatever it is given', () => {
expect(hasControlChar(contentDisposition(`a${CRLF}X-Evil: 1.jpg`))).toBe(false);
});
it('never emits an unbalanced quote', () => {
const header = contentDisposition(`a${QUOTE}b.jpg`);
expect((header.match(/"/g) ?? []).length % 2).toBe(0);
});
it('survives a missing name', () => {
expect(contentDisposition(undefined)).toContain('filename="photo"');
});
});

View File

@@ -82,9 +82,16 @@ describe('dictionary parity', () => {
});
it('leaves no English string untranslated', () => {
// Proper nouns and units read the same in both languages; `coverAria` is
// pure interpolation with no words of its own.
const shared = new Set(['nav.journal', 'journal.title', 'lightbox.iso', 'subpage.coverAria']);
// Proper nouns, loanwords and units read the same in both languages;
// `coverAria` is pure interpolation with no words of its own.
const shared = new Set([
'nav.journal',
'journal.title',
'lightbox.iso',
'lightbox.copyLinkShort',
'lightbox.downloadShort',
'subpage.coverAria',
]);
const identical = enLeaves
.filter(([path]) => !shared.has(path))

View File

@@ -381,6 +381,63 @@ describe('ImmichClient', () => {
});
});
// #522: a CJK album name slugified to '', so its link was "/" and the album
// was unreachable — no URL segment can ever be the empty string.
describe('getAlbumBySlug() with non-Latin names', () => {
function mockList(albumName: string) {
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/search/metadata')) {
return json({ assets: { items: [], nextPage: null, total: 0 } });
}
if (/\/albums\/album-2/.test(url)) {
return json({ id: 'album-2', albumName, assetCount: 0, assets: [], order: 'desc' });
}
return json([{ id: 'album-2', albumName, assetCount: 0 }]);
});
}
it('gives a CJK album a non-empty slug and resolves it', async () => {
mockList('家族相册');
const albums = await immich.getAlbums();
expect(albums[0].slug).toBe('家族相册');
const album = await immich.getAlbumBySlug('家族相册');
expect(album?.id).toBe('album-2');
});
it('resolves a Hangul slug that arrives decomposed (NFD) from the URL', async () => {
mockList('가족 앨범');
await immich.getAlbums();
const album = await immich.getAlbumBySlug('가족-앨범'.normalize('NFD'));
expect(album?.id).toBe('album-2');
});
it('resolves a slug that arrives percent-encoded from the route', async () => {
mockList('家族相册');
await immich.getAlbums();
const encoded = encodeURIComponent('家族相册');
expect(encoded).not.toBe('家族相册');
const album = await immich.getAlbumBySlug(encoded);
expect(album?.id).toBe('album-2');
});
it('falls back to the album id when the name has no letters at all', async () => {
mockList('🎉');
const albums = await immich.getAlbums();
expect(albums[0].slug).toBe('album-2');
const album = await immich.getAlbumBySlug('album-2');
expect(album?.id).toBe('album-2');
});
});
describe('getStandaloneAlbums()', () => {
it('orders albums by gallery.yaml order, not Immich API order', async () => {
// The Immich API returns albums in its own (creation/update) order;

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
LIGHTBOX_SHORTCUTS,
lightboxActionFor,
shortcutDisplayKeys,
shortcutKeyLabel,
} from '@/lib/lightboxShortcuts';
import { en } from '@/lib/i18n/locales/en';
import { de } from '@/lib/i18n/locales/de';
describe('LIGHTBOX_SHORTCUTS', () => {
it('is not empty', () => {
expect(LIGHTBOX_SHORTCUTS.length).toBeGreaterThan(0);
});
/**
* The whole point of the shared catalogue: the `?` overlay and the admin
* help render this list, so a label that does not resolve would leave one of
* them blank.
*/
it.each(['en', 'de'])('every label resolves in %s', (locale) => {
const dictionary = locale === 'en' ? en.lightbox : de.lightbox;
for (const shortcut of LIGHTBOX_SHORTCUTS) {
const label = dictionary[shortcut.labelKey];
expect(typeof label, shortcut.labelKey).toBe('string');
expect((label as string).length).toBeGreaterThan(0);
}
});
it('gives every shortcut at least one key', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
expect(shortcut.bindings.length, shortcut.labelKey).toBeGreaterThan(0);
for (const binding of shortcut.bindings) {
expect(binding.eventKeys.length, binding.display).toBeGreaterThan(0);
}
}
});
it('binds no key twice, which would make the list contradict itself', () => {
const keys = LIGHTBOX_SHORTCUTS.flatMap((shortcut) =>
shortcut.bindings.map((binding) => binding.display),
);
expect(new Set(keys).size).toBe(keys.length);
});
/**
* Two rows claiming the same physical key would make the viewer's behaviour
* depend on the order of this list, and the help would describe one of them
* wrongly.
*/
it('binds no KeyboardEvent.key to two actions', () => {
const eventKeys = LIGHTBOX_SHORTCUTS.flatMap((shortcut) =>
shortcut.bindings.flatMap((binding) => [...binding.eventKeys]),
);
expect(new Set(eventKeys).size).toBe(eventKeys.length);
});
it('lists each label once', () => {
const labels = LIGHTBOX_SHORTCUTS.map((shortcut) => shortcut.labelKey);
expect(new Set(labels).size).toBe(labels.length);
});
it('documents the conditional shortcuts, since the reader cannot see them fail', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
if (shortcut.availability !== 'always') {
expect(shortcut.note, shortcut.labelKey).toBeTruthy();
}
}
});
});
/**
* The catalogue is the only route from a keypress to an action (#473), so
* these cover the join the viewer relies on.
*/
describe('lightboxActionFor', () => {
it('resolves every declared key to its action', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
for (const binding of shortcut.bindings) {
for (const key of binding.eventKeys) {
expect(lightboxActionFor(key), key).toBe(binding.action);
}
}
}
});
it('resolves the keys a visitor actually presses', () => {
expect(lightboxActionFor('ArrowLeft')).toBe('prev');
expect(lightboxActionFor('ArrowRight')).toBe('next');
expect(lightboxActionFor('Escape')).toBe('close');
expect(lightboxActionFor('?')).toBe('shortcutList');
expect(lightboxActionFor('h')).toBe('shortcutList');
expect(lightboxActionFor('H')).toBe('shortcutList');
});
it('takes both cases of every letter key, since Shift must not matter', () => {
for (const [lower, upper] of [
['i', 'I'],
['f', 'F'],
['s', 'S'],
['c', 'C'],
['d', 'D'],
]) {
expect(lightboxActionFor(lower), lower).not.toBeNull();
expect(lightboxActionFor(upper), upper).toBe(lightboxActionFor(lower));
}
});
it('returns null for a key the viewer does not bind', () => {
for (const key of ['a', 'Enter', 'Tab', ' ', 'ArrowUp', 'F5']) {
expect(lightboxActionFor(key), key).toBeNull();
}
});
});
describe('shortcutKeyLabel', () => {
it('renders the arrow names as arrows', () => {
expect(shortcutKeyLabel('ARROW_LEFT')).toBe(String.fromCharCode(0x2190));
expect(shortcutKeyLabel('ARROW_RIGHT')).toBe(String.fromCharCode(0x2192));
});
it('passes ordinary keys through', () => {
expect(shortcutKeyLabel('S')).toBe('S');
expect(shortcutKeyLabel('Esc')).toBe('Esc');
expect(shortcutKeyLabel('?')).toBe('?');
});
it('leaves no placeholder name unrendered', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
for (const key of shortcutDisplayKeys(shortcut)) {
expect(key).not.toMatch(/^ARROW_/);
}
}
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import {
placeLabel,
isLocationPrecision,
strictestPrecision,
quantiseCoordinate,
applyPrecision,
LOCATION_PRECISIONS,
type LocationPrecision,
} from '@/lib/mapPrecision';
describe('isLocationPrecision', () => {
it.each(LOCATION_PRECISIONS)('accepts %s', (level) => {
expect(isLocationPrecision(level)).toBe(true);
});
it.each(['', 'EXACT', 'town', 'precise', 'none'])('rejects %s', (value) => {
expect(isLocationPrecision(value)).toBe(false);
});
});
describe('strictestPrecision', () => {
it.each([
[['exact', 'city'], 'city'],
[['city', 'country'], 'country'],
[['exact', 'hidden'], 'hidden'],
[['country', 'city', 'exact'], 'country'],
[['exact'], 'exact'],
[['exact', 'exact'], 'exact'],
] as [LocationPrecision[], LocationPrecision][])('%s → %s', (levels, expected) => {
expect(strictestPrecision(levels)).toBe(expected);
});
it('is order-independent', () => {
expect(strictestPrecision(['country', 'exact'])).toBe(strictestPrecision(['exact', 'country']));
});
/** Nothing contributed, so there is nothing to place. */
it('treats an empty list as hidden', () => {
expect(strictestPrecision([])).toBe('hidden');
});
});
describe('quantiseCoordinate', () => {
it('leaves an exact coordinate untouched', () => {
expect(quantiseCoordinate(52.516272, 'exact')).toBe(52.516272);
});
it('snaps to 0.05° for city', () => {
expect(quantiseCoordinate(52.516272, 'city')).toBe(52.5);
expect(quantiseCoordinate(13.377704, 'city')).toBe(13.4);
});
it('snaps to 1° for country', () => {
expect(quantiseCoordinate(52.516272, 'country')).toBe(53);
expect(quantiseCoordinate(13.377704, 'country')).toBe(13);
});
it('leaves no floating-point tail', () => {
for (const value of [52.9123, 13.0001, -3.3333, 0.049]) {
const quantised = quantiseCoordinate(value, 'city');
expect(String(quantised)).toMatch(/^-?\d+(\.\d{1,2})?$/);
}
});
it('works south and west of zero', () => {
expect(quantiseCoordinate(-33.918861, 'city')).toBe(-33.9);
expect(quantiseCoordinate(-118.24368, 'country')).toBe(-118);
});
/**
* The property that matters: the output is a fixed grid, so asking twice
* cannot average back to the true position.
*/
it('is stable across repeated calls', () => {
const value = 47.376887;
const answers = new Set(Array.from({ length: 50 }, () => quantiseCoordinate(value, 'city')));
expect(answers.size).toBe(1);
});
it('places nearby points on the same grid node, so one album does not stand out', () => {
const garden = [52.5161, 52.5169, 52.5152].map((v) => quantiseCoordinate(v, 'city'));
expect(new Set(garden).size).toBe(1);
});
it('moves a coordinate far enough to stop naming a property', () => {
const exact = 52.516272;
expect(Math.abs(quantiseCoordinate(exact, 'city') - exact)).toBeGreaterThan(0.01);
});
});
describe('applyPrecision', () => {
const berlin = { lat: 52.516272, lng: 13.377704 };
it('quantises both axes', () => {
expect(applyPrecision(berlin, 'city')).toEqual({ lat: 52.5, lng: 13.4 });
});
it('passes an exact position through', () => {
expect(applyPrecision(berlin, 'exact')).toEqual(berlin);
});
/** Not {0,0} — the caller must drop the marker, not place it in the Atlantic. */
it('returns null for hidden', () => {
expect(applyPrecision(berlin, 'hidden')).toBeNull();
});
});
/**
* Quantising the coordinates while still naming the city left the label more
* precise than the position it was meant to blur: a 1° grid cell is ~100 km,
* and "Kloster Chorin" is a village.
*/
describe('placeLabel', () => {
const place = { city: 'Chorin', country: 'Germany' };
it('keeps the city at exact', () => {
expect(placeLabel('exact', place)).toEqual({ city: 'Chorin', country: 'Germany' });
});
it('keeps the city at city precision, which is roughly what a city name says', () => {
expect(placeLabel('city', place)).toEqual({ city: 'Chorin', country: 'Germany' });
});
it('drops the city at country precision', () => {
expect(placeLabel('country', place)).toEqual({ city: '', country: 'Germany' });
});
it('drops both when the marker is hidden, so a caller cannot leak one by accident', () => {
expect(placeLabel('hidden', place)).toEqual({ city: '', country: '' });
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { parsePhotoHash, buildPhotoHash, buildPhotoPermalink } from '@/lib/photoHash';
describe('buildPhotoHash', () => {
it('presents the index 1-indexed', () => {
expect(buildPhotoHash(0)).toBe('#photo-1');
expect(buildPhotoHash(41)).toBe('#photo-42');
});
});
describe('parsePhotoHash', () => {
it('round-trips with buildPhotoHash', () => {
for (const index of [0, 1, 7, 250]) {
expect(parsePhotoHash(buildPhotoHash(index))).toBe(index);
}
});
it.each([
['no hash', ''],
['bare hash', '#'],
['another anchor', '#gallery'],
['missing number', '#photo-'],
['not a number', '#photo-abc'],
['negative', '#photo--3'],
['trailing junk', '#photo-3x'],
['leading junk', '#x-photo-3'],
])('rejects %s', (_label, hash) => {
expect(parsePhotoHash(hash)).toBeNull();
});
it('rejects #photo-0, which has no 0-based index', () => {
expect(parsePhotoHash('#photo-0')).toBeNull();
});
});
describe('buildPhotoPermalink', () => {
const location = {
origin: 'https://folio.example',
pathname: '/travel/iceland',
search: '',
};
it('builds an absolute link to the photo', () => {
expect(buildPhotoPermalink(location, 2)).toBe('https://folio.example/travel/iceland#photo-3');
});
it('preserves an existing query string', () => {
expect(buildPhotoPermalink({ ...location, search: '?preview=true' }, 0)).toBe(
'https://folio.example/travel/iceland?preview=true#photo-1',
);
});
it('works at the site root', () => {
expect(buildPhotoPermalink({ ...location, pathname: '/' }, 0)).toBe(
'https://folio.example/#photo-1',
);
});
});

View File

@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest';
import { publicPaths, type SiteShape } from '@/lib/publicPages';
const site = (over: Partial<SiteShape> = {}): SiteShape => ({
siteLocked: false,
subpages: [],
standaloneAlbums: [],
journal: [],
isAlbumProtected: () => false,
aboutEnabled: false,
mapEnabled: false,
journalEnabled: false,
...over,
});
const openSubpage = (slug: string, albums: { id: string; slug: string }[] = []) => ({
slug,
enabled: true,
hidden: false,
isProtected: false,
albums,
});
describe('publicPaths', () => {
it('always lists the home page', () => {
expect(publicPaths(site())).toEqual(['/']);
});
it('lists the static pages that are switched on', () => {
const paths = publicPaths(site({ aboutEnabled: true, mapEnabled: true, journalEnabled: true }));
expect(paths).toEqual(['/', '/about', '/map', '/journal']);
});
it('lists a subpage and its albums', () => {
const paths = publicPaths(
site({ subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])] }),
);
expect(paths).toEqual(['/', '/travel', '/travel/iceland']);
});
it('lists standalone albums at the root', () => {
expect(publicPaths(site({ standaloneAlbums: [{ id: 'a', slug: 'portraits' }] }))).toEqual([
'/',
'/portraits',
]);
});
// ── Exclusions ────────────────────────────────────────────────
it('yields nothing at all for a locked site', () => {
const paths = publicPaths(
site({
siteLocked: true,
aboutEnabled: true,
subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])],
standaloneAlbums: [{ id: 'b', slug: 'portraits' }],
}),
);
expect(paths).toEqual([]);
});
it('omits a disabled subpage', () => {
const sp = { ...openSubpage('travel', [{ id: 'a', slug: 'iceland' }]), enabled: false };
expect(publicPaths(site({ subpages: [sp] }))).toEqual(['/']);
});
it('omits a hidden subpage — a listing is the opposite of a direct link', () => {
const sp = { ...openSubpage('travel', [{ id: 'a', slug: 'iceland' }]), hidden: true };
expect(publicPaths(site({ subpages: [sp] }))).toEqual(['/']);
});
it('omits a password-protected subpage', () => {
const sp = { ...openSubpage('clients', [{ id: 'a', slug: 'wedding' }]), isProtected: true };
expect(publicPaths(site({ subpages: [sp] }))).toEqual(['/']);
});
it('omits a protected album inside an open subpage', () => {
const paths = publicPaths(
site({
subpages: [
openSubpage('travel', [
{ id: 'open', slug: 'iceland' },
{ id: 'locked', slug: 'private' },
]),
],
isAlbumProtected: (id) => id === 'locked',
}),
);
expect(paths).toEqual(['/', '/travel', '/travel/iceland']);
});
it('omits a protected standalone album', () => {
const paths = publicPaths(
site({
standaloneAlbums: [
{ id: 'open', slug: 'portraits' },
{ id: 'locked', slug: 'clients' },
],
isAlbumProtected: (id) => id === 'locked',
}),
);
expect(paths).toEqual(['/', '/portraits']);
});
/**
* Inheritance — the case the issue calls out. The album itself has no
* password, but it can only be reached through a subpage that does.
*/
it('omits a public album that sits under a protected subpage', () => {
const sp = { ...openSubpage('clients', [{ id: 'open', slug: 'wedding' }]), isProtected: true };
const paths = publicPaths(site({ subpages: [sp] }));
expect(paths).not.toContain('/clients/wedding');
expect(paths).toEqual(['/']);
});
it('omits a public album under a hidden subpage for the same reason', () => {
const sp = { ...openSubpage('secret', [{ id: 'open', slug: 'wedding' }]), hidden: true };
expect(publicPaths(site({ subpages: [sp] }))).not.toContain('/secret/wedding');
});
it('omits journal drafts', () => {
const paths = publicPaths(
site({
journalEnabled: true,
journal: [
{ slug: 'published', draft: false },
{ slug: 'wip', draft: true },
],
}),
);
expect(paths).toEqual(['/', '/journal', '/journal/published']);
});
it('omits journal entries entirely when the journal is off', () => {
const paths = publicPaths(
site({ journalEnabled: false, journal: [{ slug: 'published', draft: false }] }),
);
expect(paths).toEqual(['/']);
});
// ── Shape ─────────────────────────────────────────────────────
it('emits no duplicates when a subpage and an album share a slug', () => {
const paths = publicPaths(
site({
subpages: [openSubpage('travel')],
standaloneAlbums: [{ id: 'a', slug: 'travel' }],
}),
);
expect(paths).toEqual([...new Set(paths)]);
});
it('emits only absolute-looking, single-slash paths', () => {
const paths = publicPaths(
site({
aboutEnabled: true,
journalEnabled: true,
journal: [{ slug: 'story', draft: false }],
subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])],
standaloneAlbums: [{ id: 'b', slug: 'portraits' }],
}),
);
for (const p of paths) {
expect(p.startsWith('/')).toBe(true);
expect(p).not.toMatch(/\/\//);
}
});
});

View File

@@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import { normaliseSiteUrl, resolveSiteUrl, absoluteUrl } from '@/lib/siteUrl';
describe('normaliseSiteUrl', () => {
it('keeps a plain https origin', () => {
expect(normaliseSiteUrl('https://folio.example')).toBe('https://folio.example');
});
it('drops the trailing slash so callers can join with /', () => {
expect(normaliseSiteUrl('https://folio.example/')).toBe('https://folio.example');
expect(normaliseSiteUrl('https://folio.example/gallery//')).toBe(
'https://folio.example/gallery',
);
});
it('keeps a sub-path, since a portfolio may live under one', () => {
expect(normaliseSiteUrl('https://example.com/folio')).toBe('https://example.com/folio');
});
it('drops query and fragment', () => {
expect(normaliseSiteUrl('https://folio.example/?utm=1#top')).toBe('https://folio.example');
});
it('trims surrounding whitespace', () => {
expect(normaliseSiteUrl(' https://folio.example ')).toBe('https://folio.example');
});
it('allows http, for a LAN instance behind no TLS', () => {
expect(normaliseSiteUrl('http://192.168.1.10:7211')).toBe('http://192.168.1.10:7211');
});
/**
* A wrong absolute URL is worse than none: it would be emitted into the
* sitemap, the feed and the JSON-LD block.
*/
it.each([
['undefined', undefined],
['null', null],
['empty', ''],
['whitespace', ' '],
['a bare host', 'folio.example'],
['a path only', '/gallery'],
['javascript:', 'javascript:alert(1)'],
['data:', 'data:text/html,x'],
['ftp', 'ftp://folio.example'],
['nonsense', 'not a url'],
])('rejects %s', (_label, raw) => {
expect(normaliseSiteUrl(raw)).toBeNull();
});
});
describe('resolveSiteUrl', () => {
/**
* settings.yaml wins, matching how `settings.title ?? env.SITE_TITLE`
* already resolves. The admin field must never save and do nothing.
*/
it('prefers settings.yaml over the environment', () => {
expect(resolveSiteUrl('https://from-yaml.example', 'https://from-env.example')).toEqual({
url: 'https://from-yaml.example',
source: 'settings',
});
});
it('falls back to the environment when the file says nothing', () => {
expect(resolveSiteUrl(undefined, 'https://from-env.example')).toEqual({
url: 'https://from-env.example',
source: 'env',
});
});
/** An unusable value in the file must not shadow a good one in the env. */
it('ignores an invalid settings value and uses the environment', () => {
expect(resolveSiteUrl('not a url', 'https://from-env.example')).toEqual({
url: 'https://from-env.example',
source: 'env',
});
});
it('reports none when neither is set', () => {
expect(resolveSiteUrl(undefined, undefined)).toEqual({ url: null, source: 'none' });
});
});
describe('absoluteUrl', () => {
it('joins a path', () => {
expect(absoluteUrl('https://folio.example', '/travel')).toBe('https://folio.example/travel');
});
it('adds the missing leading slash', () => {
expect(absoluteUrl('https://folio.example', 'travel')).toBe('https://folio.example/travel');
});
it('never emits a double slash', () => {
expect(absoluteUrl('https://folio.example', '/travel/')).toBe('https://folio.example/travel');
});
it('keeps the root as a single slash', () => {
expect(absoluteUrl('https://folio.example', '/')).toBe('https://folio.example/');
});
it('works under a sub-path', () => {
expect(absoluteUrl('https://example.com/folio', '/travel')).toBe(
'https://example.com/folio/travel',
);
});
/** A relative sitemap entry is invalid and a relative feed link points at the reader. */
it('returns null without a site URL', () => {
expect(absoluteUrl(null, '/travel')).toBeNull();
});
});
/**
* A sitemap, a canonical link and a JSON-LD block all want a URI, not an IRI.
* Non-Latin album slugs only became possible with #522, so this surfaced with
* them.
*/
describe('absoluteUrl encoding', () => {
it('percent-encodes a non-ASCII path', () => {
expect(absoluteUrl('https://example.com', '/家族相册')).toBe(
'https://example.com/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C',
);
});
it('leaves an ASCII path exactly as it was', () => {
expect(absoluteUrl('https://example.com', '/kloster-chorin')).toBe(
'https://example.com/kloster-chorin',
);
});
it('keeps a query string intact', () => {
expect(absoluteUrl('https://example.com', '/api/image/abc?size=preview')).toBe(
'https://example.com/api/image/abc?size=preview',
);
});
// A portfolio may live under a sub-path, which normaliseSiteUrl keeps.
it('preserves a sub-path in the site URL', () => {
expect(absoluteUrl('https://example.com/folio', '/家族相册')).toBe(
'https://example.com/folio/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C',
);
});
// Encoding blindly would turn a '%' into '%25' and corrupt the URL.
it('does not encode an already-encoded path a second time', () => {
expect(absoluteUrl('https://example.com', '/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C')).toBe(
'https://example.com/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C',
);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { nextSlideshowSpeed, SLIDESHOW_SPEEDS, type SlideshowSpeed } from '@/lib/slideshow';
describe('nextSlideshowSpeed', () => {
it('cycles off → 3s → 5s → 10s → off', () => {
expect(nextSlideshowSpeed(null)).toBe(3);
expect(nextSlideshowSpeed(3)).toBe(5);
expect(nextSlideshowSpeed(5)).toBe(10);
expect(nextSlideshowSpeed(10)).toBeNull();
});
it('returns to off after one full cycle, so the key always lets go', () => {
let speed: SlideshowSpeed = null;
for (let i = 0; i < SLIDESHOW_SPEEDS.length; i++) speed = nextSlideshowSpeed(speed);
expect(speed).toBeNull();
});
it('never yields a speed outside the advertised set', () => {
let speed: SlideshowSpeed = null;
for (let i = 0; i < 20; i++) {
speed = nextSlideshowSpeed(speed);
expect(SLIDESHOW_SPEEDS).toContain(speed);
}
});
it('re-enters the cycle from an unrecognised value rather than sticking', () => {
expect(nextSlideshowSpeed(7 as SlideshowSpeed)).toBe(3);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { albumStructuredData } from '@/lib/structuredData';
const base = {
siteUrl: 'https://folio.example',
pageUrl: 'https://folio.example/travel/iceland',
albumName: 'Iceland',
};
describe('albumStructuredData', () => {
it('describes the album as an ImageGallery', () => {
const data = albumStructuredData(base)!;
expect(data['@context']).toBe('https://schema.org');
expect(data['@type']).toBe('ImageGallery');
expect(data.name).toBe('Iceland');
expect(data.url).toBe('https://folio.example/travel/iceland');
});
/**
* Structured data is a set of claims about absolute URLs. Without one there
* is nothing truthful to claim, so nothing is emitted.
*/
it.each([
['no site URL', { ...base, siteUrl: null }],
['no page URL', { ...base, pageUrl: null }],
['a blank album name', { ...base, albumName: ' ' }],
])('emits nothing for %s', (_label, input) => {
expect(albumStructuredData(input)).toBeNull();
});
it('omits optional fields rather than emitting empty ones', () => {
const data = albumStructuredData(base)!;
for (const key of ['description', 'creator', 'license', 'image', 'numberOfItems']) {
expect(data).not.toHaveProperty(key);
}
});
it('carries creator and license when configured', () => {
const data = albumStructuredData({
...base,
creator: 'Jane Doe',
license: 'https://creativecommons.org/licenses/by-nc/4.0/',
})!;
expect(data.creator).toEqual({ '@type': 'Person', name: 'Jane Doe' });
expect(data.license).toBe('https://creativecommons.org/licenses/by-nc/4.0/');
});
it('repeats creator and license on the cover image', () => {
const data = albumStructuredData({
...base,
coverUrl: 'https://folio.example/api/image/tok',
creator: 'Jane Doe',
license: 'CC BY-NC 4.0',
})!;
expect(data.image).toEqual({
'@type': 'ImageObject',
contentUrl: 'https://folio.example/api/image/tok',
creator: { '@type': 'Person', name: 'Jane Doe' },
license: 'CC BY-NC 4.0',
});
});
it('treats blank creator and license as absent', () => {
const data = albumStructuredData({ ...base, creator: ' ', license: ' ' })!;
expect(data).not.toHaveProperty('creator');
expect(data).not.toHaveProperty('license');
});
it('trims the values it does emit', () => {
const data = albumStructuredData({
...base,
albumName: ' Iceland ',
description: ' Winter light. ',
creator: ' Jane Doe ',
})!;
expect(data.name).toBe('Iceland');
expect(data.description).toBe('Winter light.');
expect(data.creator).toEqual({ '@type': 'Person', name: 'Jane Doe' });
});
it('reports the photo count when given', () => {
expect(albumStructuredData({ ...base, photoCount: 42 })!.numberOfItems).toBe(42);
expect(albumStructuredData({ ...base, photoCount: 0 })!.numberOfItems).toBe(0);
});
it('survives JSON serialisation, which is how it is emitted', () => {
const data = albumStructuredData({ ...base, creator: 'Jane Doe', photoCount: 3 })!;
expect(() => JSON.parse(JSON.stringify(data))).not.toThrow();
});
});

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
}));
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
import { deriveGallery } from '@/lib/config';
import type { GalleryYaml } from '@/lib/config/schema';
const A = '11111111-1111-1111-1111-111111111111';
function subpage(entry: Record<string, unknown>) {
const gallery = deriveGallery({
subpages: [{ name: 'Travel', albums: [A], ...entry }],
} as unknown as GalleryYaml);
return gallery.subpages[0];
}
/**
* The album covers and the photos inside those albums used to share a single
* `grid` key, so a gap typed into the admin's "Album Cover Grid" retuned every
* photo grid on the page (#523).
*/
describe('subpage grid vs. coverGrid', () => {
it('keeps the two independent once both are set', () => {
const sp = subpage({ grid: { columns: 4, gap: 6 }, coverGrid: { columns: 2, gap: 24 } });
expect(sp.grid).toEqual({ columns: 4, gap: 6 });
expect(sp.coverGrid).toEqual({ columns: 2, gap: 24 });
});
it('leaves the photo grid alone when only the covers are configured', () => {
const sp = subpage({ coverGrid: { columns: 2, gap: 24 } });
expect(sp.grid).toBeUndefined();
expect(sp.coverGrid).toEqual({ columns: 2, gap: 24 });
});
// A gallery.yaml written before the split has no `coverGrid` at all, and its
// covers were sized by `grid`. Falling back to it keeps that page rendering
// exactly as it did.
it('falls back to the photo grid when no coverGrid was written', () => {
const sp = subpage({ grid: { columns: 4, gap: 6, layout: 'uniform' } });
expect(sp.coverGrid).toEqual({ columns: 4, gap: 6, layout: 'uniform' });
});
it('reports no cover override when the page states neither', () => {
expect(subpage({}).coverGrid).toBeUndefined();
});
it('reads coverGrid from the map form of subpages too', () => {
const gallery = deriveGallery({
subpages: {
Travel: { albums: [A], grid: { gap: 6 }, coverGrid: { columns: 2 } },
},
} as unknown as GalleryYaml);
expect(gallery.subpages[0].grid).toEqual({ gap: 6 });
expect(gallery.subpages[0].coverGrid).toEqual({ columns: 2 });
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { compareVersions, isNewerVersion, parseLatestTag } from '@/lib/updateCheck';
describe('compareVersions', () => {
it.each([
['0.12.0', '0.12.0', 0],
['0.12.1', '0.12.0', 1],
['0.12.0', '0.12.1', -1],
['1.0.0', '0.99.99', 1],
['0.13.0', '0.9.0', 1],
])('%s vs %s', (a, b, expected) => {
expect(Math.sign(compareVersions(a, b))).toBe(expected);
});
/** 10 must not sort before 9, which is what a string compare would do. */
it('compares numerically, not lexically', () => {
expect(compareVersions('0.10.0', '0.9.0')).toBeGreaterThan(0);
expect(compareVersions('0.2.0', '0.10.0')).toBeLessThan(0);
});
it('tolerates the leading v the releases API uses', () => {
expect(compareVersions('v0.12.0', '0.12.0')).toBe(0);
expect(compareVersions('V0.13.0', '0.12.0')).toBeGreaterThan(0);
});
it('treats missing segments as zero', () => {
expect(compareVersions('1', '1.0.0')).toBe(0);
expect(compareVersions('1.1', '1.0.9')).toBeGreaterThan(0);
});
it('compares a pre-release on its numbers alone', () => {
expect(compareVersions('0.13.0-rc.1', '0.12.0')).toBeGreaterThan(0);
expect(compareVersions('0.12.0-rc.1', '0.12.0')).toBe(0);
});
it('does not crash on rubbish', () => {
expect(() => compareVersions('', 'x.y.z')).not.toThrow();
expect(compareVersions('x.y.z', '')).toBe(0);
});
});
describe('isNewerVersion', () => {
it('is true only for a strictly newer release', () => {
expect(isNewerVersion('0.13.0', '0.12.0')).toBe(true);
expect(isNewerVersion('0.12.0', '0.12.0')).toBe(false);
expect(isNewerVersion('0.11.0', '0.12.0')).toBe(false);
});
/** A downgraded tag must never be advertised as an update. */
it('does not nag when running ahead of the latest release', () => {
expect(isNewerVersion('v0.12.0', '0.13.0-dev')).toBe(false);
});
});
describe('parseLatestTag', () => {
it('reads tag_name', () => {
expect(parseLatestTag({ tag_name: 'v0.13.0' })).toBe('v0.13.0');
});
it('trims surrounding whitespace', () => {
expect(parseLatestTag({ tag_name: ' v0.13.0 ' })).toBe('v0.13.0');
});
it.each([
['null', null],
['a string', 'v0.13.0'],
['an array', []],
['an error object served with 200', { message: 'API rate limit exceeded' }],
['a non-string tag', { tag_name: 42 }],
['a tag that is not a version', { tag_name: 'nightly' }],
// The check only guarded the *start* of the tag, so anything beginning
// with a digit slipped through and was then compared as a version. A
// date-style tag reads as version 2024 and leaves every instance showing
// an update that does not exist, permanently.
['a date-style tag', { tag_name: '2024-01-05' }],
['a tag with a digit and then rubbish', { tag_name: '1abc' }],
])('returns null for %s', (_label, payload) => {
expect(parseLatestTag(payload)).toBeNull();
});
it('still accepts the shapes the project actually tags', () => {
expect(parseLatestTag({ tag_name: 'v0.13.0' })).toBe('v0.13.0');
expect(parseLatestTag({ tag_name: '0.13.0' })).toBe('0.13.0');
expect(parseLatestTag({ tag_name: 'v1.0' })).toBe('v1.0');
expect(parseLatestTag({ tag_name: 'v1.2.3-rc1' })).toBe('v1.2.3-rc1');
// Build metadata is ignorable for ordering, so rejecting it would mean
// never mentioning a release that is genuinely newer.
expect(parseLatestTag({ tag_name: 'v1.2.3+build.7' })).toBe('v1.2.3+build.7');
});
});

View File

@@ -17,6 +17,7 @@ import {
assetPlaceholder,
assetExifSummary,
assetAspectRatio,
assetCaption,
} from '@/lib/urls';
describe('imageUrl', () => {
@@ -168,3 +169,58 @@ describe('assetAspectRatio', () => {
).toBeUndefined();
});
});
describe('assetCaption', () => {
/** An asset carrying nothing but the description under test. */
const withDescription = (description: string | null) => ({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: null,
exifImageHeight: null,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description,
},
});
it('returns the description when captions are shown', () => {
expect(assetCaption(withDescription('A gull over the harbour'), true)).toBe(
'A gull over the harbour',
);
});
it('trims surrounding whitespace', () => {
expect(assetCaption(withDescription(' Low tide\n'), true)).toBe('Low tide');
});
it.each([
['null', null],
['empty', ''],
['whitespace only', ' \n\t '],
])('returns undefined for a %s description, leaving alt=""', (_label, value) => {
expect(assetCaption(withDescription(value), true)).toBeUndefined();
});
it('returns undefined when the asset has no exifInfo at all', () => {
expect(assetCaption({ exifInfo: undefined }, true)).toBeUndefined();
});
/**
* The point of #506: the description is the one field that can hold private
* notes. Alt text publishes it as surely as a visible caption does, so the
* caption switch has to gate it too.
*/
it('withholds the description when captions are switched off', () => {
expect(assetCaption(withDescription('Client hates this crop'), false)).toBeUndefined();
});
});