Clicking the Reactbin home link (or any navigation to / that removes ?page=) now resets the displayed page by subscribing to queryParamMap for post-init URL changes. Cards with many tags no longer push the pagination bar down since the tag row is clamped to one line. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
296 lines
13 KiB
TypeScript
296 lines
13 KiB
TypeScript
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', 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',
|
|
}));
|
|
});
|
|
});
|