From 0ce195ce36cbb6996e03e3fd22f1fd85193652a1 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Sat, 27 Dec 2025 11:23:48 +1030 Subject: [PATCH] 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. --- src/tests/upgrades/filters.test.ts | 607 +++++++++++++++++++++++++++ src/tests/upgrades/normalize.test.ts | 595 ++++++++++++++++++++++++++ src/tests/upgrades/selectors.test.ts | 396 +++++++++++++++++ 3 files changed, 1598 insertions(+) create mode 100644 src/tests/upgrades/filters.test.ts create mode 100644 src/tests/upgrades/normalize.test.ts create mode 100644 src/tests/upgrades/selectors.test.ts diff --git a/src/tests/upgrades/filters.test.ts b/src/tests/upgrades/filters.test.ts new file mode 100644 index 0000000..c99c188 --- /dev/null +++ b/src/tests/upgrades/filters.test.ts @@ -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(); diff --git a/src/tests/upgrades/normalize.test.ts b/src/tests/upgrades/normalize.test.ts new file mode 100644 index 0000000..f0328a2 --- /dev/null +++ b/src/tests/upgrades/normalize.test.ts @@ -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 { + 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 { + 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 { + 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(); + const profileMap = new Map(); + + 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(); diff --git a/src/tests/upgrades/selectors.test.ts b/src/tests/upgrades/selectors.test.ts new file mode 100644 index 0000000..4a05cf6 --- /dev/null +++ b/src/tests/upgrades/selectors.test.ts @@ -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();