mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-28 13:30:56 +01:00
Add unit tests for normalization and selector logic
- Implement tests for normalization functions in `normalize.test.ts`, covering various scenarios including field mapping, size conversion, ratings, cutoff calculations, date handling, and batch normalization. - Create tests for selector functions in `selectors.test.ts`, validating the behavior of different selectors such as random, oldest, newest, lowest score, most popular, and least popular, along with edge cases and integration scenarios.
This commit is contained in:
607
src/tests/upgrades/filters.test.ts
Normal file
607
src/tests/upgrades/filters.test.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Tests for filter evaluation logic
|
||||
* Tests evaluateRule() and evaluateGroup() from shared/filters.ts
|
||||
*/
|
||||
|
||||
import { BaseTest } from '../base/BaseTest.ts';
|
||||
import { assertEquals } from '@std/assert';
|
||||
import {
|
||||
evaluateRule,
|
||||
evaluateGroup,
|
||||
type FilterRule,
|
||||
type FilterGroup
|
||||
} from '../../lib/shared/filters.ts';
|
||||
|
||||
class FilterEvaluationTest extends BaseTest {
|
||||
runTests(): void {
|
||||
// =====================
|
||||
// Boolean Operators
|
||||
// =====================
|
||||
|
||||
this.test('boolean: is operator matches true', () => {
|
||||
const item = { monitored: true };
|
||||
const rule: FilterRule = { type: 'rule', field: 'monitored', operator: 'is', value: true };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('boolean: is operator rejects false', () => {
|
||||
const item = { monitored: false };
|
||||
const rule: FilterRule = { type: 'rule', field: 'monitored', operator: 'is', value: true };
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('boolean: is_not operator matches', () => {
|
||||
const item = { monitored: false };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'monitored',
|
||||
operator: 'is_not',
|
||||
value: true
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('boolean: is_not operator rejects', () => {
|
||||
const item = { monitored: true };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'monitored',
|
||||
operator: 'is_not',
|
||||
value: true
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Number Operators
|
||||
// =====================
|
||||
|
||||
this.test('number: eq operator matches', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'eq', value: 2023 };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('number: eq operator rejects', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'eq', value: 2024 };
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('number: neq operator matches', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'neq', value: 2024 };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('number: neq operator rejects', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'neq', value: 2023 };
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('number: gt operator matches', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'gt', value: 2020 };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('number: gt operator rejects equal value', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'gt', value: 2023 };
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('number: gte operator matches equal value', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'gte', value: 2023 };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('number: gte operator matches greater value', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'gte', value: 2020 };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('number: lt operator matches', () => {
|
||||
const item = { year: 2020 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'lt', value: 2023 };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('number: lt operator rejects equal value', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'lt', value: 2023 };
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('number: lte operator matches equal value', () => {
|
||||
const item = { year: 2023 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'lte', value: 2023 };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('number: lte operator matches less value', () => {
|
||||
const item = { year: 2020 };
|
||||
const rule: FilterRule = { type: 'rule', field: 'year', operator: 'lte', value: 2023 };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Text Operators
|
||||
// =====================
|
||||
|
||||
this.test('text: contains operator matches (case insensitive)', () => {
|
||||
const item = { title: 'The Dark Knight' };
|
||||
const rule: FilterRule = { type: 'rule', field: 'title', operator: 'contains', value: 'dark' };
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('text: contains operator rejects', () => {
|
||||
const item = { title: 'The Dark Knight' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'title',
|
||||
operator: 'contains',
|
||||
value: 'batman'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('text: not_contains operator matches', () => {
|
||||
const item = { title: 'The Dark Knight' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'title',
|
||||
operator: 'not_contains',
|
||||
value: 'batman'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('text: not_contains operator rejects', () => {
|
||||
const item = { title: 'The Dark Knight' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'title',
|
||||
operator: 'not_contains',
|
||||
value: 'dark'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('text: starts_with operator matches (case insensitive)', () => {
|
||||
const item = { title: 'The Dark Knight' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'title',
|
||||
operator: 'starts_with',
|
||||
value: 'the'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('text: starts_with operator rejects', () => {
|
||||
const item = { title: 'The Dark Knight' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'title',
|
||||
operator: 'starts_with',
|
||||
value: 'dark'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('text: ends_with operator matches (case insensitive)', () => {
|
||||
const item = { title: 'The Dark Knight' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'title',
|
||||
operator: 'ends_with',
|
||||
value: 'KNIGHT'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('text: ends_with operator rejects', () => {
|
||||
const item = { title: 'The Dark Knight' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'title',
|
||||
operator: 'ends_with',
|
||||
value: 'dark'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('text: eq operator matches (case insensitive)', () => {
|
||||
const item = { quality_profile: 'HD-1080p' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'quality_profile',
|
||||
operator: 'eq',
|
||||
value: 'hd-1080p'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('text: neq operator matches (case insensitive)', () => {
|
||||
const item = { quality_profile: 'HD-1080p' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'quality_profile',
|
||||
operator: 'neq',
|
||||
value: '4K'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Date Operators
|
||||
// =====================
|
||||
|
||||
this.test('date: before operator matches', () => {
|
||||
const item = { date_added: '2023-01-15T00:00:00Z' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'date_added',
|
||||
operator: 'before',
|
||||
value: '2023-06-01T00:00:00Z'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('date: before operator rejects', () => {
|
||||
const item = { date_added: '2023-06-15T00:00:00Z' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'date_added',
|
||||
operator: 'before',
|
||||
value: '2023-06-01T00:00:00Z'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('date: after operator matches', () => {
|
||||
const item = { date_added: '2023-06-15T00:00:00Z' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'date_added',
|
||||
operator: 'after',
|
||||
value: '2023-06-01T00:00:00Z'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('date: after operator rejects', () => {
|
||||
const item = { date_added: '2023-01-15T00:00:00Z' };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'date_added',
|
||||
operator: 'after',
|
||||
value: '2023-06-01T00:00:00Z'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('date: in_last operator matches recent date', () => {
|
||||
const now = new Date();
|
||||
const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000);
|
||||
const item = { date_added: fiveDaysAgo.toISOString() };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'date_added',
|
||||
operator: 'in_last',
|
||||
value: 7 // days
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('date: in_last operator rejects old date', () => {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const item = { date_added: thirtyDaysAgo.toISOString() };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'date_added',
|
||||
operator: 'in_last',
|
||||
value: 7 // days
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('date: not_in_last operator matches old date', () => {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const item = { date_added: thirtyDaysAgo.toISOString() };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'date_added',
|
||||
operator: 'not_in_last',
|
||||
value: 7 // days
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('date: not_in_last operator rejects recent date', () => {
|
||||
const now = new Date();
|
||||
const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000);
|
||||
const item = { date_added: fiveDaysAgo.toISOString() };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'date_added',
|
||||
operator: 'not_in_last',
|
||||
value: 7 // days
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Null/Undefined Handling
|
||||
// =====================
|
||||
|
||||
this.test('null field: is operator returns false', () => {
|
||||
const item = { title: null };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'title',
|
||||
operator: 'is',
|
||||
value: 'something'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), false);
|
||||
});
|
||||
|
||||
this.test('null field: is_not operator returns true', () => {
|
||||
const item = { title: null };
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'title',
|
||||
operator: 'is_not',
|
||||
value: 'something'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('undefined field: neq operator returns true', () => {
|
||||
const item = { title: 'Test' }; // no 'genres' field
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'genres',
|
||||
operator: 'neq',
|
||||
value: 'Action'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
this.test('undefined field: not_contains operator returns true', () => {
|
||||
const item = { title: 'Test' }; // no 'genres' field
|
||||
const rule: FilterRule = {
|
||||
type: 'rule',
|
||||
field: 'genres',
|
||||
operator: 'not_contains',
|
||||
value: 'Action'
|
||||
};
|
||||
assertEquals(evaluateRule(item, rule), true);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Group Evaluation - AND Logic
|
||||
// =====================
|
||||
|
||||
this.test('group: empty group matches all items', () => {
|
||||
const item = { title: 'Test', year: 2023 };
|
||||
const group: FilterGroup = { type: 'group', match: 'all', children: [] };
|
||||
assertEquals(evaluateGroup(item, group), true);
|
||||
});
|
||||
|
||||
this.test('group: all match - both rules pass', () => {
|
||||
const item = { monitored: true, year: 2023 };
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'all',
|
||||
children: [
|
||||
{ type: 'rule', field: 'monitored', operator: 'is', value: true },
|
||||
{ type: 'rule', field: 'year', operator: 'gte', value: 2020 }
|
||||
]
|
||||
};
|
||||
assertEquals(evaluateGroup(item, group), true);
|
||||
});
|
||||
|
||||
this.test('group: all match - one rule fails', () => {
|
||||
const item = { monitored: false, year: 2023 };
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'all',
|
||||
children: [
|
||||
{ type: 'rule', field: 'monitored', operator: 'is', value: true },
|
||||
{ type: 'rule', field: 'year', operator: 'gte', value: 2020 }
|
||||
]
|
||||
};
|
||||
assertEquals(evaluateGroup(item, group), false);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Group Evaluation - OR Logic
|
||||
// =====================
|
||||
|
||||
this.test('group: any match - one rule passes', () => {
|
||||
const item = { monitored: false, year: 2023 };
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'any',
|
||||
children: [
|
||||
{ type: 'rule', field: 'monitored', operator: 'is', value: true },
|
||||
{ type: 'rule', field: 'year', operator: 'gte', value: 2020 }
|
||||
]
|
||||
};
|
||||
assertEquals(evaluateGroup(item, group), true);
|
||||
});
|
||||
|
||||
this.test('group: any match - no rules pass', () => {
|
||||
const item = { monitored: false, year: 2015 };
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'any',
|
||||
children: [
|
||||
{ type: 'rule', field: 'monitored', operator: 'is', value: true },
|
||||
{ type: 'rule', field: 'year', operator: 'gte', value: 2020 }
|
||||
]
|
||||
};
|
||||
assertEquals(evaluateGroup(item, group), false);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Nested Groups
|
||||
// =====================
|
||||
|
||||
this.test('nested groups: AND containing OR', () => {
|
||||
// Match: monitored AND (year >= 2020 OR quality_profile = 'HD')
|
||||
const item = { monitored: true, year: 2015, quality_profile: 'HD' };
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'all',
|
||||
children: [
|
||||
{ type: 'rule', field: 'monitored', operator: 'is', value: true },
|
||||
{
|
||||
type: 'group',
|
||||
match: 'any',
|
||||
children: [
|
||||
{ type: 'rule', field: 'year', operator: 'gte', value: 2020 },
|
||||
{ type: 'rule', field: 'quality_profile', operator: 'eq', value: 'HD' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
assertEquals(evaluateGroup(item, group), true);
|
||||
});
|
||||
|
||||
this.test('nested groups: OR containing AND', () => {
|
||||
// Match: (monitored AND year >= 2020) OR (quality_profile = '4K')
|
||||
const item = { monitored: false, year: 2023, quality_profile: '4K' };
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'any',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
match: 'all',
|
||||
children: [
|
||||
{ type: 'rule', field: 'monitored', operator: 'is', value: true },
|
||||
{ type: 'rule', field: 'year', operator: 'gte', value: 2020 }
|
||||
]
|
||||
},
|
||||
{ type: 'rule', field: 'quality_profile', operator: 'eq', value: '4K' }
|
||||
]
|
||||
};
|
||||
assertEquals(evaluateGroup(item, group), true);
|
||||
});
|
||||
|
||||
this.test('nested groups: deeply nested structure', () => {
|
||||
// Complex nested structure
|
||||
const item = {
|
||||
monitored: true,
|
||||
year: 2023,
|
||||
genres: 'Action, Thriller',
|
||||
tmdb_rating: 8.5
|
||||
};
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'all',
|
||||
children: [
|
||||
{ type: 'rule', field: 'monitored', operator: 'is', value: true },
|
||||
{
|
||||
type: 'group',
|
||||
match: 'any',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
match: 'all',
|
||||
children: [
|
||||
{ type: 'rule', field: 'year', operator: 'gte', value: 2020 },
|
||||
{ type: 'rule', field: 'genres', operator: 'contains', value: 'action' }
|
||||
]
|
||||
},
|
||||
{ type: 'rule', field: 'tmdb_rating', operator: 'gte', value: 9 }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
assertEquals(evaluateGroup(item, group), true);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Real-world Scenarios
|
||||
// =====================
|
||||
|
||||
this.test('scenario: find unmonitored movies from 2020+', () => {
|
||||
const movies = [
|
||||
{ title: 'Movie 1', monitored: true, year: 2023 },
|
||||
{ title: 'Movie 2', monitored: false, year: 2021 },
|
||||
{ title: 'Movie 3', monitored: false, year: 2019 },
|
||||
{ title: 'Movie 4', monitored: false, year: 2022 }
|
||||
];
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'all',
|
||||
children: [
|
||||
{ type: 'rule', field: 'monitored', operator: 'is', value: false },
|
||||
{ type: 'rule', field: 'year', operator: 'gte', value: 2020 }
|
||||
]
|
||||
};
|
||||
|
||||
const matched = movies.filter((m) => evaluateGroup(m, group));
|
||||
assertEquals(matched.length, 2);
|
||||
assertEquals(matched[0].title, 'Movie 2');
|
||||
assertEquals(matched[1].title, 'Movie 4');
|
||||
});
|
||||
|
||||
this.test('scenario: find high-rated action movies', () => {
|
||||
const movies = [
|
||||
{ title: 'Movie 1', genres: 'Action, Adventure', tmdb_rating: 8.5 },
|
||||
{ title: 'Movie 2', genres: 'Comedy', tmdb_rating: 9.0 },
|
||||
{ title: 'Movie 3', genres: 'Action, Thriller', tmdb_rating: 7.5 },
|
||||
{ title: 'Movie 4', genres: 'Action', tmdb_rating: 8.8 }
|
||||
];
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'all',
|
||||
children: [
|
||||
{ type: 'rule', field: 'genres', operator: 'contains', value: 'action' },
|
||||
{ type: 'rule', field: 'tmdb_rating', operator: 'gte', value: 8.0 }
|
||||
]
|
||||
};
|
||||
|
||||
const matched = movies.filter((m) => evaluateGroup(m, group));
|
||||
assertEquals(matched.length, 2);
|
||||
assertEquals(matched[0].title, 'Movie 1');
|
||||
assertEquals(matched[1].title, 'Movie 4');
|
||||
});
|
||||
|
||||
this.test('scenario: find movies where cutoff not met', () => {
|
||||
const movies = [
|
||||
{ title: 'Movie 1', cutoff_met: true, monitored: true },
|
||||
{ title: 'Movie 2', cutoff_met: false, monitored: true },
|
||||
{ title: 'Movie 3', cutoff_met: false, monitored: false },
|
||||
{ title: 'Movie 4', cutoff_met: false, monitored: true }
|
||||
];
|
||||
const group: FilterGroup = {
|
||||
type: 'group',
|
||||
match: 'all',
|
||||
children: [
|
||||
{ type: 'rule', field: 'cutoff_met', operator: 'is', value: false },
|
||||
{ type: 'rule', field: 'monitored', operator: 'is', value: true }
|
||||
]
|
||||
};
|
||||
|
||||
const matched = movies.filter((m) => evaluateGroup(m, group));
|
||||
assertEquals(matched.length, 2);
|
||||
assertEquals(matched[0].title, 'Movie 2');
|
||||
assertEquals(matched[1].title, 'Movie 4');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create instance and run tests
|
||||
const filterTest = new FilterEvaluationTest();
|
||||
filterTest.runTests();
|
||||
595
src/tests/upgrades/normalize.test.ts
Normal file
595
src/tests/upgrades/normalize.test.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* Tests for normalization logic
|
||||
* Tests normalizeRadarrItem() and normalizeRadarrItems() from upgrades/normalize.ts
|
||||
*/
|
||||
|
||||
import { BaseTest } from '../base/BaseTest.ts';
|
||||
import { assertEquals, assertAlmostEquals } from '@std/assert';
|
||||
import { normalizeRadarrItem, normalizeRadarrItems } from '../../lib/server/upgrades/normalize.ts';
|
||||
import type {
|
||||
RadarrMovie,
|
||||
RadarrMovieFile,
|
||||
RadarrQualityProfile
|
||||
} from '../../lib/server/utils/arr/types.ts';
|
||||
|
||||
class NormalizeTest extends BaseTest {
|
||||
/**
|
||||
* Create a mock movie based on real Radarr API response
|
||||
*/
|
||||
private createMockMovie(overrides: Partial<RadarrMovie> = {}): RadarrMovie {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Beetlejuice Beetlejuice',
|
||||
originalTitle: 'Beetlejuice Beetlejuice',
|
||||
originalLanguage: { id: 1, name: 'English' },
|
||||
year: 2024,
|
||||
qualityProfileId: 7,
|
||||
hasFile: true,
|
||||
movieFileId: 16,
|
||||
monitored: false,
|
||||
minimumAvailability: 'announced',
|
||||
runtime: 105,
|
||||
tmdbId: 917496,
|
||||
imdbId: 'tt2049403',
|
||||
added: '2024-12-28T00:48:06Z',
|
||||
ratings: {
|
||||
imdb: { votes: 166926, value: 6.6 },
|
||||
tmdb: { votes: 2863, value: 6.961 },
|
||||
rottenTomatoes: { votes: 0, value: 75 },
|
||||
trakt: { votes: 12513, value: 6.80532 }
|
||||
},
|
||||
genres: ['Comedy', 'Fantasy', 'Horror'],
|
||||
studio: 'Warner Bros. Pictures',
|
||||
path: '/data/media/movies/Beetlejuice Beetlejuice (2024)',
|
||||
sizeOnDisk: 13880140407,
|
||||
status: 'released',
|
||||
tags: [1, 2, 3],
|
||||
collection: { title: 'Beetlejuice Collection', tmdbId: 945475 },
|
||||
popularity: 6.5513,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock movie file based on real Radarr API response
|
||||
*/
|
||||
private createMockMovieFile(overrides: Partial<RadarrMovieFile> = {}): RadarrMovieFile {
|
||||
return {
|
||||
id: 16,
|
||||
movieId: 1,
|
||||
relativePath:
|
||||
'Beetlejuice Beetlejuice (2024) {tmdb-917496} [Bluray-1080p][EAC3 7.1][x264]-ZoroSenpai.mkv',
|
||||
path: '/data/media/movies/Beetlejuice Beetlejuice (2024)/Beetlejuice Beetlejuice (2024) {tmdb-917496} [Bluray-1080p][EAC3 7.1][x264]-ZoroSenpai.mkv',
|
||||
size: 13880140407,
|
||||
dateAdded: '2024-12-28T23:25:51Z',
|
||||
sceneName: 'Beetlejuice.Beetlejuice.2024.Hybrid.1080p.BluRay.DDP7.1.x264-ZoroSenpai',
|
||||
releaseGroup: 'ZoroSenpai',
|
||||
edition: '',
|
||||
languages: [{ id: 1, name: 'English' }],
|
||||
quality: {
|
||||
quality: {
|
||||
id: 7,
|
||||
name: 'Bluray-1080p',
|
||||
source: 'bluray',
|
||||
resolution: 1080
|
||||
},
|
||||
revision: { version: 1, real: 0, isRepack: false }
|
||||
},
|
||||
customFormats: [
|
||||
{ id: 1474, name: '1080p' },
|
||||
{ id: 1424, name: '1080p Bluray' },
|
||||
{ id: 1444, name: '1080p Quality Tier 1' },
|
||||
{ id: 1434, name: 'Dolby Digital +' }
|
||||
],
|
||||
customFormatScore: 225600,
|
||||
mediaInfo: {
|
||||
audioBitrate: 1536000,
|
||||
audioChannels: 7.1,
|
||||
audioCodec: 'EAC3',
|
||||
audioLanguages: 'eng/eng',
|
||||
audioStreamCount: 2,
|
||||
videoBitDepth: 8,
|
||||
videoBitrate: 16025380,
|
||||
videoCodec: 'x264',
|
||||
videoFps: 23.976,
|
||||
resolution: '1920x1038',
|
||||
runTime: '1:44:41',
|
||||
scanType: 'Progressive'
|
||||
},
|
||||
originalFilePath:
|
||||
'Beetlejuice.Beetlejuice.2024.Hybrid.1080p.BluRay.DDP7.1.x264-ZoroSenpai.mkv',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock quality profile based on real Radarr API response
|
||||
*/
|
||||
private createMockProfile(overrides: Partial<RadarrQualityProfile> = {}): RadarrQualityProfile {
|
||||
return {
|
||||
id: 7,
|
||||
name: '1080p Quality',
|
||||
upgradeAllowed: true,
|
||||
cutoff: 1001,
|
||||
cutoffFormatScore: 400000,
|
||||
minFormatScore: 20000,
|
||||
formatItems: [
|
||||
{ format: 1463, name: 'Not Original', score: -999999 },
|
||||
{ format: 1440, name: '1080p WEB-DL', score: 200000 },
|
||||
{ format: 1424, name: '1080p Bluray', score: 140000 }
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
runTests(): void {
|
||||
// =====================
|
||||
// Basic Field Mapping
|
||||
// =====================
|
||||
|
||||
this.test('normalizes basic movie fields correctly', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.id, 1);
|
||||
assertEquals(result.title, 'Beetlejuice Beetlejuice');
|
||||
assertEquals(result.year, 2024);
|
||||
assertEquals(result.monitored, false);
|
||||
assertEquals(result.minimum_availability, 'announced');
|
||||
assertEquals(result.runtime, 105);
|
||||
});
|
||||
|
||||
this.test('normalizes quality profile name', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile({ name: 'Custom 4K Profile' });
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.quality_profile, 'Custom 4K Profile');
|
||||
});
|
||||
|
||||
this.test('normalizes collection title', () => {
|
||||
const movie = this.createMockMovie({
|
||||
collection: { title: 'Marvel Cinematic Universe', tmdbId: 12345 }
|
||||
});
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.collection, 'Marvel Cinematic Universe');
|
||||
});
|
||||
|
||||
this.test('normalizes studio', () => {
|
||||
const movie = this.createMockMovie({ studio: 'Warner Bros. Pictures' });
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.studio, 'Warner Bros. Pictures');
|
||||
});
|
||||
|
||||
this.test('normalizes original language', () => {
|
||||
const movie = this.createMockMovie({
|
||||
originalLanguage: { id: 2, name: 'Japanese' }
|
||||
});
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.original_language, 'Japanese');
|
||||
});
|
||||
|
||||
this.test('normalizes genres as comma-separated string', () => {
|
||||
const movie = this.createMockMovie({
|
||||
genres: ['Comedy', 'Fantasy', 'Horror']
|
||||
});
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.genres, 'Comedy, Fantasy, Horror');
|
||||
});
|
||||
|
||||
this.test('normalizes release group from movie file', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const movieFile = this.createMockMovieFile({ releaseGroup: 'ZoroSenpai' });
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.release_group, 'ZoroSenpai');
|
||||
});
|
||||
|
||||
this.test('normalizes popularity', () => {
|
||||
const movie = this.createMockMovie({ popularity: 6.5513 });
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertAlmostEquals(result.popularity, 6.5513, 0.0001);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Size Conversion
|
||||
// =====================
|
||||
|
||||
this.test('converts size to GB correctly', () => {
|
||||
const movie = this.createMockMovie({ sizeOnDisk: 13880140407 }); // ~12.93 GB
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
// 13880140407 / (1024^3) = 12.927...
|
||||
assertAlmostEquals(result.size_on_disk, 12.927, 0.01);
|
||||
});
|
||||
|
||||
this.test('handles zero size', () => {
|
||||
const movie = this.createMockMovie({ sizeOnDisk: 0 });
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.size_on_disk, 0);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Ratings
|
||||
// =====================
|
||||
|
||||
this.test('normalizes all ratings correctly', () => {
|
||||
const movie = this.createMockMovie({
|
||||
ratings: {
|
||||
imdb: { votes: 166926, value: 6.6 },
|
||||
tmdb: { votes: 2863, value: 6.961 },
|
||||
rottenTomatoes: { votes: 0, value: 75 },
|
||||
trakt: { votes: 12513, value: 6.80532 }
|
||||
}
|
||||
});
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertAlmostEquals(result.imdb_rating, 6.6, 0.001);
|
||||
assertAlmostEquals(result.tmdb_rating, 6.961, 0.001);
|
||||
assertEquals(result.tomato_rating, 75);
|
||||
assertAlmostEquals(result.trakt_rating, 6.80532, 0.00001);
|
||||
});
|
||||
|
||||
this.test('handles missing ratings', () => {
|
||||
const movie = this.createMockMovie({ ratings: undefined });
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.imdb_rating, 0);
|
||||
assertEquals(result.tmdb_rating, 0);
|
||||
assertEquals(result.tomato_rating, 0);
|
||||
assertEquals(result.trakt_rating, 0);
|
||||
});
|
||||
|
||||
this.test('handles partial ratings', () => {
|
||||
const movie = this.createMockMovie({
|
||||
ratings: {
|
||||
imdb: { votes: 100, value: 7.5 }
|
||||
// tmdb, rottenTomatoes, trakt are undefined
|
||||
}
|
||||
});
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertAlmostEquals(result.imdb_rating, 7.5, 0.001);
|
||||
assertEquals(result.tmdb_rating, 0);
|
||||
assertEquals(result.tomato_rating, 0);
|
||||
assertEquals(result.trakt_rating, 0);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Cutoff Calculation
|
||||
// =====================
|
||||
|
||||
this.test('calculates cutoff_met correctly when score meets threshold', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const movieFile = this.createMockMovieFile({ customFormatScore: 320000 });
|
||||
const profile = this.createMockProfile({ cutoffFormatScore: 400000 });
|
||||
|
||||
// 80% of 400000 = 320000, score is 320000 so cutoff is met
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.cutoff_met, true);
|
||||
assertEquals(result.score, 320000);
|
||||
});
|
||||
|
||||
this.test('calculates cutoff_met correctly when score below threshold', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const movieFile = this.createMockMovieFile({ customFormatScore: 200000 });
|
||||
const profile = this.createMockProfile({ cutoffFormatScore: 400000 });
|
||||
|
||||
// 80% of 400000 = 320000, score is 200000 so cutoff is NOT met
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.cutoff_met, false);
|
||||
});
|
||||
|
||||
this.test('calculates cutoff with different percentages', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const movieFile = this.createMockMovieFile({ customFormatScore: 200000 });
|
||||
const profile = this.createMockProfile({ cutoffFormatScore: 400000 });
|
||||
|
||||
// 50% of 400000 = 200000, score is 200000 so cutoff is met
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 50);
|
||||
|
||||
assertEquals(result.cutoff_met, true);
|
||||
});
|
||||
|
||||
this.test('handles zero cutoff score in profile', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const movieFile = this.createMockMovieFile({ customFormatScore: 100 });
|
||||
const profile = this.createMockProfile({ cutoffFormatScore: 0 });
|
||||
|
||||
// 80% of 0 = 0, any score >= 0 meets cutoff
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.cutoff_met, true);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Date Handling
|
||||
// =====================
|
||||
|
||||
this.test('normalizes date_added from movie', () => {
|
||||
const movie = this.createMockMovie({ added: '2024-12-28T00:48:06Z' });
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.date_added, '2024-12-28T00:48:06Z');
|
||||
assertEquals(result.dateAdded, '2024-12-28T00:48:06Z');
|
||||
});
|
||||
|
||||
this.test('uses current date when added is missing', () => {
|
||||
const movie = this.createMockMovie({ added: undefined });
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const before = new Date().toISOString();
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
const after = new Date().toISOString();
|
||||
|
||||
// date_added should be between before and after
|
||||
assertEquals(result.date_added >= before, true);
|
||||
assertEquals(result.date_added <= after, true);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Tags and Raw Data
|
||||
// =====================
|
||||
|
||||
this.test('preserves tags from movie', () => {
|
||||
const movie = this.createMockMovie({ tags: [1, 2, 3] });
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result._tags, [1, 2, 3]);
|
||||
});
|
||||
|
||||
this.test('handles empty tags', () => {
|
||||
const movie = this.createMockMovie({ tags: [] });
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result._tags, []);
|
||||
});
|
||||
|
||||
this.test('handles undefined tags', () => {
|
||||
const movie = this.createMockMovie({ tags: undefined });
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result._tags, []);
|
||||
});
|
||||
|
||||
this.test('preserves raw movie data', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const movieFile = this.createMockMovieFile();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result._raw, movie);
|
||||
assertEquals(result._raw.id, 1);
|
||||
assertEquals(result._raw.title, 'Beetlejuice Beetlejuice');
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Missing Data Handling
|
||||
// =====================
|
||||
|
||||
this.test('handles undefined movie file', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const profile = this.createMockProfile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, undefined, profile, 80);
|
||||
|
||||
assertEquals(result.score, 0);
|
||||
assertEquals(result.release_group, '');
|
||||
assertEquals(result.cutoff_met, false); // 0 < 320000 (80% of 400000)
|
||||
});
|
||||
|
||||
this.test('handles undefined profile', () => {
|
||||
const movie = this.createMockMovie();
|
||||
const movieFile = this.createMockMovieFile();
|
||||
|
||||
const result = normalizeRadarrItem(movie, movieFile, undefined, 80);
|
||||
|
||||
assertEquals(result.quality_profile, 'Unknown');
|
||||
assertEquals(result.cutoff_met, true); // 0 threshold, any score meets it
|
||||
});
|
||||
|
||||
this.test('handles all undefined optional fields', () => {
|
||||
const movie: RadarrMovie = {
|
||||
id: 1,
|
||||
title: 'Minimal Movie',
|
||||
qualityProfileId: 1,
|
||||
hasFile: false
|
||||
};
|
||||
|
||||
const result = normalizeRadarrItem(movie, undefined, undefined, 80);
|
||||
|
||||
assertEquals(result.id, 1);
|
||||
assertEquals(result.title, 'Minimal Movie');
|
||||
assertEquals(result.year, 0);
|
||||
assertEquals(result.monitored, false);
|
||||
assertEquals(result.minimum_availability, 'released');
|
||||
assertEquals(result.quality_profile, 'Unknown');
|
||||
assertEquals(result.collection, '');
|
||||
assertEquals(result.studio, '');
|
||||
assertEquals(result.original_language, '');
|
||||
assertEquals(result.genres, '');
|
||||
assertEquals(result.release_group, '');
|
||||
assertEquals(result.popularity, 0);
|
||||
assertEquals(result.runtime, 0);
|
||||
assertEquals(result.size_on_disk, 0);
|
||||
assertEquals(result.imdb_rating, 0);
|
||||
assertEquals(result.tmdb_rating, 0);
|
||||
assertEquals(result.tomato_rating, 0);
|
||||
assertEquals(result.trakt_rating, 0);
|
||||
assertEquals(result.score, 0);
|
||||
assertEquals(result._tags, []);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Batch Normalization
|
||||
// =====================
|
||||
|
||||
this.test('normalizes batch of movies correctly', () => {
|
||||
const movies = [
|
||||
this.createMockMovie({ id: 1, title: 'Movie 1', qualityProfileId: 7 }),
|
||||
this.createMockMovie({ id: 2, title: 'Movie 2', qualityProfileId: 7 }),
|
||||
this.createMockMovie({ id: 3, title: 'Movie 3', qualityProfileId: 8 })
|
||||
];
|
||||
|
||||
const movieFileMap = new Map([
|
||||
[1, this.createMockMovieFile({ movieId: 1, customFormatScore: 100000 })],
|
||||
[2, this.createMockMovieFile({ movieId: 2, customFormatScore: 200000 })]
|
||||
// Movie 3 has no file
|
||||
]);
|
||||
|
||||
const profileMap = new Map([
|
||||
[7, this.createMockProfile({ id: 7, name: '1080p Quality' })],
|
||||
[8, this.createMockProfile({ id: 8, name: '4K Quality', cutoffFormatScore: 500000 })]
|
||||
]);
|
||||
|
||||
const results = normalizeRadarrItems(movies, movieFileMap, profileMap, 80);
|
||||
|
||||
assertEquals(results.length, 3);
|
||||
|
||||
assertEquals(results[0].id, 1);
|
||||
assertEquals(results[0].title, 'Movie 1');
|
||||
assertEquals(results[0].quality_profile, '1080p Quality');
|
||||
assertEquals(results[0].score, 100000);
|
||||
|
||||
assertEquals(results[1].id, 2);
|
||||
assertEquals(results[1].title, 'Movie 2');
|
||||
assertEquals(results[1].score, 200000);
|
||||
|
||||
assertEquals(results[2].id, 3);
|
||||
assertEquals(results[2].title, 'Movie 3');
|
||||
assertEquals(results[2].quality_profile, '4K Quality');
|
||||
assertEquals(results[2].score, 0); // No movie file
|
||||
});
|
||||
|
||||
this.test('batch normalization handles empty input', () => {
|
||||
const movieFileMap = new Map<number, RadarrMovieFile>();
|
||||
const profileMap = new Map<number, RadarrQualityProfile>();
|
||||
|
||||
const results = normalizeRadarrItems([], movieFileMap, profileMap, 80);
|
||||
|
||||
assertEquals(results.length, 0);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Real-world Scenarios
|
||||
// =====================
|
||||
|
||||
this.test('scenario: movie with file meeting cutoff', () => {
|
||||
const movie = this.createMockMovie({
|
||||
id: 1,
|
||||
title: 'Beetlejuice Beetlejuice',
|
||||
monitored: true,
|
||||
year: 2024
|
||||
});
|
||||
const movieFile = this.createMockMovieFile({
|
||||
customFormatScore: 225600,
|
||||
releaseGroup: 'ZoroSenpai'
|
||||
});
|
||||
const profile = this.createMockProfile({
|
||||
cutoffFormatScore: 400000
|
||||
});
|
||||
|
||||
// Cutoff at 80%: 400000 * 0.8 = 320000
|
||||
// Score 225600 < 320000, so cutoff NOT met
|
||||
const result = normalizeRadarrItem(movie, movieFile, profile, 80);
|
||||
|
||||
assertEquals(result.cutoff_met, false);
|
||||
assertEquals(result.score, 225600);
|
||||
|
||||
// But at 50% cutoff: 400000 * 0.5 = 200000
|
||||
// Score 225600 >= 200000, so cutoff IS met
|
||||
const result50 = normalizeRadarrItem(movie, movieFile, profile, 50);
|
||||
assertEquals(result50.cutoff_met, true);
|
||||
});
|
||||
|
||||
this.test('scenario: filter upgrade candidates', () => {
|
||||
const movies = [
|
||||
this.createMockMovie({ id: 1, title: 'Good Quality', monitored: true }),
|
||||
this.createMockMovie({ id: 2, title: 'Needs Upgrade', monitored: true }),
|
||||
this.createMockMovie({ id: 3, title: 'Unmonitored', monitored: false }),
|
||||
this.createMockMovie({ id: 4, title: 'No File', monitored: true, hasFile: false })
|
||||
];
|
||||
|
||||
const movieFileMap = new Map([
|
||||
[1, this.createMockMovieFile({ movieId: 1, customFormatScore: 350000 })], // Above 80%
|
||||
[2, this.createMockMovieFile({ movieId: 2, customFormatScore: 150000 })] // Below 80%
|
||||
]);
|
||||
|
||||
const profileMap = new Map([
|
||||
[7, this.createMockProfile({ cutoffFormatScore: 400000 })]
|
||||
]);
|
||||
|
||||
const results = normalizeRadarrItems(movies, movieFileMap, profileMap, 80);
|
||||
|
||||
// Filter for monitored, cutoff not met
|
||||
const upgradeCandidates = results.filter((r) => r.monitored && !r.cutoff_met);
|
||||
|
||||
assertEquals(upgradeCandidates.length, 2);
|
||||
assertEquals(upgradeCandidates[0].title, 'Needs Upgrade');
|
||||
assertEquals(upgradeCandidates[1].title, 'No File');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create instance and run tests
|
||||
const normalizeTest = new NormalizeTest();
|
||||
normalizeTest.runTests();
|
||||
396
src/tests/upgrades/selectors.test.ts
Normal file
396
src/tests/upgrades/selectors.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Tests for selector logic
|
||||
* Tests all selectors from shared/selectors.ts
|
||||
*/
|
||||
|
||||
import { BaseTest } from '../base/BaseTest.ts';
|
||||
import { assertEquals, assertNotEquals, assert } from '@std/assert';
|
||||
import { selectors, getSelector, isValidSelector, getAllSelectorIds } from '../../lib/shared/selectors.ts';
|
||||
|
||||
interface MockItem {
|
||||
id: number;
|
||||
title: string;
|
||||
dateAdded: string;
|
||||
score: number;
|
||||
popularity: number;
|
||||
}
|
||||
|
||||
class SelectorsTest extends BaseTest {
|
||||
private createMockItems(): MockItem[] {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Movie A',
|
||||
dateAdded: '2023-01-15T00:00:00Z',
|
||||
score: 50,
|
||||
popularity: 100
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Movie B',
|
||||
dateAdded: '2023-06-20T00:00:00Z',
|
||||
score: 75,
|
||||
popularity: 200
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Movie C',
|
||||
dateAdded: '2022-03-10T00:00:00Z',
|
||||
score: 25,
|
||||
popularity: 150
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Movie D',
|
||||
dateAdded: '2024-01-01T00:00:00Z',
|
||||
score: 100,
|
||||
popularity: 50
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Movie E',
|
||||
dateAdded: '2023-09-05T00:00:00Z',
|
||||
score: 60,
|
||||
popularity: 300
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
runTests(): void {
|
||||
// =====================
|
||||
// Helper Function Tests
|
||||
// =====================
|
||||
|
||||
this.test('getSelector returns correct selector', () => {
|
||||
const selector = getSelector('random');
|
||||
assertEquals(selector?.id, 'random');
|
||||
assertEquals(selector?.label, 'Random');
|
||||
});
|
||||
|
||||
this.test('getSelector returns undefined for invalid id', () => {
|
||||
const selector = getSelector('nonexistent');
|
||||
assertEquals(selector, undefined);
|
||||
});
|
||||
|
||||
this.test('isValidSelector returns true for valid ids', () => {
|
||||
assertEquals(isValidSelector('random'), true);
|
||||
assertEquals(isValidSelector('oldest'), true);
|
||||
assertEquals(isValidSelector('newest'), true);
|
||||
assertEquals(isValidSelector('lowest_score'), true);
|
||||
assertEquals(isValidSelector('most_popular'), true);
|
||||
assertEquals(isValidSelector('least_popular'), true);
|
||||
});
|
||||
|
||||
this.test('isValidSelector returns false for invalid ids', () => {
|
||||
assertEquals(isValidSelector('nonexistent'), false);
|
||||
assertEquals(isValidSelector(''), false);
|
||||
});
|
||||
|
||||
this.test('getAllSelectorIds returns all selector ids', () => {
|
||||
const ids = getAllSelectorIds();
|
||||
assertEquals(ids.length, 6);
|
||||
assert(ids.includes('random'));
|
||||
assert(ids.includes('oldest'));
|
||||
assert(ids.includes('newest'));
|
||||
assert(ids.includes('lowest_score'));
|
||||
assert(ids.includes('most_popular'));
|
||||
assert(ids.includes('least_popular'));
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Random Selector
|
||||
// =====================
|
||||
|
||||
this.test('random: selects correct count', () => {
|
||||
const items = this.createMockItems();
|
||||
const selector = getSelector('random')!;
|
||||
const selected = selector.select(items, 3);
|
||||
assertEquals(selected.length, 3);
|
||||
});
|
||||
|
||||
this.test('random: handles count larger than items', () => {
|
||||
const items = this.createMockItems();
|
||||
const selector = getSelector('random')!;
|
||||
const selected = selector.select(items, 10);
|
||||
assertEquals(selected.length, 5); // Only 5 items available
|
||||
});
|
||||
|
||||
this.test('random: returns empty array for empty input', () => {
|
||||
const selector = getSelector('random')!;
|
||||
const selected = selector.select([], 5);
|
||||
assertEquals(selected.length, 0);
|
||||
});
|
||||
|
||||
this.test('random: does not modify original array', () => {
|
||||
const items = this.createMockItems();
|
||||
const originalFirst = items[0];
|
||||
const selector = getSelector('random')!;
|
||||
selector.select(items, 3);
|
||||
assertEquals(items[0], originalFirst);
|
||||
assertEquals(items.length, 5);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Oldest Selector
|
||||
// =====================
|
||||
|
||||
this.test('oldest: selects oldest items first', () => {
|
||||
const items = this.createMockItems();
|
||||
const selector = getSelector('oldest')!;
|
||||
const selected = selector.select(items, 3);
|
||||
|
||||
assertEquals(selected.length, 3);
|
||||
// Movie C (2022-03-10) is oldest
|
||||
assertEquals(selected[0].id, 3);
|
||||
assertEquals(selected[0].title, 'Movie C');
|
||||
// Movie A (2023-01-15) is second oldest
|
||||
assertEquals(selected[1].id, 1);
|
||||
// Movie B (2023-06-20) is third oldest
|
||||
assertEquals(selected[2].id, 2);
|
||||
});
|
||||
|
||||
this.test('oldest: handles items with same date', () => {
|
||||
const items: MockItem[] = [
|
||||
{ id: 1, title: 'A', dateAdded: '2023-01-15T00:00:00Z', score: 0, popularity: 0 },
|
||||
{ id: 2, title: 'B', dateAdded: '2023-01-15T00:00:00Z', score: 0, popularity: 0 },
|
||||
{ id: 3, title: 'C', dateAdded: '2022-01-15T00:00:00Z', score: 0, popularity: 0 }
|
||||
];
|
||||
const selector = getSelector('oldest')!;
|
||||
const selected = selector.select(items, 2);
|
||||
|
||||
assertEquals(selected.length, 2);
|
||||
assertEquals(selected[0].id, 3); // Oldest first
|
||||
});
|
||||
|
||||
this.test('oldest: handles missing dateAdded', () => {
|
||||
const items = [
|
||||
{ id: 1, title: 'A', score: 0, popularity: 0 },
|
||||
{ id: 2, title: 'B', dateAdded: '2023-01-15T00:00:00Z', score: 0, popularity: 0 }
|
||||
] as MockItem[];
|
||||
const selector = getSelector('oldest')!;
|
||||
const selected = selector.select(items, 2);
|
||||
|
||||
assertEquals(selected.length, 2);
|
||||
// Item without dateAdded should be treated as epoch (very old)
|
||||
assertEquals(selected[0].id, 1);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Newest Selector
|
||||
// =====================
|
||||
|
||||
this.test('newest: selects newest items first', () => {
|
||||
const items = this.createMockItems();
|
||||
const selector = getSelector('newest')!;
|
||||
const selected = selector.select(items, 3);
|
||||
|
||||
assertEquals(selected.length, 3);
|
||||
// Movie D (2024-01-01) is newest
|
||||
assertEquals(selected[0].id, 4);
|
||||
assertEquals(selected[0].title, 'Movie D');
|
||||
// Movie E (2023-09-05) is second newest
|
||||
assertEquals(selected[1].id, 5);
|
||||
// Movie B (2023-06-20) is third newest
|
||||
assertEquals(selected[2].id, 2);
|
||||
});
|
||||
|
||||
this.test('newest: handles count of 1', () => {
|
||||
const items = this.createMockItems();
|
||||
const selector = getSelector('newest')!;
|
||||
const selected = selector.select(items, 1);
|
||||
|
||||
assertEquals(selected.length, 1);
|
||||
assertEquals(selected[0].id, 4); // Movie D is newest
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Lowest Score Selector
|
||||
// =====================
|
||||
|
||||
this.test('lowest_score: selects items with lowest score first', () => {
|
||||
const items = this.createMockItems();
|
||||
const selector = getSelector('lowest_score')!;
|
||||
const selected = selector.select(items, 3);
|
||||
|
||||
assertEquals(selected.length, 3);
|
||||
// Movie C (score: 25) has lowest score
|
||||
assertEquals(selected[0].id, 3);
|
||||
assertEquals(selected[0].score, 25);
|
||||
// Movie A (score: 50) has second lowest
|
||||
assertEquals(selected[1].id, 1);
|
||||
assertEquals(selected[1].score, 50);
|
||||
// Movie E (score: 60) has third lowest
|
||||
assertEquals(selected[2].id, 5);
|
||||
assertEquals(selected[2].score, 60);
|
||||
});
|
||||
|
||||
this.test('lowest_score: handles zero scores', () => {
|
||||
const items: MockItem[] = [
|
||||
{ id: 1, title: 'A', dateAdded: '', score: 0, popularity: 0 },
|
||||
{ id: 2, title: 'B', dateAdded: '', score: 50, popularity: 0 },
|
||||
{ id: 3, title: 'C', dateAdded: '', score: -10, popularity: 0 } // Negative score
|
||||
];
|
||||
const selector = getSelector('lowest_score')!;
|
||||
const selected = selector.select(items, 2);
|
||||
|
||||
assertEquals(selected.length, 2);
|
||||
assertEquals(selected[0].id, 3); // Negative is lowest
|
||||
assertEquals(selected[1].id, 1); // Zero is next
|
||||
});
|
||||
|
||||
this.test('lowest_score: handles missing score', () => {
|
||||
const items = [
|
||||
{ id: 1, title: 'A', dateAdded: '', popularity: 0 },
|
||||
{ id: 2, title: 'B', dateAdded: '', score: 50, popularity: 0 }
|
||||
] as MockItem[];
|
||||
const selector = getSelector('lowest_score')!;
|
||||
const selected = selector.select(items, 2);
|
||||
|
||||
assertEquals(selected.length, 2);
|
||||
// Item without score (treated as 0) should be first
|
||||
assertEquals(selected[0].id, 1);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Most Popular Selector
|
||||
// =====================
|
||||
|
||||
this.test('most_popular: selects most popular items first', () => {
|
||||
const items = this.createMockItems();
|
||||
const selector = getSelector('most_popular')!;
|
||||
const selected = selector.select(items, 3);
|
||||
|
||||
assertEquals(selected.length, 3);
|
||||
// Movie E (popularity: 300) is most popular
|
||||
assertEquals(selected[0].id, 5);
|
||||
assertEquals(selected[0].popularity, 300);
|
||||
// Movie B (popularity: 200) is second
|
||||
assertEquals(selected[1].id, 2);
|
||||
assertEquals(selected[1].popularity, 200);
|
||||
// Movie C (popularity: 150) is third
|
||||
assertEquals(selected[2].id, 3);
|
||||
assertEquals(selected[2].popularity, 150);
|
||||
});
|
||||
|
||||
this.test('most_popular: handles same popularity', () => {
|
||||
const items: MockItem[] = [
|
||||
{ id: 1, title: 'A', dateAdded: '', score: 0, popularity: 100 },
|
||||
{ id: 2, title: 'B', dateAdded: '', score: 0, popularity: 100 },
|
||||
{ id: 3, title: 'C', dateAdded: '', score: 0, popularity: 200 }
|
||||
];
|
||||
const selector = getSelector('most_popular')!;
|
||||
const selected = selector.select(items, 2);
|
||||
|
||||
assertEquals(selected.length, 2);
|
||||
assertEquals(selected[0].id, 3); // Most popular first
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Least Popular Selector
|
||||
// =====================
|
||||
|
||||
this.test('least_popular: selects least popular items first', () => {
|
||||
const items = this.createMockItems();
|
||||
const selector = getSelector('least_popular')!;
|
||||
const selected = selector.select(items, 3);
|
||||
|
||||
assertEquals(selected.length, 3);
|
||||
// Movie D (popularity: 50) is least popular
|
||||
assertEquals(selected[0].id, 4);
|
||||
assertEquals(selected[0].popularity, 50);
|
||||
// Movie A (popularity: 100) is second least
|
||||
assertEquals(selected[1].id, 1);
|
||||
assertEquals(selected[1].popularity, 100);
|
||||
// Movie C (popularity: 150) is third least
|
||||
assertEquals(selected[2].id, 3);
|
||||
assertEquals(selected[2].popularity, 150);
|
||||
});
|
||||
|
||||
this.test('least_popular: handles missing popularity', () => {
|
||||
const items = [
|
||||
{ id: 1, title: 'A', dateAdded: '', score: 0 },
|
||||
{ id: 2, title: 'B', dateAdded: '', score: 0, popularity: 100 }
|
||||
] as MockItem[];
|
||||
const selector = getSelector('least_popular')!;
|
||||
const selected = selector.select(items, 2);
|
||||
|
||||
assertEquals(selected.length, 2);
|
||||
// Item without popularity (treated as 0) should be first (least popular)
|
||||
assertEquals(selected[0].id, 1);
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Edge Cases
|
||||
// =====================
|
||||
|
||||
this.test('edge case: count of 0 returns empty array', () => {
|
||||
const items = this.createMockItems();
|
||||
for (const selector of selectors) {
|
||||
const selected = selector.select(items, 0);
|
||||
assertEquals(selected.length, 0, `${selector.id} should return empty array for count=0`);
|
||||
}
|
||||
});
|
||||
|
||||
this.test('edge case: negative count returns empty array', () => {
|
||||
const items = this.createMockItems();
|
||||
for (const selector of selectors) {
|
||||
const selected = selector.select(items, -5);
|
||||
assertEquals(selected.length, 0, `${selector.id} should return empty array for negative count`);
|
||||
}
|
||||
});
|
||||
|
||||
this.test('edge case: single item', () => {
|
||||
const items: MockItem[] = [
|
||||
{ id: 1, title: 'Only One', dateAdded: '2023-01-15T00:00:00Z', score: 50, popularity: 100 }
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const selected = selector.select(items, 1);
|
||||
assertEquals(selected.length, 1, `${selector.id} should return 1 item`);
|
||||
assertEquals(selected[0].id, 1, `${selector.id} should return the only item`);
|
||||
}
|
||||
});
|
||||
|
||||
// =====================
|
||||
// Integration Scenarios
|
||||
// =====================
|
||||
|
||||
this.test('scenario: upgrade workflow with lowest score selector', () => {
|
||||
// Simulate items that passed filter evaluation
|
||||
const filteredItems: MockItem[] = [
|
||||
{ id: 10, title: 'Needs Upgrade', dateAdded: '2023-01-01T00:00:00Z', score: 30, popularity: 100 },
|
||||
{ id: 20, title: 'Almost There', dateAdded: '2023-02-01T00:00:00Z', score: 70, popularity: 200 },
|
||||
{ id: 30, title: 'Very Low', dateAdded: '2023-03-01T00:00:00Z', score: 10, popularity: 150 },
|
||||
{ id: 40, title: 'Medium', dateAdded: '2023-04-01T00:00:00Z', score: 50, popularity: 80 }
|
||||
];
|
||||
|
||||
const selector = getSelector('lowest_score')!;
|
||||
const toUpgrade = selector.select(filteredItems, 2);
|
||||
|
||||
assertEquals(toUpgrade.length, 2);
|
||||
// Should select the two with lowest scores for upgrade
|
||||
assertEquals(toUpgrade[0].id, 30); // score: 10
|
||||
assertEquals(toUpgrade[1].id, 10); // score: 30
|
||||
});
|
||||
|
||||
this.test('scenario: upgrade workflow with oldest selector', () => {
|
||||
// Simulate prioritizing oldest items that need upgrade
|
||||
const filteredItems: MockItem[] = [
|
||||
{ id: 10, title: 'Recent', dateAdded: '2024-01-01T00:00:00Z', score: 30, popularity: 100 },
|
||||
{ id: 20, title: 'Old', dateAdded: '2020-01-01T00:00:00Z', score: 40, popularity: 200 },
|
||||
{ id: 30, title: 'Very Old', dateAdded: '2018-06-01T00:00:00Z', score: 50, popularity: 150 }
|
||||
];
|
||||
|
||||
const selector = getSelector('oldest')!;
|
||||
const toUpgrade = selector.select(filteredItems, 2);
|
||||
|
||||
assertEquals(toUpgrade.length, 2);
|
||||
assertEquals(toUpgrade[0].id, 30); // Very Old (2018)
|
||||
assertEquals(toUpgrade[1].id, 20); // Old (2020)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create instance and run tests
|
||||
const selectorsTest = new SelectorsTest();
|
||||
selectorsTest.runTests();
|
||||
Reference in New Issue
Block a user