mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
feat(downloads): derive queue and library view model
This commit is contained in:
1 parent
bc29f24eb2
commit
d6e1ee5d0c
8 files changed
+1290
-5
No files matched your search
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'portal-downloads-feature',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/portal/downloads/feature',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -6,6 +6,14 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/portal/downloads/feature/jest.config.ts",
|
||||
"tsConfig": "libs/portal/downloads/feature/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
|
||||
export interface DownloadLibraryRow {
|
||||
readonly item: DownloadItem;
|
||||
readonly episodeLabel: string;
|
||||
readonly sourceName: string;
|
||||
}
|
||||
|
||||
interface DownloadLibraryEntityBase {
|
||||
readonly key: string;
|
||||
readonly newestTimestamp: number;
|
||||
readonly sourceName: string;
|
||||
readonly trackedBytes: number;
|
||||
}
|
||||
|
||||
interface DownloadItemLibraryEntityBase extends DownloadLibraryEntityBase {
|
||||
readonly item: DownloadItem;
|
||||
}
|
||||
|
||||
export interface DownloadMovieCardViewModel extends DownloadItemLibraryEntityBase {
|
||||
readonly kind: 'movie';
|
||||
}
|
||||
|
||||
export interface DownloadEpisodeCardViewModel extends DownloadItemLibraryEntityBase {
|
||||
readonly kind: 'episode';
|
||||
readonly episodeLabel: string;
|
||||
}
|
||||
|
||||
export interface DownloadSeriesCardViewModel extends DownloadLibraryEntityBase {
|
||||
readonly kind: 'series';
|
||||
readonly representative: DownloadItem;
|
||||
readonly members: readonly DownloadItem[];
|
||||
readonly seriesXtreamId: number;
|
||||
readonly title: string;
|
||||
readonly posterUrl?: string;
|
||||
readonly firstSeason?: number;
|
||||
readonly lastSeason?: number;
|
||||
}
|
||||
|
||||
export type DownloadLibraryEntity =
|
||||
| DownloadMovieCardViewModel
|
||||
| DownloadEpisodeCardViewModel
|
||||
| DownloadSeriesCardViewModel;
|
||||
|
||||
const STANDARD_EPISODE_TITLE = /^(.+?) - S\d{2}E\d{2} - /;
|
||||
|
||||
export function normalizedDownloadTimestamp(value: string | undefined): number {
|
||||
const timestamp = Date.parse(value ?? '');
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
export function isUsableDownloadOrderNumber(
|
||||
value: number | undefined
|
||||
): value is number {
|
||||
return Number.isInteger(value) && (value ?? -1) >= 0;
|
||||
}
|
||||
|
||||
export function deriveStandardizedSeriesTitle(
|
||||
item: DownloadItem
|
||||
): string | undefined {
|
||||
if (item.contentType !== 'episode') {
|
||||
return undefined;
|
||||
}
|
||||
return STANDARD_EPISODE_TITLE.exec(item.title)?.[1];
|
||||
}
|
||||
|
||||
function compareOrderNumber(
|
||||
left: number | undefined,
|
||||
right: number | undefined
|
||||
): number {
|
||||
const leftUsable = isUsableDownloadOrderNumber(left);
|
||||
const rightUsable = isUsableDownloadOrderNumber(right);
|
||||
if (leftUsable && rightUsable) {
|
||||
return left - right;
|
||||
}
|
||||
if (leftUsable) {
|
||||
return -1;
|
||||
}
|
||||
return rightUsable ? 1 : 0;
|
||||
}
|
||||
|
||||
function compareMembers(left: DownloadItem, right: DownloadItem): number {
|
||||
return (
|
||||
compareOrderNumber(left.seasonNumber, right.seasonNumber) ||
|
||||
compareOrderNumber(left.episodeNumber, right.episodeNumber) ||
|
||||
normalizedDownloadTimestamp(left.createdAt) -
|
||||
normalizedDownloadTimestamp(right.createdAt) ||
|
||||
left.id - right.id
|
||||
);
|
||||
}
|
||||
|
||||
function compareNewest(left: DownloadItem, right: DownloadItem): number {
|
||||
return (
|
||||
normalizedDownloadTimestamp(right.createdAt) -
|
||||
normalizedDownloadTimestamp(left.createdAt) || right.id - left.id
|
||||
);
|
||||
}
|
||||
|
||||
function isValidSeriesId(value: number | undefined): value is number {
|
||||
return Number.isSafeInteger(value) && (value ?? 0) > 0;
|
||||
}
|
||||
|
||||
function createSeriesEntity(
|
||||
rows: readonly DownloadLibraryRow[],
|
||||
seriesXtreamId: number
|
||||
): DownloadSeriesCardViewModel {
|
||||
const members = rows.map(({ item }) => item).sort(compareMembers);
|
||||
const newestFirst = [...members].sort(compareNewest);
|
||||
const representative = newestFirst[0];
|
||||
const posterUrl = newestFirst.find(
|
||||
(item) => (item.posterUrl?.trim().length ?? 0) > 0
|
||||
)?.posterUrl;
|
||||
const standardizedTitle = members
|
||||
.map(deriveStandardizedSeriesTitle)
|
||||
.find((title) => title !== undefined);
|
||||
const legacyTitle =
|
||||
members.find((item) => item.title.trim().length > 0)?.title ?? '';
|
||||
const seasons = members
|
||||
.map(({ seasonNumber }) => seasonNumber)
|
||||
.filter(isUsableDownloadOrderNumber);
|
||||
|
||||
return {
|
||||
kind: 'series',
|
||||
key: `series:${representative.playlistId}:${seriesXtreamId}`,
|
||||
representative,
|
||||
members,
|
||||
seriesXtreamId,
|
||||
title: standardizedTitle ?? legacyTitle,
|
||||
...(posterUrl === undefined ? {} : { posterUrl }),
|
||||
newestTimestamp: normalizedDownloadTimestamp(representative.createdAt),
|
||||
sourceName: rows[0].sourceName,
|
||||
trackedBytes: members.reduce(
|
||||
(total, item) => total + (item.bytesDownloaded ?? 0),
|
||||
0
|
||||
),
|
||||
...(seasons.length === 0
|
||||
? {}
|
||||
: {
|
||||
firstSeason: Math.min(...seasons),
|
||||
lastSeason: Math.max(...seasons),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDownloadLibrary(
|
||||
rows: readonly DownloadLibraryRow[]
|
||||
): DownloadLibraryEntity[] {
|
||||
const entities: DownloadLibraryEntity[] = [];
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
readonly seriesXtreamId: number;
|
||||
readonly rows: DownloadLibraryRow[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const { item } = row;
|
||||
if (item.contentType === 'vod') {
|
||||
entities.push({
|
||||
kind: 'movie',
|
||||
key: `movie:${item.id}`,
|
||||
item,
|
||||
newestTimestamp: normalizedDownloadTimestamp(item.createdAt),
|
||||
sourceName: row.sourceName,
|
||||
trackedBytes: item.bytesDownloaded ?? 0,
|
||||
});
|
||||
} else if (isValidSeriesId(item.seriesXtreamId)) {
|
||||
const key = `${item.playlistId}:${item.seriesXtreamId}`;
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.rows.push(row);
|
||||
} else {
|
||||
groups.set(key, {
|
||||
seriesXtreamId: item.seriesXtreamId,
|
||||
rows: [row],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
entities.push({
|
||||
kind: 'episode',
|
||||
key: `episode:${item.id}`,
|
||||
item,
|
||||
newestTimestamp: normalizedDownloadTimestamp(item.createdAt),
|
||||
sourceName: row.sourceName,
|
||||
trackedBytes: item.bytesDownloaded ?? 0,
|
||||
episodeLabel: row.episodeLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { rows: members, seriesXtreamId } of groups.values()) {
|
||||
entities.push(createSeriesEntity(members, seriesXtreamId));
|
||||
}
|
||||
|
||||
return entities.sort(
|
||||
(left, right) =>
|
||||
right.newestTimestamp - left.newestTimestamp ||
|
||||
(left.key < right.key ? -1 : left.key > right.key ? 1 : 0)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildDownloadManagerViewModel,
|
||||
normalizeDownloadFilter,
|
||||
type DownloadEpisodeCardViewModel,
|
||||
type DownloadLibraryEntity,
|
||||
type DownloadManagerViewModel,
|
||||
type DownloadMovieCardViewModel,
|
||||
type DownloadSeriesCardViewModel,
|
||||
} from './download-manager.viewmodel';
|
||||
|
||||
type PlaylistSummary = Pick<Playlist, '_id' | 'title'>;
|
||||
type ExpectedDownloadLibraryEntity =
|
||||
| DownloadMovieCardViewModel
|
||||
| DownloadEpisodeCardViewModel
|
||||
| DownloadSeriesCardViewModel;
|
||||
|
||||
const PLAYLISTS: readonly PlaylistSummary[] = [
|
||||
{ _id: 'playlist-a', title: 'Alpha Source' },
|
||||
{ _id: 'playlist-b', title: 'Beta Source' },
|
||||
];
|
||||
|
||||
function download(
|
||||
id: number,
|
||||
overrides: Partial<DownloadItem> = {}
|
||||
): DownloadItem {
|
||||
return {
|
||||
id,
|
||||
playlistId: 'playlist-a',
|
||||
xtreamId: id * 10,
|
||||
contentType: 'vod',
|
||||
title: `Download ${id}`,
|
||||
url: `https://example.test/${id}`,
|
||||
status: 'completed',
|
||||
createdAt: `2026-01-${String(id).padStart(2, '0')}T00:00:00Z`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function build(
|
||||
downloads: readonly DownloadItem[],
|
||||
options: {
|
||||
playlists?: readonly PlaylistSummary[];
|
||||
scopePlaylistId?: string;
|
||||
filter?: string;
|
||||
searchTerm?: string;
|
||||
} = {}
|
||||
): DownloadManagerViewModel {
|
||||
return buildDownloadManagerViewModel({
|
||||
downloads,
|
||||
playlists: options.playlists ?? PLAYLISTS,
|
||||
scopePlaylistId: options.scopePlaylistId,
|
||||
filter: options.filter ?? 'all',
|
||||
searchTerm: options.searchTerm,
|
||||
});
|
||||
}
|
||||
|
||||
function rowIds(
|
||||
rows:
|
||||
| DownloadManagerViewModel['active']
|
||||
| DownloadManagerViewModel['attention']
|
||||
): number[] {
|
||||
return rows.map(({ item }) => item.id);
|
||||
}
|
||||
|
||||
function libraryItemIds(entities: readonly DownloadLibraryEntity[]): number[] {
|
||||
const typedEntities: readonly ExpectedDownloadLibraryEntity[] = entities;
|
||||
return typedEntities.flatMap((entity) =>
|
||||
entity.kind === 'series'
|
||||
? entity.members.map(({ id }) => id)
|
||||
: [entity.item.id]
|
||||
);
|
||||
}
|
||||
|
||||
describe('normalizeDownloadFilter', () => {
|
||||
it.each(['all', 'movie', 'series', 'in-progress'] as const)(
|
||||
'keeps the known %s filter',
|
||||
(filter) => {
|
||||
expect(normalizeDownloadFilter(filter)).toBe(filter);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([null, undefined, '', 'unknown', 'MOVIE'])(
|
||||
'normalizes %p to all',
|
||||
(filter) => {
|
||||
expect(normalizeDownloadFilter(filter)).toBe('all');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildDownloadManagerViewModel', () => {
|
||||
it('supports global and playlist-scoped views with resolved source labels', () => {
|
||||
const downloads = [
|
||||
download(1, { status: 'queued' }),
|
||||
download(2, {
|
||||
playlistId: 'playlist-b',
|
||||
status: 'failed',
|
||||
}),
|
||||
download(3, {
|
||||
playlistId: 'missing-playlist',
|
||||
status: 'completed',
|
||||
}),
|
||||
];
|
||||
|
||||
const global = build(downloads);
|
||||
const scoped = build(downloads, { scopePlaylistId: 'playlist-a' });
|
||||
|
||||
expect(global.scopedItems.map(({ id }) => id)).toEqual([1, 2, 3]);
|
||||
expect(global.active[0].sourceName).toBe('Alpha Source');
|
||||
expect(global.attention[0].sourceName).toBe('Beta Source');
|
||||
expect(global.library[0].sourceName).toBe('');
|
||||
expect(scoped.scopedItems.map(({ id }) => id)).toEqual([1]);
|
||||
expect(rowIds(scoped.active)).toEqual([1]);
|
||||
expect(scoped.attention).toEqual([]);
|
||||
expect(scoped.library).toEqual([]);
|
||||
});
|
||||
|
||||
it('applies all four display filters across active, attention, and library partitions', () => {
|
||||
const downloads = [
|
||||
download(1, { status: 'queued' }),
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
status: 'failed',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
}),
|
||||
download(3),
|
||||
download(4, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 44,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const all = build(downloads);
|
||||
const movies = build(downloads, { filter: 'movie' });
|
||||
const series = build(downloads, { filter: 'series' });
|
||||
const inProgress = build(downloads, { filter: 'in-progress' });
|
||||
|
||||
expect(rowIds(all.active)).toEqual([1]);
|
||||
expect(rowIds(all.attention)).toEqual([2]);
|
||||
expect(all.library.map(({ kind }) => kind)).toEqual([
|
||||
'series',
|
||||
'movie',
|
||||
]);
|
||||
|
||||
expect(rowIds(movies.active)).toEqual([1]);
|
||||
expect(movies.attention).toEqual([]);
|
||||
expect(movies.library.map(({ kind }) => kind)).toEqual(['movie']);
|
||||
|
||||
expect(series.active).toEqual([]);
|
||||
expect(rowIds(series.attention)).toEqual([2]);
|
||||
expect(series.library.map(({ kind }) => kind)).toEqual(['series']);
|
||||
|
||||
expect(rowIds(inProgress.active)).toEqual([1]);
|
||||
expect(inProgress.attention).toEqual([]);
|
||||
expect(inProgress.library).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps scoped counts stable when search hides every displayed item', () => {
|
||||
const downloads = [
|
||||
download(1, {
|
||||
status: 'queued',
|
||||
bytesDownloaded: 1,
|
||||
}),
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
status: 'downloading',
|
||||
bytesDownloaded: 2,
|
||||
}),
|
||||
download(3, {
|
||||
status: 'paused',
|
||||
bytesDownloaded: 3,
|
||||
}),
|
||||
download(4, {
|
||||
contentType: 'episode',
|
||||
status: 'failed',
|
||||
bytesDownloaded: 4,
|
||||
}),
|
||||
download(5, {
|
||||
status: 'canceled',
|
||||
bytesDownloaded: 5,
|
||||
}),
|
||||
download(6, { bytesDownloaded: 6 }),
|
||||
download(7, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 70,
|
||||
bytesDownloaded: 7,
|
||||
}),
|
||||
download(8, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 70,
|
||||
bytesDownloaded: 8,
|
||||
}),
|
||||
download(9, {
|
||||
contentType: 'episode',
|
||||
bytesDownloaded: 9,
|
||||
}),
|
||||
download(10, {
|
||||
playlistId: 'playlist-b',
|
||||
status: 'queued',
|
||||
bytesDownloaded: 100,
|
||||
}),
|
||||
];
|
||||
|
||||
const visible = build(downloads, { scopePlaylistId: 'playlist-a' });
|
||||
const hidden = build(downloads, {
|
||||
scopePlaylistId: 'playlist-a',
|
||||
searchTerm: 'no result can match this',
|
||||
});
|
||||
|
||||
expect(visible.counts).toEqual({
|
||||
all: 8,
|
||||
movie: 4,
|
||||
series: 4,
|
||||
inProgress: 3,
|
||||
});
|
||||
expect(hidden.counts).toEqual(visible.counts);
|
||||
expect(hidden.scopedItems).toHaveLength(9);
|
||||
expect(hidden.active).toEqual([]);
|
||||
expect(hidden.attention).toEqual([]);
|
||||
expect(hidden.library).toEqual([]);
|
||||
expect(hidden.activeCount).toBe(2);
|
||||
expect(hidden.trackedBytes).toBe(45);
|
||||
expect(hidden.hasClearable).toBe(true);
|
||||
});
|
||||
|
||||
it('searches stored title, derived series title, source, episode label, and error message', () => {
|
||||
const downloads = [
|
||||
download(1, {
|
||||
title: 'Solar Feature',
|
||||
status: 'queued',
|
||||
}),
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
title: 'Archive Show - S01E02 - The Pilot',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
status: 'paused',
|
||||
}),
|
||||
download(3, {
|
||||
playlistId: 'playlist-b',
|
||||
title: 'Unrelated',
|
||||
status: 'failed',
|
||||
}),
|
||||
download(4, {
|
||||
contentType: 'episode',
|
||||
title: 'A legacy chapter',
|
||||
seasonNumber: 7,
|
||||
episodeNumber: 8,
|
||||
}),
|
||||
download(5, {
|
||||
title: 'Another unrelated item',
|
||||
status: 'canceled',
|
||||
errorMessage: 'Checksum mismatch',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
libraryItemIds(
|
||||
build(downloads, {
|
||||
searchTerm: ' A LEGACY CHAPTER ',
|
||||
}).library
|
||||
)
|
||||
).toEqual([4]);
|
||||
expect(
|
||||
rowIds(
|
||||
build(downloads, {
|
||||
searchTerm: 'archive show',
|
||||
}).active
|
||||
)
|
||||
).toEqual([2]);
|
||||
expect(
|
||||
rowIds(
|
||||
build(downloads, {
|
||||
searchTerm: 'beta source',
|
||||
}).attention
|
||||
)
|
||||
).toEqual([3]);
|
||||
expect(
|
||||
libraryItemIds(
|
||||
build(downloads, {
|
||||
searchTerm: 's07e08',
|
||||
}).library
|
||||
)
|
||||
).toEqual([4]);
|
||||
expect(
|
||||
rowIds(
|
||||
build(downloads, {
|
||||
searchTerm: 'checksum mismatch',
|
||||
}).attention
|
||||
)
|
||||
).toEqual([5]);
|
||||
|
||||
const episode = build(downloads).active.find(
|
||||
({ item }) => item.id === 2
|
||||
);
|
||||
expect(episode).toMatchObject({
|
||||
episodeLabel: 'S01E02',
|
||||
seriesTitle: 'Archive Show',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives labels only from usable episode numbers and preserves legacy titles', () => {
|
||||
const downloads = [
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
title: 'Modern - S03E09 - A title',
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 9,
|
||||
status: 'queued',
|
||||
}),
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
title: 'Legacy episode title',
|
||||
seasonNumber: Number.NaN,
|
||||
episodeNumber: 2,
|
||||
status: 'queued',
|
||||
}),
|
||||
download(3, {
|
||||
contentType: 'episode',
|
||||
title: 'Negative episode',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: -1,
|
||||
status: 'queued',
|
||||
}),
|
||||
download(4, {
|
||||
title: 'Movie title',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
status: 'queued',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
build(downloads).active.map(
|
||||
({ item, episodeLabel, seriesTitle }) => ({
|
||||
id: item.id,
|
||||
episodeLabel,
|
||||
seriesTitle,
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
episodeLabel: 'S03E09',
|
||||
seriesTitle: 'Modern',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
episodeLabel: '',
|
||||
seriesTitle: 'Legacy episode title',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
episodeLabel: '',
|
||||
seriesTitle: 'Negative episode',
|
||||
},
|
||||
{ id: 4, episodeLabel: '', seriesTitle: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('partitions every supported status without leaking items between sections', () => {
|
||||
const downloads = [
|
||||
download(1, { status: 'queued' }),
|
||||
download(2, { status: 'downloading' }),
|
||||
download(3, { status: 'paused' }),
|
||||
download(4, { status: 'failed' }),
|
||||
download(5, { status: 'canceled' }),
|
||||
download(6, { status: 'completed' }),
|
||||
];
|
||||
|
||||
const result = build(downloads);
|
||||
|
||||
expect(rowIds(result.active)).toEqual([1, 2, 3]);
|
||||
expect(rowIds(result.attention)).toEqual([4, 5]);
|
||||
expect(libraryItemIds(result.library)).toEqual([6]);
|
||||
});
|
||||
|
||||
it('excludes paused rows from activeCount independently of filter and search', () => {
|
||||
const result = build(
|
||||
[
|
||||
download(1, { status: 'queued' }),
|
||||
download(2, { status: 'downloading' }),
|
||||
download(3, { status: 'paused' }),
|
||||
download(4, {
|
||||
playlistId: 'playlist-b',
|
||||
status: 'downloading',
|
||||
}),
|
||||
],
|
||||
{
|
||||
scopePlaylistId: 'playlist-a',
|
||||
filter: 'movie',
|
||||
searchTerm: 'nothing',
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.activeCount).toBe(2);
|
||||
expect(result.active).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums scoped tracked bytes and reports whether a row can be cleared', () => {
|
||||
const clearable = build(
|
||||
[
|
||||
download(1, {
|
||||
status: 'queued',
|
||||
bytesDownloaded: undefined,
|
||||
}),
|
||||
download(2, {
|
||||
status: 'completed',
|
||||
bytesDownloaded: 25,
|
||||
}),
|
||||
download(3, {
|
||||
playlistId: 'playlist-b',
|
||||
status: 'failed',
|
||||
bytesDownloaded: 100,
|
||||
}),
|
||||
],
|
||||
{ scopePlaylistId: 'playlist-a' }
|
||||
);
|
||||
const activeOnly = build([
|
||||
download(4, { status: 'paused', bytesDownloaded: 4 }),
|
||||
]);
|
||||
|
||||
expect(clearable.trackedBytes).toBe(25);
|
||||
expect(clearable.hasClearable).toBe(true);
|
||||
expect(activeOnly.trackedBytes).toBe(4);
|
||||
expect(activeOnly.hasClearable).toBe(false);
|
||||
});
|
||||
|
||||
it('sorts active and attention rows by normalized timestamp then numeric id', () => {
|
||||
const downloads = [
|
||||
download(9, { status: 'queued', createdAt: undefined }),
|
||||
download(7, {
|
||||
status: 'downloading',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
download(3, {
|
||||
status: 'paused',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
download(10, {
|
||||
status: 'queued',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
}),
|
||||
download(2, {
|
||||
status: 'queued',
|
||||
createdAt: 'invalid',
|
||||
}),
|
||||
download(15, {
|
||||
status: 'failed',
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
}),
|
||||
download(12, {
|
||||
status: 'canceled',
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
}),
|
||||
download(11, {
|
||||
status: 'failed',
|
||||
createdAt: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = build(downloads);
|
||||
|
||||
expect(rowIds(result.active)).toEqual([2, 9, 10, 3, 7]);
|
||||
expect(rowIds(result.attention)).toEqual([11, 12, 15]);
|
||||
});
|
||||
|
||||
it('sorts completed library entities by newest timestamp then key', () => {
|
||||
const result = build([
|
||||
download(3, { createdAt: '2025-01-01T00:00:00Z' }),
|
||||
download(20, { createdAt: '2025-01-01T00:00:00Z' }),
|
||||
download(4, { createdAt: 'invalid' }),
|
||||
download(5, { createdAt: '2026-01-01T00:00:00Z' }),
|
||||
]);
|
||||
|
||||
expect(result.library.map(({ key }) => key)).toEqual([
|
||||
'movie:5',
|
||||
'movie:20',
|
||||
'movie:3',
|
||||
'movie:4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups completed episodes only for a positive safe series id', () => {
|
||||
const result = build([
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 70,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
}),
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 70,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.library).toHaveLength(1);
|
||||
expect(result.library[0]).toMatchObject({
|
||||
kind: 'series',
|
||||
key: 'series:playlist-a:70',
|
||||
seriesXtreamId: 70,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps identical series ids from different playlists in separate groups', () => {
|
||||
const result = build([
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 70,
|
||||
}),
|
||||
download(2, {
|
||||
playlistId: 'playlist-b',
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 70,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.library.map(({ key }) => key).sort()).toEqual([
|
||||
'series:playlist-a:70',
|
||||
'series:playlist-b:70',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid', Number.NaN],
|
||||
['zero', 0],
|
||||
['negative', -1],
|
||||
['unsafe', Number.MAX_SAFE_INTEGER + 1],
|
||||
['missing', undefined],
|
||||
])(
|
||||
'keeps an episode with a %s series id as an individual card',
|
||||
(_description, seriesXtreamId) => {
|
||||
const result = build([
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 4,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.library).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'episode',
|
||||
key: 'episode:1',
|
||||
episodeLabel: 'S01E04',
|
||||
}),
|
||||
]);
|
||||
expect('seriesXtreamId' in result.library[0]).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('prefers a standardized series prefix and otherwise keeps the first non-empty legacy title', () => {
|
||||
const result = build([
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
title: 'Legacy chapter',
|
||||
}),
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
title: 'Canonical Show - S01E02 - Second',
|
||||
}),
|
||||
download(3, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 20,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
title: ' ',
|
||||
}),
|
||||
download(4, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 20,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
title: 'Original legacy title',
|
||||
}),
|
||||
]);
|
||||
|
||||
const series = result.library.filter(
|
||||
(entity) => entity.kind === 'series'
|
||||
);
|
||||
expect(
|
||||
series.find(({ seriesXtreamId }) => seriesXtreamId === 10)?.title
|
||||
).toBe('Canonical Show');
|
||||
expect(
|
||||
series.find(({ seriesXtreamId }) => seriesXtreamId === 20)?.title
|
||||
).toBe('Original legacy title');
|
||||
});
|
||||
|
||||
it('uses the newest member as representative', () => {
|
||||
const result = build([
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
}),
|
||||
download(9, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
createdAt: '2026-03-01T00:00:00Z',
|
||||
}),
|
||||
download(8, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
createdAt: '2026-03-01T00:00:00Z',
|
||||
}),
|
||||
]);
|
||||
|
||||
const [series] = result.library;
|
||||
expect(series.kind).toBe('series');
|
||||
if (series.kind === 'series') {
|
||||
expect(series.representative.id).toBe(9);
|
||||
expect(series.newestTimestamp).toBe(
|
||||
Date.parse('2026-03-01T00:00:00Z')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the newest member with a non-empty poster for series artwork', () => {
|
||||
const result = build([
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
posterUrl: 'https://image.test/old.jpg',
|
||||
}),
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
posterUrl: 'https://image.test/new.jpg',
|
||||
}),
|
||||
download(3, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
createdAt: '2026-03-01T00:00:00Z',
|
||||
posterUrl: ' ',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.library[0]).toMatchObject({
|
||||
kind: 'series',
|
||||
posterUrl: 'https://image.test/new.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('orders series members by season, episode, timestamp, then id with invalid numbers last', () => {
|
||||
const episodes = [
|
||||
download(9, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: undefined,
|
||||
episodeNumber: 1,
|
||||
}),
|
||||
download(8, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
}),
|
||||
download(7, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: undefined,
|
||||
}),
|
||||
download(6, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
download(5, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
}),
|
||||
download(4, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
}),
|
||||
download(3, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
const [series] = build(episodes).library;
|
||||
expect(series.kind).toBe('series');
|
||||
if (series.kind === 'series') {
|
||||
expect(series.members.map(({ id }) => id)).toEqual([
|
||||
4, 5, 6, 3, 7, 8, 9,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('derives the usable season range for a series', () => {
|
||||
const [series] = build([
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 4,
|
||||
}),
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
download(3, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 1,
|
||||
}),
|
||||
download(4, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: -1,
|
||||
}),
|
||||
]).library;
|
||||
|
||||
expect(series).toMatchObject({
|
||||
kind: 'series',
|
||||
firstSeason: 1,
|
||||
lastSeason: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates tracked bytes across every member of a series', () => {
|
||||
const [series] = build([
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
bytesDownloaded: 30,
|
||||
}),
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
bytesDownloaded: undefined,
|
||||
}),
|
||||
download(3, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
bytesDownloaded: 12,
|
||||
}),
|
||||
]).library;
|
||||
|
||||
expect(series).toMatchObject({
|
||||
kind: 'series',
|
||||
trackedBytes: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate input arrays or objects while sorting and grouping', () => {
|
||||
const first = Object.freeze(
|
||||
download(2, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 2,
|
||||
})
|
||||
);
|
||||
const second = Object.freeze(
|
||||
download(1, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
seasonNumber: 1,
|
||||
})
|
||||
);
|
||||
const downloads = Object.freeze([first, second]);
|
||||
const playlist = Object.freeze({
|
||||
_id: 'playlist-a',
|
||||
title: 'Alpha Source',
|
||||
});
|
||||
const playlists = Object.freeze([playlist]);
|
||||
|
||||
const result = build(downloads, { playlists });
|
||||
|
||||
expect(downloads).toEqual([first, second]);
|
||||
expect(playlists).toEqual([playlist]);
|
||||
expect(result.scopedItems[0]).toBe(first);
|
||||
const [series] = result.library;
|
||||
expect(series.kind).toBe('series');
|
||||
if (series.kind === 'series') {
|
||||
expect(series.members.map(({ id }) => id)).toEqual([1, 2]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildDownloadLibrary,
|
||||
deriveStandardizedSeriesTitle,
|
||||
isUsableDownloadOrderNumber,
|
||||
normalizedDownloadTimestamp,
|
||||
type DownloadLibraryEntity,
|
||||
type DownloadLibraryRow,
|
||||
} from './download-library.viewmodel';
|
||||
|
||||
export type {
|
||||
DownloadEpisodeCardViewModel,
|
||||
DownloadLibraryEntity,
|
||||
DownloadMovieCardViewModel,
|
||||
DownloadSeriesCardViewModel,
|
||||
} from './download-library.viewmodel';
|
||||
|
||||
export type DownloadFilterId = 'all' | 'movie' | 'series' | 'in-progress';
|
||||
|
||||
export interface DownloadListItemViewModel extends DownloadLibraryRow {
|
||||
readonly seriesTitle: string;
|
||||
}
|
||||
|
||||
export interface DownloadManagerCounts {
|
||||
readonly all: number;
|
||||
readonly movie: number;
|
||||
readonly series: number;
|
||||
readonly inProgress: number;
|
||||
}
|
||||
|
||||
export interface DownloadManagerViewModel {
|
||||
readonly scopedItems: readonly DownloadItem[];
|
||||
readonly active: readonly DownloadListItemViewModel[];
|
||||
readonly attention: readonly DownloadListItemViewModel[];
|
||||
readonly library: readonly DownloadLibraryEntity[];
|
||||
readonly counts: DownloadManagerCounts;
|
||||
readonly activeCount: number;
|
||||
readonly trackedBytes: number;
|
||||
readonly hasClearable: boolean;
|
||||
}
|
||||
|
||||
export interface BuildDownloadManagerViewModelInput {
|
||||
readonly downloads: readonly DownloadItem[];
|
||||
readonly playlists: readonly Pick<Playlist, '_id' | 'title'>[];
|
||||
readonly scopePlaylistId?: string;
|
||||
readonly filter: string | null | undefined;
|
||||
readonly searchTerm?: string;
|
||||
}
|
||||
|
||||
export function normalizeDownloadFilter(
|
||||
value: string | null | undefined
|
||||
): DownloadFilterId {
|
||||
switch (value) {
|
||||
case 'movie':
|
||||
case 'series':
|
||||
case 'in-progress':
|
||||
return value;
|
||||
default:
|
||||
return 'all';
|
||||
}
|
||||
}
|
||||
|
||||
function episodeLabel(item: DownloadItem): string {
|
||||
if (
|
||||
item.contentType !== 'episode' ||
|
||||
!isUsableDownloadOrderNumber(item.seasonNumber) ||
|
||||
!isUsableDownloadOrderNumber(item.episodeNumber)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const season = String(item.seasonNumber).padStart(2, '0');
|
||||
const episode = String(item.episodeNumber).padStart(2, '0');
|
||||
return `S${season}E${episode}`;
|
||||
}
|
||||
|
||||
function seriesTitle(item: DownloadItem): string {
|
||||
if (item.contentType !== 'episode') {
|
||||
return '';
|
||||
}
|
||||
return deriveStandardizedSeriesTitle(item) ?? item.title;
|
||||
}
|
||||
|
||||
function isActive(item: DownloadItem): boolean {
|
||||
return (
|
||||
item.status === 'queued' ||
|
||||
item.status === 'downloading' ||
|
||||
item.status === 'paused'
|
||||
);
|
||||
}
|
||||
|
||||
function needsAttention(item: DownloadItem): boolean {
|
||||
return item.status === 'failed' || item.status === 'canceled';
|
||||
}
|
||||
|
||||
function compareQueued(
|
||||
left: DownloadListItemViewModel,
|
||||
right: DownloadListItemViewModel
|
||||
): number {
|
||||
return (
|
||||
normalizedDownloadTimestamp(left.item.createdAt) -
|
||||
normalizedDownloadTimestamp(right.item.createdAt) ||
|
||||
left.item.id - right.item.id
|
||||
);
|
||||
}
|
||||
|
||||
function matchesSearch(
|
||||
row: DownloadListItemViewModel,
|
||||
searchTerm: string
|
||||
): boolean {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
return (
|
||||
query.length === 0 ||
|
||||
[
|
||||
row.item.title,
|
||||
row.seriesTitle,
|
||||
row.sourceName,
|
||||
row.episodeLabel,
|
||||
row.item.errorMessage ?? '',
|
||||
].some((value) => value.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
function matchesFilter(
|
||||
row: DownloadListItemViewModel,
|
||||
filter: DownloadFilterId
|
||||
): boolean {
|
||||
if (filter === 'in-progress') {
|
||||
return isActive(row.item);
|
||||
}
|
||||
if (filter === 'movie') {
|
||||
return row.item.contentType === 'vod';
|
||||
}
|
||||
if (filter === 'series') {
|
||||
return row.item.contentType === 'episode';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function countContentType(
|
||||
rows: readonly DownloadListItemViewModel[],
|
||||
contentType: DownloadItem['contentType']
|
||||
): number {
|
||||
return rows.filter((row) => row.item.contentType === contentType).length;
|
||||
}
|
||||
|
||||
export function buildDownloadManagerViewModel({
|
||||
downloads,
|
||||
playlists,
|
||||
scopePlaylistId,
|
||||
filter,
|
||||
searchTerm = '',
|
||||
}: BuildDownloadManagerViewModelInput): DownloadManagerViewModel {
|
||||
const sourceNames = new Map(
|
||||
playlists.map(({ _id, title }) => [_id, title] as const)
|
||||
);
|
||||
const scopedItems = downloads.filter(
|
||||
(item) =>
|
||||
scopePlaylistId === undefined || item.playlistId === scopePlaylistId
|
||||
);
|
||||
const scopedRows = scopedItems.map((item) => ({
|
||||
item,
|
||||
episodeLabel: episodeLabel(item),
|
||||
seriesTitle: seriesTitle(item),
|
||||
sourceName: sourceNames.get(item.playlistId) ?? '',
|
||||
}));
|
||||
const scopedActive = scopedRows.filter(({ item }) => isActive(item));
|
||||
const scopedAttention = scopedRows.filter(({ item }) =>
|
||||
needsAttention(item)
|
||||
);
|
||||
const scopedLibrary = buildDownloadLibrary(
|
||||
scopedRows.filter(({ item }) => item.status === 'completed')
|
||||
);
|
||||
const activeFilter = normalizeDownloadFilter(filter);
|
||||
const displayed = scopedRows
|
||||
.filter((row) => matchesSearch(row, searchTerm))
|
||||
.filter((row) => matchesFilter(row, activeFilter));
|
||||
const active = displayed
|
||||
.filter(({ item }) => isActive(item))
|
||||
.sort(compareQueued);
|
||||
const attention = displayed
|
||||
.filter(({ item }) => needsAttention(item))
|
||||
.sort(compareQueued);
|
||||
const library = buildDownloadLibrary(
|
||||
displayed.filter(({ item }) => item.status === 'completed')
|
||||
);
|
||||
|
||||
return {
|
||||
scopedItems,
|
||||
active,
|
||||
attention,
|
||||
library,
|
||||
counts: {
|
||||
all:
|
||||
scopedActive.length +
|
||||
scopedAttention.length +
|
||||
scopedLibrary.length,
|
||||
movie:
|
||||
countContentType(scopedActive, 'vod') +
|
||||
countContentType(scopedAttention, 'vod') +
|
||||
scopedLibrary.filter(({ kind }) => kind === 'movie').length,
|
||||
series:
|
||||
countContentType(scopedActive, 'episode') +
|
||||
countContentType(scopedAttention, 'episode') +
|
||||
scopedLibrary.filter(({ kind }) => kind !== 'movie').length,
|
||||
inProgress: scopedActive.length,
|
||||
},
|
||||
activeCount: scopedItems.filter(
|
||||
(item) => item.status === 'queued' || item.status === 'downloading'
|
||||
).length,
|
||||
trackedBytes: scopedItems.reduce(
|
||||
(total, item) => total + (item.bytesDownloaded ?? 0),
|
||||
0
|
||||
),
|
||||
hasClearable: scopedItems.some(
|
||||
(item) =>
|
||||
item.status === 'completed' ||
|
||||
item.status === 'failed' ||
|
||||
item.status === 'canceled'
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -124,6 +124,13 @@
|
||||
"validationCommand": "pnpm nx test portal-catalog-feature",
|
||||
"e2eTags": ["@xtream", "@stalker"]
|
||||
},
|
||||
{
|
||||
"name": "portal-downloads-feature",
|
||||
"root": "libs/portal/downloads/feature",
|
||||
"sourceRoot": "libs/portal/downloads/feature/src",
|
||||
"validationCommand": "pnpm nx test portal-downloads-feature",
|
||||
"e2eTags": ["@downloads", "@electron", "@persistence"]
|
||||
},
|
||||
{
|
||||
"name": "portal-shared-data-access",
|
||||
"root": "libs/portal/shared/data-access",
|
||||
@@ -330,11 +337,6 @@
|
||||
"root": "libs/shared/testing",
|
||||
"reason": "Test helper library."
|
||||
},
|
||||
{
|
||||
"name": "portal-downloads-feature",
|
||||
"root": "libs/portal/downloads/feature",
|
||||
"reason": "No test target yet; add focused tests when feature work changes this area."
|
||||
},
|
||||
{
|
||||
"name": "remote-control",
|
||||
"root": "libs/ui/remote-control",
|
||||
|
||||
Reference in new issue
Block a user