release: v0.17.0 (#677)

See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
Ralksta
2026-09-23 12:09:08 +02:00
committed by GitHub
parent a3a6319c29
commit 89a8fac564
78 changed files with 9485 additions and 2050 deletions

View File

@@ -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');
});
});

View File

@@ -90,6 +90,7 @@ describe('dictionary parity', () => {
'lightbox.iso',
'lightbox.copyLinkShort',
'lightbox.downloadShort',
'common.website',
'subpage.coverAria',
]);

View 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',
]);
});
});

View 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: '&lt;b&gt;x&lt;/b&gt;' },
],
});
});
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);
});
});

View 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('![a1, b2, c3](Three)');
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('![a1, b2](Two)');
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);
});
});

View 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([]);
});
});

View File

@@ -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']);
});
});

View 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('');
}
}
}
});
}
});