import { describe, expect, it } from 'vitest'; import { evaluateAssetAlert, evaluateCategoryAlert, evaluateIndexAlert, inCooldown, inQuietHours, targetHit, type AlertRow, type AssetState } from './evaluate.js'; const alert = (over: Partial): AlertRow => ({ id: 'a', userId: 'u', alertType: 'price_below', targetType: 'asset', targetId: 'rare_1', threshold: 1000, active: true, lastTriggeredAt: null, cooldownMinutes: 1440, name: null, channel: 'both', ...over }); const state = (over: Partial): AssetState => ({ title: 'Charizard', slug: 'charizard', rivUsd: 900, rivConfidence: 0.8, rivSampleSize: 12, athUsd: 5000, latestSaleUsd: 950, latestSaleAt: new Date(), sales30d: 4, baselineSales30d: 2, newListings: [], newAuctionLots: [], endingLots: [], newRecordSale: null, populationChange: null, ...over }); describe('alert evaluation', () => { it('price thresholds', () => { expect(evaluateAssetAlert(alert({}), state({}))?.title).toContain('below'); expect(evaluateAssetAlert(alert({}), state({ rivUsd: 1200 }))).toBeNull(); expect(evaluateAssetAlert(alert({ alertType: 'price_above', threshold: 800 }), state({}))?.title).toContain('above'); expect(evaluateAssetAlert(alert({}), state({ rivUsd: null }))).toBeNull(); }); it('respects cooldown and inactive', () => { const now = new Date(); expect(inCooldown(alert({ lastTriggeredAt: new Date(now.getTime() - 60_000) }), now)).toBe(true); expect(inCooldown(alert({ lastTriggeredAt: new Date(now.getTime() - 2 * 86_400_000) }), now)).toBe(false); expect(evaluateAssetAlert(alert({ active: false }), state({}))).toBeNull(); }); it('listings, records, volume, population', () => { expect(evaluateAssetAlert(alert({ alertType: 'new_listing' }), state({ newListings: [{ id: 'l', priceUsd: 700, sourceId: 'ebay', firstSeenAt: new Date() }] }))?.facts).toContainEqual(['Lowest ask', '$700.00']); expect(evaluateAssetAlert(alert({ alertType: 'record_sale' }), state({ newRecordSale: { priceUsd: 6000, saleDate: new Date('2026-01-01'), sourceId: 'goldin' } }))?.title).toContain('$6,000'); expect(evaluateAssetAlert(alert({ alertType: 'unusual_volume', threshold: 50 }), state({ sales30d: 6, baselineSales30d: 2 }))?.title).toContain('+200.0%'); expect(evaluateAssetAlert(alert({ alertType: 'unusual_volume', threshold: 50 }), state({ sales30d: 2, baselineSales30d: 2 }))).toBeNull(); expect(evaluateAssetAlert(alert({ alertType: 'population_update' }), state({ populationChange: { grader: 'psa', from: 100, to: 120, date: '2026-09-01' } }))?.title).toContain('100 → 120'); const soon = new Date(Date.now() + 3600_000); expect(evaluateAssetAlert(alert({ alertType: 'auction_ending' }), state({ endingLots: [{ id: 'x', title: 'Lot 1', endsAt: soon, auctionHouse: 'Heritage' }] }))?.title).toContain('ends'); }); it('category and index alerts', () => { expect(evaluateCategoryAlert(alert({ alertType: 'market_move', targetType: 'category', threshold: 3 }), { name: 'Pokémon', slug: 'pokemon', change1d: 0.045, newRecordSale: null, radarFindings: [], newAuctionLots: 0, endingLots: 0, sales30d: 0, baselineSales30d: null })?.title).toContain('+4.5%'); expect(evaluateCategoryAlert(alert({ alertType: 'market_move', targetType: 'category', threshold: 5 }), { name: 'Pokémon', slug: 'pokemon', change1d: 0.045, newRecordSale: null, radarFindings: [], newAuctionLots: 0, endingLots: 0, sales30d: 0, baselineSales30d: null })).toBeNull(); expect(evaluateIndexAlert(alert({ alertType: 'market_move', targetType: 'index', threshold: 2 }), { ticker: 'RARE', name: 'RareIndex', change1d: -0.03, value: 1042 })?.title).toContain('-3.0%'); }); it('quiet hours and targets', () => { expect(inQuietHours({ quietStart: 22, quietEnd: 7 }, new Date('2026-01-01T23:00:00Z'))).toBe(true); expect(inQuietHours({ quietStart: 22, quietEnd: 7 }, new Date('2026-01-01T12:00:00Z'))).toBe(false); expect(inQuietHours({ quietStart: 9, quietEnd: 17 }, new Date('2026-01-01T12:00:00Z'))).toBe(true); expect(inQuietHours({}, new Date())).toBe(false); expect(targetHit('below', 1000, 900)).toBe(true); expect(targetHit('above', 1000, 900)).toBe(false); expect(targetHit('above', 1000, null)).toBe(false); }); });