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', short_id: 'ShrtImg1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: 'ShrtImg1', thumbnail_key: null, file_url: '/api/v1/i/ShrtImg1/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), short_id: `Shrt${String(i + 1).padStart(4, '0')}`, filename: `img${i + 1}.jpg`, tags: [], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: `Shrt${String(i + 1).padStart(4, '0')}`, thumbnail_key: null, file_url: `/api/v1/i/Shrt${String(i + 1).padStart(4, '0')}/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: page window (T002) ---- it('pageWindow returns [1,2,3,4] on page 1 of 20', () => { const fixture = TestBed.createComponent(LibraryComponent); fixture.componentInstance.currentPage = 1; fixture.componentInstance.totalPages = 20; expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3, 4]); }); it('pageWindow returns [17,18,19,20] on page 20 of 20', () => { const fixture = TestBed.createComponent(LibraryComponent); fixture.componentInstance.currentPage = 20; fixture.componentInstance.totalPages = 20; expect(fixture.componentInstance.pageWindow).toEqual([17, 18, 19, 20]); }); it('pageWindow returns [6,7,8,9] on page 7 of 20', () => { const fixture = TestBed.createComponent(LibraryComponent); fixture.componentInstance.currentPage = 7; fixture.componentInstance.totalPages = 20; expect(fixture.componentInstance.pageWindow).toEqual([6, 7, 8, 9]); }); it('pageWindow returns all pages when totalPages < 4', () => { const fixture = TestBed.createComponent(LibraryComponent); fixture.componentInstance.currentPage = 2; fixture.componentInstance.totalPages = 3; expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3]); }); it('pageWindow returns [1] when totalPages is 1', () => { const fixture = TestBed.createComponent(LibraryComponent); fixture.componentInstance.currentPage = 1; fixture.componentInstance.totalPages = 1; expect(fixture.componentInstance.pageWindow).toEqual([1]); }); it('numbered page buttons are rendered (T002)', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); const pageBtns = (fixture.nativeElement as HTMLElement).querySelectorAll('.page-btn'); expect(pageBtns.length).toBe(2); // 2 total pages expect(pageBtns[0].textContent?.trim()).toBe('1'); expect(pageBtns[1].textContent?.trim()).toBe('2'); }); it('active page button has .active class', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); const activeBtn = (fixture.nativeElement as HTMLElement).querySelector('.page-btn.active'); expect(activeBtn).not.toBeNull(); expect(activeBtn?.textContent?.trim()).toBe('1'); }); it('goToPage() calls imageService.list with correct offset', () => { 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.goToPage(2); expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24); }); 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('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: ‹ › disabled states (T006) ---- it('prev-btn (‹) is disabled on page 1', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement; expect(prevBtn).not.toBeNull(); expect(prevBtn.disabled).toBeTrue(); }); it('next-btn (›) is disabled 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(); const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement; expect(nextBtn).not.toBeNull(); expect(nextBtn.disabled).toBeTrue(); }); it('prev-btn (‹) is enabled when not on page 1', () => { 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(); const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement; expect(prevBtn.disabled).toBeFalse(); }); it('next-btn (›) is enabled 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(); const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement; expect(nextBtn.disabled).toBeFalse(); }); it('both prev and next buttons always rendered when totalPages > 1', () => { 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')).not.toBeNull(); }); // ---- Pagination: 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(); 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', })); }); it('clicking an image card navigates to /i/:short_id', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE)); fixture.detectChanges(); const router = TestBed.inject(Router); spyOn(router, 'navigate'); const card = (fixture.nativeElement as HTMLElement).querySelector('.image-card') as HTMLElement; card.click(); expect(router.navigate).toHaveBeenCalledWith(['/i', 'ShrtImg1']); }); // ---- Pagination: « » first/last (T008) ---- it('firstPage() navigates to page 1', () => { 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.firstPage(); expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 0); expect(fixture.componentInstance.currentPage).toBe(1); }); it('lastPage() navigates to last page', () => { 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.lastPage(); expect(fixture.componentInstance.currentPage).toBe(fixture.componentInstance.totalPages); }); it('first-page button («) is disabled on page 1', () => { const fixture = TestBed.createComponent(LibraryComponent); const imgSvc = TestBed.inject(ImageService); spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE)); fixture.detectChanges(); const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement; expect(firstBtn).not.toBeNull(); expect(firstBtn.disabled).toBeTrue(); }); it('last-page button (») is disabled 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(); const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement; expect(lastBtn).not.toBeNull(); expect(lastBtn.disabled).toBeTrue(); }); it('first-page button («) is enabled when not on page 1', () => { 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(); const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement; expect(firstBtn.disabled).toBeFalse(); }); it('last-page button (») is enabled 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(); const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement; expect(lastBtn.disabled).toBeFalse(); }); });