diff --git a/libs/portal/downloads/feature/src/lib/download-library.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.spec.ts new file mode 100644 index 000000000..09b7f656b --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.spec.ts @@ -0,0 +1,202 @@ +import type { DownloadItem } from '@iptvnator/services'; +import { + buildDownloadLibrary, + compareDownloadIds, + deriveStandardizedSeriesTitle, + isUsableDownloadOrderNumber, + type DownloadLibraryRow, + type DownloadSeriesCardViewModel, +} from './download-library.viewmodel'; + +const CREATED_AT = '2026-01-01T00:00:00Z'; +const MALFORMED_ID_ORDER = [ + 'nan', + 'negative-infinity', + 'negative', + 'zero', + 'fractional', + 'unsafe', + 'positive-infinity', +] as const; +const ID_CASES = [ + { id: 7, title: 'valid-7' }, + { id: Number.NaN, title: 'nan' }, + { id: Number.POSITIVE_INFINITY, title: 'positive-infinity' }, + { id: Number.NEGATIVE_INFINITY, title: 'negative-infinity' }, + { id: -3, title: 'negative' }, + { id: 0, title: 'zero' }, + { id: 42.5, title: 'fractional' }, + { id: Number.MAX_SAFE_INTEGER + 1, title: 'unsafe' }, + { id: 2, title: 'valid-2' }, +] as const; + +function download( + id: number, + overrides: Partial = {} +): DownloadItem { + return { + id, + playlistId: 'playlist-a', + xtreamId: 1, + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + episodeNumber: 1, + title: `Episode ${id}`, + url: 'https://example.test/episode', + status: 'completed', + createdAt: CREATED_AT, + ...overrides, + }; +} + +function row(item: DownloadItem): DownloadLibraryRow { + return { + item, + episodeLabel: '', + sourceName: 'Alpha Source', + }; +} + +function buildSeries( + items: readonly DownloadItem[] +): DownloadSeriesCardViewModel { + const [entity] = buildDownloadLibrary(items.map(row)); + expect(entity.kind).toBe('series'); + if (entity.kind !== 'series') { + throw new Error('Expected a grouped series entity'); + } + return entity; +} + +function idEpisodes( + cases: readonly (typeof ID_CASES)[number][] +): DownloadItem[] { + return cases.map(({ id, title }) => download(id, { title })); +} + +describe('download library view model helpers', () => { + it('does not derive a series title from a non-episode item', () => { + const movie = download(1, { + contentType: 'vod', + title: 'Movie - S01E02 - Not an episode', + }); + + expect(deriveStandardizedSeriesTitle(movie)).toBeUndefined(); + }); + + it('derives the standardized prefix from an episode title', () => { + const episode = download(1, { + title: 'Canonical Show - S01E02 - The episode', + }); + + expect(deriveStandardizedSeriesTitle(episode)).toBe('Canonical Show'); + }); + + it.each([ + [0, true], + [1, true], + [Number.MAX_SAFE_INTEGER, true], + [1.5, false], + [Number.MAX_SAFE_INTEGER + 1, false], + [Number.NaN, false], + [Number.POSITIVE_INFINITY, false], + [-1, false], + ])('classifies %p as usable=%s', (value, expected) => { + expect(isUsableDownloadOrderNumber(value)).toBe(expected); + }); + + it('returns finite results for duplicate and zero malformed ids', () => { + expect(compareDownloadIds(3, 3)).toBe(0); + expect(compareDownloadIds(Number.NaN, Number.NaN)).toBe(0); + expect(compareDownloadIds(0, Number.NaN)).toBe(1); + }); + + it('keeps a fractional series id as an individual episode card', () => { + const entities = buildDownloadLibrary([ + row(download(1, { seriesXtreamId: 42.5 })), + ]); + + expect(entities).toEqual([ + expect.objectContaining({ + kind: 'episode', + key: 'episode:1', + }), + ]); + }); + + it('orders member ids with valid ascending before the documented malformed fallback', () => { + const expected = ['valid-2', 'valid-7', ...MALFORMED_ID_ORDER]; + const forward = buildSeries(idEpisodes(ID_CASES)); + const reversed = buildSeries(idEpisodes([...ID_CASES].reverse())); + + expect(forward.members.map(({ title }) => title)).toEqual(expected); + expect(reversed.members.map(({ title }) => title)).toEqual(expected); + }); + + it('chooses the greatest valid id as representative before malformed ids', () => { + const forward = buildSeries(idEpisodes(ID_CASES)); + const reversed = buildSeries(idEpisodes([...ID_CASES].reverse())); + + expect(forward.representative.title).toBe('valid-7'); + expect(reversed.representative.title).toBe('valid-7'); + }); + + it('chooses a malformed representative by canonical fallback independent of input order', () => { + const malformed = ID_CASES.filter( + ({ title }) => !title.startsWith('valid') + ); + const forward = buildSeries(idEpisodes(malformed)); + const reversed = buildSeries(idEpisodes([...malformed].reverse())); + + expect(forward.representative.title).toBe('nan'); + expect(reversed.representative.title).toBe('nan'); + }); + + it('sorts fractional and unsafe season or episode values last', () => { + const unsafe = Number.MAX_SAFE_INTEGER + 1; + const series = buildSeries([ + download(1, { title: 'valid', seasonNumber: 1, episodeNumber: 2 }), + download(2, { + title: 'fractional-season', + seasonNumber: 1.5, + episodeNumber: 1, + }), + download(3, { + title: 'unsafe-season', + seasonNumber: unsafe, + episodeNumber: 1, + }), + download(4, { + title: 'fractional-episode', + seasonNumber: 1, + episodeNumber: 1.5, + }), + download(5, { + title: 'unsafe-episode', + seasonNumber: 1, + episodeNumber: unsafe, + }), + ]); + + expect(series.members.map(({ title }) => title)).toEqual([ + 'valid', + 'fractional-episode', + 'unsafe-episode', + 'fractional-season', + 'unsafe-season', + ]); + }); + + it('ignores fractional and unsafe seasons when deriving the range', () => { + const unsafe = Number.MAX_SAFE_INTEGER + 1; + const series = buildSeries([ + download(1, { seasonNumber: 2 }), + download(2, { seasonNumber: 1.5 }), + download(3, { seasonNumber: unsafe }), + ]); + + expect(series.firstSeason).toBe(2); + expect(series.lastSeason).toBe(2); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts index 3a7fcaddd..f21b50352 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts @@ -43,6 +43,15 @@ export type DownloadLibraryEntity = | DownloadSeriesCardViewModel; const STANDARD_EPISODE_TITLE = /^(.+?) - S\d{2}E\d{2} - /; +const MALFORMED_DOWNLOAD_ID_CLASS = { + NAN: '0:nan', + NEGATIVE_INFINITY: '1:negative-infinity', + NEGATIVE: '2:negative', + ZERO: '3:zero', + FRACTIONAL: '4:fractional', + UNSAFE: '5:unsafe', + POSITIVE_INFINITY: '6:positive-infinity', +} as const; export function normalizedDownloadTimestamp(value: string | undefined): number { const timestamp = Date.parse(value ?? ''); @@ -52,7 +61,57 @@ export function normalizedDownloadTimestamp(value: string | undefined): number { export function isUsableDownloadOrderNumber( value: number | undefined ): value is number { - return Number.isInteger(value) && (value ?? -1) >= 0; + return Number.isSafeInteger(value) && value !== undefined && value >= 0; +} + +function malformedDownloadIdKey(value: number): string { + if (Number.isNaN(value)) { + return MALFORMED_DOWNLOAD_ID_CLASS.NAN; + } + if (value === Number.NEGATIVE_INFINITY) { + return MALFORMED_DOWNLOAD_ID_CLASS.NEGATIVE_INFINITY; + } + if (value === Number.POSITIVE_INFINITY) { + return MALFORMED_DOWNLOAD_ID_CLASS.POSITIVE_INFINITY; + } + if (value < 0) { + return `${MALFORMED_DOWNLOAD_ID_CLASS.NEGATIVE}:${String(value)}`; + } + if (value === 0) { + return MALFORMED_DOWNLOAD_ID_CLASS.ZERO; + } + if (!Number.isInteger(value)) { + return `${MALFORMED_DOWNLOAD_ID_CLASS.FRACTIONAL}:${String(value)}`; + } + return `${MALFORMED_DOWNLOAD_ID_CLASS.UNSAFE}:${String(value)}`; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function compareDownloadIds( + left: number, + right: number, + descending = false +): number { + const leftValid = Number.isSafeInteger(left) && left > 0; + const rightValid = Number.isSafeInteger(right) && right > 0; + if (leftValid && rightValid) { + const first = descending ? right : left; + const second = descending ? left : right; + return first < second ? -1 : first > second ? 1 : 0; + } + if (leftValid) { + return -1; + } + if (rightValid) { + return 1; + } + return compareText( + malformedDownloadIdKey(left), + malformedDownloadIdKey(right) + ); } export function deriveStandardizedSeriesTitle( @@ -85,14 +144,15 @@ function compareMembers(left: DownloadItem, right: DownloadItem): number { compareOrderNumber(left.episodeNumber, right.episodeNumber) || normalizedDownloadTimestamp(left.createdAt) - normalizedDownloadTimestamp(right.createdAt) || - left.id - right.id + compareDownloadIds(left.id, right.id) ); } function compareNewest(left: DownloadItem, right: DownloadItem): number { return ( normalizedDownloadTimestamp(right.createdAt) - - normalizedDownloadTimestamp(left.createdAt) || right.id - left.id + normalizedDownloadTimestamp(left.createdAt) || + compareDownloadIds(left.id, right.id, true) ); } diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts index 6dec2816d..2de1ff712 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts @@ -244,6 +244,41 @@ describe('buildDownloadManagerViewModel', () => { expect(hidden.hasClearable).toBe(true); }); + it('keeps counts stable under every display filter', () => { + const downloads = [ + download(1, { status: 'queued' }), + download(2, { + contentType: 'episode', + status: 'paused', + }), + download(3, { status: 'failed' }), + download(4), + download(5, { + contentType: 'episode', + seriesXtreamId: 50, + }), + download(6, { + contentType: 'episode', + seriesXtreamId: 50, + }), + ]; + const expected = { + all: 5, + movie: 3, + series: 2, + inProgress: 2, + }; + + for (const filter of [ + 'all', + 'movie', + 'series', + 'in-progress', + ] as const) { + expect(build(downloads, { filter }).counts).toEqual(expected); + } + }); + it('searches stored title, derived series title, source, episode label, and error message', () => { const downloads = [ download(1, { @@ -400,6 +435,43 @@ describe('buildDownloadManagerViewModel', () => { ]); }); + it('leaves episode labels blank for fractional and unsafe season or episode numbers', () => { + const unsafe = Number.MAX_SAFE_INTEGER + 1; + const result = build([ + download(1, { + contentType: 'episode', + seasonNumber: 1.5, + episodeNumber: 2, + status: 'queued', + }), + download(2, { + contentType: 'episode', + seasonNumber: unsafe, + episodeNumber: 2, + status: 'queued', + }), + download(3, { + contentType: 'episode', + seasonNumber: 1, + episodeNumber: 2.5, + status: 'queued', + }), + download(4, { + contentType: 'episode', + seasonNumber: 1, + episodeNumber: unsafe, + status: 'queued', + }), + ]); + + expect(result.active.map(({ episodeLabel }) => episodeLabel)).toEqual([ + '', + '', + '', + '', + ]); + }); + it('partitions every supported status without leaking items between sections', () => { const downloads = [ download(1, { status: 'queued' }), @@ -516,6 +588,41 @@ describe('buildDownloadManagerViewModel', () => { expect(rowIds(result.attention)).toEqual([11, 12, 15]); }); + it('totally orders valid and malformed queue ids independent of input order', () => { + const createdAt = '2026-01-01T00:00:00Z'; + const cases = [ + { id: 7, title: 'valid-7' }, + { id: Number.NaN, title: 'nan' }, + { id: Number.POSITIVE_INFINITY, title: 'positive-infinity' }, + { id: Number.NEGATIVE_INFINITY, title: 'negative-infinity' }, + { id: -3, title: 'negative' }, + { id: 0, title: 'zero' }, + { id: 42.5, title: 'fractional' }, + { id: Number.MAX_SAFE_INTEGER + 1, title: 'unsafe' }, + { id: 2, title: 'valid-2' }, + ]; + const expected = [ + 'valid-2', + 'valid-7', + 'nan', + 'negative-infinity', + 'negative', + 'zero', + 'fractional', + 'unsafe', + 'positive-infinity', + ]; + const titlesFor = (input: typeof cases) => + build( + input.map(({ id, title }) => + download(id, { title, status: 'queued', createdAt }) + ) + ).active.map(({ item }) => item.title); + + expect(titlesFor(cases)).toEqual(expected); + expect(titlesFor([...cases].reverse())).toEqual(expected); + }); + it('sorts completed library entities by newest timestamp then key', () => { const result = build([ download(3, { createdAt: '2025-01-01T00:00:00Z' }), diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts index 663cff1b9..5d9257b08 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -2,6 +2,7 @@ import type { DownloadItem } from '@iptvnator/services'; import type { Playlist } from '@iptvnator/shared/interfaces'; import { buildDownloadLibrary, + compareDownloadIds, deriveStandardizedSeriesTitle, isUsableDownloadOrderNumber, normalizedDownloadTimestamp, @@ -101,7 +102,7 @@ function compareQueued( return ( normalizedDownloadTimestamp(left.item.createdAt) - normalizedDownloadTimestamp(right.item.createdAt) || - left.item.id - right.item.id + compareDownloadIds(left.item.id, right.item.id) ); }