Ctrl K
ComunidadGuías…

server/src/rules/__tests__/forecast.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
  });
});