import { TestBed } from '@angular/core/testing'; import { provideRouter, ActivatedRoute, Router } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { of, throwError } from 'rxjs'; import { LibraryComponent } from './library.component'; import { ImageService } from '../services/image.service'; import { routes } from '../app.routes'; function makeActivatedRoute(queryParams: Record = {}) { const paramMap = { get: (key: string) => queryParams[key] ?? null }; return { snapshot: { queryParamMap: paramMap }, queryParamMap: of(paramMap), }; } const EMPTY_PAGE = { items: [], total: 0, limit: 24, offset: 0 }; const ONE_IMAGE = { items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, file_url: '/api/v1/images/1/file', thumbnail_url: null, created_at: '' }], total: 1, limit: 24, offset: 0, }; const MULTI_PAGE = { items: Array(24).fill(null).map((_, i) => ({ id: String(i + 1), filename: `img${i + 1}.jpg`, tags: [], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, file_url: `/api/v1/images/${i + 1}/file`, thumbnail_url: null, created_at: '', })), total: 48, limit: 24, offset: 0, }; describe('LibraryComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [LibraryComponent], providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter(routes)], }).compileComponents(); }); it('should render image grid from service response', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE)); fixture.detectChanges(); expect((fixture.nativeElement as HTMLElement).querySelectorAll('.image-card').length).toBe(1); }); it('should trigger new API call with tags param on filter change', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE)); fixture.detectChanges(); fixture.componentInstance.applyFilter(['cat', 'funny']); expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number)); }); it('showSpinner is false initially', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE)); fixture.detectChanges(); expect(fixture.componentInstance.showSpinner).toBeFalse(); }); it('renders 8 skeleton cards while showSpinner is true', () => { const fixture = TestBed.createComponent(LibraryComponent); fixture.componentInstance.showSpinner = true; fixture.detectChanges(); const skeletons = (fixture.nativeElement as HTMLElement).querySelectorAll('.card-skeleton'); expect(skeletons.length).toBe(8); }); it('error is false initially', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE)); fixture.detectChanges(); expect(fixture.componentInstance.error).toBeFalse(); }); it('shows error card when error is true', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(throwError(() => new Error('fail'))); fixture.detectChanges(); expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull(); }); it('error card has retry button that calls load()', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(throwError(() => new Error('fail'))); fixture.detectChanges(); spyOn(fixture.componentInstance, 'load'); const retryBtn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement; expect(retryBtn).not.toBeNull(); retryBtn.click(); expect(fixture.componentInstance.load).toHaveBeenCalled(); }); it('empty state contains routerLink to /upload', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE)); fixture.detectChanges(); const link = (fixture.nativeElement as HTMLElement).querySelector('.empty-state a[href="/upload"]'); expect(link).not.toBeNull(); }); it('onImgError sets src to placeholder SVG', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgEl = document.createElement('img'); imgEl.src = 'http://example.com/image.jpg'; const event = { target: imgEl } as unknown as Event; fixture.componentInstance.onImgError(event); expect(imgEl.src).toContain('data:image/svg+xml'); }); it('onImgError does not recurse when src already contains placeholder', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgEl = document.createElement('img'); imgEl.src = 'data:image/svg+xml,placeholder'; const originalSrc = imgEl.src; const event = { target: imgEl } as unknown as Event; fixture.componentInstance.onImgError(event); expect(imgEl.src).toBe(originalSrc); }); it('pre-populates activeFilters from ?tags= query param on init', () => { TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ tags: 'cat,funny' }) }); const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE)); fixture.detectChanges(); expect(fixture.componentInstance.activeFilters).toEqual(['cat', 'funny']); expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number)); }); it('does not set activeFilters when no ?tags= param present', () => { TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute() }); const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE)); fixture.detectChanges(); expect(fixture.componentInstance.activeFilters).toEqual([]); }); it('header contains a link to /tags', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE)); fixture.detectChanges(); const link = (fixture.nativeElement as HTMLElement).querySelector('a[href="/tags"]'); expect(link).not.toBeNull(); }); // ---- Pagination: US1 ---- it('page indicator shows "Page 1 of 2" when totalPages > 1', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); const indicator = (fixture.nativeElement as HTMLElement).querySelector('.page-indicator'); expect(indicator?.textContent).toContain('Page 1 of 2'); }); it('total count renders with correct number', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); const el = (fixture.nativeElement as HTMLElement).querySelector('.total-count'); expect(el?.textContent).toContain('48'); }); it('"Next" button present when not on last page', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).not.toBeNull(); }); it('"Previous" button absent on first page', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).toBeNull(); }); it('"Previous" present and "Next" absent on last page', () => { TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) }); const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).not.toBeNull(); expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).toBeNull(); }); it('no pagination controls when all images fit on one page', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE)); fixture.detectChanges(); expect((fixture.nativeElement as HTMLElement).querySelector('.pagination-bar')).toBeNull(); }); it('nextPage() calls imageService.list with offset=24', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); listSpy.calls.reset(); fixture.componentInstance.nextPage(); expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24); }); it('prevPage() from page 2 calls imageService.list with offset=0', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); fixture.componentInstance.currentPage = 2; fixture.componentInstance.totalPages = 2; listSpy.calls.reset(); fixture.componentInstance.prevPage(); expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 0); }); it('applyFilter() resets to page 1 (offset=0)', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); fixture.componentInstance.currentPage = 2; listSpy.calls.reset(); fixture.componentInstance.applyFilter(['cat']); expect(listSpy).toHaveBeenCalledWith(['cat'], jasmine.any(Number), 0); }); // ---- Pagination: US2 — URL state ---- it('reads ?page=2 from queryParamMap on init and calls list with offset=24', () => { TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) }); const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); expect(fixture.componentInstance.currentPage).toBe(2); expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24); }); it('clamps out-of-range ?page=9999 to page 1 after load resolves', () => { TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '9999' }) }); const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); // After load, totalPages=2, currentPage should be clamped to 2 (not 9999), then router corrects URL expect(fixture.componentInstance.currentPage).toBeLessThanOrEqual(fixture.componentInstance.totalPages); }); it('nextPage() calls router.navigate with page=2 and queryParamsHandling merge', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); const router = TestBed.inject(Router); spyOn(router, 'navigate'); fixture.componentInstance.nextPage(); expect(router.navigate).toHaveBeenCalledWith([], jasmine.objectContaining({ queryParams: jasmine.objectContaining({ page: 2 }), queryParamsHandling: 'merge', })); }); it('applyFilter() calls router.navigate with page=1 and queryParamsHandling merge', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); fixture.componentInstance.currentPage = 2; const router = TestBed.inject(Router); spyOn(router, 'navigate'); fixture.componentInstance.applyFilter(['dog']); expect(router.navigate).toHaveBeenCalledWith([], jasmine.objectContaining({ queryParams: jasmine.objectContaining({ page: 1 }), queryParamsHandling: 'merge', })); }); });