server/src/rules/__tests__/regression.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from 'vitest';
import { upgradeToV2 } from '../migration';
import { evaluateNode } from '../conditions';
import type { MediaItem } from '../../types';
function item(overrides: Partial<MediaItem> = {}): MediaItem {
const daysAgo = (d: number) => new Date(Date.now() - d * 86400000).toISOString();
return {
id: 1,
type: 'movie',
title: 'Sample',
plex_id: null,
sonarr_id: null,
radarr_id: null,
tmdb_id: null,
imdb_id: null,
tvdb_id: null,
year: 2015,
poster_url: null,
file_path: null,
file_size: 10 * 1024 * 1024 * 1024,
resolution: '1080p',
codec: 'h264',
added_at: daysAgo(60),
last_watched_at: null,
play_count: 0,
watched_by: null,
status: 'monitored',
marked_at: null,
delete_after: null,
deleted_at: null,
is_protected: false,
protection_reason: null,
genres: null,
tags: null,
studio: null,
audio_codec: null,
video_codec: null,
hdr: null,
bitrate: null,
runtime_minutes: null,
season_count: null,
episode_count: null,
watched_episode_count: null,
episode_progress: null,
series_status: null,
rating_imdb: null,
rating_tmdb: null,
rating_rt: null,
content_rating: null,
original_language: null,
requested_by: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}
/**
* Legacy flat AND evaluator — mimics the v1 code path in scheduler/tasks.ts.
*/
function legacyEvaluate(
i: MediaItem,
conditions: Array<{ field: string; operator: string; value: any }>
): boolean {
if (conditions.length === 0) return false;
for (const c of conditions) {
const fv = legacyField(i, c.field);
let m = false;
switch (c.operator) {
case 'equals':
m = fv === c.value;
break;
case 'not_equals':
m = fv !== c.value;
break;
case 'greater_than':
m = typeof fv === 'number' && fv > c.value;
break;
case 'less_than':
m = typeof fv === 'number' && fv < c.value;
break;
}
if (!m) return false;
}
return true;
}
function legacyField(i: MediaItem, field: string): unknown {
if (field === 'days_since_added')
return i.added_at
? Math.floor((Date.now() - new Date(i.added_at).getTime()) / 86400000)
: null;
if (field === 'days_since_watched')
return i.last_watched_at
? Math.floor((Date.now() - new Date(i.last_watched_at).getTime()) / 86400000)
: null;
if (field === 'size_gb') return i.file_size ? i.file_size / (1024 ** 3) : null;
return (i as any)[field];
}
describe('v1 → v2 regression: legacy rules evaluate identically after upgrade', () => {
const items = [
item({ id: 1, play_count: 0, year: 2010 }),
item({ id: 2, play_count: 5, year: 1995 }),
item({ id: 3, play_count: 0, year: 2023 }),
item({ id: 4, play_count: 2, year: 2008, file_size: 20 * 1024 ** 3 }),
];
const samples = [
{
name: 'never watched + old',
conditions: [
{ field: 'play_count', operator: 'equals', value: 0 },
{ field: 'days_since_added', operator: 'greater_than', value: 30 },
],
},
{
name: 'large + old year',
conditions: [
{ field: 'size_gb', operator: 'greater_than', value: 15 },
{ field: 'year', operator: 'less_than', value: 2020 },
],
},
{
name: 'single condition',
conditions: [{ field: 'year', operator: 'less_than', value: 2000 }],
},
];
for (const sample of samples) {
it(`matches legacy behavior for: ${sample.name}`, () => {
const v2 = upgradeToV2(sample.conditions);
for (const it of items) {
const legacyMatch = legacyEvaluate(it, sample.conditions);
const v2Match = evaluateNode(v2.root, it);
expect(v2Match).toBe(legacyMatch);
}
});
}
});