import { describe, expect, it } from 'vitest'; import { tenderFingerprint, valueBucketsContradict } from './tender-fingerprint'; /** * SCHEMA-03 (D-04) — pure fingerprint function, v2 formula (WINDOWS.md #11). * v2 hashes only [buyerName, title, deadlineAt]. CPV and estimatedValue are * NOT hash inputs anymore — see the file comment on tender-fingerprint.ts * for why and for the three limits this plan deliberately leaves in place. */ describe('tenderFingerprint', () => { it('is the Kernregression this plan exists for: a DOE-shaped record and a scraper-shaped record of the same tender collide', () => { const doeShaped = { buyerName: 'Stadt München', title: 'Neubau einer Kindertagesstätte', cpvDivisions: ['45'], deadlineAt: new Date('2026-06-01T00:00:00Z'), estimatedValue: 387291, }; const scraperShaped = { buyerName: 'Stadt München', title: 'Neubau einer Kindertagesstätte', cpvDivisions: [] as string[], deadlineAt: new Date('2026-06-01T00:00:00Z'), estimatedValue: null as number | null, }; // Extra fields (cpvDivisions, estimatedValue) prove they can't reach // the hash: they're passed straight through as typed variables, the // way the resolver calls this function with its wider normalized // record — TS excess-property checking only fires on object literals. expect(tenderFingerprint(doeShaped)).toBe(tenderFingerprint(scraperShaped)); }); it('differs for a different title (collision guard, Pitfall 1)', () => { const base = { buyerName: 'Stadt München', title: 'Neubau einer Kindertagesstätte', deadlineAt: null }; const a = tenderFingerprint(base); const b = tenderFingerprint({ ...base, title: 'Sanierung eines Kindergartens' }); expect(a).not.toBe(b); }); it('normalizes umlauts so "München" and "Muenchen" contribute the same text', () => { const base = { title: 'Neubau einer Kindertagesstätte', deadlineAt: null }; const a = tenderFingerprint({ ...base, buyerName: 'Stadt München' }); const b = tenderFingerprint({ ...base, buyerName: 'Stadt Muenchen' }); expect(a).toBe(b); }); it('collapses two deadlines on the same day to the same fingerprint, and separates two different days', () => { const base = { buyerName: 'Stadt München', title: 'Neubau einer Kindertagesstätte' }; const sameDayMorning = tenderFingerprint({ ...base, deadlineAt: new Date('2026-06-01T08:00:00Z') }); const sameDayEvening = tenderFingerprint({ ...base, deadlineAt: new Date('2026-06-01T20:00:00Z') }); const otherDay = tenderFingerprint({ ...base, deadlineAt: new Date('2026-06-02T08:00:00Z') }); expect(sameDayMorning).toBe(sameDayEvening); expect(sameDayMorning).not.toBe(otherDay); }); it('treats a null buyerName as an empty segment, not a crash', () => { const args = { buyerName: null as string | null, title: 'Neubau einer Kindertagesstätte', deadlineAt: null }; expect(() => tenderFingerprint(args)).not.toThrow(); const a = tenderFingerprint(args); const b = tenderFingerprint(args); expect(a).toBe(b); }); it('returns "v2:" followed by 64 hex characters', () => { const result = tenderFingerprint({ buyerName: 'Stadt München', title: 'Neubau einer Kindertagesstätte', deadlineAt: null, }); expect(result).toMatch(/^v2:[a-f0-9]{64}$/); }); it('gold value: nails the segment count and order — a fourth (even empty) segment or a reordering turns this red', () => { // Hand-computed independently of production code from the canonical // string "stadt musterstadt|neubau turnhalle|2026-03-04" — not built // with normText/deadlineKey. const result = tenderFingerprint({ buyerName: 'Stadt Musterstadt', title: 'Neubau Turnhalle', deadlineAt: new Date('2026-03-04T09:00:00Z'), }); expect(result).toBe( 'v2:6104874e683082d4e656e5585947629dcb8c667eb77888c456020ce86993be34', ); }); }); describe('valueBucketsContradict', () => { it('is false when the left side is null', () => { expect(valueBucketsContradict(null, 5000)).toBe(false); }); it('is false when the right side is null', () => { expect(valueBucketsContradict(5000, null)).toBe(false); }); it('is false when both sides are null', () => { expect(valueBucketsContradict(null, null)).toBe(false); }); it('is false for two values of the same order of magnitude', () => { expect(valueBucketsContradict(12000, 15000)).toBe(false); }); it('is true for two values across a magnitude boundary', () => { expect(valueBucketsContradict(9000, 90000)).toBe(true); }); });