Files
familienarchiv/frontend/src/lib/shared/utils/documentDate.spec.ts
Marcel 8ed5b1e9e3 fix(dates): make DAY precision locale-aware in formatDocumentDate
DAY precision routed through formatDate() which hard-coded de-DE, so an
en/es reader saw the German month name ("24. Dezember 1943"). Route DAY
through Intl.DateTimeFormat(locale, …) like the other branches, keeping
the T12:00:00 UTC-safety convention. Add en/es DAY+MONTH parity cases to
docs/date-label-fixtures.json (TS-only; the Java title formatter stays
German by design) and assert them in the spec.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:19:09 +02:00

160 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { formatDocumentDate } from './documentDate';
import { m } from '$lib/paraglide/messages.js';
// ─── Shared drift-guard fixture ─────────────────────────────────────────────
// The same table is asserted by the Java DocumentTitleFormatter test so the two
// label implementations cannot drift. Expected values are the German canonical
// form (see docs/date-label-fixtures.json).
type FixtureCase = {
name: string;
precision: string;
anchor: string | null;
end: string | null;
raw: string | null;
expected: string;
};
type LocaleFixtureCase = FixtureCase & { locale: string };
const fixtures = JSON.parse(
readFileSync(resolve(process.cwd(), '../docs/date-label-fixtures.json'), 'utf-8')
) as { cases: FixtureCase[]; localeCases: LocaleFixtureCase[] };
describe('formatDocumentDate shared fixture table (de)', () => {
for (const c of fixtures.cases) {
it(c.name, () => {
expect(
formatDocumentDate(
c.anchor,
c.precision as Parameters<typeof formatDocumentDate>[1],
c.end,
c.raw,
'de'
)
).toBe(c.expected);
});
}
});
// TS-only locale parity (the Java title formatter is German-only by design, so
// localeCases are asserted here and never fed to DocumentTitleFormatterTest).
describe('formatDocumentDate shared fixture table (en/es locale parity)', () => {
for (const c of fixtures.localeCases) {
it(`${c.name} [${c.locale}]`, () => {
expect(
formatDocumentDate(
c.anchor,
c.precision as Parameters<typeof formatDocumentDate>[1],
c.end,
c.raw,
c.locale
)
).toBe(c.expected);
});
}
});
// ─── Anti-fabrication: suppressed components never leak ──────────────────────
describe('formatDocumentDate suppressed precision components', () => {
it('YEAR of a June date renders the year only, never the month', () => {
const label = formatDocumentDate('1916-06-15', 'YEAR');
expect(label).toBe('1916');
expect(label).not.toContain('Juni');
expect(label).not.toContain('15');
});
it('MONTH never renders the day-of-month', () => {
const label = formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916');
expect(label).toBe('Juni 1916');
expect(label).not.toMatch(/\b1\.\s/);
});
});
// ─── i18n: localized structured label ───────────────────────────────────────
describe('formatDocumentDate localization', () => {
it('localizes the UNKNOWN label per locale', () => {
expect(formatDocumentDate(null, 'UNKNOWN', null, '?', 'en')).toBe(
m.date_precision_unknown(undefined, { locale: 'en' })
);
});
it('localizes the APPROX prefix per locale', () => {
expect(formatDocumentDate('1920-01-01', 'APPROX', null, null, 'en')).toBe(
`${m.date_precision_approx_prefix(undefined, { locale: 'en' })} 1920`
);
});
it('localizes the SEASON word per locale when raw is absent', () => {
expect(formatDocumentDate('1916-07-01', 'SEASON', null, null, 'en')).toBe(
`${m.date_season_summer(undefined, { locale: 'en' })} 1916`
);
});
it('localizes the SEASON word even when the raw cell is verbatim German (Decision 4)', () => {
expect(formatDocumentDate('1916-06-01', 'SEASON', null, 'Sommer 1916', 'en')).toBe(
`${m.date_season_summer(undefined, { locale: 'en' })} 1916`
);
});
// DAY precision must honour the active locale (regression: it was hard-wired
// to de-DE, so an English/Spanish reader saw "24. Dezember 1943").
it('localizes the DAY month name in English', () => {
expect(formatDocumentDate('1943-12-24', 'DAY', null, null, 'en')).toBe(
new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long', year: 'numeric' }).format(
new Date('1943-12-24T12:00:00')
)
);
});
it('localizes the DAY month name in Spanish', () => {
expect(formatDocumentDate('1943-12-24', 'DAY', null, null, 'es')).toBe(
new Intl.DateTimeFormat('es', { day: 'numeric', month: 'long', year: 'numeric' }).format(
new Date('1943-12-24T12:00:00')
)
);
});
it('localizes the MONTH month name in English', () => {
expect(formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916', 'en')).toBe(
new Intl.DateTimeFormat('en', { month: 'long', year: 'numeric' }).format(
new Date('1916-06-01T12:00:00')
)
);
});
it('localizes the MONTH month name in Spanish', () => {
expect(formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916', 'es')).toBe(
new Intl.DateTimeFormat('es', { month: 'long', year: 'numeric' }).format(
new Date('1916-06-01T12:00:00')
)
);
});
});
// ─── Security: untrusted raw must never influence the structured label ───────
describe('formatDocumentDate security', () => {
it('ignores a malicious raw value for the structured label (raw is rendered separately, escaped)', () => {
const label = formatDocumentDate(null, 'UNKNOWN', null, '<img src=x onerror=alert(1)>');
expect(label).toBe('Datum unbekannt');
expect(label).not.toContain('<img');
});
});
// ─── Defensive null handling ─────────────────────────────────────────────────
describe('formatDocumentDate defensive null handling', () => {
it('renders the unknown label when the anchor is null but precision is not UNKNOWN', () => {
expect(formatDocumentDate(null, 'DAY')).toBe('Datum unbekannt');
});
it('falls back to start-day only for a RANGE whose end is null', () => {
expect(formatDocumentDate('1917-01-10', 'RANGE', null)).toBe('ab 10. Jan. 1917');
});
});