diff --git a/code/frontend/src/app/core/api/search-stats.api.spec.ts b/code/frontend/src/app/core/api/search-stats.api.spec.ts index e68a11a5..0fb750b5 100644 --- a/code/frontend/src/app/core/api/search-stats.api.spec.ts +++ b/code/frontend/src/app/core/api/search-stats.api.spec.ts @@ -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'); + }); }); diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts index 13c76915..c89027d6 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts @@ -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; toastSuccess: ReturnType; resetCycle: ReturnType; + getPreview: ReturnType; 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>(() => 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(); diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.spec.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.spec.ts new file mode 100644 index 00000000..e7caf853 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.spec.ts @@ -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 { + 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: ``, +}) +class HostComponent { + readonly instance = signal(INSTANCE); + readonly visible = signal(false); +} + +describe('SeekerPreviewDrawerComponent', () => { + function setup(response: ReturnType = of(preview())) { + const getPreview = vi.fn<(instanceId: string) => typeof response>(() => response); + TestBed.configureTestingModule({ + providers: [{ provide: SearchStatsApi, useValue: { getPreview } }], + }); + const fixture: ComponentFixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + return { fixture, host: fixture.componentInstance, getPreview }; + } + + function open(fixture: ComponentFixture, 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); + }); +});