import { describe, it, expect } from 'vitest'; import { detectChanges, shouldTriggerAlert, determineSeverity, computeChangeConfidence, } from '../src/change-detector'; import { PropertySnapshot, PropertyChange, DetectionConfig } from '../src/types'; const baselineSnapshot: PropertySnapshot = { id: 'snap-1', propertyId: 'prop-001', capturedAt: '2026-01-01T00:00:00Z', ownerName: 'John Doe', address: { streetNumber: '123', streetName: 'main', streetType: 'st', city: 'springfield', state: 'IL', zip: '62701', }, deedDate: '2020-03-15', taxId: 'tax-123', propertyType: 'residential', taxAmount: 2500, lienCount: 0, }; describe('detectChanges', () => { it('detects ownership transfer via name change', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', ownerName: 'Jane Smith', }; const result = detectChanges(baselineSnapshot, current); expect(result.changeType).toBe('ownership_transfer'); expect(result.severity).toBe('critical'); expect(result.changes.some(c => c.field === 'ownerName')).toBe(true); }); it('detects deed change via deed date update', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', deedDate: '2026-01-15', }; const result = detectChanges(baselineSnapshot, current); expect(result.changes.some(c => c.changeType === 'deed_change')).toBe(true); expect(result.severity).toBe('warning'); }); it('detects tax change', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', taxAmount: 3200, }; const result = detectChanges(baselineSnapshot, current); expect(result.changes.some(c => c.changeType === 'tax_change')).toBe(true); expect(result.severity).toBe('info'); }); it('detects lien filing when lien count increases', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', lienCount: 1, }; const result = detectChanges(baselineSnapshot, current); expect(result.changes.some(c => c.changeType === 'lien_filing')).toBe(true); expect(result.severity).toBe('warning'); }); it('detects multiple changes with highest severity', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', ownerName: 'Jane Smith', deedDate: '2026-01-15', taxAmount: 3200, }; const result = detectChanges(baselineSnapshot, current); expect(result.severity).toBe('critical'); expect(result.changes.length).toBeGreaterThanOrEqual(3); }); it('returns no changes for identical snapshots', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z' }; const result = detectChanges(baselineSnapshot, current); expect(result.changes.length).toBe(0); expect(result.severity).toBe('info'); }); it('detects address changes as metadata changes', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', address: { ...baselineSnapshot.address, streetNumber: '125', }, }; const result = detectChanges(baselineSnapshot, current); expect(result.changes.some(c => c.field === 'address.streetNumber')).toBe(true); }); it('detects tax ID change as deed change', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', taxId: 'tax-456', }; const result = detectChanges(baselineSnapshot, current); expect(result.changes.some(c => c.changeType === 'deed_change')).toBe(true); }); it('respects configurable ownership threshold', () => { const config: DetectionConfig = { ownershipNameThreshold: 0.5, deedDateSensitivity: 0.9, taxAmountChangePercent: 15, }; const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', ownerName: 'Jon Doe', }; const result = detectChanges(baselineSnapshot, current, config); expect(result.changes.some(c => c.field === 'ownerName')).toBe(true); }); it('populates previous and current snapshots in result', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', ownerName: 'Jane Smith', }; const result = detectChanges(baselineSnapshot, current); expect(result.previousSnapshot).toBe(baselineSnapshot); expect(result.currentSnapshot).toBe(current); }); it('includes detectedAt timestamp', () => { const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', ownerName: 'Jane Smith', }; const result = detectChanges(baselineSnapshot, current); expect(result.detectedAt).toBeDefined(); expect(new Date(result.detectedAt).getTime()).toBeGreaterThan(0); }); }); describe('shouldTriggerAlert', () => { it('triggers for critical severity above default threshold', () => { const result = { propertyId: 'prop-001', changeType: 'ownership_transfer' as const, severity: 'critical' as const, confidence: 0.95, changes: [], previousSnapshot: baselineSnapshot, currentSnapshot: baselineSnapshot, detectedAt: new Date().toISOString(), }; expect(shouldTriggerAlert(result)).toBe(true); }); it('triggers for warning severity with high confidence', () => { const result = { propertyId: 'prop-001', changeType: 'deed_change' as const, severity: 'warning' as const, confidence: 0.85, changes: [], previousSnapshot: baselineSnapshot, currentSnapshot: baselineSnapshot, detectedAt: new Date().toISOString(), }; expect(shouldTriggerAlert(result)).toBe(true); }); it('does not trigger for info severity with default threshold', () => { const result = { propertyId: 'prop-001', changeType: 'tax_change' as const, severity: 'info' as const, confidence: 0.85, changes: [], previousSnapshot: baselineSnapshot, currentSnapshot: baselineSnapshot, detectedAt: new Date().toISOString(), }; expect(shouldTriggerAlert(result)).toBe(false); }); it('does not trigger when confidence below 0.7', () => { const result = { propertyId: 'prop-001', changeType: 'deed_change' as const, severity: 'warning' as const, confidence: 0.5, changes: [], previousSnapshot: baselineSnapshot, currentSnapshot: baselineSnapshot, detectedAt: new Date().toISOString(), }; expect(shouldTriggerAlert(result)).toBe(false); }); it('triggers info when minSeverity set to info', () => { const result = { propertyId: 'prop-001', changeType: 'tax_change' as const, severity: 'info' as const, confidence: 0.85, changes: [], previousSnapshot: baselineSnapshot, currentSnapshot: baselineSnapshot, detectedAt: new Date().toISOString(), }; expect(shouldTriggerAlert(result, 'info')).toBe(true); }); }); describe('determineSeverity', () => { it('returns major when ownership transfer present', () => { const changes: PropertyChange[] = [ { field: 'ownerName', oldValue: 'John', newValue: 'Jane', changeType: 'ownership_transfer' }, ]; expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('critical'); }); it('returns warning when only deed change', () => { const changes: PropertyChange[] = [ { field: 'deedDate', oldValue: '2020-01-01', newValue: '2026-01-01', changeType: 'deed_change' }, ]; expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('warning'); }); it('returns info when only metadata changes', () => { const changes: PropertyChange[] = [ { field: 'propertyType', oldValue: 'residential', newValue: 'commercial', changeType: 'metadata_change' }, ]; expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('info'); }); it('respects severity overrides', () => { const changes: PropertyChange[] = [ { field: 'taxAmount', oldValue: 1000, newValue: 2000, changeType: 'tax_change' }, ]; const config: DetectionConfig = { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15, severityOverrides: { tax_change: 'warning' }, }; expect(determineSeverity(changes, config)).toBe('warning'); }); }); describe('computeChangeConfidence', () => { it('returns 0 for empty changes', () => { expect(computeChangeConfidence([], { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe(0); }); it('returns high confidence for ownership transfer', () => { const changes: PropertyChange[] = [ { field: 'ownerName', oldValue: 'John', newValue: 'Jane', changeType: 'ownership_transfer' }, ]; const conf = computeChangeConfidence(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 }); expect(conf).toBeCloseTo(0.95, 2); }); it('returns high confidence for lien filing', () => { const changes: PropertyChange[] = [ { field: 'lienCount', oldValue: 0, newValue: 1, changeType: 'lien_filing' }, ]; const conf = computeChangeConfidence(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 }); expect(conf).toBeCloseTo(0.9, 2); }); it('averages confidence across multiple changes', () => { const changes: PropertyChange[] = [ { field: 'ownerName', oldValue: 'John', newValue: 'Jane', changeType: 'ownership_transfer' }, { field: 'taxAmount', oldValue: 1000, newValue: 2000, changeType: 'tax_change' }, ]; const conf = computeChangeConfidence(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 }); expect(conf).toBeGreaterThan(0.7); expect(conf).toBeLessThan(1.0); }); });