610 lines
26 KiB
TypeScript
610 lines
26 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, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||
import { SubredditsPage } from '../../apps/dashboard/src/features/subreddits/subreddits-page.js';
|
||
|
||
const baseSubreddits = [
|
||
{ name: 'casualconversation', type: 'chat', weight: 1, tone: 'default', enabled: true },
|
||
{ name: 'r4r', type: 'chat', weight: 3, tone: 'playful', enabled: true, conversation: { mode: 'r4r', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3 } },
|
||
{ name: 'sleepy', type: 'image', weight: 4, tone: 'gentle', enabled: false },
|
||
];
|
||
|
||
const baseConfig = {
|
||
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
|
||
browser: { dryRun: true },
|
||
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } },
|
||
subreddits: baseSubreddits,
|
||
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
|
||
};
|
||
|
||
const performance = [
|
||
{ subreddit: 'casualconversation', totalSent: 40, totalResponses: 10, responseRate: 0.25, totalImages: 2, imageRate: 0.05 },
|
||
{ subreddit: 'r4r', totalSent: 5, totalResponses: 4, responseRate: 0.8, totalImages: 0, imageRate: 0 },
|
||
];
|
||
|
||
let config: Record<string, any> = structuredClone(baseConfig);
|
||
/** Subreddit arrays received by PUT /api/config, in order. */
|
||
let saved: any[][] = [];
|
||
|
||
const server = setupServer(
|
||
http.get('/api/config', () => HttpResponse.json(config)),
|
||
http.get('/api/analytics/by-subreddit', () => HttpResponse.json(performance)),
|
||
http.put('/api/config', async ({ request }) => {
|
||
const body = await request.json() as Record<string, any>;
|
||
saved.push(body.subreddits);
|
||
config = { ...config, subreddits: body.subreddits };
|
||
return HttpResponse.json({
|
||
success: true,
|
||
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
|
||
subreddits: config.subreddits,
|
||
});
|
||
}),
|
||
);
|
||
|
||
function renderSubreddits() {
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||
});
|
||
const router = createMemoryRouter([{ path: '/subreddits', element: <SubredditsPage /> }], {
|
||
initialEntries: ['/subreddits'],
|
||
});
|
||
return render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<RouterProvider router={router} />
|
||
</QueryClientProvider>,
|
||
);
|
||
}
|
||
|
||
async function findRows() {
|
||
const list = await screen.findByRole('list', { name: 'Configured subreddits' });
|
||
return within(list).getAllByRole('listitem');
|
||
}
|
||
|
||
async function row(name: string) {
|
||
const rows = await findRows();
|
||
const match = rows.find((item) => rowName(item) === `r/${name}`);
|
||
if (!match) throw new Error(`No row for r/${name}`);
|
||
return match;
|
||
}
|
||
|
||
/** The row's own name element, not its concatenated text content. */
|
||
function rowName(item: HTMLElement): string {
|
||
return within(item).getByRole('heading', { level: 3 }).textContent ?? '';
|
||
}
|
||
|
||
async function rowOrder(): Promise<string[]> {
|
||
return (await findRows()).map(rowName);
|
||
}
|
||
|
||
/** Reads one metric from a row's definition list by its label. */
|
||
function metric(item: HTMLElement, label: string): string {
|
||
const term = within(item).getAllByRole('term').find((node) => node.textContent === label);
|
||
if (!term) throw new Error(`No ${label} metric in row`);
|
||
return term.parentElement?.querySelector('dd')?.textContent ?? '';
|
||
}
|
||
|
||
async function openEditor(name: string) {
|
||
await userEvent.setup().click(within(await row(name)).getByRole('button', { name: 'Edit' }));
|
||
return screen.findByRole('dialog', { name: `r/${name}` });
|
||
}
|
||
|
||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||
beforeEach(() => {
|
||
config = structuredClone(baseConfig);
|
||
saved = [];
|
||
});
|
||
afterEach(() => {
|
||
cleanup();
|
||
server.resetHandlers();
|
||
vi.restoreAllMocks();
|
||
});
|
||
afterAll(() => server.close());
|
||
|
||
describe('subreddits page', () => {
|
||
it('lists each subreddit with its share of polling, mode, and performance', async () => {
|
||
renderSubreddits();
|
||
const rows = await findRows();
|
||
expect(rows).toHaveLength(3);
|
||
|
||
// Enabled weights are 1 and 3, so the shares are 25% and 75%. The disabled
|
||
// row takes none of it despite having the largest weight.
|
||
const casual = await row('casualconversation');
|
||
expect(metric(casual, 'Share')).toContain('25%');
|
||
expect(metric(casual, 'Sent')).toBe('40');
|
||
expect(metric(casual, 'Replies')).toContain('25% rate');
|
||
|
||
expect(metric(await row('r4r'), 'Share')).toContain('75%');
|
||
|
||
const sleepy = await row('sleepy');
|
||
expect(metric(sleepy, 'Share')).toBe('Off');
|
||
expect(metric(sleepy, 'Sent')).toBe('—');
|
||
expect(within(sleepy).getByText('Paused')).toBeInTheDocument();
|
||
});
|
||
|
||
it('summarises how many are polled and how many reply autonomously', async () => {
|
||
renderSubreddits();
|
||
await findRows();
|
||
expect(screen.getByText('of 3 being polled')).toBeInTheDocument();
|
||
expect(screen.getByText('replying autonomously')).toBeInTheDocument();
|
||
expect(await row('r4r')).toHaveTextContent('Autonomous');
|
||
});
|
||
|
||
it('orders by share by default and can be re-sorted', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
|
||
// Share order leads with the heaviest; name order is alphabetical. The
|
||
// fixture makes the two orders differ so this proves the sort runs.
|
||
expect(await rowOrder()).toEqual(['r/r4r', 'r/casualconversation', 'r/sleepy']);
|
||
|
||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sort subreddits' }), 'Name');
|
||
expect(await rowOrder()).toEqual(['r/casualconversation', 'r/r4r', 'r/sleepy']);
|
||
|
||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sort subreddits' }), 'Messages sent');
|
||
expect(await rowOrder()).toEqual(['r/casualconversation', 'r/r4r', 'r/sleepy']);
|
||
});
|
||
|
||
it('filters the list by name', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
await findRows();
|
||
|
||
await user.type(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }), 'sleep');
|
||
expect(await findRows()).toHaveLength(1);
|
||
expect(screen.getByText('r/sleepy')).toBeInTheDocument();
|
||
|
||
await user.clear(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }));
|
||
await user.type(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }), 'nothing');
|
||
expect(await screen.findByText(/No subreddits match/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('commits the polling toggle immediately, without a save step', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
const sleepy = await row('sleepy');
|
||
|
||
await user.click(within(sleepy).getByRole('checkbox', { name: 'Poll r/sleepy' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0].find((item: any) => item.name === 'sleepy').enabled).toBe(true);
|
||
// Enabling weight 4 alongside 3 and 1 rebalances every share.
|
||
expect(metric(await row('sleepy'), 'Share')).toContain('50%');
|
||
});
|
||
|
||
describe('editing one subreddit', () => {
|
||
it('can target qualifying commenters on an image subreddit', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('sleepy');
|
||
const user = userEvent.setup();
|
||
await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Recipient' }), 'comment-author');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0].find((item: any) => item.name === 'sleepy').targetingMode).toBe('comment-author');
|
||
});
|
||
|
||
it('previews the share the chosen weight will actually produce', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('casualconversation');
|
||
const user = userEvent.setup();
|
||
|
||
expect(within(dialog).getByText('25%')).toBeInTheDocument();
|
||
const weight = within(dialog).getByRole('spinbutton', { name: 'Weight' });
|
||
await user.clear(weight);
|
||
await user.type(weight, '3');
|
||
expect(within(dialog).getByText('50%')).toBeInTheDocument();
|
||
});
|
||
|
||
it('saves only that subreddit and leaves the others untouched', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('casualconversation');
|
||
const user = userEvent.setup();
|
||
|
||
await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Tone' }), 'direct');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0]).toHaveLength(3);
|
||
expect(saved[0][0]).toMatchObject({ name: 'casualconversation', tone: 'direct' });
|
||
expect(saved[0][1]).toMatchObject({ name: 'r4r', tone: 'playful' });
|
||
expect(saved[0][2]).toMatchObject({ name: 'sleepy', tone: 'gentle' });
|
||
});
|
||
|
||
it('rejects a name that is already configured', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('sleepy');
|
||
const user = userEvent.setup();
|
||
|
||
const name = within(dialog).getByRole('textbox', { name: 'Subreddit name' });
|
||
await user.clear(name);
|
||
await user.type(name, 'R4R');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
|
||
expect(within(dialog).getByText('r/R4R is already configured.')).toBeInTheDocument();
|
||
expect(saved).toHaveLength(0);
|
||
});
|
||
|
||
it('rejects a name Reddit would not accept', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('sleepy');
|
||
const user = userEvent.setup();
|
||
|
||
const name = within(dialog).getByRole('textbox', { name: 'Subreddit name' });
|
||
await user.clear(name);
|
||
await user.type(name, 'has spaces');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
|
||
expect(within(dialog).getByText(/2–21 letters, numbers or underscores/)).toBeInTheDocument();
|
||
expect(saved).toHaveLength(0);
|
||
});
|
||
|
||
it('accepts a name pasted with its r/ prefix', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('sleepy');
|
||
const user = userEvent.setup();
|
||
|
||
const name = within(dialog).getByRole('textbox', { name: 'Subreddit name' });
|
||
await user.clear(name);
|
||
await user.type(name, 'r/quietplace');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0].map((item: any) => item.name)).toContain('quietplace');
|
||
});
|
||
|
||
it('rejects a weight of zero', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('sleepy');
|
||
const user = userEvent.setup();
|
||
|
||
const weight = within(dialog).getByRole('spinbutton', { name: 'Weight' });
|
||
await user.clear(weight);
|
||
await user.type(weight, '0');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
|
||
expect(within(dialog).getByText('Weight must be a number greater than 0.')).toBeInTheDocument();
|
||
expect(saved).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('conversation policy', () => {
|
||
it('reveals the turn budget only for the autonomous modes', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('casualconversation');
|
||
const user = userEvent.setup();
|
||
|
||
expect(within(dialog).queryByRole('spinbutton', { name: 'Maximum automated turns' })).not.toBeInTheDocument();
|
||
|
||
await user.click(within(dialog).getByRole('radio', { name: /Autonomous conversation/ }));
|
||
expect(within(dialog).getByRole('spinbutton', { name: 'Maximum automated turns' })).toBeInTheDocument();
|
||
expect(within(dialog).queryByRole('spinbutton', { name: 'Rapport turns before the ask' })).not.toBeInTheDocument();
|
||
|
||
await user.click(within(dialog).getByRole('radio', { name: /then one media ask/ }));
|
||
expect(within(dialog).getByRole('spinbutton', { name: 'Rapport turns before the ask' })).toBeInTheDocument();
|
||
});
|
||
|
||
it('requires explicit confirmation before a subreddit replies autonomously', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('casualconversation');
|
||
const user = userEvent.setup();
|
||
|
||
await user.click(within(dialog).getByRole('radio', { name: /Autonomous conversation/ }));
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
|
||
expect(await screen.findByText(/r\/casualconversation will reply autonomously/)).toBeInTheDocument();
|
||
expect(saved).toHaveLength(0);
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Apply risky settings' }));
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0][0].conversation.mode).toBe('r4r');
|
||
});
|
||
|
||
it('rejects rapport turns that are not lower than the total turn budget', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('casualconversation');
|
||
const user = userEvent.setup();
|
||
|
||
await user.click(within(dialog).getByRole('radio', { name: /then one media ask/ }));
|
||
const total = within(dialog).getByRole('spinbutton', { name: 'Maximum automated turns' });
|
||
await user.clear(total);
|
||
await user.type(total, '4');
|
||
const rapport = within(dialog).getByRole('spinbutton', { name: 'Rapport turns before the ask' });
|
||
await user.clear(rapport);
|
||
await user.type(rapport, '4');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
|
||
expect(within(dialog).getByText('Rapport turns must be lower than the maximum automated turns.')).toBeInTheDocument();
|
||
expect(saved).toHaveLength(0);
|
||
});
|
||
|
||
it('does not re-confirm a subreddit that was already autonomous', async () => {
|
||
renderSubreddits();
|
||
const dialog = await openEditor('r4r');
|
||
const user = userEvent.setup();
|
||
|
||
const total = within(dialog).getByRole('spinbutton', { name: 'Maximum automated turns' });
|
||
await user.clear(total);
|
||
await user.type(total, '8');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(screen.queryByText('Confirm risky settings')).not.toBeInTheDocument();
|
||
expect(saved[0][1].conversation.maxAutomatedTurns).toBe(8);
|
||
});
|
||
|
||
it('never prompts about a subreddit other than the one being edited', async () => {
|
||
renderSubreddits();
|
||
// r/r4r is autonomous. Editing an unrelated row must not surface its
|
||
// policy for re-approval — the old array form confirmed by row index.
|
||
const dialog = await openEditor('sleepy');
|
||
const user = userEvent.setup();
|
||
|
||
await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Tone' }), 'direct');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(screen.queryByText('Confirm risky settings')).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe('adding and removing', () => {
|
||
it('adds a subreddit through a dialog, defaulting to the safest policy', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
await findRows();
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Add subreddit' }));
|
||
const dialog = await screen.findByRole('dialog', { name: 'Add a subreddit' });
|
||
expect(within(dialog).getByRole('radio', { name: /Manual only/ })).toBeChecked();
|
||
|
||
await user.type(within(dialog).getByRole('textbox', { name: 'Subreddit name' }), 'newplace');
|
||
await user.click(within(dialog).getByRole('button', { name: 'Add subreddit' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0]).toHaveLength(4);
|
||
expect(saved[0][3]).toMatchObject({ name: 'newplace', conversation: { mode: 'manual' } });
|
||
expect(await row('newplace')).toBeInTheDocument();
|
||
});
|
||
|
||
it('will not add a subreddit with no name', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
await findRows();
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Add subreddit' }));
|
||
const dialog = await screen.findByRole('dialog', { name: 'Add a subreddit' });
|
||
await user.click(within(dialog).getByRole('button', { name: 'Add subreddit' }));
|
||
|
||
expect(within(dialog).getByText('Subreddit name is required.')).toBeInTheDocument();
|
||
expect(saved).toHaveLength(0);
|
||
});
|
||
|
||
it('confirms a removal before dropping the subreddit', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
await user.click(within(await row('sleepy')).getByRole('button', { name: 'Remove' }));
|
||
|
||
expect(await screen.findByRole('heading', { name: 'Stop using r/sleepy?' })).toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { name: 'Keep it' }));
|
||
expect(saved).toHaveLength(0);
|
||
|
||
await user.click(within(await row('sleepy')).getByRole('button', { name: 'Remove' }));
|
||
await user.click(await screen.findByRole('button', { name: 'Remove subreddit' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0].map((item: any) => item.name)).toEqual(['casualconversation', 'r4r']);
|
||
});
|
||
|
||
it('refuses to remove the last remaining subreddit', async () => {
|
||
config = { ...structuredClone(baseConfig), subreddits: [baseSubreddits[0]] };
|
||
renderSubreddits();
|
||
|
||
const only = await row('casualconversation');
|
||
expect(within(only).getByRole('button', { name: 'Remove' })).toBeDisabled();
|
||
});
|
||
});
|
||
|
||
describe('bulk actions', () => {
|
||
async function select(...names: string[]) {
|
||
const user = userEvent.setup();
|
||
for (const name of names) {
|
||
await user.click(within(await row(name)).getByRole('checkbox', { name: `Select r/${name}` }));
|
||
}
|
||
return screen.getByRole('region', { name: 'Bulk actions' });
|
||
}
|
||
|
||
it('stays hidden until something is selected', async () => {
|
||
renderSubreddits();
|
||
await findRows();
|
||
expect(screen.queryByRole('region', { name: 'Bulk actions' })).not.toBeInTheDocument();
|
||
|
||
const bar = await select('sleepy');
|
||
expect(within(bar).getByText('1 selected')).toBeInTheDocument();
|
||
expect(within(bar).getByText('0 polling, 1 paused')).toBeInTheDocument();
|
||
});
|
||
|
||
it('pauses every selected subreddit in one write', async () => {
|
||
renderSubreddits();
|
||
const bar = await select('casualconversation', 'r4r');
|
||
|
||
await userEvent.setup().click(within(bar).getByRole('button', { name: 'Pause polling' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0].map((item: any) => [item.name, item.enabled])).toEqual([
|
||
['casualconversation', false],
|
||
['r4r', false],
|
||
['sleepy', false],
|
||
]);
|
||
});
|
||
|
||
it('offers only the action that would change something', async () => {
|
||
renderSubreddits();
|
||
const bar = await select('casualconversation');
|
||
|
||
// Already polling, so there is nothing for "Start polling" to do.
|
||
expect(within(bar).getByRole('button', { name: 'Start polling' })).toBeDisabled();
|
||
expect(within(bar).getByRole('button', { name: 'Pause polling' })).toBeEnabled();
|
||
});
|
||
|
||
it('sets one tone across the selection', async () => {
|
||
renderSubreddits();
|
||
const bar = await select('casualconversation', 'sleepy');
|
||
|
||
await userEvent.setup().selectOptions(
|
||
within(bar).getByRole('combobox', { name: 'Set tone for the selected subreddits' }),
|
||
'direct',
|
||
);
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0].map((item: any) => item.tone)).toEqual(['direct', 'playful', 'direct']);
|
||
});
|
||
|
||
it('names every subreddit turning autonomous before applying it', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
const bar = await select('casualconversation', 'r4r', 'sleepy');
|
||
|
||
await user.selectOptions(
|
||
within(bar).getByRole('combobox', { name: 'Set conversation policy for the selected subreddits' }),
|
||
'Autonomous conversation',
|
||
);
|
||
|
||
// r/r4r is already autonomous, so only the other two are called out.
|
||
const dialog = await screen.findByRole('dialog', { name: 'Confirm risky settings' });
|
||
expect(within(dialog).getByText(/2 subreddits will reply autonomously/)).toBeInTheDocument();
|
||
expect(within(dialog).getByText(/r\/casualconversation, r\/sleepy/)).toBeInTheDocument();
|
||
expect(saved).toHaveLength(0);
|
||
|
||
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0].every((item: any) => item.conversation.mode === 'r4r')).toBe(true);
|
||
});
|
||
|
||
it('applies a non-autonomous policy without a confirmation step', async () => {
|
||
renderSubreddits();
|
||
const bar = await select('r4r');
|
||
|
||
await userEvent.setup().selectOptions(
|
||
within(bar).getByRole('combobox', { name: 'Set conversation policy for the selected subreddits' }),
|
||
'Manual only',
|
||
);
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(screen.queryByText('Confirm risky settings')).not.toBeInTheDocument();
|
||
expect(saved[0][1].conversation.mode).toBe('manual');
|
||
});
|
||
|
||
it('keeps the hybrid mode valid when bulk-applied to a short turn budget', async () => {
|
||
// r/casualconversation defaults to 3 total / 3 rapport, which the hybrid
|
||
// mode rejects. A bulk change has no form to fix it, so it must normalise.
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
const bar = await select('casualconversation');
|
||
|
||
await user.selectOptions(
|
||
within(bar).getByRole('combobox', { name: 'Set conversation policy for the selected subreddits' }),
|
||
'Autonomous, then one media ask',
|
||
);
|
||
await user.click(await screen.findByRole('button', { name: 'Apply risky settings' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
const { maxAutomatedTurns, rapportTurnsBeforeMediaAsk } = saved[0][0].conversation;
|
||
expect(rapportTurnsBeforeMediaAsk).toBeLessThan(maxAutomatedTurns);
|
||
});
|
||
|
||
it('confirms a bulk removal and lists what will go', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
const bar = await select('r4r', 'sleepy');
|
||
|
||
await user.click(within(bar).getByRole('button', { name: 'Remove' }));
|
||
const dialog = await screen.findByRole('dialog', { name: 'Stop using 2 subreddits?' });
|
||
expect(within(dialog).getByText('r/r4r')).toBeInTheDocument();
|
||
expect(within(dialog).getByText('r/sleepy')).toBeInTheDocument();
|
||
|
||
await user.click(within(dialog).getByRole('button', { name: 'Remove subreddits' }));
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
expect(saved[0].map((item: any) => item.name)).toEqual(['casualconversation']);
|
||
expect(screen.queryByRole('region', { name: 'Bulk actions' })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('refuses to remove every subreddit at once', async () => {
|
||
renderSubreddits();
|
||
const bar = await select('casualconversation', 'r4r', 'sleepy');
|
||
|
||
const remove = within(bar).getByRole('button', { name: 'Remove' });
|
||
expect(remove).toBeDisabled();
|
||
expect(remove).toHaveAttribute('title', 'At least one subreddit must stay configured.');
|
||
});
|
||
|
||
it('selects only the rows the filter is showing', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
await findRows();
|
||
|
||
await user.type(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }), 'r4r');
|
||
await user.click(screen.getByRole('checkbox', { name: 'Select all 1 matching subreddits' }));
|
||
|
||
await user.clear(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }));
|
||
const bar = await screen.findByRole('region', { name: 'Bulk actions' });
|
||
expect(within(bar).getByText('1 selected')).toBeInTheDocument();
|
||
});
|
||
|
||
it('selects and clears the whole list', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
await findRows();
|
||
|
||
await user.click(screen.getByRole('checkbox', { name: 'Select all subreddits' }));
|
||
const bar = await screen.findByRole('region', { name: 'Bulk actions' });
|
||
expect(within(bar).getByText('3 selected')).toBeInTheDocument();
|
||
|
||
await user.click(within(bar).getByRole('button', { name: 'Clear' }));
|
||
expect(screen.queryByRole('region', { name: 'Bulk actions' })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('drops a removed subreddit from the selection', async () => {
|
||
renderSubreddits();
|
||
const user = userEvent.setup();
|
||
await select('casualconversation', 'sleepy');
|
||
|
||
// Remove one of the selected rows through its own row control.
|
||
await user.click(within(await row('sleepy')).getByRole('button', { name: 'Remove' }));
|
||
await user.click(await screen.findByRole('button', { name: 'Remove subreddit' }));
|
||
|
||
await waitFor(() => expect(saved).toHaveLength(1));
|
||
const bar = await screen.findByRole('region', { name: 'Bulk actions' });
|
||
expect(within(bar).getByText('1 selected')).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it('still lists subreddits when performance data is unavailable', async () => {
|
||
server.use(http.get('/api/analytics/by-subreddit', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||
renderSubreddits();
|
||
|
||
expect(await findRows()).toHaveLength(3);
|
||
expect(await screen.findByText(/performance could not be loaded/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('shows a recoverable error when the configuration cannot be loaded', async () => {
|
||
let requests = 0;
|
||
server.use(
|
||
http.get('/api/config', () => {
|
||
requests += 1;
|
||
return requests === 1 ? HttpResponse.json({ error: 'nope' }, { status: 500 }) : HttpResponse.json(config);
|
||
}),
|
||
);
|
||
renderSubreddits();
|
||
|
||
expect(await screen.findByRole('alert')).toHaveTextContent('Subreddits unavailable');
|
||
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }));
|
||
expect(await findRows()).toHaveLength(3);
|
||
});
|
||
});
|