50eca0627f
Benchmark-gated round (benches/ROUND12.md; every change ships with a BEFORE/AFTER harness + equivalence gates, one candidate rejected by its own bench): DB / query shapes (bench_round12_queries): - NC sharee search: username-only projection instead of the 21-column row (incl. the <=512 KiB avatar) per match, + gin_trgm_ops indexes on auth.users for the leading-wildcard ILIKE (4.98x; 54.7x with index). - Password login: delete the redundant full-row update_user — create_session already stamps last_login_at in its own txn (4.45x per login). - Email-verified stamp: narrow conditional UPDATE (8.9x); OIDC repeat login now compares profile state in memory and issues ZERO queries when nothing changed (was: full 17-column rewrite per login). - Refresh rotation: revoke+insert+stamp fused into one transaction via new rotate_session port method (1.18x). - WOPI CheckFileInfo / authorize_wopi_access: require(Read) + get_file + check(Update) overlapped with tokio::join!, original result precedence (cold 1.34x). - Upload quota gate: user-envelope + drive-cap checks fused into ONE round-trip (check_upload_quotas) — the NC chunked PUT pays this per chunk (1.81x, 2 -> 1 queries/chunk); shared verdict evaluators keep error shapes byte-identical. CPU / allocs (bench_round12_micro): - sized_json: pre-sized listing serialization replacing axum Json's 128 B seed + doubling-realloc chain on files/folder-resources/photos/search responses (1.40x, 13 -> 2 allocs per 500-row page; byte-identical). - Security headers: 4 SetResponseHeaderLayer folded into the CSP middleware pass (5 layers -> 1; 1.43x per request, -26 allocs; header set gated byte-identical incl. 304s). - Media capture-metadata: single-read extraction — nom-exif now parses the buffer kamadak already read (zero-copy Bytes) and videos open once with a kind() dispatch; per-image opens 2-3 -> 1 (1.44x warm geomean, 1.6-3.2x cold cache; extraction outputs gated identical incl. the MIME-mislabel track fallback). - Chunked-upload session ops: owner gate folded into the operation's own DashMap lookup + stack-encoded uuid compare (5 -> 3 lookups, -2 allocs, 1.28x per chunk). Blob cache (bench_blob_cache_index + round-3 regression guard): - CachedBlobBackend index: tokio::sync::Mutex<LruCache> -> moka::sync::Cache with byte weigher. The mutex serialized every cached chunk read and scaled NEGATIVELY (2.08 -> 1.07 Mops/s from 1 -> 2 readers); moka probes are lock-free (2.17x at K=2). Byte budget now enforced by moka (manual current_size + collect_evictions machinery deleted); eviction listener unlinks size-evicted files only (Replaced entries keep their file — gated). Single-flight miss gate unchanged (16 concurrent misses -> 1 fetch re-verified via the round-3 harness). - put_blob now populates the cache BEFORE the inner backend consumes the source file (the old order failed 100% of the time — local renames, S3/Azure delete the source — so the first read after a whole-file put re-downloaded from the remote); inner-put failure invalidates the entry. Frontend (vitest gates): - List-view thumbnails request the 150px icon rendition instead of 400px preview into a 40px slot (~7.1x fewer pixels, ~4-5x fewer bytes per thumbnail across list views); grid keeps preview. Rejected by its own bench (kept as evidence in bench_round12_micro §2): - Single-pass compression predicate: the monomorphized And-chain already costs ~4.6 ns / 0 allocs total; the fused node measured within noise. New migration: 20260719000000_users_search_trgm.sql (trgm indexes). Deferred with prepared design: grouped file/grid view virtualization (single-VirtualRows flatten, the photos pattern) — next round's headline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfidAJD5AHw23jtvBUNamB
86 lines
3.3 KiB
TypeScript
86 lines
3.3 KiB
TypeScript
import { it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, fireEvent, screen, waitFor } from '@testing-library/svelte';
|
|
|
|
vi.mock('$lib/api/endpoints/people', () => ({
|
|
fetchPeople: vi.fn(),
|
|
fetchPersonPhotos: vi.fn(),
|
|
renamePerson: vi.fn()
|
|
}));
|
|
vi.mock('$lib/api/endpoints/files', () => ({
|
|
fileThumbnailUrl: () => '/thumb.png',
|
|
thumbSizeForView: () => 'preview' as const
|
|
}));
|
|
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog: vi.fn() }));
|
|
|
|
import { fetchPeople, fetchPersonPhotos, renamePerson } from '$lib/api/endpoints/people';
|
|
import { promptDialog } from '$lib/stores/dialogs.svelte';
|
|
import PeopleView from './PeopleView.svelte';
|
|
|
|
const fp = fetchPeople as unknown as ReturnType<typeof vi.fn>;
|
|
const fpp = fetchPersonPhotos as unknown as ReturnType<typeof vi.fn>;
|
|
const rn = renamePerson as unknown as ReturnType<typeof vi.fn>;
|
|
const pd = promptDialog as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it('renders the people grid (named + unnamed)', async () => {
|
|
fp.mockResolvedValue([
|
|
{ id: 'p1', name: 'Alice', face_count: 3, cover_file_id: 'c1' },
|
|
{ id: 'p2', name: '', face_count: 1, cover_file_id: null }
|
|
]);
|
|
render(PeopleView);
|
|
expect(await screen.findByText('Alice')).toBeTruthy();
|
|
expect(screen.getByText('Unnamed')).toBeTruthy();
|
|
});
|
|
|
|
it('shows an empty state when there are no people', async () => {
|
|
fp.mockResolvedValue([]);
|
|
render(PeopleView);
|
|
expect(await screen.findByText('No people yet')).toBeTruthy();
|
|
});
|
|
|
|
it('shows the disabled state when the list errors', async () => {
|
|
fp.mockRejectedValue(new Error('off'));
|
|
render(PeopleView);
|
|
expect(await screen.findByText('Face recognition is disabled')).toBeTruthy();
|
|
});
|
|
|
|
it('drills into a person and back to the list', async () => {
|
|
fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 2, cover_file_id: null }]);
|
|
fpp.mockResolvedValue(['ph1', 'ph2']);
|
|
render(PeopleView);
|
|
const btn = (await screen.findByText('Alice')).closest('button')!;
|
|
await fireEvent.click(btn);
|
|
await waitFor(() => expect(fpp).toHaveBeenCalledWith('p1'));
|
|
await fireEvent.click(screen.getByLabelText('Back'));
|
|
expect(await screen.findByText('Alice')).toBeTruthy();
|
|
});
|
|
|
|
it('renames the current person', async () => {
|
|
fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 1, cover_file_id: null }]);
|
|
fpp.mockResolvedValue([]);
|
|
pd.mockResolvedValue('Bob');
|
|
rn.mockResolvedValue(undefined);
|
|
render(PeopleView);
|
|
await fireEvent.click((await screen.findByText('Alice')).closest('button')!);
|
|
await waitFor(() => screen.getByLabelText('Name this person'));
|
|
await fireEvent.click(screen.getByLabelText('Name this person'));
|
|
await waitFor(() => expect(rn).toHaveBeenCalledWith('p1', 'Bob'));
|
|
});
|
|
|
|
it("opens a person's photo in the lightbox", async () => {
|
|
fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 2, cover_file_id: null }]);
|
|
fpp.mockResolvedValue(['ph1', 'ph2']);
|
|
const { container } = render(PeopleView);
|
|
await fireEvent.click((await screen.findByText('Alice')).closest('button')!);
|
|
await waitFor(() => expect(fpp).toHaveBeenCalledWith('p1'));
|
|
const tiles = await waitFor(() => {
|
|
const found = container.querySelectorAll('.photos__open');
|
|
if (found.length === 0) throw new Error('no tiles yet');
|
|
return found;
|
|
});
|
|
expect(tiles.length).toBe(2);
|
|
await fireEvent.click(tiles[0]);
|
|
expect(await screen.findByTestId('photo-lightbox')).toBeTruthy();
|
|
});
|