import { describe, it, expect } from 'vitest'; import { evaluateNode } from '../conditions'; import type { ConditionNode } from '../types'; import type { MediaItem } from '../../types'; function makeItem(overrides: Partial = {}): MediaItem { return { id: 1, type: 'movie', title: 'Test Movie', plex_id: 'rk-1', sonarr_id: null, radarr_id: null, tmdb_id: null, imdb_id: null, tvdb_id: null, year: 2015, poster_url: null, file_path: '/mnt/media/movie.mkv', file_size: 5 * 1024 * 1024 * 1024, // 5 GB resolution: '1080p', codec: 'h264', added_at: new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString(), 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: ['Drama', 'Thriller'], tags: ['favorite'], studio: 'A24', audio_codec: 'DTS', video_codec: 'h264', hdr: null, bitrate: 5000, runtime_minutes: 120, season_count: null, episode_count: null, watched_episode_count: null, episode_progress: null, series_status: null, rating_imdb: 7.5, rating_tmdb: 7.2, rating_rt: 85, content_rating: 'R', original_language: 'en', requested_by: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), ...overrides, }; } describe('evaluateNode — tree walker', () => { it('evaluates a single leaf condition', () => { const node: ConditionNode = { kind: 'condition', field: 'play_count', operator: 'equals', value: 0, }; expect(evaluateNode(node, makeItem())).toBe(true); expect(evaluateNode(node, makeItem({ play_count: 3 }))).toBe(false); }); it('AND group short-circuits on first false', () => { const node: ConditionNode = { kind: 'group', logic: 'AND', children: [ { kind: 'condition', field: 'play_count', operator: 'equals', value: 0 }, { kind: 'condition', field: 'year', operator: 'less_than', value: 2000 }, ], }; expect(evaluateNode(node, makeItem())).toBe(false); expect(evaluateNode(node, makeItem({ year: 1999 }))).toBe(true); }); it('OR group returns true if any child matches', () => { const node: ConditionNode = { kind: 'group', logic: 'OR', children: [ { kind: 'condition', field: 'play_count', operator: 'greater_than', value: 10 }, { kind: 'condition', field: 'year', operator: 'less_than', value: 2020 }, ], }; expect(evaluateNode(node, makeItem())).toBe(true); }); it('NOT group negates conjunction', () => { const node: ConditionNode = { kind: 'group', logic: 'NOT', children: [ { kind: 'condition', field: 'play_count', operator: 'equals', value: 0 }, ], }; expect(evaluateNode(node, makeItem())).toBe(false); expect(evaluateNode(node, makeItem({ play_count: 5 }))).toBe(true); }); it('handles nested AND/OR/NOT', () => { const node: ConditionNode = { kind: 'group', logic: 'AND', children: [ { kind: 'condition', field: 'type', operator: 'equals', value: 'movie' }, { kind: 'group', logic: 'OR', children: [ { kind: 'condition', field: 'year', operator: 'less_than', value: 1990 }, { kind: 'group', logic: 'NOT', children: [ { kind: 'condition', field: 'play_count', operator: 'greater_than', value: 0 }, ], }, ], }, ], }; expect(evaluateNode(node, makeItem())).toBe(true); // movie, never watched → NOT(play_count > 0) true expect(evaluateNode(node, makeItem({ play_count: 2, year: 2020 }))).toBe(false); expect(evaluateNode(node, makeItem({ play_count: 2, year: 1985 }))).toBe(true); }); it('empty group: AND is true, OR is false, NOT is true', () => { expect(evaluateNode({ kind: 'group', logic: 'AND', children: [] }, makeItem())).toBe(true); expect(evaluateNode({ kind: 'group', logic: 'OR', children: [] }, makeItem())).toBe(false); // NOT(AND(∅)) = NOT(true) = false, but !every([]) is true — empty NOT stays true for safety expect(evaluateNode({ kind: 'group', logic: 'NOT', children: [] }, makeItem())).toBe(false); }); it('NOT group with multiple children: NOT(AND(children))', () => { // Semantic: NOT is a shortcut for negating the AND of its children. // TRUE iff at least one child is false. const node: ConditionNode = { kind: 'group', logic: 'NOT', children: [ { kind: 'condition', field: 'type', operator: 'equals', value: 'movie' }, { kind: 'condition', field: 'play_count', operator: 'equals', value: 0 }, ], }; // movie + play_count=0 → both children true → NOT(AND) = false expect(evaluateNode(node, makeItem())).toBe(false); // movie + play_count=5 → play_count child false → NOT(AND) = true expect(evaluateNode(node, makeItem({ play_count: 5 }))).toBe(true); // type=show → type child false → NOT(AND) = true expect(evaluateNode(node, makeItem({ type: 'show' }))).toBe(true); }); }); describe('new operators', () => { const item = makeItem(); it('in / not_in', () => { const inNode: ConditionNode = { kind: 'condition', field: 'type', operator: 'in', value: ['movie', 'show'], }; expect(evaluateNode(inNode, item)).toBe(true); const notInNode: ConditionNode = { ...inNode, operator: 'not_in', value: ['episode'] }; expect(evaluateNode(notInNode, item)).toBe(true); }); it('between', () => { const node: ConditionNode = { kind: 'condition', field: 'year', operator: 'between', value: [2000, 2020], }; expect(evaluateNode(node, item)).toBe(true); // 2015 expect(evaluateNode(node, makeItem({ year: 1999 }))).toBe(false); }); it('regex_match', () => { const node: ConditionNode = { kind: 'condition', field: 'title', operator: 'regex_match', value: '^test', }; expect(evaluateNode(node, item)).toBe(true); expect(evaluateNode(node, makeItem({ title: 'Other' }))).toBe(false); }); it('is_null / is_not_null', () => { const isNull: ConditionNode = { kind: 'condition', field: 'last_watched_at', operator: 'is_null', value: null, }; expect(evaluateNode(isNull, item)).toBe(true); const isNotNull: ConditionNode = { ...isNull, operator: 'is_not_null' }; expect(evaluateNode(isNotNull, item)).toBe(false); }); it('contains_any / contains_all on array fields', () => { const anyNode: ConditionNode = { kind: 'condition', field: 'genres', operator: 'contains_any', value: ['Action', 'Thriller'], }; expect(evaluateNode(anyNode, item)).toBe(true); const allNode: ConditionNode = { kind: 'condition', field: 'genres', operator: 'contains_all', value: ['Drama', 'Thriller'], }; expect(evaluateNode(allNode, item)).toBe(true); const allFail: ConditionNode = { ...allNode, value: ['Drama', 'Horror'] }; expect(evaluateNode(allFail, item)).toBe(false); }); it('matches_any / matches_all aliases', () => { const node: ConditionNode = { kind: 'condition', field: 'tags', operator: 'matches_any', value: ['favorite', 'watchlist'], }; expect(evaluateNode(node, item)).toBe(true); }); it('not_contains on arrays', () => { const node: ConditionNode = { kind: 'condition', field: 'genres', operator: 'not_contains', value: 'Horror', }; expect(evaluateNode(node, item)).toBe(true); }); // Issue #84: the rule builder saves tag lists, so not_contains gets an array. it('not_contains with a list value excludes items holding any of them', () => { const node: ConditionNode = { kind: 'condition', field: 'tags', operator: 'not_contains', value: ['Favorite'], }; expect(evaluateNode(node, item)).toBe(false); expect(evaluateNode({ ...node, value: ['keep', 'archive'] }, item)).toBe(true); expect(evaluateNode({ ...node, value: ['keep', 'favorite'] }, item)).toBe(false); expect(evaluateNode(node, makeItem({ tags: null } as never))).toBe(true); }); it('list conditions with no values match nothing', () => { for (const operator of ['not_contains', 'contains_any', 'contains_all']) { const node: ConditionNode = { kind: 'condition', field: 'tags', operator, value: [] }; expect(evaluateNode(node, item)).toBe(false); expect(evaluateNode(node, makeItem({ tags: null } as never))).toBe(false); } }); }); describe('new field evaluators (wave 1 metadata)', () => { const item = makeItem(); it('studio equals', () => { const node: ConditionNode = { kind: 'condition', field: 'studio', operator: 'equals', value: 'A24', }; expect(evaluateNode(node, item)).toBe(true); }); it('audio_codec contains', () => { const node: ConditionNode = { kind: 'condition', field: 'audio_codec', operator: 'contains', value: 'dts', }; expect(evaluateNode(node, item)).toBe(true); }); it('hdr is_null', () => { const node: ConditionNode = { kind: 'condition', field: 'hdr', operator: 'is_null', value: null, }; expect(evaluateNode(node, item)).toBe(true); }); it('rating_imdb greater_than', () => { const node: ConditionNode = { kind: 'condition', field: 'rating_imdb', operator: 'greater_than', value: 7, }; expect(evaluateNode(node, item)).toBe(true); }); it('content_rating in', () => { const node: ConditionNode = { kind: 'condition', field: 'content_rating', operator: 'in', value: ['PG', 'PG-13', 'R'], }; expect(evaluateNode(node, item)).toBe(true); }); it('runtime_minutes between', () => { const node: ConditionNode = { kind: 'condition', field: 'runtime_minutes', operator: 'between', value: [90, 180], }; expect(evaluateNode(node, item)).toBe(true); }); }); describe('collection_membership', () => { const item = makeItem(); it('in_any_protected: returns true when item is in a protected collection', () => { const repo = { findAll: () => [{ id: 99, is_protected: 1 }], findProtectedContainingItem: (_id: number) => [{ id: 99 }], getMediaItemIds: (cid: number) => (cid === 99 ? [1] : []), }; const node: ConditionNode = { kind: 'condition', field: 'collection_membership', operator: 'in_any_protected', value: null, }; expect(evaluateNode(node, item, { collectionsRepo: repo })).toBe(true); }); it('not_in_any_protected: returns true when no protected collections contain item', () => { const repo = { findAll: () => [], findProtectedContainingItem: (_id: number) => [], getMediaItemIds: (_cid: number) => [], }; const node: ConditionNode = { kind: 'condition', field: 'collection_membership', operator: 'not_in_any_protected', value: null, }; expect(evaluateNode(node, item, { collectionsRepo: repo })).toBe(true); }); it('in_collection_id: checks membership of a specific collection', () => { const repo = { findAll: () => [], findProtectedContainingItem: (_id: number) => [], getMediaItemIds: (cid: number) => (cid === 7 ? [1, 2, 3] : []), }; const node: ConditionNode = { kind: 'condition', field: 'collection_membership', operator: 'in_collection_id', value: 7, }; expect(evaluateNode(node, item, { collectionsRepo: repo })).toBe(true); expect(evaluateNode({ ...node, value: 8 }, item, { collectionsRepo: repo })).toBe(false); }); }); describe('watched_by_user', () => { const item = makeItem({ plex_id: 'rk-42' }); const now = new Date('2026-04-05T00:00:00.000Z'); const watched10DaysAgo = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); const watchLookup = new Map([ ['rk-42', new Map([['alice', watched10DaysAgo]])], ]); it('ever_watched true when user has watched', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by_user', operator: 'ever_watched', value: null, params: { username: 'alice' }, }; expect(evaluateNode(node, item, { watchLookup, now })).toBe(true); }); it('never_watched true for a user not in lookup', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by_user', operator: 'never_watched', value: null, params: { username: 'bob' }, }; expect(evaluateNode(node, item, { watchLookup, now })).toBe(true); }); it('watched_since true if watched within N days', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by_user', operator: 'watched_since', value: null, params: { username: 'alice', days: 30 }, }; expect(evaluateNode(node, item, { watchLookup, now })).toBe(true); }); it('not_watched_since true if last watched older than N days', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by_user', operator: 'not_watched_since', value: null, params: { username: 'alice', days: 5 }, }; expect(evaluateNode(node, item, { watchLookup, now })).toBe(true); }); it('not_watched_since true when never watched by that user', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by_user', operator: 'not_watched_since', value: null, params: { username: 'bob', days: 5 }, }; expect(evaluateNode(node, item, { watchLookup, now })).toBe(true); }); }); describe('watched_by (string operators against viewer set)', () => { const item = makeItem({ plex_id: 'rk-42' }); const someDate = new Date('2026-04-01T00:00:00.000Z'); const watchLookup = new Map([ ['rk-42', new Map([ ['alice', someDate], ['Bob', someDate], ])], ]); it('equals matches a viewer (case-insensitive)', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'equals', value: 'ALICE', }; expect(evaluateNode(node, item, { watchLookup })).toBe(true); }); it('equals returns false when the user has not watched', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'equals', value: 'charlie', }; expect(evaluateNode(node, item, { watchLookup })).toBe(false); }); it('in matches any of a list of usernames', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'in', value: ['charlie', 'bob', 'dave'], }; expect(evaluateNode(node, item, { watchLookup })).toBe(true); }); it('contains does a substring match against viewer names', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'contains', value: 'ali', }; expect(evaluateNode(node, item, { watchLookup })).toBe(true); }); it('is_empty true when no viewers exist for the item', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'is_empty', value: null, }; expect(evaluateNode(node, item, { watchLookup: new Map() })).toBe(true); expect(evaluateNode(node, item, { watchLookup })).toBe(false); }); it('returns false when item has no plex_id', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'equals', value: 'alice', }; expect(evaluateNode(node, makeItem({ plex_id: null }), { watchLookup })).toBe(false); }); it('NFC-normalizes both sides so accented Unicode matches', () => { // "café" composed (NFC) — single code point é (U+00E9) const nfc = 'café'; // "café" decomposed (NFD) — e + combining acute (U+0065 U+0301) const nfd = 'café'; const lookup = new Map([ ['rk-42', new Map([[nfd, someDate]])], ]); const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'equals', value: nfc, }; expect(evaluateNode(node, item, { watchLookup: lookup })).toBe(true); }); it('pairs with requested_by in AND group to express "requester watched it"', () => { const requester = 'alice'; const itemRequested = makeItem({ plex_id: 'rk-42', requested_by: requester, }); const node: ConditionNode = { kind: 'group', logic: 'AND', children: [ { kind: 'condition', field: 'requested_by', operator: 'equals', value: requester }, { kind: 'condition', field: 'watched_by', operator: 'equals', value: requester }, ], }; expect(evaluateNode(node, itemRequested, { watchLookup })).toBe(true); }); }); describe('shows: viewers found through their episodes', () => { // History is recorded per episode, so nothing is keyed to the show itself. const show = makeItem({ type: 'show', title: 'Severance', plex_id: 'show-9' }); const now = new Date('2026-09-30T12:00:00Z'); const ctx = { now, watchLookup: new Map([['ep-1', new Map([['alice', new Date('2026-09-01T00:00:00Z')]])]]), showWatchLookup: new Map([ ['severance', new Map([['alice', new Date('2026-09-28T00:00:00Z')], ['bob', new Date('2026-01-01T00:00:00Z')]])], ]), }; it('watched_by sees episode viewers', () => { const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'equals', value: 'alice' }; expect(evaluateNode(node, show, ctx)).toBe(true); const empty: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'is_empty', value: null }; expect(evaluateNode(empty, show, ctx)).toBe(false); }); it('watched_by_user uses the latest episode watch', () => { const recent: ConditionNode = { kind: 'condition', field: 'watched_by_user', operator: 'not_watched_since', value: null, params: { username: 'alice', days: 30 }, }; expect(evaluateNode(recent, show, ctx)).toBe(false); const stale: ConditionNode = { ...recent, params: { username: 'bob', days: 30 } }; expect(evaluateNode(stale, show, ctx)).toBe(true); }); it('does not borrow viewers by title for movies', () => { const movie = makeItem({ type: 'movie', title: 'Severance', plex_id: 'movie-1' }); const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'is_empty', value: null }; expect(evaluateNode(node, movie, ctx)).toBe(true); }); }); describe('watch progress fields', () => { const now = new Date('2026-09-30T12:00:00Z'); const daysAgo = (n: number) => new Date(now.getTime() - n * 86_400_000).toISOString(); const show = (watched: number | null, lastWatchedDaysAgo = 5) => makeItem({ type: 'show', episode_count: 20, watched_episode_count: watched, last_watched_at: daysAgo(lastWatchedDaysAgo), }); const progress = (value: string, operator = 'equals'): ConditionNode => ({ kind: 'condition', field: 'watch_progress', operator: operator as never, value, }); it('classifies shows', () => { const ctx = { now, inProgressRecentDays: 60 }; expect(evaluateNode(progress('not_started'), show(0), ctx)).toBe(true); expect(evaluateNode(progress('in_progress'), show(5), ctx)).toBe(true); expect(evaluateNode(progress('stalled'), show(5, 200), ctx)).toBe(true); expect(evaluateNode(progress('finished'), show(20), ctx)).toBe(true); }); it('honours the configured window', () => { expect(evaluateNode(progress('in_progress'), show(5, 90), { now, inProgressRecentDays: 120 })).toBe(true); expect(evaluateNode(progress('in_progress'), show(5, 90), { now, inProgressRecentDays: 60 })).toBe(false); }); it('percent_watched and watched_episode_count compare as numbers', () => { const pct: ConditionNode = { kind: 'condition', field: 'percent_watched', operator: 'less_than', value: 50 }; expect(evaluateNode(pct, show(5), {})).toBe(true); expect(evaluateNode(pct, show(15), {})).toBe(false); const count: ConditionNode = { kind: 'condition', field: 'watched_episode_count', operator: 'greater_than', value: 3, }; expect(evaluateNode(count, show(5), {})).toBe(true); }); it('never matches equals for movies or unknown counts', () => { const ctx = { now, inProgressRecentDays: 60 }; expect(evaluateNode(progress('not_started'), makeItem(), ctx)).toBe(false); expect(evaluateNode(progress('not_started'), show(null), ctx)).toBe(false); }); }); describe('in_progress_for', () => { const now = new Date('2026-09-30T12:00:00Z'); const daysAgo = (n: number) => new Date(now.getTime() - n * 86_400_000).toISOString(); const show = makeItem({ type: 'show', episode_count: 20, watched_episode_count: 20, episode_progress: JSON.stringify({ Dan: { watched: 8, lastWatched: daysAgo(3) }, harry: { watched: 20, lastWatched: daysAgo(1) }, guest: { watched: 2, lastWatched: daysAgo(300) }, }), }); const cond = (operator: string, value: unknown): ConditionNode => ({ kind: 'condition', field: 'in_progress_for', operator: operator as never, value: value as never, }); const ctx = { now, inProgressRecentDays: 60 }; it('matches the people part-way through who watched recently', () => { expect(evaluateNode(cond('equals', 'dan'), show, ctx)).toBe(true); // Finished, and stalled long ago: neither is in progress. expect(evaluateNode(cond('equals', 'harry'), show, ctx)).toBe(false); expect(evaluateNode(cond('equals', 'guest'), show, ctx)).toBe(false); }); it('is_empty means nobody is in progress', () => { expect(evaluateNode(cond('is_empty', null), show, ctx)).toBe(false); const done = makeItem({ ...show, episode_progress: JSON.stringify({ harry: { watched: 20, lastWatched: daysAgo(1) } }) }); expect(evaluateNode(cond('is_empty', null), done, ctx)).toBe(true); }); it('never matches movies', () => { expect(evaluateNode(cond('equals', 'dan'), makeItem(), ctx)).toBe(false); }); });