Files
reactbin/ui/src/app/detail/detail.component.spec.ts
agatha f953c88984 Feat: Pre-generate WebP thumbnails on upload for faster library load
- Add Pillow dependency and thumbnail.py with generate_thumbnail() — produces
  WebP ≤400px, preserves aspect ratio, never upscales, handles GIF frame 0
- Alembic migration 002 adds nullable thumbnail_key column to images table
- Upload route generates thumbnail via asyncio.to_thread (non-blocking),
  stores at {hash}-thumb; failure is tolerated and upload succeeds with null key
- New GET /api/v1/images/{id}/thumbnail endpoint: serves WebP thumbnail or
  falls back to original for pre-feature images; ETag + immutable cache headers
- Delete route cleans up thumbnail storage object alongside original
- Library grid switches from /file to /thumbnail for all image src bindings
- 59 tests passing (46 existing + 13 new across unit, upload, serving, delete)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 17:26:16 +00:00

84 lines
3.0 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 { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { DetailComponent } from './detail.component';
import { ImageService } from '../services/image.service';
import { routes } from '../app.routes';
const MOCK_IMAGE = {
id: 'img-1',
hash: 'abc',
filename: 'test.jpg',
mime_type: 'image/jpeg',
size_bytes: 100,
width: 10,
height: 10,
storage_key: 'abc',
thumbnail_key: null,
created_at: '2026-01-01T00:00:00Z',
tags: ['cat', 'funny'],
};
describe('DetailComponent', () => {
function setup(imageId = 'img-1') {
TestBed.configureTestingModule({
imports: [DetailComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter(routes),
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => imageId } } } },
],
}).compileComponents();
const fixture = TestBed.createComponent(DetailComponent);
const component = fixture.componentInstance;
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'get').and.returnValue(of(MOCK_IMAGE));
fixture.detectChanges();
return { fixture, component, imgSvc };
}
it('should call PATCH with removed tag absent when chip × is clicked', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
component.removeTag('cat');
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['funny']);
});
it('should call PATCH with new tag included on addTag', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] }));
component.addTag('new');
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['cat', 'funny', 'new']);
});
it('should call DELETE and navigate to library on confirm delete', () => {
const { component, imgSvc } = setup();
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
component.confirmDelete();
expect(imgSvc.delete).toHaveBeenCalledWith('img-1');
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('should NOT call DELETE when cancel is clicked', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
component.showDeleteDialog = true;
component.cancelDelete();
expect(imgSvc.delete).not.toHaveBeenCalled();
expect(component.showDeleteDialog).toBeFalse();
});
it('back button should navigate to library', () => {
const { component } = setup();
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
component.goBack();
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
});