mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
added seeker preview drawer specs
This commit is contained in:
1 parent
becb58b147
commit
c70f2117fc
3 files changed
+259
No files matched your search
@@ -59,4 +59,12 @@ describe('SearchStatsApi', () => {
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/api/seeker/cycles/instance-1/reset', null);
|
||||
});
|
||||
|
||||
it('gets the preview for the instance', () => {
|
||||
const { api, get } = setup();
|
||||
|
||||
api.getPreview('instance-1');
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/api/seeker/preview/instance-1');
|
||||
});
|
||||
});
|
||||
+20
@@ -2,6 +2,7 @@ import { PAGE_SIZE_STORAGE_KEYS } from '@core/services/pagination.service';
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { SearchStatsApi, SearchEventsSortBy, SortDirection } from '@core/api/search-stats.api';
|
||||
import type { SearchEventsQuery } from '@core/api/search-stats.api';
|
||||
import { SeekerSearchType, SeekerSearchReason, SeekerContentKind, SearchCommandStatus } from '@core/models/search-stats.models';
|
||||
@@ -114,6 +115,7 @@ interface Harness {
|
||||
toastError: ReturnType<typeof vi.fn>;
|
||||
toastSuccess: ReturnType<typeof vi.fn>;
|
||||
resetCycle: ReturnType<typeof vi.fn>;
|
||||
getPreview: ReturnType<typeof vi.fn>;
|
||||
summaryCalls: () => number;
|
||||
confirm: ConfirmService;
|
||||
}
|
||||
@@ -131,6 +133,7 @@ describe('SearchesTabComponent', () => {
|
||||
const toastErrorSpy = vi.fn();
|
||||
const toastSuccessSpy = vi.fn();
|
||||
const resetCycleSpy = vi.fn(() => of(null));
|
||||
const getPreviewSpy = vi.fn<(instanceId: string) => Observable<null>>(() => of(null));
|
||||
let summaryCalls = 0;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -153,6 +156,7 @@ describe('SearchesTabComponent', () => {
|
||||
return of(EVENTS);
|
||||
},
|
||||
resetCycle: resetCycleSpy,
|
||||
getPreview: getPreviewSpy,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -177,6 +181,7 @@ describe('SearchesTabComponent', () => {
|
||||
toastError: toastErrorSpy,
|
||||
toastSuccess: toastSuccessSpy,
|
||||
resetCycle: resetCycleSpy,
|
||||
getPreview: getPreviewSpy,
|
||||
summaryCalls: () => summaryCalls,
|
||||
confirm: TestBed.inject(ConfirmService),
|
||||
};
|
||||
@@ -188,6 +193,21 @@ describe('SearchesTabComponent', () => {
|
||||
);
|
||||
}
|
||||
|
||||
it('opens the preview drawer and requests the preview for the clicked instance', () => {
|
||||
const { fixture, component, getPreview } = setup();
|
||||
|
||||
const buttons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.instance-card__actions button'),
|
||||
) as HTMLButtonElement[];
|
||||
const previewButton = buttons.find((button) => button.textContent!.includes('Preview'))!;
|
||||
previewButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.previewOpen()).toBe(true);
|
||||
expect(component.previewInstance()?.instanceId).toBe(component.sortedInstanceStats()[0].instanceId);
|
||||
expect(getPreview).toHaveBeenCalledWith(component.sortedInstanceStats()[0].instanceId);
|
||||
});
|
||||
|
||||
it('sorts the instance cards by type then name while leaving the filter options in API order', () => {
|
||||
const { component } = setup();
|
||||
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { SearchStatsApi } from '@core/api/search-stats.api';
|
||||
import { SeekerContentKind, SeekerExclusionReason, SeekerSearchReason } from '@core/models/search-stats.models';
|
||||
import type { InstanceFunnel, InstanceSearchStat, SeekerPreview, SeekerPreviewExclusion } from '@core/models/search-stats.models';
|
||||
import { SeekerPreviewDrawerComponent } from './seeker-preview-drawer.component';
|
||||
|
||||
const FUNNEL: InstanceFunnel = {
|
||||
libraryItems: 100,
|
||||
releasedItems: 90,
|
||||
monitoredItems: 80,
|
||||
tagFilteredItems: 70,
|
||||
eligibleUnits: 60,
|
||||
queuedUnits: 5,
|
||||
recentlyGrabbedUnits: 4,
|
||||
searchedUnits: 30,
|
||||
remainingUnits: 21,
|
||||
countedAt: '2026-07-30T10:00:00Z',
|
||||
};
|
||||
|
||||
const INSTANCE: InstanceSearchStat = {
|
||||
instanceId: 'instance-1',
|
||||
instanceName: 'Main Sonarr',
|
||||
instanceType: 'Sonarr',
|
||||
itemsTracked: 10,
|
||||
totalSearchCount: 5,
|
||||
lastSearchedAt: null,
|
||||
lastProcessedAt: null,
|
||||
currentCycleId: null,
|
||||
cycleItemsSearched: 0,
|
||||
cycleItemsTotal: 0,
|
||||
cycleStartedAt: null,
|
||||
cycleItemsRemaining: 0,
|
||||
cycleCompletedAt: null,
|
||||
lastCycleStartedAt: null,
|
||||
lastCycleCompletedAt: null,
|
||||
funnel: null,
|
||||
};
|
||||
|
||||
function preview(partial: Partial<SeekerPreview> = {}): SeekerPreview {
|
||||
return {
|
||||
funnel: FUNNEL,
|
||||
cycleWaitingUntil: null,
|
||||
randomPick: false,
|
||||
nextUp: [
|
||||
{ name: 'Alpha S01E01', reason: SeekerSearchReason.Missing, contentKind: null },
|
||||
{ name: 'Beta S02E03', reason: SeekerSearchReason.QualityCutoffNotMet, contentKind: SeekerContentKind.Anime },
|
||||
],
|
||||
excluded: [
|
||||
{ name: 'Gamma Show', reason: SeekerExclusionReason.NotMonitored },
|
||||
{ name: 'delta show', reason: SeekerExclusionReason.InQueue },
|
||||
{ name: 'Epsilon', reason: SeekerExclusionReason.NotMonitored },
|
||||
],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function manyExcluded(count: number): SeekerPreviewExclusion[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
name: `Item ${String(i + 1).padStart(2, '0')}`,
|
||||
reason: SeekerExclusionReason.NotMonitored,
|
||||
}));
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-host',
|
||||
standalone: true,
|
||||
imports: [SeekerPreviewDrawerComponent],
|
||||
template: `<app-seeker-preview-drawer [instance]="instance()" [(visible)]="visible" />`,
|
||||
})
|
||||
class HostComponent {
|
||||
readonly instance = signal<InstanceSearchStat | null>(INSTANCE);
|
||||
readonly visible = signal(false);
|
||||
}
|
||||
|
||||
describe('SeekerPreviewDrawerComponent', () => {
|
||||
function setup(response: ReturnType<SearchStatsApi['getPreview']> = of(preview())) {
|
||||
const getPreview = vi.fn<(instanceId: string) => typeof response>(() => response);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: SearchStatsApi, useValue: { getPreview } }],
|
||||
});
|
||||
const fixture: ComponentFixture<HostComponent> = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
return { fixture, host: fixture.componentInstance, getPreview };
|
||||
}
|
||||
|
||||
function open(fixture: ComponentFixture<HostComponent>, host: HostComponent): SeekerPreviewDrawerComponent {
|
||||
host.visible.set(true);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
return fixture.debugElement.children[0].componentInstance as SeekerPreviewDrawerComponent;
|
||||
}
|
||||
|
||||
function texts(selector: string): string[] {
|
||||
return Array.from(document.body.querySelectorAll(selector)).map((el) => el.textContent!.trim());
|
||||
}
|
||||
|
||||
function names(selector: string): string[] {
|
||||
return Array.from(document.body.querySelectorAll(`${selector} .preview__name`)).map((el) => el.textContent!.trim());
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
document.body.querySelectorAll('.cdk-overlay-container').forEach((el) => {
|
||||
el.replaceChildren();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not request the preview while hidden', () => {
|
||||
const { fixture, getPreview } = setup();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests the preview for the instance when opened', () => {
|
||||
const { fixture, host, getPreview } = setup();
|
||||
|
||||
open(fixture, host);
|
||||
|
||||
expect(getPreview).toHaveBeenCalledTimes(1);
|
||||
expect(getPreview).toHaveBeenCalledWith('instance-1');
|
||||
});
|
||||
|
||||
it('renders the funnel stages', () => {
|
||||
const { fixture, host } = setup();
|
||||
|
||||
open(fixture, host);
|
||||
|
||||
const stages = texts('.preview__funnel-stage');
|
||||
expect(stages[0]).toContain('100');
|
||||
expect(stages[0]).toContain('series');
|
||||
expect(stages).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('lists the next up rows with a reason badge', () => {
|
||||
const { fixture, host } = setup();
|
||||
|
||||
open(fixture, host);
|
||||
|
||||
expect(names('.preview__row--next')).toEqual(['Alpha S01E01', 'Beta S02E03']);
|
||||
const rows = texts('.preview__row--next');
|
||||
expect(rows[0]).toContain('Missing');
|
||||
expect(rows[1]).toContain('Cutoff Unmet');
|
||||
});
|
||||
|
||||
it('shows the random draw note only when randomPick is set', () => {
|
||||
const first = setup(of(preview({ randomPick: true })));
|
||||
open(first.fixture, first.host);
|
||||
expect(document.body.textContent).toContain('Random draw');
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
document.body.querySelectorAll('.cdk-overlay-container').forEach((el) => {
|
||||
el.replaceChildren();
|
||||
});
|
||||
|
||||
const second = setup(of(preview({ randomPick: false })));
|
||||
open(second.fixture, second.host);
|
||||
expect(document.body.textContent).not.toContain('Random draw');
|
||||
});
|
||||
|
||||
it('replaces the next up list with the waiting note when the cycle is waiting', () => {
|
||||
const { fixture, host } = setup(of(preview({ cycleWaitingUntil: '2026-08-01T10:00:00Z' })));
|
||||
|
||||
open(fixture, host);
|
||||
|
||||
expect(document.body.querySelector('.preview__note--waiting')).not.toBeNull();
|
||||
expect(document.body.querySelectorAll('.preview__row--next')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('narrows the excluded list by reason', () => {
|
||||
const { fixture, host } = setup();
|
||||
const drawer = open(fixture, host);
|
||||
|
||||
expect(names('.preview__row--excluded')).toHaveLength(3);
|
||||
|
||||
drawer.onReasonChange(SeekerExclusionReason.InQueue);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(names('.preview__row--excluded')).toEqual(['delta show']);
|
||||
});
|
||||
|
||||
it('narrows the excluded list by title ignoring case', () => {
|
||||
const { fixture, host } = setup();
|
||||
const drawer = open(fixture, host);
|
||||
|
||||
drawer.titleFilter.set('SHOW');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(names('.preview__row--excluded')).toEqual(['Gamma Show', 'delta show']);
|
||||
});
|
||||
|
||||
it('pages the excluded list at 25 per page', () => {
|
||||
const { fixture, host } = setup(of(preview({ excluded: manyExcluded(30) })));
|
||||
open(fixture, host);
|
||||
|
||||
expect(names('.preview__row--excluded')).toHaveLength(25);
|
||||
|
||||
const next = Array.from(document.body.querySelectorAll('.paginator__controls button')).find(
|
||||
(button) => button.textContent!.trim() === 'Next',
|
||||
) as HTMLButtonElement;
|
||||
next.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(names('.preview__row--excluded')).toEqual(manyExcluded(30).slice(25).map((item) => item.name));
|
||||
});
|
||||
|
||||
it('shows the error message when the request fails', () => {
|
||||
const { fixture, host } = setup(throwError(() => new Error('preview unavailable')));
|
||||
|
||||
open(fixture, host);
|
||||
|
||||
expect(document.body.textContent).toContain('Preview failed');
|
||||
expect(document.body.textContent).toContain('preview unavailable');
|
||||
});
|
||||
|
||||
it('reloads the preview when Refresh is clicked', () => {
|
||||
const { fixture, host, getPreview } = setup();
|
||||
open(fixture, host);
|
||||
|
||||
const refresh = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent!.trim() === 'Refresh',
|
||||
) as HTMLButtonElement;
|
||||
refresh.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getPreview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user