import { describe, expect, it } from 'vitest'; import { validateStructuredOutput } from '../../src/document-workbench/structured-output-validation'; const sample = { fields: [ { name: 'name', type: 'string' as const, required: true }, { name: 'count', type: 'integer' as const, required: true }, ], value: { name: 'Acme', count: 12 }, sources: [{ id: 'page-1', text: 'Name: Acme\nCount: 12' }], evidence: [ { field: 'name', sourceId: 'page-1', start: 6, end: 10, transform: 'exact' as const }, { field: 'count', sourceId: 'page-1', start: 18, end: 20, transform: 'integer' as const }, ], }; describe('structured output mechanical validation', () => { it('accepts typed fields with matching source ranges', () => { expect(validateStructuredOutput(sample).status).toBe('mechanically_validated'); }); it('does not accept invented, mistyped or extra values', () => { const result = validateStructuredOutput({ ...sample, value: { name: 'Other', count: '12', extra: 1 } }); expect(result).toMatchObject({ status: 'needs_review', issues: expect.arrayContaining([ { field: 'name', code: 'evidence_mismatch' }, { field: 'count', code: 'invalid_type' }, { field: 'extra', code: 'unexpected_field' }, ]), }); }); it('rejects absent, duplicate or out-of-range evidence and ambiguous sources', () => { expect(validateStructuredOutput({ ...sample, evidence: [] }).status).toBe('needs_review'); expect( validateStructuredOutput({ ...sample, evidence: [...sample.evidence, sample.evidence[0]!] }).status, ).toBe('needs_review'); expect( validateStructuredOutput({ ...sample, evidence: [{ ...sample.evidence[0]!, end: 999 }, sample.evidence[1]!], }).status, ).toBe('needs_review'); expect( validateStructuredOutput({ ...sample, sources: [...sample.sources, sample.sources[0]!] }).status, ).toBe('needs_review'); }); it('refuses unsafe integers and absent required fields', () => { expect( validateStructuredOutput({ ...sample, value: { name: 'Acme', count: Number.MAX_SAFE_INTEGER + 1 } }) .status, ).toBe('needs_review'); expect( validateStructuredOutput({ ...sample, value: { name: 'Acme' }, evidence: [sample.evidence[0]!] }) .status, ).toBe('needs_review'); }); it('preserves exact decimal precision and accepts real ISO calendar dates', () => { const text = '9007199254740993.20 2024-02-29'; const input = { fields: [ { name: 'amount', type: 'decimal' as const, required: true }, { name: 'date', type: 'date' as const, required: true }, ], value: { amount: '9007199254740993.20', date: '2024-02-29' }, sources: [{ id: 'p', text }], evidence: [ { field: 'amount', sourceId: 'p', start: 0, end: 19, transform: 'decimal' as const }, { field: 'date', sourceId: 'p', start: 20, end: 30, transform: 'date' as const }, ], }; expect(validateStructuredOutput(input).status).toBe('mechanically_validated'); expect( validateStructuredOutput({ ...input, value: { ...input.value, amount: 9007199254740994 } }).status, ).toBe('needs_review'); expect( validateStructuredOutput({ ...input, value: { ...input.value, amount: '9007199254740993.2' } }).status, ).toBe('needs_review'); }); it.each(['2023-02-29', '1900-02-29', '2024-13-01', '2024-04-31', '0000-01-01'])( 'rejects invalid date %s even if present in the source', (text) => { expect( validateStructuredOutput({ fields: [{ name: 'date', type: 'date', required: true }], value: { date: text }, sources: [{ id: 'p', text }], evidence: [{ field: 'date', sourceId: 'p', start: 0, end: text.length, transform: 'date' }], }).status, ).toBe('needs_review'); }, ); it.each(['01.0', '1e3', '1,25', '+1.0', '.5'])( 'rejects ambiguous decimal %s rather than guessing', (text) => { expect( validateStructuredOutput({ fields: [{ name: 'amount', type: 'decimal', required: true }], value: { amount: text }, sources: [{ id: 'p', text }], evidence: [{ field: 'amount', sourceId: 'p', start: 0, end: text.length, transform: 'decimal' }], }).status, ).toBe('needs_review'); }, ); }); describe('locale-aware evidence (decimal + date)', () => { const fields = [ { name: 'amount', type: 'decimal' as const, required: true }, { name: 'due', type: 'date' as const, required: true }, ]; const sources = [{ id: 's1', text: 'Belopp: 12 500,00 SEK\nFörfallodatum: 14/10/2026' }]; const span = (needle: string) => { const start = sources[0]!.text.indexOf(needle); return { start, end: start + needle.length }; }; it('accepts an unambiguous locale amount and day-first date', () => { const out = validateStructuredOutput({ fields, sources, value: { amount: '12500.00', due: '2026-10-14' }, evidence: [ { field: 'amount', sourceId: 's1', ...span('12 500,00'), transform: 'decimal' }, { field: 'due', sourceId: 's1', ...span('14/10/2026'), transform: 'date' }, ], }); expect(out.status).toBe('mechanically_validated'); }); it('refuses a date that is ambiguous between day-first and month-first', () => { const ambiguous = [{ id: 's1', text: 'Datum: 01/02/2026' }]; const start = ambiguous[0]!.text.indexOf('01/02/2026'); const out = validateStructuredOutput({ fields: [{ name: 'due', type: 'date', required: true }], sources: ambiguous, value: { due: '2026-02-01' }, evidence: [{ field: 'due', sourceId: 's1', start, end: start + 10, transform: 'date' }], }); expect(out.status).toBe('needs_review'); }); });