import { describe, it, expect } from 'vitest'; import { bytesFreed, forecastItem, prepareRules, type ForecastOptions } from '../forecast'; import type { MediaItem, Rule } from '../../types'; const NOW = new Date('2026-10-03T12:00:00Z'); const daysAgo = (n: number) => new Date(NOW.getTime() - n * 86_400_000).toISOString(); const item = (overrides: Partial = {}): MediaItem => ({ id: 1, title: 'Arrival', type: 'movie', plex_id: '100', status: 'monitored', play_count: 0, file_size: 10 * 1024 ** 3, added_at: daysAgo(300), last_watched_at: null, is_protected: false, library_key: '1', ...overrides, }) as MediaItem; let nextId = 1; const rule = (root: object, overrides: Partial = {}): Rule => ({ id: nextId++, name: `Rule ${nextId}`, action: 'delete', enabled: true, media_type: 'all', library_keys: [], priority: 0, grace_period_days: 7, deletion_action: 'unmonitor_and_delete', conditions: JSON.stringify({ version: 2, root }), ...overrides, }) as unknown as Rule; const and = (...children: object[]) => ({ kind: 'group', logic: 'AND', children }); const leaf = (field: string, operator: string, value: unknown, params?: object) => ({ kind: 'condition', field, operator, value, ...(params ? { params } : {}), }); const opts = (overrides: Partial = {}): ForecastOptions => ({ now: NOW, horizonDays: 730, ctx: { now: NOW, watchLookup: new Map(), showWatchLookup: new Map() }, inProgress: { protect: true, recentDays: 60 }, ...overrides, }); describe('forecastItem', () => { it('finds the day an age condition is crossed', () => { const rules = prepareRules([rule(and(leaf('days_since_added', 'greater_than', 365)))]); const match = forecastItem(item(), rules, opts()); // Added 300 days ago; "more than 365" first holds at 366 days, 66 days from now. expect(match?.dayOffset).toBe(66); expect(match?.certainty).toBe('predictable'); expect(match?.reasons[0]).toMatchObject({ field: 'days_since_added', actual: 366 }); }); it('matches today when the rule already holds', () => { const rules = prepareRules([rule(and(leaf('days_since_added', 'greater_than', 30)))]); expect(forecastItem(item(), rules, opts())?.dayOffset).toBe(0); }); it('lets a later delete rule through once a notify rule stops matching', () => { // Notify leaves the item monitored, so the next scan checks it again. const rules = prepareRules([ rule(and(leaf('days_since_added', 'less_than', 30)), { action: 'notify' } as Partial), rule(and(leaf('days_since_added', 'greater_than', 90))), ]); const match = forecastItem(item({ added_at: daysAgo(10) }), rules, opts()); expect(match?.rule.action).toBe('delete'); expect(match?.dayOffset).toBe(81); }); it('stops at a flag rule, which takes the item out of monitoring', () => { const rules = prepareRules([ rule(and(leaf('days_since_added', 'greater_than', 30)), { action: 'flag' } as Partial), rule(and(leaf('days_since_added', 'greater_than', 90))), ]); expect(forecastItem(item(), rules, opts())?.rule.action).toBe('flag'); }); it('returns null when nothing matches inside the horizon', () => { const rules = prepareRules([rule(and(leaf('days_since_added', 'greater_than', 5000)))]); expect(forecastItem(item(), rules, opts())).toBeNull(); }); it('calls anything that watching could change conditional', () => { const rules = prepareRules([ rule(and(leaf('play_count', 'equals', 0), leaf('days_since_added', 'greater_than', 365))), ]); expect(forecastItem(item(), rules, opts())?.certainty).toBe('conditional'); }); it('follows days since watched', () => { const rules = prepareRules([rule(and(leaf('days_since_watched', 'greater_than', 180)))]); const match = forecastItem(item({ last_watched_at: daysAgo(170), play_count: 1 }), rules, opts()); expect(match?.dayOffset).toBe(11); }); it('follows a person’s last watch', () => { const rules = prepareRules([ rule(and(leaf('watched_by_user', 'not_watched_since', null, { username: 'dan', days: 30 }))), ]); const ctx = { now: NOW, watchLookup: new Map([['100', new Map([['dan', new Date(daysAgo(20))]])]]), showWatchLookup: new Map(), }; const match = forecastItem(item({ play_count: 1 }), rules, opts({ ctx })); expect(match?.dayOffset).toBeGreaterThanOrEqual(10); expect(match?.dayOffset).toBeLessThanOrEqual(11); }); it('gives the item to the first rule by priority on the day it first matches', () => { const later = rule(and(leaf('days_since_added', 'greater_than', 400)), { name: 'High priority, later' }); const sooner = rule(and(leaf('days_since_added', 'greater_than', 320)), { name: 'Low priority, sooner' }); // Rules arrive sorted by priority, highest first, as the scan reads them. const match = forecastItem(item(), prepareRules([later, sooner]), opts()); expect(match?.rule.name).toBe('Low priority, sooner'); expect(match?.dayOffset).toBe(21); const both = rule(and(leaf('days_since_added', 'greater_than', 320)), { name: 'High priority, same day' }); expect(forecastItem(item(), prepareRules([both, sooner]), opts())?.rule.name).toBe('High priority, same day'); }); it('respects the rule’s scope', () => { const tvOnly = rule(and(leaf('days_since_added', 'greater_than', 30)), { media_type: 'show' } as Partial); expect(forecastItem(item(), prepareRules([tvOnly]), opts())).toBeNull(); }); it('waits for an in-progress show to stall', () => { const show = item({ type: 'show', episode_count: 10, watched_episode_count: 4, last_watched_at: daysAgo(20), play_count: 4, }); const rules = prepareRules([rule(and(leaf('days_since_added', 'greater_than', 30)))]); const match = forecastItem(show, rules, opts()); // Watched 20 days ago; protection lifts once that's more than 60 days ago. expect(match?.dayOffset).toBe(41); expect(match?.certainty).toBe('conditional'); const unprotected = forecastItem(show, rules, opts({ inProgress: { protect: false, recentDays: 60 } })); expect(unprotected?.dayOffset).toBe(0); }); }); describe('forecast reasons', () => { const group = (logic: string, ...children: object[]) => ({ kind: 'group', logic, children }); it('explains an ANY rule by the branch that matched', () => { const rules = prepareRules([ rule(group('OR', leaf('size_gb', 'greater_than', 50), leaf('days_since_added', 'greater_than', 30))), ]); const match = forecastItem(item(), rules, opts()); expect(match?.reasons.map((r) => r.field)).toEqual(['days_since_added']); }); it('shows a NONE rule’s conditions as negated', () => { const rules = prepareRules([ rule(and(leaf('days_since_added', 'greater_than', 30), group('NOT', leaf('requested_by', 'equals', 'alice')))), ]); const match = forecastItem(item({ requested_by: 'bob' } as Partial), rules, opts()); expect(match?.reasons).toMatchObject([ { field: 'days_since_added' }, { field: 'requested_by', value: 'alice', negated: true }, ]); }); it('judges certainty on the conditions that matched', () => { const rules = prepareRules([ rule(group('OR', leaf('play_count', 'greater_than', 100), leaf('days_since_added', 'greater_than', 30))), ]); expect(forecastItem(item(), rules, opts())?.certainty).toBe('predictable'); }); }); describe('bytesFreed', () => { it('frees nothing when the rule only unmonitors', () => { expect(bytesFreed({ file_size: 100 }, 'unmonitor_only')).toBe(0); expect(bytesFreed({ file_size: 100 }, 'unmonitor_and_delete')).toBe(100); expect(bytesFreed({ file_size: null } as never, 'delete')).toBe(0); }); });