Files
Jamie BruntonandClaude Opus 5 c43e534cf1 refactor(dashboard): rethink settings, split out subreddits and accounts
The settings page had accumulated five tabs whose names no longer matched
their contents, three different save models, and the whole subreddit
configuration buried under a tab named after a checkbox.

Structure:
- Subreddits becomes its own page: a list showing each community's mode,
  tone, computed share of polling, and live sent/reply stats, with one
  subreddit edited at a time in a drawer.
- Accounts becomes its own page. It is session lifecycle, not config.
- Settings collapses to one scrolling page: Safety, Pacing (Monitoring and
  Rate limits merged), Targeting, Notifications, Advanced.
- The sidebar is grouped, having gone from 9 to 11 destinations.

Saving:
- Every section owns its own draft, validation, risk gate and save via
  useConfigCard, submitting only the keys it owns. A syntax error in the
  advanced JSON can no longer block a rate-limit change, and enabling an
  autonomous policy is its own deliberate commit.
- UnsavedChangesProvider restores the leave-page guard across cards that no
  longer share a draft.

Subreddit editing:
- Errors are keyed by field name instead of array index, removing the
  defensive invalidation that existed because row indices shifted on add
  and remove. Confirming an autonomous mode can no longer be attributed to
  the wrong row.
- Weight is shown as the share it actually produces, live while editing.
- Bulk actions cover start/pause polling, tone, policy and removal, because
  production runs 30-40 subreddits and per-row editing does not scale.
  Bulk policy changes name every subreddit turning autonomous, and
  normalise the hybrid mode's turn budget, which has no form to catch it.

Physique targeting moves into its own Targeting card. It sends only
reddit.physiqueTargeting, which the config route applies independently, so
it cannot clobber the pacing card's fields. Turning screening on now warns
that it will reduce how many posts qualify.

Also surfaces the effective sending rate, since the hourly limit and the
minimum delay both throttle and only the binding one matters.

Legacy ?section= links redirect or resolve to anchors.

Fixes found while verifying in a browser: a radio-card rule was shrinking
nested number inputs; .share-preview had no styles; the throughput readout
reported a rate from a blank field; and the reconcile logic warned about a
server change whenever a sibling card saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:07:31 +01:00

504 lines
21 KiB
TypeScript

// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, 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 { SettingsRoute } from '../../apps/dashboard/src/features/settings/settings-route.js';
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: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
advanced: { openrouter: { chatModel: 'example/chat-model' } },
};
const pushSettings = {
enabled: false,
serverUrl: 'https://ntfy.sh',
topic: '',
priority: 'default',
events: {
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
},
hasAuthToken: false,
};
let config: Record<string, unknown> = structuredClone(baseConfig);
/** Bodies received by PUT /api/config, in order. */
let saved: Record<string, unknown>[] = [];
const server = setupServer(
http.get('/api/config', () => HttpResponse.json(config)),
http.get('/api/push-notifications', () => HttpResponse.json(pushSettings)),
);
/** Applies a partial update the way the real endpoint does, and echoes it back. */
function installConfigWriter() {
server.use(
http.put('/api/config', async ({ request }) => {
const body = await request.json() as Record<string, any>;
saved.push(body);
config = {
...config,
rateLimit: { ...(config.rateLimit as object), ...(body.rateLimit ?? {}) },
browser: { ...(config.browser as object), ...(body.browser ?? {}) },
reddit: { ...(config.reddit as object), ...(body.reddit ?? {}) },
subreddits: body.subreddits ?? config.subreddits,
advanced: body.advanced ?? config.advanced,
};
return HttpResponse.json({
success: true,
config: {
rateLimit: config.rateLimit,
browser: config.browser,
reddit: config.reddit,
},
subreddits: config.subreddits,
advanced: config.advanced,
});
}),
);
}
function renderSettings(path = '/settings') {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter(
[
{ path: '/settings', element: <SettingsRoute /> },
{ path: '/subreddits', element: <h1>Subreddits page</h1> },
{ path: '/accounts', element: <h1>Accounts page</h1> },
{ path: '/elsewhere', element: <h1>Elsewhere</h1> },
],
{ initialEntries: [path] },
);
const view = render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
return { ...view, queryClient, router };
}
function card(name: string) {
return screen.getByRole('region', { name });
}
async function findCard(name: string) {
return screen.findByRole('region', { name });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeEach(() => {
config = structuredClone(baseConfig);
saved = [];
});
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('settings page', () => {
it('stacks every section on one page instead of hiding them behind tabs', async () => {
renderSettings();
for (const name of ['Safety', 'Pacing', 'Targeting', 'Notifications', 'Advanced']) {
expect(await findCard(name)).toBeInTheDocument();
}
});
it('shows a recoverable initial configuration error', async () => {
let requests = 0;
server.use(
http.get('/api/config', () => {
requests += 1;
return requests === 1 ? HttpResponse.json({ error: 'nope' }, { status: 500 }) : HttpResponse.json(config);
}),
);
const user = userEvent.setup();
renderSettings();
expect(await screen.findByRole('alert')).toHaveTextContent('Settings unavailable');
await user.click(screen.getByRole('button', { name: 'Retry settings' }));
expect(await findCard('Pacing')).toBeInTheDocument();
});
describe('legacy section links', () => {
it('redirects the subreddit tab to the subreddits page', async () => {
renderSettings('/settings?section=behaviour');
expect(await screen.findByRole('heading', { name: 'Subreddits page' })).toBeInTheDocument();
});
it('redirects the access tab to the accounts page', async () => {
renderSettings('/settings?section=access');
expect(await screen.findByRole('heading', { name: 'Accounts page' })).toBeInTheDocument();
});
it('keeps former in-page tabs on the settings page', async () => {
renderSettings('/settings?section=rate-limits');
expect(await findCard('Pacing')).toBeInTheDocument();
});
});
describe('pacing', () => {
it('reports the effective rate when the minimum delay is the binding limit', async () => {
renderSettings();
const pacing = await findCard('Pacing');
// 5 min minimum delay caps sending at 12/hr, below the 8/hr limit... so
// here the limit binds. Raise the delay and the readout should flip.
expect(pacing).toHaveTextContent('8 messages per hour');
expect(pacing).toHaveTextContent('the hourly limit is the binding limit');
const user = userEvent.setup();
const delay = within(pacing).getByRole('spinbutton', { name: 'Minimum delay' });
await user.clear(delay);
await user.type(delay, '30');
expect(pacing).toHaveTextContent('2 messages per hour');
expect(pacing).toHaveTextContent('the minimum delay is the binding limit');
});
it('rejects blank and out-of-range values before sending anything', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const pacing = await findCard('Pacing');
await user.clear(within(pacing).getByRole('spinbutton', { name: 'Minimum delay' }));
const readyPosts = within(pacing).getByRole('spinbutton', { name: 'Maximum ready posts' });
await user.clear(readyPosts);
await user.type(readyPosts, '900');
await user.click(within(pacing).getByRole('button', { name: 'Save pacing' }));
expect(within(pacing).getByText('Minimum delay is required.')).toBeInTheDocument();
expect(within(pacing).getByText('Ready-post limit cannot exceed 500.')).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('saves only the keys it owns, never the subreddit array', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const pacing = await findCard('Pacing');
const jitter = within(pacing).getByRole('spinbutton', { name: 'Random jitter' });
await user.clear(jitter);
await user.type(jitter, '90');
await user.click(within(pacing).getByRole('button', { name: 'Save pacing' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0]).toEqual({
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 90_000 },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } },
});
expect(saved[0]).not.toHaveProperty('subreddits');
expect(saved[0]).not.toHaveProperty('browser');
expect(await within(pacing).findByText('Pacing saved.')).toBeInTheDocument();
});
it('confirms an unusually large hourly limit before applying it', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const pacing = await findCard('Pacing');
const limit = within(pacing).getByRole('spinbutton', { name: 'Hourly limit' });
await user.clear(limit);
await user.type(limit, '60');
await user.click(within(pacing).getByRole('button', { name: 'Save pacing' }));
expect(await screen.findByText(/will allow 60 messages per hour/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
await user.click(screen.getByRole('button', { name: 'Apply risky settings' }));
await waitFor(() => expect(saved).toHaveLength(1));
});
it('resets edits back to the persisted values', async () => {
const user = userEvent.setup();
renderSettings();
const pacing = await findCard('Pacing');
const limit = within(pacing).getByRole('spinbutton', { name: 'Hourly limit' });
await user.clear(limit);
await user.type(limit, '19');
expect(within(pacing).getByRole('status')).toHaveTextContent('Unsaved changes in this section.');
await user.click(within(pacing).getByRole('button', { name: 'Reset' }));
expect(limit).toHaveValue(8);
expect(within(pacing).getByText('Saved.')).toBeInTheDocument();
});
});
describe('safety', () => {
it('states the current sending state before offering the switch', async () => {
renderSettings();
const safety = await findCard('Safety');
expect(within(safety).getByRole('status')).toHaveTextContent('No messages are being sent.');
expect(within(safety).getByText('Dry run')).toBeInTheDocument();
});
it('requires confirmation before dry run is turned off', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const safety = await findCard('Safety');
await user.click(within(safety).getByRole('checkbox', { name: 'Dry run mode' }));
await user.click(within(safety).getByRole('button', { name: 'Save safety' }));
expect(await screen.findByText(/Dry run is being turned off/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
await user.click(screen.getByRole('button', { name: 'Apply risky settings' }));
await waitFor(() => expect(saved).toEqual([{ browser: { dryRun: false } }]));
expect(await within(safety).findByText('Sending live')).toBeInTheDocument();
});
it('does not re-confirm when dry run is left off', async () => {
config = { ...structuredClone(baseConfig), browser: { dryRun: false } };
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const safety = await findCard('Safety');
// Toggle on and back off: the persisted value never allowed sending to
// start, so there is nothing to confirm.
await user.click(within(safety).getByRole('checkbox', { name: 'Dry run mode' }));
await user.click(within(safety).getByRole('checkbox', { name: 'Dry run mode' }));
expect(within(safety).getByRole('button', { name: 'Save safety' })).toBeDisabled();
});
});
describe('targeting', () => {
it('sends only the physique range, leaving the pacing fields alone', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const targeting = await findCard('Targeting');
const min = within(targeting).getByRole('spinbutton', { name: 'Minimum score' });
await user.clear(min);
await user.type(min, '4');
await user.click(within(targeting).getByRole('button', { name: 'Save targeting' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0]).toEqual({ reddit: { physiqueTargeting: { enabled: false, minScore: 4, maxScore: 10 } } });
expect(saved[0].reddit).not.toHaveProperty('maxReadyCandidates');
});
it('rejects a minimum above the maximum', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const targeting = await findCard('Targeting');
const min = within(targeting).getByRole('spinbutton', { name: 'Minimum score' });
await user.clear(min);
await user.type(min, '9');
const max = within(targeting).getByRole('spinbutton', { name: 'Maximum score' });
await user.clear(max);
await user.type(max, '3');
await user.click(within(targeting).getByRole('button', { name: 'Save targeting' }));
expect(within(targeting).getByText('Minimum physique score cannot exceed maximum physique score.')).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('rejects a score outside 1 to 10', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const targeting = await findCard('Targeting');
const max = within(targeting).getByRole('spinbutton', { name: 'Maximum score' });
await user.clear(max);
await user.type(max, '11');
await user.click(within(targeting).getByRole('button', { name: 'Save targeting' }));
expect(within(targeting).getByText('Physique scores must be between 1 and 10.')).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('warns that turning screening on will cut how many posts qualify', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const targeting = await findCard('Targeting');
await user.click(within(targeting).getByRole('checkbox', { name: 'Enable physique targeting' }));
await user.click(within(targeting).getByRole('button', { name: 'Save targeting' }));
expect(await screen.findByText(/posts without enough visible body evidence will be skipped/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
await user.click(screen.getByRole('button', { name: 'Apply risky settings' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(await within(card('Targeting')).findByText('Screening on')).toBeInTheDocument();
});
it('says the range is inert while screening is off', async () => {
renderSettings();
const targeting = await findCard('Targeting');
expect(within(targeting).getByText(/saved but not applied/)).toBeInTheDocument();
expect(within(targeting).getByText('Off')).toBeInTheDocument();
});
});
describe('advanced', () => {
it('stays locked behind a deliberate step and lists what it holds', async () => {
renderSettings();
const advanced = await findCard('Advanced');
expect(within(advanced).getByText('Locked')).toBeInTheDocument();
expect(within(advanced).getByText('openrouter')).toBeInTheDocument();
expect(within(advanced).queryByRole('textbox')).not.toBeInTheDocument();
await userEvent.setup().click(within(advanced).getByRole('button', { name: 'Edit raw configuration' }));
expect(within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' })).toBeInTheDocument();
});
it('explains why the JSON is invalid instead of silently blocking', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
await user.click(within(await findCard('Advanced')).getByRole('button', { name: 'Edit raw configuration' }));
const editor = within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' });
await user.clear(editor);
await user.type(editor, '{{"openrouter": ');
await user.click(within(card('Advanced')).getByRole('button', { name: 'Save advanced' }));
expect(within(card('Advanced')).getByText(/Not valid JSON/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('rejects a JSON array, which would not merge as configuration', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
await user.click(within(await findCard('Advanced')).getByRole('button', { name: 'Edit raw configuration' }));
const editor = within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' });
await user.clear(editor);
await user.type(editor, '[[1]');
await user.click(within(card('Advanced')).getByRole('button', { name: 'Save advanced' }));
expect(within(card('Advanced')).getByText(/must be a JSON object/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('saves valid JSON on its own', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
await user.click(within(await findCard('Advanced')).getByRole('button', { name: 'Edit raw configuration' }));
const editor = within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' });
await user.clear(editor);
await user.type(editor, '{{"openrouter": {{"chatModel": "other/model"}}');
await user.click(within(card('Advanced')).getByRole('button', { name: 'Save advanced' }));
await waitFor(() => expect(saved).toEqual([{ advanced: { openrouter: { chatModel: 'other/model' } } }]));
});
});
describe('card independence', () => {
it('lets pacing save while the advanced JSON is broken', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
await user.click(within(await findCard('Advanced')).getByRole('button', { name: 'Edit raw configuration' }));
const editor = within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' });
await user.clear(editor);
await user.type(editor, 'not json at all');
const pacing = card('Pacing');
const jitter = within(pacing).getByRole('spinbutton', { name: 'Random jitter' });
await user.clear(jitter);
await user.type(jitter, '15');
await user.click(within(pacing).getByRole('button', { name: 'Save pacing' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0]).toHaveProperty('rateLimit');
expect(saved[0]).not.toHaveProperty('advanced');
expect(await within(card('Pacing')).findByText('Pacing saved.')).toBeInTheDocument();
});
it('does not report another card as changed on the server after a sibling saves', async () => {
installConfigWriter();
const user = userEvent.setup();
renderSettings();
const safety = await findCard('Safety');
// Leave pacing dirty, then save safety. Pacing must not claim the server
// moved underneath it: nothing it owns actually changed.
const limit = within(card('Pacing')).getByRole('spinbutton', { name: 'Hourly limit' });
await user.clear(limit);
await user.type(limit, '9');
await user.click(within(safety).getByRole('checkbox', { name: 'Dry run mode' }));
await user.click(within(safety).getByRole('button', { name: 'Save safety' }));
await user.click(await screen.findByRole('button', { name: 'Apply risky settings' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(within(card('Pacing')).queryByText(/Changed on the server/)).not.toBeInTheDocument();
expect(within(card('Pacing')).getByRole('spinbutton', { name: 'Hourly limit' })).toHaveValue(9);
});
it('flags a genuine server-side change and loads it on reset', async () => {
const user = userEvent.setup();
const { queryClient } = renderSettings();
const pacing = await findCard('Pacing');
const limit = within(pacing).getByRole('spinbutton', { name: 'Hourly limit' });
await user.clear(limit);
await user.type(limit, '11');
config = { ...structuredClone(baseConfig), rateLimit: { ...baseConfig.rateLimit, maxMessagesPerHour: 40 } };
await queryClient.refetchQueries({ queryKey: ['dashboard', 'config'] });
expect(await within(card('Pacing')).findByText(/Changed on the server/)).toBeInTheDocument();
await user.click(within(card('Pacing')).getByRole('button', { name: 'Reset' }));
expect(within(card('Pacing')).getByRole('spinbutton', { name: 'Hourly limit' })).toHaveValue(40);
});
});
it('confirms navigation away while any card is dirty, and can discard', async () => {
const user = userEvent.setup();
const { router } = renderSettings();
const pacing = await findCard('Pacing');
const jitter = within(pacing).getByRole('spinbutton', { name: 'Random jitter' });
await user.clear(jitter);
await user.type(jitter, '45');
await act(() => router.navigate('/elsewhere'));
expect(await screen.findByRole('heading', { name: 'Leave without saving?' })).toBeInTheDocument();
expect(router.state.location.pathname).toBe('/settings');
await user.click(screen.getByRole('button', { name: 'Discard and leave' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/elsewhere'));
});
});