Files
reactbin/ui/src/app/library/library.component.spec.ts
agatha 0ad82e60ac Feat: Replace pagination bar with numbered page buttons and chevron controls
Adds « ‹ [1][2][3][4] › » navigation to the library. Page window
slides to keep the current page in view. Prev/next/first/last controls
are always rendered but disabled at their respective bounds. Also wires
up karmaConfig in angular.json so FirefoxHeadless is used for tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:11:18 +00:00

454 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {}) {
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();
});
});