Shared foundation: - New Modal component on native <dialog>.showModal() (focus trap, inert background, Escape, focus restore, scroll lock); all eight dialogs and both globalThis.confirm calls migrated to it - Design tokens for success/warning/danger surfaces; pill danger variant; .sr-only utility; toast layering above dialog backdrops; not-allowed cursor for validity-disabled buttons Conversations/attention: auto-scroll with jump-to-latest, per-conversation draft persistence, suggestion undo, 30s auto-refresh with refresh controls, unread emphasis and counts, blur-consistent share dialog, empty-accounts guidance, collapsed user lookup, relative timestamps, danger pills for critical items Media/library/outreach: click-blurred-to-reveal, lightbox with arrow navigation and captions, dirty-form preservation across refetches, upload validation with thumbnail preview, save feedback, result counts with load-more and clear-filters, ticking countdowns from a single clock Overview/performance/operations/settings: mutation errors inside the confirm dialog, live sending-window countdown, offline-aware loading states, per-panel loading/error with retry, 7/30/90-day range toggle, console scroll yielding with shown/retained counts, risky-save matching by subreddit name instead of index, useBlocker unsaved-changes guard, single-pass validation with focus-to-first-error, access panel JSON validation and input reset Tests: 806 -> 844 passing; both typechecks clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import '@testing-library/jest-dom/vitest';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { HttpResponse, http } from 'msw';
|
|
import { setupServer } from 'msw/node';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { MediaLibraryPage } from '../../apps/dashboard/src/features/media-library/media-library-page.js';
|
|
|
|
const asset = {
|
|
id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie'], safety: 'sfw',
|
|
classificationStatus: 'ready', description: 'A face-led self portrait.', confidence: 0.91,
|
|
fileName: 'portrait.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 640, height: 480,
|
|
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
|
sendCount: 0, lastSentAt: null,
|
|
urlPath: '/library-media/portrait.jpg',
|
|
};
|
|
const secondAsset = {
|
|
...asset,
|
|
id: '029fe789-1b99-7ce1-8402-b3cbc32a445b',
|
|
fileName: 'second.jpg',
|
|
urlPath: '/library-media/second.jpg',
|
|
};
|
|
const server = setupServer();
|
|
|
|
function renderPage() {
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
return render(<QueryClientProvider client={client}><MediaLibraryPage /></QueryClientProvider>);
|
|
}
|
|
|
|
function cardFor(fileName: string): HTMLElement {
|
|
const title = screen.getByText(fileName, { selector: 'strong' });
|
|
const card = title.closest('article');
|
|
if (!card) throw new Error(`No card for ${fileName}`);
|
|
return card;
|
|
}
|
|
|
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); });
|
|
afterAll(() => server.close());
|
|
|
|
describe('exchange library page', () => {
|
|
it('keeps images blurred and saves operator tag corrections', async () => {
|
|
let update: unknown;
|
|
server.use(
|
|
http.get('/api/media-library', () => HttpResponse.json([asset])),
|
|
http.patch('/api/media-library/:id', async ({ request }) => {
|
|
update = await request.json();
|
|
return HttpResponse.json({ ...asset, ...(update as object) });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
|
|
const image = await screen.findByRole('img', { name: /Library image tagged selfie/i });
|
|
expect(image).toHaveClass('is-blurred');
|
|
const revealToggle = screen.getByRole('button', { name: 'Reveal portrait.jpg' });
|
|
expect(revealToggle).toHaveAttribute('aria-pressed', 'false');
|
|
await user.click(revealToggle);
|
|
expect(image).not.toHaveClass('is-blurred');
|
|
expect(screen.getByRole('button', { name: 'Blur portrait.jpg' })).toHaveAttribute('aria-pressed', 'true');
|
|
await user.click(screen.getByRole('checkbox', { name: 'topless' }));
|
|
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
|
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Safety' }), 'nsfw');
|
|
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'topless'], safety: 'nsfw', enabled: true }));
|
|
|
|
// Transient save feedback next to the Save button.
|
|
const saved = await screen.findByText('Saved');
|
|
expect(saved).toHaveAttribute('role', 'status');
|
|
await waitFor(() => expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('opens a revealed library image in the enlarging preview dialog', async () => {
|
|
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
|
|
await screen.findByRole('img', { name: /Library image tagged selfie/i });
|
|
await user.click(screen.getByRole('button', { name: 'Reveal library image portrait.jpg' }));
|
|
await user.click(screen.getByRole('button', { name: 'View portrait.jpg larger' }));
|
|
|
|
const dialog = screen.getByRole('dialog', { name: 'Preview of portrait.jpg' });
|
|
expect(within(dialog).getByRole('img')).toHaveAttribute('src', '/library-media/portrait.jpg');
|
|
// Single-item mode: no prev/next controls.
|
|
expect(within(dialog).queryByRole('button', { name: 'Next media' })).not.toBeInTheDocument();
|
|
await user.click(within(dialog).getByRole('button', { name: 'Close media preview' }));
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('preserves a touched form when a refetch replaces the assets', async () => {
|
|
let update: unknown;
|
|
server.use(
|
|
http.get('/api/media-library', () => HttpResponse.json([asset, secondAsset])),
|
|
http.patch('/api/media-library/:id', async ({ request }) => {
|
|
update = await request.json();
|
|
return HttpResponse.json({ ...asset, ...(update as object) });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
|
|
await screen.findByText('portrait.jpg');
|
|
const secondCard = cardFor('second.jpg');
|
|
|
|
// Touch the second card's form without saving it.
|
|
await user.click(within(secondCard).getByRole('checkbox', { name: 'topless' }));
|
|
expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument();
|
|
|
|
// Saving the first card invalidates and refetches the whole list.
|
|
const firstCard = cardFor('portrait.jpg');
|
|
await user.click(within(firstCard).getByRole('checkbox', { name: 'chest' }));
|
|
await user.click(within(firstCard).getByRole('button', { name: 'Save changes' }));
|
|
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'chest'], safety: 'sfw', enabled: true }));
|
|
await within(firstCard).findByText('Saved');
|
|
|
|
// The refetch must not clobber the second card's unsaved edit.
|
|
expect(within(secondCard).getByRole('checkbox', { name: 'topless' })).toBeChecked();
|
|
expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument();
|
|
});
|
|
|
|
it('explains why saving is disabled when no tags are selected', async () => {
|
|
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
|
|
await screen.findByText('portrait.jpg');
|
|
await user.click(screen.getByRole('checkbox', { name: 'selfie' }));
|
|
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
|
|
expect(screen.getByText('Select at least one tag to save')).toBeInTheDocument();
|
|
});
|
|
|
|
it('arms deletion away from the Delete position and disarms it after five seconds', async () => {
|
|
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
|
|
await screen.findByText('portrait.jpg');
|
|
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
|
const actions = screen.getByRole('button', { name: 'Confirm delete' }).parentElement!;
|
|
const buttons = within(actions).getAllByRole('button');
|
|
// Cancel occupies the slot Delete was in; Confirm sits after it.
|
|
expect(buttons.map((button) => button.textContent)).toEqual(['Save changes', 'Cancel', 'Confirm delete']);
|
|
|
|
await waitFor(
|
|
() => expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(),
|
|
{ timeout: 7_000 },
|
|
);
|
|
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
|
}, 15_000);
|
|
|
|
it('requires consent in the UI and uploads the selected image for classification', async () => {
|
|
let consentHeader: string | null = null;
|
|
server.use(
|
|
http.get('/api/media-library', () => HttpResponse.json([])),
|
|
http.post('/api/media-library', ({ request }) => {
|
|
consentHeader = request.headers.get('x-consent-confirmed');
|
|
return HttpResponse.json(asset, { status: 201 });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
await screen.findByText('No exchange images yet.');
|
|
const upload = screen.getByRole('button', { name: 'Upload and classify' });
|
|
expect(upload).toBeDisabled();
|
|
const input = screen.getByLabelText(/Choose image/) as HTMLInputElement;
|
|
await user.upload(input, new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
|
|
expect(upload).toBeDisabled();
|
|
await user.click(screen.getByRole('checkbox', { name: /I confirm every depicted adult consented/i }));
|
|
expect(upload).toBeEnabled();
|
|
await user.click(upload);
|
|
await screen.findByText('Image added. Review its tags before relying on it.');
|
|
expect(consentHeader).toBe('true');
|
|
|
|
// The input resets so the same file can be picked again.
|
|
expect(input.value).toBe('');
|
|
expect(screen.getByText('No image selected')).toBeInTheDocument();
|
|
});
|
|
|
|
it('rejects non-image and oversized files before uploading', async () => {
|
|
server.use(http.get('/api/media-library', () => HttpResponse.json([])));
|
|
const user = userEvent.setup({ applyAccept: false });
|
|
renderPage();
|
|
await screen.findByText('No exchange images yet.');
|
|
const input = screen.getByLabelText(/Choose image/) as HTMLInputElement;
|
|
|
|
await user.upload(input, new File(['nope'], 'notes.txt', { type: 'text/plain' }));
|
|
expect(screen.getByRole('alert')).toHaveTextContent('Only image files can be uploaded.');
|
|
expect(screen.getByRole('button', { name: 'Upload and classify' })).toBeDisabled();
|
|
|
|
const oversized = new File(['big'], 'huge.jpg', { type: 'image/jpeg' });
|
|
Object.defineProperty(oversized, 'size', { value: 16 * 1024 * 1024 });
|
|
await user.upload(input, oversized);
|
|
expect(screen.getByRole('alert')).toHaveTextContent('Images must be 15 MB or smaller.');
|
|
expect(screen.getByText('No image selected')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows a thumbnail preview of the picked image when object URLs are available', async () => {
|
|
const objectUrls: string[] = [];
|
|
const globalUrl = URL as unknown as Record<string, unknown>;
|
|
globalUrl.createObjectURL = vi.fn(() => {
|
|
const url = `blob:preview-${objectUrls.length}`;
|
|
objectUrls.push(url);
|
|
return url;
|
|
});
|
|
globalUrl.revokeObjectURL = vi.fn();
|
|
try {
|
|
server.use(http.get('/api/media-library', () => HttpResponse.json([])));
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
await screen.findByText('No exchange images yet.');
|
|
|
|
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
|
|
expect(screen.getByRole('img', { name: 'Preview of portrait.jpg' })).toHaveAttribute('src', 'blob:preview-0');
|
|
} finally {
|
|
delete globalUrl.createObjectURL;
|
|
delete globalUrl.revokeObjectURL;
|
|
}
|
|
});
|
|
});
|