Configure browsing session pacing and size

This commit is contained in:
jjbrunton committed 2026-08-11 22:17:18 +01:00
1 parent 1fd154e1eb
commit e890f74478
14 files changed
+201 -56

No files matched your search

@@ -178,6 +178,11 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
const runNow = useRunBrowsingSimulationMutation(account.accountId);
const { settings, stats, todayTarget, recentDecisions } = account.browsing;
const selected = recentDecisions.filter((decision) => decision.decision === 'simulated-upvote');
const [gapMinutes, setGapMinutes] = useState(String(settings.minimumGapMs / 60_000));
const [selectionsPerSession, setSelectionsPerSession] = useState(String(settings.selectionsPerSession));
useEffect(() => setGapMinutes(String(settings.minimumGapMs / 60_000)), [settings.minimumGapMs]);
useEffect(() => setSelectionsPerSession(String(settings.selectionsPerSession)), [settings.selectionsPerSession]);
return (
<section aria-labelledby="browsing-simulation-heading" className="panel settings-card">
@@ -207,6 +212,56 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
label="Submit selected upvotes"
onChange={(autoVoteEnabled) => update.mutate({ autoVoteEnabled })}
/>
<div className="growth-pacing">
<NumberField
hint={`Shortest gap between sessions. The system adds a random 0–${minutes(settings.jitterMs)} on top.`}
label="Minimum session gap"
min={45}
max={1_440}
name="browsing-minimum-gap"
onChange={setGapMinutes}
suffix="min"
value={gapMinutes}
/>
<button
className="button secondary"
disabled={update.isPending}
onClick={() => {
const parsed = Number.parseInt(gapMinutes, 10);
if (Number.isInteger(parsed) && parsed >= 45 && parsed <= 1_440) {
update.mutate({ minimumGapMs: parsed * 60_000 });
}
}}
type="button"
>
Save session gap
</button>
</div>
<div className="growth-pacing">
<NumberField
hint="Maximum eligible posts or comments selected by each scheduled or manual session."
label="Selections per session"
min={1}
max={12}
name="browsing-selections-per-session"
onChange={setSelectionsPerSession}
suffix="/session"
value={selectionsPerSession}
/>
<button
className="button secondary"
disabled={update.isPending}
onClick={() => {
const parsed = Number.parseInt(selectionsPerSession, 10);
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 12) {
update.mutate({ selectionsPerSession: parsed });
}
}}
type="button"
>
Save session size
</button>
</div>
<button
className="button secondary"
disabled={!settings.enabled || runNow.isPending}
+1 -1
View File
@@ -105,7 +105,7 @@ A per-community `dailyCap` only distributes the account allowance; caps are not
Community weights feed a new weighted-random pick for each discovered growth thread. The grower queues only one new thread at a time, drains due retries and existing queue work before polling another community, and exposes an account-scoped queue clear that removes only unposted discovered/retryable rows.
Browsing sessions are configured separately per account through `/api/growth/{accountId}/browsing` and reuse the enabled growth-community list and weights. Sessions default off and target 3–6 selections per local day with a 60/40 post/comment split, enforce at least 45 minutes between sessions, respect quiet hours, exclude NSFW unless enabled, and have a non-overridable 12/day ceiling. `autoVoteEnabled` is a separate, default-off operator setting; it can submit the selected upvote only in running mode when browser dry-run is off. `nextDueAt` is service-owned durable state and cannot be set through the API.
Browsing sessions are configured separately per account through `/api/growth/{accountId}/browsing` and reuse the enabled growth-community list and weights. Sessions default off and target 3–6 selections per local day with a 60/40 post/comment split, respect quiet hours, exclude NSFW unless enabled, and have a non-overridable 12/day ceiling. The operator chooses 1–12 selections per session and a minimum gap of at least 45 minutes; the service adds a random delay of up to `jitterMs` on top before persisting the next due time. A scheduled session is capped by the remaining daily target, while a manually requested session bypasses that target but remains capped by the remaining hard daily ceiling. `autoVoteEnabled` is a separate, default-off operator setting; it can submit selected upvotes only in running mode when browser dry-run is off. `nextDueAt` is service-owned durable state and cannot be set through the API.
## Runtime-editable subset
+2 -2
View File
@@ -62,9 +62,9 @@ Do not add local retries around this layer without preserving the shared schedul
## Browsing session flow
After reply handling, response checks, and comment-growth work, an enabled account may run one due browsing session. The durable `next_due_at` gate survives restarts; a deterministic per-account daily target and randomized waking-hours spacing avoid recalculating a flapping schedule on each minute tick. The session picks one configured growth community by weight, reads its hot listing through the shared Reddit request coordinator, optionally reads typed top-level comments, applies deterministic exclusions, and only then selects randomly.
After reply handling, response checks, and comment-growth work, an enabled account may run one due browsing session. The durable `next_due_at` gate survives restarts; each completed attempt schedules the next one at the operator's minimum gap plus bounded random jitter, moved past quiet hours when necessary. The session picks one configured growth community by weight, reads its hot listing through the shared Reddit request coordinator, optionally reads typed top-level comments, applies deterministic exclusions, and only then selects up to the configured session size without replacement. Scheduled sessions cannot select beyond the remaining deterministic daily target.
The terminal operation records a pending selection in SQLite before any remote write, so an interrupted or uncertain vote is never retried. With the separate automatic-upvote setting enabled, running mode sends one authenticated Reddit `api/vote` request through the same browser transport and shared coordinator as growth comments, then marks the audit record `submitted` only after Reddit confirms it; safe failures become `failed`. Scan mode and browser dry-run record `simulated` selections; stopped mode performs no discovery or decision work. Reddit 429 responses extend the shared cooldown, while ordinary read failures remain isolated from replies and comment growth. An operator can also run one immediate session for an enabled, running account: it bypasses the due, quiet-hours, and daily-target gates while preserving content exclusions and follows the same automatic-upvote and dry-run rules.
Each terminal selection is recorded as pending in SQLite before its remote write, so an interrupted or uncertain vote is never retried. With the separate automatic-upvote setting enabled, running mode sends authenticated Reddit `api/vote` requests sequentially through the same browser transport and shared coordinator as growth comments, then marks each audit record `submitted` only after Reddit confirms it; safe failures become `failed`. Scan mode and browser dry-run record `simulated` selections; stopped mode performs no discovery or decision work. Reddit 429 responses extend the shared cooldown, while ordinary read failures remain isolated from replies and comment growth. An operator can also run one immediate session for an enabled, running account: it bypasses the due, quiet-hours, and normal daily-target gates, uses the configured session size up to the remaining hard 12/day ceiling, preserves content exclusions, and follows the same automatic-upvote and dry-run rules.
## Reply-first conversation automation
+1 -1
View File
@@ -20,7 +20,7 @@ The application uses one `better-sqlite3` database, normally `data/reddit-bot.db
- `account_growth_settings`: per-account commenting switch, adaptive trust level, optional operator daily-limit override (`daily_limit`, null means adaptive), spacing/jitter, quiet hours, NSFW opt-in, and the operator-clearable pause reason.
- `account_growth_subreddits`: per-account community list with weight, daily cap, and any Reddit-imposed disable reason or cooldown.
- `account_growth_comments`: durable growth comment records from discovery through posting and verification, including the exact text posted, score history, removal and public-visibility verdicts.
- `account_browsing_settings`: disabled-by-default per-account browsing policy, conservative daily range, post/comment weights, quiet hours, spacing, NSFW policy, default-off automatic-upvote setting, and durable next due time.
- `account_browsing_settings`: disabled-by-default per-account browsing policy, conservative daily range, selections per session, post/comment weights, quiet hours, minimum spacing plus jitter, NSFW policy, default-off automatic-upvote setting, and durable next due time.
- `account_browsing_decisions`: minimal post/comment identity, permalink, decision/reason, durable vote status (`simulated`, `pending`, `submitted`, or `failed`), and scheduled-session audit. Reddit text is deliberately not retained.
The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID.
+1 -1
View File
@@ -26,7 +26,7 @@ Navigation is declared in `app/navigation.ts`; route elements are composed in `a
The account-growth page states plainly that comments are AI-written and posted without review, shows the adaptive allowance and trust level rather than a settable rate, and surfaces every posted comment's exact text alongside its score and removal state. A paused account replaces the pause control with a resume control and explains why it stopped, because resuming is the one action that can compound the damage.
Its browsing section separates session scheduling from the default-off automatic-upvote switch, shows whether each selection was submitted or simulated, today's deterministic target, post/comment split, exclusion counts, durable next session, and recent selections with Reddit permalinks. Scan mode and browser dry-run remain simulation-only. An enabled, running account can also run one immediate selection; it bypasses the schedule and follows the same automatic-upvote setting. Enabling browsing does not enable comment growth or bot processing.
Its browsing section separates session scheduling from the default-off automatic-upvote switch, lets the operator set the minimum gap and selections per session, and shows whether each selection was submitted or simulated, today's deterministic target, post/comment split, exclusion counts, durable next session, and recent selections with Reddit permalinks. The displayed minimum is followed by bounded random jitter. Scan mode and browser dry-run remain simulation-only. An enabled, running account can also run one immediate session at the configured size, subject to the hard daily ceiling; it bypasses the schedule and follows the same automatic-upvote setting. Enabling browsing does not enable comment growth or bot processing.
The Monitoring settings section owns the optional physique-targeting toggle and inclusive 1–10 range. It explains that the signal is subjective, applies only to image posts, and screens out unscorable images while enabled.
+2
View File
@@ -24,6 +24,7 @@ export const BrowsingSettingsSchema = z.strictObject({
quietHours: GrowthQuietHoursSchema,
minimumGapMs: z.number().int(),
jitterMs: z.number().int(),
selectionsPerSession: z.number().int(),
allowNsfw: z.boolean(),
autoVoteEnabled: z.boolean(),
nextDueAt: z.number().nullable(),
@@ -163,6 +164,7 @@ export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
quietHours: GrowthQuietHoursSchema.optional(),
minimumGapMs: z.number().int().min(45 * 60_000).max(24 * 60 * 60_000).optional(),
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
selectionsPerSession: z.number().int().min(1).max(12).optional(),
allowNsfw: z.boolean().optional(),
autoVoteEnabled: z.boolean().optional(),
});
+13 -3
View File
@@ -15,6 +15,7 @@ export interface AccountBrowsingSettingsRecord {
quietHours: GrowthQuietHours;
minimumGapMs: number;
jitterMs: number;
selectionsPerSession: number;
allowNsfw: boolean;
autoVoteEnabled: boolean;
nextDueAt: number | null;
@@ -30,6 +31,7 @@ export interface AccountBrowsingSettingsPatch {
quietHours?: GrowthQuietHours;
minimumGapMs?: number;
jitterMs?: number;
selectionsPerSession?: number;
allowNsfw?: boolean;
autoVoteEnabled?: boolean;
}
@@ -78,6 +80,7 @@ export class AccountBrowsingSimulationRepository {
quietHours: { ...DEFAULT_QUIET_HOURS },
minimumGapMs: 45 * 60_000,
jitterMs: 15 * 60_000,
selectionsPerSession: 1,
allowNsfw: false,
autoVoteEnabled: false,
nextDueAt: null,
@@ -101,9 +104,9 @@ export class AccountBrowsingSimulationRepository {
this.db.prepare(`
INSERT INTO account_browsing_settings (
account_id, enabled, daily_target_min, daily_target_max, post_weight,
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw, auto_vote_enabled,
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, selections_per_session, allow_nsfw, auto_vote_enabled,
next_due_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
enabled = excluded.enabled,
daily_target_min = excluded.daily_target_min,
@@ -113,13 +116,15 @@ export class AccountBrowsingSimulationRepository {
quiet_hours_json = excluded.quiet_hours_json,
minimum_gap_ms = excluded.minimum_gap_ms,
jitter_ms = excluded.jitter_ms,
selections_per_session = excluded.selections_per_session,
allow_nsfw = excluded.allow_nsfw,
auto_vote_enabled = excluded.auto_vote_enabled,
updated_at = excluded.updated_at
`).run(
accountId, merged.enabled ? 1 : 0, merged.dailyTargetMin, merged.dailyTargetMax,
merged.postWeight, merged.commentWeight, JSON.stringify(merged.quietHours),
merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0, merged.autoVoteEnabled ? 1 : 0,
merged.minimumGapMs, merged.jitterMs, merged.selectionsPerSession,
merged.allowNsfw ? 1 : 0, merged.autoVoteEnabled ? 1 : 0,
merged.nextDueAt, now,
);
return this.getSettings(accountId, now);
@@ -274,6 +279,10 @@ function validateSettings(settings: AccountBrowsingSettingsRecord): void {
if (!Number.isInteger(settings.jitterMs) || settings.jitterMs < 0) {
throw new Error('Browsing jitter must be a non-negative whole number');
}
if (!Number.isInteger(settings.selectionsPerSession)
|| settings.selectionsPerSession < 1 || settings.selectionsPerSession > 12) {
throw new Error('Browsing selections per session must be a whole number between 1 and 12');
}
const { startHour, endHour } = settings.quietHours;
if (!Number.isInteger(startHour) || startHour < 0 || startHour > 23
|| !Number.isInteger(endHour) || endHour < 0 || endHour > 23) {
@@ -292,6 +301,7 @@ function mapSettings(row: Record<string, unknown>): AccountBrowsingSettingsRecor
quietHours: JSON.parse(row.quiet_hours_json as string) as GrowthQuietHours,
minimumGapMs: row.minimum_gap_ms as number,
jitterMs: row.jitter_ms as number,
selectionsPerSession: row.selections_per_session as number,
allowNsfw: row.allow_nsfw === 1,
autoVoteEnabled: row.auto_vote_enabled === 1,
nextDueAt: (row.next_due_at as number | null) ?? null,
+3
View File
@@ -616,6 +616,9 @@ export function runMigrations(db: Database.Database): void {
addColumnIfNotExists(db, 'account_browsing_decisions', 'vote_status', "TEXT NOT NULL DEFAULT 'simulated' CHECK(vote_status IN ('simulated', 'pending', 'submitted', 'failed'))");
db.exec("UPDATE account_browsing_decisions SET vote_status = 'submitted' WHERE vote_submitted = 1");
},
() => {
addColumnIfNotExists(db, 'account_browsing_settings', 'selections_per_session', 'INTEGER NOT NULL DEFAULT 1 CHECK(selections_per_session BETWEEN 1 AND 12)');
},
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
+62 -42
View File
@@ -12,6 +12,7 @@ import type { Logger } from '../utils/logger.js';
const MAX_CONTENT_AGE_MS = 7 * 24 * 60 * 60_000;
const MIN_POST_CONTEXT = 12;
const MIN_COMMENT_CONTEXT = 20;
const MAX_DAILY_SELECTIONS = 12;
export interface BrowsingDecisionSink {
recordSimulation(decision: BrowsingDecisionRecord): Promise<void>;
@@ -42,7 +43,13 @@ export interface AccountBrowsingSimulationServiceOptions {
export type BrowsingSimulationOutcome =
| { kind: 'idle'; reason: string }
| { kind: 'scheduled'; nextDueAt: number }
| { kind: 'simulated'; decision: BrowsingDecisionRecord; nextDueAt: number };
| {
kind: 'simulated';
/** Retained for callers that only need the first selection. */
decision: BrowsingDecisionRecord;
decisions: BrowsingDecisionRecord[];
nextDueAt: number;
};
/**
* Selects hypothetical votes without exposing any Reddit write dependency.
@@ -87,7 +94,13 @@ export class AccountBrowsingSimulationService {
const today = startOfLocalDay(now);
const target = dailyTarget(this.options.accountId, today, settings);
if (!force && this.options.repository.countSelectedSince(this.options.accountId, today) >= target) {
const selectedToday = this.options.repository.countSelectedSince(this.options.accountId, today);
if (selectedToday >= MAX_DAILY_SELECTIONS) {
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
}
if (!force && selectedToday >= target) {
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
@@ -107,46 +120,57 @@ export class AccountBrowsingSimulationService {
const preferredKind: BrowsingThingKind = this.random() * (settings.postWeight + settings.commentWeight)
< settings.postWeight ? 'post' : 'comment';
let candidates: BrowsingThing[] = [];
let candidates: BrowsingThing[] = eligiblePosts;
let fallbackCandidates: BrowsingThing[] = [];
if (preferredKind === 'comment' && eligiblePosts.length > 0) {
const parent = eligiblePosts[Math.floor(this.random() * eligiblePosts.length)];
const comments = await this.options.reddit.getBrowsingComments(parent.subreddit, parent.parentPostId, 25);
candidates = await this.screenThings(comments, settings, sessionId, now);
fallbackCandidates = eligiblePosts;
}
if (candidates.length === 0) candidates = eligiblePosts;
const nextDueAt = this.scheduleNext(settings, now);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
if (candidates.length === 0) return { kind: 'scheduled', nextDueAt };
if (candidates.length === 0 && fallbackCandidates.length === 0) return { kind: 'scheduled', nextDueAt };
const selected = candidates[Math.floor(this.random() * candidates.length)];
const sessionLimit = force
? Math.min(settings.selectionsPerSession, MAX_DAILY_SELECTIONS - selectedToday)
: Math.min(settings.selectionsPerSession, target - selectedToday);
const selected = pickRandom(candidates, sessionLimit, this.random);
if (selected.length < sessionLimit) {
selected.push(...pickRandom(fallbackCandidates, sessionLimit - selected.length, this.random));
}
const voter = settings.autoVoteEnabled && allowVoting && !this.options.dryRun
? this.options.voter
: undefined;
const decision = toDecision(
this.options.accountId, selected, 'simulated-upvote',
voter ? 'Upvote pending Reddit confirmation' : 'Eligible browsing selection',
sessionId, now, voter ? 'pending' : 'simulated',
);
await this.options.sink.recordSimulation(decision);
if (voter) {
const result = await voter.upvote(selected.fullname);
if (result.status === 'submitted') {
this.options.repository.markVoteSubmitted(this.options.accountId, selected.fullname);
decision.voteSubmitted = true;
decision.voteStatus = 'submitted';
decision.reason = 'Reddit upvote submitted';
this.options.logger.info(`Submitted browsing upvote for ${selected.fullname} in r/${selected.subreddit}`);
} else {
decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`;
decision.voteStatus = 'failed';
this.options.repository.markVoteFailed(this.options.accountId, selected.fullname, decision.reason);
this.options.logger.warn(`Browsing upvote for ${selected.fullname} was not submitted: ${result.reason ?? result.status}`);
const decisions: BrowsingDecisionRecord[] = [];
for (const thing of selected) {
const decision = toDecision(
this.options.accountId, thing, 'simulated-upvote',
voter ? 'Upvote pending Reddit confirmation' : 'Eligible browsing selection',
sessionId, now, voter ? 'pending' : 'simulated',
);
await this.options.sink.recordSimulation(decision);
if (voter) {
const result = await voter.upvote(thing.fullname);
if (result.status === 'submitted') {
this.options.repository.markVoteSubmitted(this.options.accountId, thing.fullname);
decision.voteSubmitted = true;
decision.voteStatus = 'submitted';
decision.reason = 'Reddit upvote submitted';
this.options.logger.info(`Submitted browsing upvote for ${thing.fullname} in r/${thing.subreddit}`);
} else {
decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`;
decision.voteStatus = 'failed';
this.options.repository.markVoteFailed(this.options.accountId, thing.fullname, decision.reason);
this.options.logger.warn(`Browsing upvote for ${thing.fullname} was not submitted: ${result.reason ?? result.status}`);
}
}
this.options.onActivity?.(decision);
if (!decision.voteSubmitted) this.options.logger.info(`Simulated browsing selection for ${thing.fullname} in r/${thing.subreddit}; no Reddit vote was submitted`);
decisions.push(decision);
}
this.options.onActivity?.(decision);
if (!decision.voteSubmitted) this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`);
return { kind: 'simulated', decision, nextDueAt };
return { kind: 'simulated', decision: decisions[0], decisions, nextDueAt };
}
private async screenPosts(
@@ -207,22 +231,23 @@ export class AccountBrowsingSimulationService {
}
private scheduleNext(settings: AccountBrowsingSettingsRecord, from: number): number {
const target = dailyTarget(this.options.accountId, startOfLocalDay(from), settings);
const quietHours = settings.quietHours.enabled
? quietDurationHours(settings.quietHours.startHour, settings.quietHours.endHour)
: 0;
const wakingMs = (24 - quietHours) * 60 * 60_000;
// Spread the day's target across the waking window while retaining bounded
// randomness. The hard minimum remains authoritative for unusual policies.
const distributedGap = (wakingMs / target) * (0.75 + this.random() * 0.5);
const jitter = Math.floor(this.random() * (settings.jitterMs + 1));
return moveOutsideQuietHours(
from + Math.max(settings.minimumGapMs, Math.floor(distributedGap)) + jitter,
from + settings.minimumGapMs + jitter,
settings,
);
}
}
function pickRandom<T>(items: readonly T[], limit: number, random: () => number): T[] {
const remaining = [...items];
const selected: T[] = [];
while (selected.length < limit && remaining.length > 0) {
selected.push(remaining.splice(Math.floor(random() * remaining.length), 1)[0]);
}
return selected;
}
export function dailyTarget(
accountId: string,
localDayStart: number,
@@ -264,11 +289,6 @@ function startOfLocalDay(timestamp: number): number {
return date.getTime();
}
function quietDurationHours(startHour: number, endHour: number): number {
if (startHour === endHour) return 0;
return endHour > startHour ? endHour - startHour : 24 - startHour + endHour;
}
function weightedPick<T>(items: readonly T[], weight: (item: T) => number, random: () => number): T {
const total = items.reduce((sum, item) => sum + weight(item), 0);
let cursor = random() * total;
+2 -1
View File
@@ -27,6 +27,7 @@ export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
quietHours: GrowthQuietHoursSchema.optional(),
minimumGapMs: z.number().int().min(45 * 60_000).max(DAY_MS).optional(),
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
selectionsPerSession: z.number().int().min(1).max(12).optional(),
allowNsfw: z.boolean().optional(),
autoVoteEnabled: z.boolean().optional(),
});
@@ -34,7 +35,7 @@ export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
export const BrowsingSettingsSchema = z.strictObject({
enabled: z.boolean(), dailyTargetMin: z.number().int(), dailyTargetMax: z.number().int(),
postWeight: z.number(), commentWeight: z.number(), quietHours: GrowthQuietHoursSchema,
minimumGapMs: z.number().int(), jitterMs: z.number().int(), allowNsfw: z.boolean(),
minimumGapMs: z.number().int(), jitterMs: z.number().int(), selectionsPerSession: z.number().int(), allowNsfw: z.boolean(),
autoVoteEnabled: z.boolean(),
nextDueAt: z.number().nullable(), updatedAt: z.number(),
});
+1
View File
@@ -315,6 +315,7 @@ function presentBrowsing(
quietHours: settings.quietHours,
minimumGapMs: settings.minimumGapMs,
jitterMs: settings.jitterMs,
selectionsPerSession: settings.selectionsPerSession,
allowNsfw: settings.allowNsfw,
autoVoteEnabled: settings.autoVoteEnabled,
nextDueAt: settings.nextDueAt,
+34 -4
View File
@@ -122,8 +122,7 @@ describe('AccountBrowsingSimulationService', () => {
const dueAt = repository.getSettings(ACCOUNT).nextDueAt;
expect(scheduled).toEqual({ kind: 'scheduled', nextDueAt: dueAt });
expect(dueAt).toBeGreaterThanOrEqual(NOW + 45 * 60_000);
expect(dueAt).toBeLessThanOrEqual(NOW + 12 * 60 * 60_000);
expect(dueAt).toBe(NOW + 50 * 60_000);
const restarted = service({ random: 0.1 });
await expect(restarted.instance.tick()).resolves.toEqual({
kind: 'idle', reason: 'Browsing simulation is not due',
@@ -157,6 +156,37 @@ describe('AccountBrowsingSimulationService', () => {
expect(voter.upvote).toHaveBeenCalledWith('t3_one');
});
it('selects the configured number of distinct things in one session', async () => {
enable({ postWeight: 100, commentWeight: 0, selectionsPerSession: 3 });
const fixture = service({ posts: [post('one'), post('two'), post('three'), post('four')] });
const result = await fixture.instance.runNow();
expect(result).toMatchObject({ kind: 'simulated' });
if (result.kind !== 'simulated') throw new Error('Expected a simulated session');
expect(result.decisions).toHaveLength(3);
expect(new Set(result.decisions.map((decision) => decision.thingFullname)).size).toBe(3);
expect(repository.listRecentSelections(ACCOUNT)).toHaveLength(3);
});
it('caps a scheduled multi-selection session at the remaining daily target', async () => {
enable({ postWeight: 100, commentWeight: 0, selectionsPerSession: 3, dailyTargetMin: 2, dailyTargetMax: 2 });
repository.recordDecision({
accountId: ACCOUNT, thingFullname: 't3_prior', thingKind: 'post', postId: 'prior',
subreddit: 'AskUK', author: 'prior_author', permalink: '/prior', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'prior',
voteSubmitted: false, voteStatus: 'simulated',
});
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const result = await service({ posts: [post('one'), post('two'), post('three')] }).instance.tick();
expect(result).toMatchObject({ kind: 'simulated' });
if (result.kind !== 'simulated') throw new Error('Expected a simulated session');
expect(result.decisions).toHaveLength(1);
expect(repository.listRecentSelections(ACCOUNT)).toHaveLength(2);
});
it('keeps an immediate session simulation-only in browser dry-run', async () => {
enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true });
const voter = { upvote: vi.fn().mockResolvedValue({ status: 'submitted' }) };
@@ -305,13 +335,13 @@ describe('browsing migration', () => {
runMigrations(db);
const version = db.pragma('user_version', { simple: true }) as number;
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
db.pragma(`user_version = ${version - 3}`);
db.pragma(`user_version = ${version - 4}`);
runMigrations(db);
insertAccount(db, ACCOUNT, 'owner');
expect(new AccountBrowsingSimulationRepository(db).getSettings(ACCOUNT)).toMatchObject({
enabled: false, dailyTargetMin: 3, dailyTargetMax: 6, nextDueAt: null,
enabled: false, dailyTargetMin: 3, dailyTargetMax: 6, selectionsPerSession: 1, nextDueAt: null,
});
expect(db.prepare(`PRAGMA table_info(account_growth_settings)`).all())
.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'daily_limit' })]));
+21
View File
@@ -54,6 +54,7 @@ function browsing(includeRecent = false) {
quietHours: { enabled: true, startHour: 23, endHour: 7 },
minimumGapMs: 2_700_000,
jitterMs: 900_000,
selectionsPerSession: 1,
allowNsfw: false,
autoVoteEnabled: false,
nextDueAt: null,
@@ -266,6 +267,26 @@ describe('GrowthPage', () => {
})));
});
it('updates browsing session pacing and size', async () => {
renderGrowth();
const user = userEvent.setup();
const gap = await screen.findByLabelText('Minimum session gap');
await user.clear(gap);
await user.type(gap, '90');
await user.click(screen.getByRole('button', { name: 'Save session gap' }));
const size = screen.getByLabelText('Selections per session');
await user.clear(size);
await user.type(size, '4');
await user.click(screen.getByRole('button', { name: 'Save session size' }));
await waitFor(() => expect(requests).toEqual(expect.arrayContaining([
expect.objectContaining({ method: 'PATCH', body: { minimumGapMs: 5_400_000 } }),
expect.objectContaining({ method: 'PATCH', body: { selectionsPerSession: 4 } }),
])));
});
it('shows an empty state when there are no accounts', async () => {
overviewPayload = [];
renderGrowth();
+3 -1
View File
@@ -265,12 +265,13 @@ describe('growth route', () => {
dailyTargetMin: 4,
dailyTargetMax: 8,
minimumGapMs: 3_600_000,
selectionsPerSession: 4,
});
expect(status).toBe(200);
const parsed = GrowthAccountResponseSchema.parse(json);
expect(parsed.account.browsing).toMatchObject({
settings: { enabled: true, dailyTargetMin: 4, dailyTargetMax: 8 },
settings: { enabled: true, dailyTargetMin: 4, dailyTargetMax: 8, selectionsPerSession: 4 },
noVotesSubmitted: true,
});
expect(pushed).toContainEqual({
@@ -281,6 +282,7 @@ describe('growth route', () => {
it.each([
{ dailyTargetMax: 13 },
{ minimumGapMs: 44 * 60_000 },
{ selectionsPerSession: 13 },
{ postWeight: 0, commentWeight: 0 },
{ nextDueAt: 123 },
])('rejects unsafe or non-operator settings %o', async (body) => {