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>
160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
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');
|
||
});
|
||
});
|