server/src/rules/__tests__/forecast.test.ts
Rama main · réplica de lectura del repositorio original.
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> = {}): 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> = {}): 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> = {}): 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>),
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>),
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<Rule>);
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<MediaItem>), 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);
});
});