cb23e3bc78
Die dritte Dedup-Stufe hashte bisher fuenf Segmente, darunter CPV-Division und geschaetzten Wert — beide werden von Nicht-DOE-Quellen systematisch nie geliefert, wodurch dieselbe Ausschreibung aus DOE und aus einem Scraper nie denselben Fingerabdruck ergab (WINDOWS.md #11). - tenderFingerprint() nimmt nur noch buyerName, title, deadlineAt entgegen - Versionsmarke FINGERPRINT_VERSION_PREFIX ("v2:") vor dem Hash, damit Task 3 veraltete Zeilen erkennen kann - valueBucketsContradict() als eigene, exportierte Funktion fuer den Wert-Veto in Task 2 — kein Hash-Bestandteil mehr - backfill-tender-source.ts an die neue Signatur angepasst - Testsuite auf das neue Verhalten umgeschrieben inkl. Goldwert-Test Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
109 lines
4.6 KiB
TypeScript
109 lines
4.6 KiB
TypeScript
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);
|
|
});
|
|
});
|