release: v0.17.0 (#677)
See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
||||
isAlbumReachable,
|
||||
withoutLockedAlbums,
|
||||
findSubpageBySlug,
|
||||
isHttpsRequest,
|
||||
} from '@/lib/auth';
|
||||
|
||||
describe('findSubpageBySlug', () => {
|
||||
@@ -286,3 +287,40 @@ describe('withoutLockedAlbums', () => {
|
||||
expect(withoutLockedAlbums([LOCKED, OPEN], getCookie)).toEqual([LOCKED, OPEN]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The `Secure` flag follows the request, not NODE_ENV (#664): the Docker image
|
||||
* always runs in production, and a browser drops a `Secure` cookie set over
|
||||
* plain HTTP — the password was accepted and every next request answered 401.
|
||||
*/
|
||||
describe('isHttpsRequest', () => {
|
||||
const req = (url: string, proto?: string) =>
|
||||
new Request(url, { headers: proto ? { 'x-forwarded-proto': proto } : {} });
|
||||
|
||||
it('reads the scheme of a direct request', () => {
|
||||
expect(isHttpsRequest(req('https://folio.example/api/auth'))).toBe(true);
|
||||
expect(isHttpsRequest(req('http://192.168.1.10:7211/api/auth'))).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the scheme a reverse proxy reports', () => {
|
||||
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https'))).toBe(true);
|
||||
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'HTTPS'))).toBe(true);
|
||||
expect(isHttpsRequest(req('https://folio.example/api/auth', 'http'))).toBe(false);
|
||||
});
|
||||
|
||||
it('takes the first entry of a chained header', () => {
|
||||
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https, http'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticate — Secure flag', () => {
|
||||
it('marks the cookie Secure by default', async () => {
|
||||
expect(await authenticate('private', 'secret123')).toMatch(/; Secure$/);
|
||||
});
|
||||
|
||||
it('leaves Secure off for a plain-HTTP visitor', async () => {
|
||||
const cookie = await authenticate('private', 'secret123', 'subpage', false);
|
||||
expect(cookie).toContain('lb_auth_private=');
|
||||
expect(cookie).not.toContain('Secure');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('dictionary parity', () => {
|
||||
'lightbox.iso',
|
||||
'lightbox.copyLinkShort',
|
||||
'lightbox.downloadShort',
|
||||
'common.website',
|
||||
'subpage.coverAria',
|
||||
]);
|
||||
|
||||
|
||||
141
lib/__tests__/journal-album.test.ts
Normal file
141
lib/__tests__/journal-album.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown, collectAssetIds } from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
import { expandAlbumBlocks, orderAlbumAssets } from '../journalAlbum';
|
||||
|
||||
const block = (md: string) => parseJournalMarkdown(md).blocks[0];
|
||||
|
||||
describe('album block syntax', () => {
|
||||
it('parses the id and every option', () => {
|
||||
expect(
|
||||
block(
|
||||
['::album abc-123', 'count: 12', 'skip: 2', 'layout: pairs', 'caption: The **day**'].join(
|
||||
'\n',
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'album',
|
||||
albumId: 'abc-123',
|
||||
count: 12,
|
||||
skip: 2,
|
||||
layout: 'pairs',
|
||||
caption: 'The <strong>day</strong>',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to grid, all photos, no skip — and allows an empty id placeholder', () => {
|
||||
expect(block('::album')).toEqual({ type: 'album', albumId: '', layout: 'grid' });
|
||||
expect(block('::album abc')).toEqual({ type: 'album', albumId: 'abc', layout: 'grid' });
|
||||
});
|
||||
|
||||
it('ignores malformed options', () => {
|
||||
expect(block('::album abc\ncount: many\nlayout: mosaic\nskip: -1')).toEqual({
|
||||
type: 'album',
|
||||
albumId: 'abc',
|
||||
layout: 'grid',
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips byte-stable and stays out of the asset ids', () => {
|
||||
const md = ['::album abc-123', 'count: 12', 'skip: 2', 'layout: wide', 'caption: Twelve'].join(
|
||||
'\n',
|
||||
);
|
||||
const parsed = parseJournalMarkdown(md);
|
||||
expect(serializeJournalMarkdown(parsed)).toBe(md);
|
||||
expect(serializeJournalMarkdown(parseJournalMarkdown('::album'))).toBe('::album');
|
||||
expect(collectAssetIds(parsed.blocks)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const assets = (n: number, type = 'IMAGE') =>
|
||||
Array.from({ length: n }, (_, i) => ({ id: `p${i + 1}`, type }));
|
||||
|
||||
describe('expandAlbumBlocks', () => {
|
||||
const lookup = (id: string) => (id === 'alb' ? assets(7) : undefined);
|
||||
const albumBlock = (
|
||||
extra: Partial<Extract<JournalBlock, { type: 'album' }>> = {},
|
||||
): JournalBlock => ({
|
||||
type: 'album',
|
||||
albumId: 'alb',
|
||||
layout: 'grid',
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('turns an album into one grid, keeping the surrounding blocks', () => {
|
||||
const before: JournalBlock = { type: 'heading', level: 2, text: 'Party' };
|
||||
const { blocks, albumAssetIds } = expandAlbumBlocks(
|
||||
[before, albumBlock({ caption: 'All of it' })],
|
||||
lookup,
|
||||
);
|
||||
expect(blocks).toEqual([
|
||||
before,
|
||||
{
|
||||
type: 'photo-grid',
|
||||
assetIds: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7'],
|
||||
caption: 'All of it',
|
||||
},
|
||||
]);
|
||||
expect(albumAssetIds).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7']);
|
||||
});
|
||||
|
||||
it('slices with count and skip', () => {
|
||||
const { blocks } = expandAlbumBlocks([albumBlock({ count: 3, skip: 2 })], lookup);
|
||||
expect(blocks).toEqual([{ type: 'photo-grid', assetIds: ['p3', 'p4', 'p5'] }]);
|
||||
});
|
||||
|
||||
it('lays out pairs with an odd tail and puts the caption last', () => {
|
||||
const { blocks } = expandAlbumBlocks(
|
||||
[albumBlock({ count: 5, layout: 'pairs', caption: 'c' })],
|
||||
lookup,
|
||||
);
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'photo-pair', assetIds: ['p1', 'p2'] },
|
||||
{ type: 'photo-pair', assetIds: ['p3', 'p4'] },
|
||||
{ type: 'photo', assetId: 'p5', layout: 'contained', caption: 'c' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lays out wide as one photo per row', () => {
|
||||
const { blocks } = expandAlbumBlocks([albumBlock({ count: 2, layout: 'wide' })], lookup);
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'photo', assetId: 'p1', layout: 'wide' },
|
||||
{ type: 'photo', assetId: 'p2', layout: 'wide' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to a pair or a single photo when a grid has too few', () => {
|
||||
expect(expandAlbumBlocks([albumBlock({ count: 2 })], lookup).blocks).toEqual([
|
||||
{ type: 'photo-pair', assetIds: ['p1', 'p2'] },
|
||||
]);
|
||||
expect(expandAlbumBlocks([albumBlock({ count: 1 })], lookup).blocks).toEqual([
|
||||
{ type: 'photo', assetId: 'p1', layout: 'contained' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops an empty id, an unknown album and an empty slice', () => {
|
||||
expect(expandAlbumBlocks([albumBlock({ albumId: '' })], lookup).blocks).toEqual([]);
|
||||
expect(expandAlbumBlocks([albumBlock({ albumId: 'nope' })], lookup).blocks).toEqual([]);
|
||||
expect(expandAlbumBlocks([albumBlock({ skip: 99 })], lookup).blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts the gallery manual order first, then the album order', () => {
|
||||
const { blocks } = expandAlbumBlocks([albumBlock({ count: 4 })], lookup, {
|
||||
alb: ['p6', 'p2', 'ghost'],
|
||||
});
|
||||
expect(blocks).toEqual([{ type: 'photo-grid', assetIds: ['p6', 'p2', 'p1', 'p3'] }]);
|
||||
});
|
||||
|
||||
it('keeps only images and videos', () => {
|
||||
const mixed = [...assets(2), { id: 'x', type: 'OTHER' }, { id: 'v', type: 'VIDEO' }];
|
||||
const { blocks } = expandAlbumBlocks([albumBlock()], () => mixed);
|
||||
expect(blocks).toEqual([{ type: 'photo-grid', assetIds: ['p1', 'p2', 'v'] }]);
|
||||
});
|
||||
|
||||
it('orderAlbumAssets pins first occurrences only', () => {
|
||||
expect(orderAlbumAssets(assets(3), ['p3', 'p3', 'p1']).map((a) => a.id)).toEqual([
|
||||
'p3',
|
||||
'p1',
|
||||
'p2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
86
lib/__tests__/journal-facts.test.ts
Normal file
86
lib/__tests__/journal-facts.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown, calculateReadingTime } from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
const roundTrip = (blocks: JournalBlock[]) =>
|
||||
parseJournalMarkdown(
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] }),
|
||||
);
|
||||
|
||||
describe('facts block', () => {
|
||||
it('parses a ::facts chunk into label/value items', () => {
|
||||
const parsed = parseJournalMarkdown('::facts\nDistance: 21 km\nElevation: 1,240 m');
|
||||
expect(parsed.blocks).toEqual([
|
||||
{
|
||||
type: 'facts',
|
||||
items: [
|
||||
{ label: 'Distance', value: '21 km' },
|
||||
{ label: 'Elevation', value: '1,240 m' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits at the first colon so a time keeps its value', () => {
|
||||
const parsed = parseJournalMarkdown('::facts\nStart: 08:30');
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
type: 'facts',
|
||||
items: [{ label: 'Start', value: '08:30' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores lines without a colon or with an empty side', () => {
|
||||
const parsed = parseJournalMarkdown('::facts\njust words\n: no label\nNo value:\nOk: yes');
|
||||
expect(parsed.blocks[0]).toEqual({ type: 'facts', items: [{ label: 'Ok', value: 'yes' }] });
|
||||
});
|
||||
|
||||
it('renders inline markdown in values and escapes the rest', () => {
|
||||
const parsed = parseJournalMarkdown(
|
||||
'::facts\nGuide: [Anna](https://example.com)\nNote: <b>x</b>',
|
||||
);
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
label: 'Guide',
|
||||
value: '<a href="https://example.com" target="_blank" rel="noopener noreferrer">Anna</a>',
|
||||
},
|
||||
{ label: 'Note', value: '<b>x</b>' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips, dropping rows the editor left empty', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'facts',
|
||||
items: [
|
||||
{ label: 'Distance', value: '21 km' },
|
||||
{ label: '', value: '' },
|
||||
{ label: 'Weather', value: 'clear, **cold**' },
|
||||
],
|
||||
};
|
||||
const parsed = roundTrip([block]);
|
||||
expect(parsed.blocks).toEqual([
|
||||
{
|
||||
type: 'facts',
|
||||
items: [
|
||||
{ label: 'Distance', value: '21 km' },
|
||||
{ label: 'Weather', value: 'clear, <strong>cold</strong>' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
// And the serialized form is stable from here on.
|
||||
const once = serializeJournalMarkdown(parsed);
|
||||
expect(serializeJournalMarkdown(parseJournalMarkdown(once))).toBe(once);
|
||||
expect(once).toBe('::facts\nDistance: 21 km\nWeather: clear, **cold**');
|
||||
});
|
||||
|
||||
it('is its own block between paragraphs', () => {
|
||||
const parsed = parseJournalMarkdown('Intro.\n\n::facts\nA: 1\n\nOutro.');
|
||||
expect(parsed.blocks.map((b) => b.type)).toEqual(['paragraph', 'facts', 'paragraph']);
|
||||
});
|
||||
|
||||
it('does not count the directive line as reading', () => {
|
||||
const { words } = calculateReadingTime('::facts\nDistance: 21 km');
|
||||
expect(words).toBe(3);
|
||||
});
|
||||
});
|
||||
70
lib/__tests__/journal-grid.test.ts
Normal file
70
lib/__tests__/journal-grid.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
serializeJournalMarkdown,
|
||||
collectAssetIds,
|
||||
mapBlockAssetIds,
|
||||
} from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
const roundTrip = (blocks: JournalBlock[]) =>
|
||||
parseJournalMarkdown(
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] }),
|
||||
);
|
||||
|
||||
describe('photo grid block', () => {
|
||||
it('parses three or more ids as a grid and keeps them all', () => {
|
||||
const parsed = parseJournalMarkdown('');
|
||||
expect(parsed.blocks).toEqual([
|
||||
{ type: 'photo-grid', assetIds: ['a1', 'b2', 'c3'], caption: 'Three' },
|
||||
]);
|
||||
expect(parsed.referencedAssetIds).toEqual(['a1', 'b2', 'c3']);
|
||||
});
|
||||
|
||||
it('no longer drops the third id on a save (pair used to keep two)', () => {
|
||||
const once = serializeJournalMarkdown(parseJournalMarkdown('![a1, b2, c3]()'));
|
||||
expect(once).toBe('![a1, b2, c3]()');
|
||||
});
|
||||
|
||||
it('keeps two ids a pair', () => {
|
||||
const parsed = parseJournalMarkdown('');
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'photo-pair', assetIds: ['a1', 'b2'] });
|
||||
});
|
||||
|
||||
it('round-trips five ids with a markdown caption', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'photo-grid',
|
||||
assetIds: ['a', 'b', 'c', 'd', 'e'],
|
||||
caption: 'A <strong>bold</strong> caption',
|
||||
};
|
||||
expect(roundTrip([block]).blocks).toEqual([block]);
|
||||
});
|
||||
|
||||
it('round-trips unfilled placeholders without referencing them', () => {
|
||||
const block: JournalBlock = { type: 'photo-grid', assetIds: ['', '', ''], caption: undefined };
|
||||
const parsed = roundTrip([block]);
|
||||
expect(parsed.blocks).toEqual([block]);
|
||||
expect(parsed.referencedAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('references only the filled ids of a half-filled grid, once each', () => {
|
||||
const blocks: JournalBlock[] = [
|
||||
{ type: 'photo-grid', assetIds: ['a', '', 'b', 'a'], caption: undefined },
|
||||
];
|
||||
expect(collectAssetIds(blocks)).toEqual(['a', 'b']);
|
||||
expect(roundTrip(blocks).referencedAssetIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('maps every id of a grid through the token function', () => {
|
||||
const block: JournalBlock = { type: 'photo-grid', assetIds: ['a', 'b', 'c'] };
|
||||
expect(mapBlockAssetIds(block, (id) => `t:${id}`)).toEqual({
|
||||
type: 'photo-grid',
|
||||
assetIds: ['t:a', 't:b', 't:c'],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves blocks without ids untouched when mapping', () => {
|
||||
const block: JournalBlock = { type: 'heading', level: 2, text: 'Hi' };
|
||||
expect(mapBlockAssetIds(block, () => 'x')).toBe(block);
|
||||
});
|
||||
});
|
||||
251
lib/__tests__/journal-map.test.ts
Normal file
251
lib/__tests__/journal-map.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
serializeJournalMarkdown,
|
||||
collectAssetIds,
|
||||
mapBlockAssetIds,
|
||||
} from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
import { pinsForEntry } from '../journalMap';
|
||||
import type { LocationPrecision } from '../mapPrecision';
|
||||
|
||||
const mapBlock = (md: string) => parseJournalMarkdown(md).blocks[0];
|
||||
|
||||
describe('map block syntax', () => {
|
||||
it('parses ::map with and without a caption, empty by default', () => {
|
||||
expect(mapBlock('::map')).toEqual({ type: 'map', caption: undefined, line: true, items: [] });
|
||||
expect(mapBlock('::map Busan → Seoul')).toMatchObject({ caption: 'Busan → Seoul' });
|
||||
});
|
||||
|
||||
it('does not mistake ::mapping for a map', () => {
|
||||
expect(mapBlock('::mapping things').type).toBe('paragraph');
|
||||
});
|
||||
|
||||
it('reads every line form, in order', () => {
|
||||
const block = mapBlock(
|
||||
[
|
||||
'::map The trip',
|
||||
'Busan harbour: 35.098, 129.036',
|
||||
'photo: aaa, bbb',
|
||||
'photos: all',
|
||||
'37.566, 126.978',
|
||||
'Photo: ccc',
|
||||
'line: off',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(block).toEqual({
|
||||
type: 'map',
|
||||
caption: 'The trip',
|
||||
line: false,
|
||||
items: [
|
||||
{ kind: 'point', label: 'Busan harbour', lat: 35.098, lng: 129.036 },
|
||||
{ kind: 'photo', assetId: 'aaa' },
|
||||
{ kind: 'photo', assetId: 'bbb' },
|
||||
{ kind: 'all-photos' },
|
||||
{ kind: 'point', lat: 37.566, lng: 126.978 },
|
||||
{ kind: 'photo', assetId: 'ccc' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed and out-of-range coordinates and unknown lines', () => {
|
||||
const block = mapBlock(
|
||||
[
|
||||
'::map',
|
||||
'Nowhere: 95, 10',
|
||||
'Also: 10, 200',
|
||||
'Text without numbers',
|
||||
'1.5',
|
||||
'Ok: -33.9, 151.2',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(block).toMatchObject({
|
||||
items: [{ kind: 'point', label: 'Ok', lat: -33.9, lng: 151.2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips byte-stable and never writes pins', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'map',
|
||||
caption: 'A <strong>trip</strong>',
|
||||
line: false,
|
||||
items: [
|
||||
{ kind: 'point', label: 'Start', lat: 52.51, lng: 13.47 },
|
||||
{ kind: 'photo', assetId: 'abc' },
|
||||
{ kind: 'all-photos' },
|
||||
{ kind: 'point', lat: 52.4, lng: 13.04 },
|
||||
],
|
||||
pins: [{ lat: 1, lng: 2, label: 'never' }],
|
||||
};
|
||||
const md = serializeJournalMarkdown({
|
||||
frontmatter: {},
|
||||
blocks: [block],
|
||||
referencedAssetIds: [],
|
||||
});
|
||||
expect(md).toBe(
|
||||
[
|
||||
'::map A **trip**',
|
||||
'Start: 52.51, 13.47',
|
||||
'photo: abc',
|
||||
'photos: all',
|
||||
'52.4, 13.04',
|
||||
'line: off',
|
||||
].join('\n'),
|
||||
);
|
||||
const reparsed = parseJournalMarkdown(md);
|
||||
const { pins: _pins, ...withoutPins } = block;
|
||||
expect(reparsed.blocks).toEqual([{ ...withoutPins, caption: 'A <strong>trip</strong>' }]);
|
||||
expect(serializeJournalMarkdown(reparsed)).toBe(md);
|
||||
});
|
||||
|
||||
it('drops invalid points and empty photo placeholders on save', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'map',
|
||||
line: true,
|
||||
items: [
|
||||
{ kind: 'point', lat: Number.NaN, lng: Number.NaN },
|
||||
{ kind: 'photo', assetId: '' },
|
||||
{ kind: 'point', label: 'Kept', lat: 1, lng: 2 },
|
||||
],
|
||||
};
|
||||
expect(
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks: [block], referencedAssetIds: [] }),
|
||||
).toBe('::map\nKept: 1, 2');
|
||||
});
|
||||
|
||||
it('collects and maps the ids of photo pins', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'map',
|
||||
line: true,
|
||||
items: [
|
||||
{ kind: 'photo', assetId: 'abc' },
|
||||
{ kind: 'all-photos' },
|
||||
{ kind: 'photo', assetId: '' },
|
||||
],
|
||||
};
|
||||
expect(collectAssetIds([block])).toEqual(['abc']);
|
||||
expect(mapBlockAssetIds(block, (id) => `t:${id}`)).toMatchObject({
|
||||
items: [
|
||||
{ kind: 'photo', assetId: 't:abc' },
|
||||
{ kind: 'all-photos' },
|
||||
{ kind: 'photo', assetId: 't:' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const asset = (
|
||||
id: string,
|
||||
lat: number | null,
|
||||
lng: number | null,
|
||||
city = 'Busan',
|
||||
country = 'South Korea',
|
||||
) => ({
|
||||
id,
|
||||
exifInfo: {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
city,
|
||||
country,
|
||||
} as unknown as NonNullable<import('../immich').ImmichAsset['exifInfo']>,
|
||||
});
|
||||
|
||||
describe('pinsForEntry', () => {
|
||||
const exact = (): LocationPrecision => 'exact';
|
||||
|
||||
it('publishes typed points as typed, no snapping, no label invented', () => {
|
||||
const pins = pinsForEntry(
|
||||
[
|
||||
{ kind: 'point', label: 'Harbour', lat: 35.098123, lng: 129.036456 },
|
||||
{ kind: 'point', lat: 37.5, lng: 127 },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
exact,
|
||||
);
|
||||
expect(pins).toEqual([
|
||||
{ lat: 35.098123, lng: 129.036456, label: 'Harbour' },
|
||||
{ lat: 37.5, lng: 127 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('places a photo pin on the 1 km grid with the city label', () => {
|
||||
const pins = pinsForEntry(
|
||||
[{ kind: 'photo', assetId: 'a' }],
|
||||
[asset('a', 52.510044, 13.467244, 'Berlin', 'Germany')],
|
||||
[],
|
||||
exact,
|
||||
);
|
||||
expect(pins).toEqual([{ lat: 52.51, lng: 13.47, label: 'Berlin, Germany' }]);
|
||||
});
|
||||
|
||||
it('expands photos: all in entry order, skipping ids listed explicitly', () => {
|
||||
const assets = [asset('a', 1, 1), asset('b', 2, 2), asset('c', 3, 3)];
|
||||
const pins = pinsForEntry(
|
||||
[{ kind: 'photo', assetId: 'c' }, { kind: 'all-photos' }],
|
||||
assets,
|
||||
['a', 'b', 'c'],
|
||||
exact,
|
||||
);
|
||||
expect(pins.map((p) => [p.lat, p.lng])).toEqual([
|
||||
[3, 3],
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the drawing order across mixed items', () => {
|
||||
const pins = pinsForEntry(
|
||||
[
|
||||
{ kind: 'point', label: 'Start', lat: 0.5, lng: 0.5 },
|
||||
{ kind: 'photo', assetId: 'a' },
|
||||
{ kind: 'point', label: 'End', lat: 9, lng: 9 },
|
||||
],
|
||||
[asset('a', 1, 1)],
|
||||
['a'],
|
||||
exact,
|
||||
);
|
||||
expect(pins.map((p) => p.label)).toEqual(['Start', 'Busan, South Korea', 'End']);
|
||||
});
|
||||
|
||||
it('applies precision to photo pins only: city snaps, country and hidden vanish', () => {
|
||||
const levels: Record<string, LocationPrecision> = { a: 'city', b: 'country', c: 'hidden' };
|
||||
const pins = pinsForEntry(
|
||||
[{ kind: 'all-photos' }, { kind: 'point', lat: 52.5163, lng: 13.3777 }],
|
||||
[asset('a', 52.5163, 13.3777, 'Berlin', 'Germany'), asset('b', 2, 2), asset('c', 3, 3)],
|
||||
['a', 'b', 'c'],
|
||||
(id) => levels[id],
|
||||
);
|
||||
expect(pins).toEqual([
|
||||
{ lat: 52.5, lng: 13.4, label: 'Berlin, Germany' },
|
||||
{ lat: 52.5163, lng: 13.3777 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips photos without coordinates but keeps the equator (#635)', () => {
|
||||
const pins = pinsForEntry(
|
||||
[{ kind: 'all-photos' }],
|
||||
[asset('a', null, null), asset('b', 0, 0, '', '')],
|
||||
['a', 'b'],
|
||||
exact,
|
||||
);
|
||||
expect(pins).toEqual([{ lat: 0, lng: 0 }]);
|
||||
});
|
||||
|
||||
it('merges items at one position into one pin', () => {
|
||||
const pins = pinsForEntry(
|
||||
[
|
||||
{ kind: 'point', lat: 52.51, lng: 13.47 },
|
||||
{ kind: 'photo', assetId: 'a' },
|
||||
],
|
||||
[asset('a', 52.510044, 13.467244)],
|
||||
[],
|
||||
exact,
|
||||
);
|
||||
expect(pins).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores photo items the page did not fetch', () => {
|
||||
expect(pinsForEntry([{ kind: 'photo', assetId: 'missing' }], [], [], exact)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
/**
|
||||
* serializeJournalMarkdown must be the exact inverse of the inline renderer
|
||||
@@ -89,4 +90,43 @@ describe('journal round trip (#628)', () => {
|
||||
expect(once).toContain('[Immich](https://immich.app)');
|
||||
expect(once).toContain("Anna's photos");
|
||||
});
|
||||
|
||||
it('round-trips an unfilled photo placeholder as a photo block, not text (#template placeholders)', () => {
|
||||
for (const layout of ['contained', 'wide', 'fullbleed'] as const) {
|
||||
const block: JournalBlock = { type: 'photo', assetId: '', caption: 'Pick a photo', layout };
|
||||
const serialized = serializeJournalMarkdown({
|
||||
frontmatter: {},
|
||||
blocks: [block],
|
||||
referencedAssetIds: [],
|
||||
});
|
||||
const parsed = parseJournalMarkdown(serialized);
|
||||
expect(parsed.blocks).toEqual([block]);
|
||||
expect(parsed.referencedAssetIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips an unfilled photo-pair placeholder without referencing empty ids', () => {
|
||||
const block: JournalBlock = { type: 'photo-pair', assetIds: ['', ''], caption: 'Two to pick' };
|
||||
const serialized = serializeJournalMarkdown({
|
||||
frontmatter: {},
|
||||
blocks: [block],
|
||||
referencedAssetIds: [],
|
||||
});
|
||||
const parsed = parseJournalMarkdown(serialized);
|
||||
expect(parsed.blocks).toEqual([block]);
|
||||
expect(parsed.referencedAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a filled id in a half-filled pair as the only reference', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'photo-pair',
|
||||
assetIds: ['abc123', ''],
|
||||
caption: undefined,
|
||||
};
|
||||
const parsed = parseJournalMarkdown(
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks: [block], referencedAssetIds: [] }),
|
||||
);
|
||||
expect(parsed.blocks).toEqual([block]);
|
||||
expect(parsed.referencedAssetIds).toEqual(['abc123']);
|
||||
});
|
||||
});
|
||||
|
||||
42
lib/__tests__/journal-templates.test.ts
Normal file
42
lib/__tests__/journal-templates.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
|
||||
import { JOURNAL_TEMPLATES } from '../journalTemplates';
|
||||
|
||||
describe('journal templates', () => {
|
||||
it('has at least one template and unique ids', () => {
|
||||
expect(JOURNAL_TEMPLATES.length).toBeGreaterThan(0);
|
||||
const ids = JOURNAL_TEMPLATES.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
for (const template of JOURNAL_TEMPLATES) {
|
||||
it(`round-trips "${template.id}" without losing or reshaping blocks`, () => {
|
||||
const serialized = serializeJournalMarkdown({
|
||||
frontmatter: {},
|
||||
blocks: template.blocks,
|
||||
referencedAssetIds: [],
|
||||
});
|
||||
const parsed = parseJournalMarkdown(serialized);
|
||||
expect(parsed.blocks).toEqual(template.blocks);
|
||||
// Placeholders must not surface as assets to probe or render.
|
||||
expect(parsed.referencedAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
it(`"${template.id}" never flags a placeholder as a legacy asset reference`, () => {
|
||||
for (const block of template.blocks) {
|
||||
if (block.type === 'photo') expect(block.assetId).toBe('');
|
||||
if (block.type === 'photo-pair' || block.type === 'photo-grid') {
|
||||
for (const id of block.assetIds) expect(id).toBe('');
|
||||
}
|
||||
// A facts row with an empty side is dropped on save; a template must
|
||||
// not ship rows that vanish on first load.
|
||||
if (block.type === 'facts') {
|
||||
for (const item of block.items) {
|
||||
expect(item.label).not.toBe('');
|
||||
expect(item.value).not.toBe('');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user