Files
RedditBot/tests/dashboard/settings-model.test.ts

355 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
createPacingDraft,
createTargetingDraft,
targetingRisks,
validateTargetingDraft,
estimateThroughput,
pacingRisks,
safetyRisks,
validateAdvancedDraft,
validatePacingDraft,
type PacingDraft,
} from '../../apps/dashboard/src/features/settings/settings-model.js';
import {
computeShares,
formatShare,
normaliseSubredditName,
previewShare,
validateSubredditDraft,
createEmptySubredditDraft,
type SubredditConfig,
type SubredditDraft,
} from '../../apps/dashboard/src/features/subreddits/subreddit-model.js';
function pacing(overrides: Partial<PacingDraft> = {}): PacingDraft {
return {
minDelayMinutes: '5',
maxMessagesPerHour: '8',
jitterSeconds: '60',
prioritizeRecentPosts: false,
recentWindowMinutes: '60',
maxReadyCandidates: '25',
...overrides,
};
}
function subreddit(overrides: Partial<SubredditConfig> = {}): SubredditConfig {
return {
name: 'example',
enabled: true,
type: 'chat',
tone: 'default',
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
...overrides,
} as SubredditConfig;
}
function draft(overrides: Partial<SubredditDraft> = {}): SubredditDraft {
return { ...createEmptySubredditDraft(), name: 'example', ...overrides };
}
describe('createPacingDraft', () => {
it('converts stored milliseconds into the units the operator types', () => {
const result = createPacingDraft({
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 90_000 },
browser: { dryRun: true },
reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 45, maxReadyCandidates: 30, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } },
subreddits: [],
tones: [],
});
expect(result).toEqual({
minDelayMinutes: '5',
maxMessagesPerHour: '8',
jitterSeconds: '90',
prioritizeRecentPosts: true,
recentWindowMinutes: '45',
maxReadyCandidates: '30',
});
});
});
describe('validatePacingDraft', () => {
it('converts back into milliseconds and omits keys it does not own', () => {
const { payload, errors } = validatePacingDraft(pacing({ jitterSeconds: '30' }));
expect(errors).toEqual({});
expect(payload).toEqual({
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 30_000 },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } },
});
});
it('collects every problem in one pass rather than one at a time', () => {
const { payload, errors } = validatePacingDraft(pacing({
minDelayMinutes: '',
jitterSeconds: '-1',
maxReadyCandidates: '0',
}));
expect(payload).toBeUndefined();
expect(Object.keys(errors).sort()).toEqual(['jitterSeconds', 'maxReadyCandidates', 'minDelayMinutes']);
});
it('rejects a fractional hourly limit', () => {
expect(validatePacingDraft(pacing({ maxMessagesPerHour: '2.5' })).errors)
.toHaveProperty('maxMessagesPerHour');
});
});
describe('pacingRisks', () => {
it('warns only when the limit both rises and clears the safe ceiling', () => {
expect(pacingRisks(pacing({ maxMessagesPerHour: '21' }), pacing())).toHaveLength(1);
expect(pacingRisks(pacing({ maxMessagesPerHour: '20' }), pacing())).toEqual([]);
// Lowering an already-high limit is a de-escalation, not a risk.
expect(pacingRisks(pacing({ maxMessagesPerHour: '30' }), pacing({ maxMessagesPerHour: '40' }))).toEqual([]);
});
});
describe('safetyRisks', () => {
it('warns only on the transition from dry run into live sending', () => {
expect(safetyRisks({ dryRun: false }, { dryRun: true })).toHaveLength(1);
expect(safetyRisks({ dryRun: true }, { dryRun: false })).toEqual([]);
expect(safetyRisks({ dryRun: false }, { dryRun: false })).toEqual([]);
});
});
describe('estimateThroughput', () => {
it('reports the hourly cap when it binds before the delay', () => {
const estimate = estimateThroughput(pacing({ minDelayMinutes: '5', jitterSeconds: '0' }));
expect(estimate).toMatchObject({ effectivePerHour: 8, isDelayBound: false });
});
it('reports the delay when it binds before the hourly cap', () => {
const estimate = estimateThroughput(pacing({ minDelayMinutes: '30', jitterSeconds: '0' }));
expect(estimate).toMatchObject({ effectivePerHour: 2, isDelayBound: true });
});
it('counts average jitter as part of the spacing', () => {
// 10 min delay plus 120s average jitter (half of 240) is 12 min spacing.
const estimate = estimateThroughput(pacing({ minDelayMinutes: '10', jitterSeconds: '240' }));
expect(estimate?.spacingLabel).toBe('about one every 12 min');
expect(estimate?.effectivePerHour).toBe(5);
});
it('gives up on unusable numbers rather than reporting nonsense', () => {
expect(estimateThroughput(pacing({ minDelayMinutes: '' }))).toBeNull();
expect(estimateThroughput(pacing({ maxMessagesPerHour: '0' }))).toBeNull();
expect(estimateThroughput(pacing({ jitterSeconds: '-5' }))).toBeNull();
});
});
describe('validateTargetingDraft', () => {
const base = { physiqueTargetingEnabled: true, physiqueMinScore: '3', physiqueMaxScore: '8' };
it('sends only the physique range, so sibling reddit fields survive', () => {
expect(validateTargetingDraft(base).payload).toEqual({
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 8 } },
});
});
it('allows a single-point range', () => {
expect(validateTargetingDraft({ ...base, physiqueMinScore: '5', physiqueMaxScore: '5' }).errors).toEqual({});
});
it('rejects an inverted range against the minimum field', () => {
expect(validateTargetingDraft({ ...base, physiqueMinScore: '9', physiqueMaxScore: '2' }).errors)
.toHaveProperty('physiqueMinScore', 'Minimum physique score cannot exceed maximum physique score.');
});
it('rejects scores outside 1 to 10 and fractional scores', () => {
expect(validateTargetingDraft({ ...base, physiqueMinScore: '0' }).errors).toHaveProperty('physiqueMinScore');
expect(validateTargetingDraft({ ...base, physiqueMaxScore: '11' }).errors).toHaveProperty('physiqueMaxScore');
expect(validateTargetingDraft({ ...base, physiqueMaxScore: '7.5' }).errors).toHaveProperty('physiqueMaxScore');
});
it('requires both bounds', () => {
const { errors } = validateTargetingDraft({ ...base, physiqueMinScore: '', physiqueMaxScore: '' });
expect(Object.keys(errors).sort()).toEqual(['physiqueMaxScore', 'physiqueMinScore']);
});
it('still validates the range while screening is disabled', () => {
// The range persists while off, so a bad one must not be storable.
expect(validateTargetingDraft({ physiqueTargetingEnabled: false, physiqueMinScore: '9', physiqueMaxScore: '2' }).payload)
.toBeUndefined();
});
});
describe('targetingRisks', () => {
it('warns only when screening is being switched on', () => {
const off = { physiqueTargetingEnabled: false, physiqueMinScore: '3', physiqueMaxScore: '8' };
const on = { ...off, physiqueTargetingEnabled: true };
expect(targetingRisks(on, off)).toHaveLength(1);
expect(targetingRisks(off, on)).toEqual([]);
// Editing the range of an already-active screen is not a new risk.
expect(targetingRisks({ ...on, physiqueMinScore: '5' }, on)).toEqual([]);
});
});
describe('createTargetingDraft', () => {
it('reads the nested physique contract into flat form fields', () => {
const draft = createTargetingDraft({
rateLimit: { minDelayBetweenMessagesMs: 0, maxMessagesPerHour: 1, jitterMs: 0 },
browser: { dryRun: true },
reddit: {
prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25,
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 9 },
},
subreddits: [],
tones: [],
});
expect(draft).toEqual({ physiqueTargetingEnabled: true, physiqueMinScore: '4', physiqueMaxScore: '9' });
});
});
describe('validateAdvancedDraft', () => {
it('accepts a JSON object', () => {
expect(validateAdvancedDraft({ advancedConfigJson: '{"a":1}' }).payload).toEqual({ advanced: { a: 1 } });
});
it('names the syntax problem rather than just failing', () => {
const { errors } = validateAdvancedDraft({ advancedConfigJson: '{"a":' });
expect(errors.advancedConfigJson).toMatch(/^Not valid JSON — /);
});
it('refuses arrays, bare values and an emptied editor', () => {
expect(validateAdvancedDraft({ advancedConfigJson: '[1]' }).errors.advancedConfigJson).toMatch(/JSON object/);
expect(validateAdvancedDraft({ advancedConfigJson: '"text"' }).errors.advancedConfigJson).toMatch(/JSON object/);
expect(validateAdvancedDraft({ advancedConfigJson: ' ' }).errors.advancedConfigJson).toMatch(/cannot be emptied/);
});
});
describe('computeShares', () => {
it('splits attention across enabled subreddits in proportion to weight', () => {
const shares = computeShares([
subreddit({ name: 'a', weight: 3 }),
subreddit({ name: 'b', weight: 1 }),
]);
expect(shares.get('a')?.share).toBeCloseTo(0.75);
expect(shares.get('b')?.share).toBeCloseTo(0.25);
});
it('treats a missing weight as the runtime default of 1', () => {
const shares = computeShares([subreddit({ name: 'a' }), subreddit({ name: 'b' })]);
expect(shares.get('a')).toEqual({ effectiveWeight: 1, share: 0.5 });
});
it('gives disabled subreddits no share and excludes them from the total', () => {
const shares = computeShares([
subreddit({ name: 'a', weight: 1 }),
subreddit({ name: 'off', weight: 99, enabled: false }),
]);
expect(shares.get('off')?.share).toBe(0);
expect(shares.get('a')?.share).toBe(1);
});
it('reports zero rather than dividing by zero when everything is disabled', () => {
const shares = computeShares([subreddit({ name: 'a', enabled: false })]);
expect(shares.get('a')?.share).toBe(0);
});
});
describe('formatShare', () => {
it('keeps tiny but non-zero shares visible', () => {
expect(formatShare(0.004)).toBe('<1%');
expect(formatShare(0)).toBe('—');
expect(formatShare(0.256)).toBe('26%');
});
});
describe('previewShare', () => {
const existing = [subreddit({ name: 'a', weight: 1 }), subreddit({ name: 'b', weight: 1 })];
it('excludes the edited row from the baseline so its old weight is not counted twice', () => {
// Editing 'a' up to weight 3 leaves 'b' at 1, so 'a' takes three quarters.
expect(previewShare(draft({ name: 'a', weight: '3' }), existing, 'a')).toBeCloseTo(0.75);
});
it('adds a brand new row on top of the existing total', () => {
expect(previewShare(draft({ name: 'c', weight: '2' }), existing, null)).toBeCloseTo(0.5);
});
it('is zero for a disabled or unusable weight', () => {
expect(previewShare(draft({ name: 'a', weight: '1', enabled: false }), existing, 'a')).toBe(0);
expect(previewShare(draft({ name: 'a', weight: '-2' }), existing, 'a')).toBe(0);
});
});
describe('normaliseSubredditName', () => {
it('strips the prefix operators paste in', () => {
expect(normaliseSubredditName(' r/Example ')).toBe('Example');
expect(normaliseSubredditName('/r/example')).toBe('example');
expect(normaliseSubredditName('example')).toBe('example');
});
});
describe('validateSubredditDraft', () => {
it('keys errors by field name, with no array index to go stale', () => {
const { errors } = validateSubredditDraft(draft({ name: '', weight: '0' }), []);
expect(Object.keys(errors).sort()).toEqual(['name', 'weight']);
});
it('rejects a duplicate name regardless of case', () => {
expect(validateSubredditDraft(draft({ name: 'Example' }), ['example']).errors)
.toHaveProperty('name', 'r/Example is already configured.');
});
it('allows a name that only collides with the row being edited', () => {
// The caller excludes the edited row from `takenNames`, so saving a
// subreddit without renaming it is not a conflict.
expect(validateSubredditDraft(draft({ name: 'example' }), []).errors).toEqual({});
});
it('rejects names Reddit itself would not accept', () => {
for (const name of ['a', 'has spaces', '_leading', 'way-too-long-for-reddit-to-accept']) {
expect(validateSubredditDraft(draft({ name }), []).errors, name).toHaveProperty('name');
}
});
it('omits an empty weight and scripted message instead of sending blanks', () => {
const { config } = validateSubredditDraft(draft({ weight: '', scripted: ' ' }), []);
expect(config).not.toHaveProperty('weight');
expect(config).not.toHaveProperty('scripted');
});
it('normalises turn settings away for modes that do not use them', () => {
const { config } = validateSubredditDraft(
draft({ conversationMode: 'legacy', maxAutomatedTurns: '19', rapportTurnsBeforeMediaAsk: '17' }),
[],
);
expect(config?.conversation).toEqual({ mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 });
});
it('accepts engagement mode without an automated turn budget', () => {
const { config } = validateSubredditDraft(
draft({ conversationMode: 'engagement', maxAutomatedTurns: '', rapportTurnsBeforeMediaAsk: '' }),
[],
);
expect(config?.conversation).toEqual({ mode: 'engagement', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 });
});
it('requires rapport turns below the total budget for the hybrid mode', () => {
const { errors } = validateSubredditDraft(
draft({ conversationMode: 'r4r-media', maxAutomatedTurns: '4', rapportTurnsBeforeMediaAsk: '4' }),
[],
);
expect(errors).toHaveProperty('rapportTurnsBeforeMediaAsk');
});
it('rejects a turn budget above the configured maximum', () => {
const { errors } = validateSubredditDraft(
draft({ conversationMode: 'r4r', maxAutomatedTurns: '21' }),
[],
);
expect(errors).toHaveProperty('maxAutomatedTurns');
});
it('defaults a new subreddit to the mode that cannot send unattended', () => {
expect(createEmptySubredditDraft().conversationMode).toBe('manual');
});
});