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>
109 lines
3.9 KiB
TypeScript
109 lines
3.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import '@testing-library/jest-dom/vitest';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { cleanup, render, screen, within } from '@testing-library/react';
|
|
import { HttpResponse, http } from 'msw';
|
|
import { setupServer } from 'msw/node';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
import { AttentionPage } from '../../apps/dashboard/src/features/attention/attention-page.js';
|
|
|
|
const MINUTE_MS = 60_000;
|
|
|
|
function attentionResponse(items: unknown[]) {
|
|
return { generatedAt: Date.now() - MINUTE_MS, items };
|
|
}
|
|
|
|
const criticalItem = {
|
|
id: 'item-critical',
|
|
kind: 'human-required',
|
|
severity: 'critical',
|
|
title: 'Human handoff required',
|
|
detail: 'Automation stopped and needs an operator reply.',
|
|
occurredAt: Date.now() - (14 * MINUTE_MS),
|
|
conversationId: 'room-alice',
|
|
username: 'alice',
|
|
};
|
|
|
|
const warningItem = {
|
|
id: 'item-warning',
|
|
kind: 'invitation-cooldown',
|
|
severity: 'warning',
|
|
title: 'Invitation cooldown active',
|
|
detail: 'Reddit is rate limiting new chat invitations.',
|
|
occurredAt: Date.now() - (2 * 60 * MINUTE_MS),
|
|
};
|
|
|
|
const server = setupServer();
|
|
|
|
function renderAttention() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<AttentionPage />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
return queryClient;
|
|
}
|
|
|
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
afterEach(() => {
|
|
cleanup();
|
|
server.resetHandlers();
|
|
});
|
|
afterAll(() => server.close());
|
|
|
|
describe('AttentionPage', () => {
|
|
it('labels critical items and the critical count with the danger pill', async () => {
|
|
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem, warningItem]))));
|
|
renderAttention();
|
|
|
|
const items = await screen.findByRole('list', { name: 'Attention items' });
|
|
const [first, second] = within(items).getAllByRole('listitem');
|
|
|
|
expect(within(first).getByText('human required')).toHaveClass('pill', 'danger');
|
|
expect(within(second).getByText('invitation cooldown')).toHaveClass('pill', 'warning');
|
|
expect(screen.getByText('1 critical')).toHaveClass('pill', 'danger');
|
|
});
|
|
|
|
it('shows relative times with exact timestamps as titles', async () => {
|
|
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem]))));
|
|
renderAttention();
|
|
|
|
const time = await screen.findByText('14m ago');
|
|
expect(time.tagName).toBe('TIME');
|
|
expect(time).toHaveAttribute('dateTime', new Date(criticalItem.occurredAt).toISOString());
|
|
expect(time).toHaveAttribute('title');
|
|
expect(screen.getByText(/Auto-refreshes every 30s/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('links conversation items and gives unlinked items a place to act', async () => {
|
|
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem, warningItem]))));
|
|
renderAttention();
|
|
|
|
expect(await screen.findByRole('link', { name: 'Open conversation' })).toHaveAttribute(
|
|
'href',
|
|
'/conversations/room-alice',
|
|
);
|
|
expect(screen.getByText('No linked conversation — check Operations.')).toBeInTheDocument();
|
|
expect(screen.getByText('Reddit is rate limiting new chat invitations.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('uses the dedicated attention dot for the open-items summary', async () => {
|
|
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem]))));
|
|
renderAttention();
|
|
|
|
await screen.findByText('Open items');
|
|
const strip = screen.getByRole('region', { name: 'Attention totals' });
|
|
const dot = strip.querySelector('.attention-status-dot');
|
|
expect(dot).toHaveClass('is-open');
|
|
expect(dot).not.toHaveClass('running');
|
|
expect(dot).not.toHaveClass('stopped');
|
|
});
|
|
});
|