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 trip',
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 trip' }]);
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,
});
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 = { 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([]);
});
});