Make subreddit list editable from the dashboard
Adds an inline subreddit editor on the dashboard that lets you add,
remove, and edit (tone, type, weight) the subreddit list, with changes
persisted to config.yaml on disk so they survive restarts.
Backend
- src/config/persistence.ts: round-trips config.yaml via
YAML.parseDocument, mutating existing row nodes in place so their
trailing inline `# ...` comments survive an edit; new rows are
appended as flow-style maps to match the on-disk idiom; removed rows
are dropped. Default values ('default' tone, 'image' type) are
omitted on write so the file stays clean. Atomic via tmp+rename.
- src/web/routes/config.ts: GET /api/config now also returns the
current subreddit list and the available tone keys. PUT /api/config
accepts a `subreddits` field, validates with the existing
SubredditConfigSchema, rejects duplicates, persists, then mutates
ctx.config.reddit.subreddits in place so the processor picks up the
new tone immediately without a restart.
Frontend
- public/index.html: replaces the read-only subreddit chip list in the
Configuration snapshot section with a grid of editable rows (name,
type, tone dropdown, weight, remove). + Add / Save buttons with
inline status. Tones are sourced from /api/config so the catalog
drives the dropdown.
Tests
- tests/config-persistence.test.ts: covers in-place mutation comment
preservation, removal, appends, reorder, default omission, and
atomic write cleanup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
46a213e3e0
commit
d9b9a79bdd
4 files changed
+476
-5
No files matched your search
+241
-4
@@ -942,6 +942,109 @@
|
|||||||
border-color: rgba(120, 150, 110, .25);
|
border-color: rgba(120, 150, 110, .25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subreddit-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(140px, 1.5fr) 90px 1fr 70px 32px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-row.new {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--terracotta-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-row input,
|
||||||
|
.subreddit-editor-row select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-row input[readonly] {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-row .remove-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-row .remove-btn:hover {
|
||||||
|
background: var(--terracotta-soft);
|
||||||
|
color: var(--terracotta-deep);
|
||||||
|
border-color: rgba(201, 123, 94, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-actions button {
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-actions button.primary {
|
||||||
|
background: var(--terracotta-deep);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--terracotta-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-actions button.primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-status.error {
|
||||||
|
color: var(--terracotta-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subreddit-editor-status.success {
|
||||||
|
color: var(--sage-deep);
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 48px 22px;
|
padding: 48px 22px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -2418,9 +2521,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-grid">
|
<div class="config-grid">
|
||||||
<div class="config-item">
|
<div class="config-item" style="grid-column: 1 / -1;">
|
||||||
<h4>Monitoring subreddits</h4>
|
<h4>Monitoring subreddits</h4>
|
||||||
<div class="value" id="config-subreddits"></div>
|
<div class="subreddit-editor" id="subreddit-editor"></div>
|
||||||
|
<div class="subreddit-editor-actions">
|
||||||
|
<button type="button" onclick="addSubredditRow()">+ Add subreddit</button>
|
||||||
|
<button type="button" class="primary" id="save-subreddits-btn" onclick="saveSubreddits()">Save changes</button>
|
||||||
|
<span class="subreddit-editor-status" id="subreddit-editor-status"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<h4>Target genders</h4>
|
<h4>Target genders</h4>
|
||||||
@@ -2615,8 +2723,6 @@
|
|||||||
controlBtn.className = 'control-btn start';
|
controlBtn.className = 'control-btn start';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('config-subreddits').innerHTML = stats.subreddits
|
|
||||||
.map(s => `<span class="tag${s.type === 'chat' ? ' tag-chat' : ''}" title="${s.type}">r/${s.name}</span>`).join('');
|
|
||||||
document.getElementById('config-genders').innerHTML = stats.targetGenders
|
document.getElementById('config-genders').innerHTML = stats.targetGenders
|
||||||
.map(g => `<span class="tag">${g}</span>`).join('');
|
.map(g => `<span class="tag">${g}</span>`).join('');
|
||||||
|
|
||||||
@@ -3691,11 +3797,142 @@
|
|||||||
const res = await fetch('/api/config');
|
const res = await fetch('/api/config');
|
||||||
currentConfig = await res.json();
|
currentConfig = await res.json();
|
||||||
populateSettingsForm();
|
populateSettingsForm();
|
||||||
|
renderSubredditEditor();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch config:', err);
|
console.error('Failed to fetch config:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Subreddit editor ----------
|
||||||
|
function renderSubredditEditor() {
|
||||||
|
const container = document.getElementById('subreddit-editor');
|
||||||
|
if (!container || !currentConfig?.subreddits) return;
|
||||||
|
const tones = currentConfig.tones || ['default'];
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const sub of currentConfig.subreddits) {
|
||||||
|
container.appendChild(buildSubredditRow(sub, tones, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubredditRow(sub, tones, isNew) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'subreddit-editor-row' + (isNew ? ' new' : '');
|
||||||
|
row.dataset.original = sub.name || '';
|
||||||
|
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.type = 'text';
|
||||||
|
nameInput.value = sub.name || '';
|
||||||
|
nameInput.placeholder = 'subreddit name';
|
||||||
|
nameInput.dataset.field = 'name';
|
||||||
|
if (!isNew) nameInput.readOnly = true;
|
||||||
|
row.appendChild(nameInput);
|
||||||
|
|
||||||
|
const typeSelect = document.createElement('select');
|
||||||
|
typeSelect.dataset.field = 'type';
|
||||||
|
for (const t of ['image', 'chat']) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t;
|
||||||
|
if ((sub.type || 'image') === t) opt.selected = true;
|
||||||
|
typeSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
row.appendChild(typeSelect);
|
||||||
|
|
||||||
|
const toneSelect = document.createElement('select');
|
||||||
|
toneSelect.dataset.field = 'tone';
|
||||||
|
for (const t of tones) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t;
|
||||||
|
if ((sub.tone || 'default') === t) opt.selected = true;
|
||||||
|
toneSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
row.appendChild(toneSelect);
|
||||||
|
|
||||||
|
const weightInput = document.createElement('input');
|
||||||
|
weightInput.type = 'number';
|
||||||
|
weightInput.min = '1';
|
||||||
|
weightInput.step = '1';
|
||||||
|
weightInput.value = sub.weight ?? '';
|
||||||
|
weightInput.placeholder = '1';
|
||||||
|
weightInput.dataset.field = 'weight';
|
||||||
|
row.appendChild(weightInput);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'remove-btn';
|
||||||
|
removeBtn.textContent = '×';
|
||||||
|
removeBtn.title = 'Remove';
|
||||||
|
removeBtn.onclick = () => row.remove();
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSubredditRow() {
|
||||||
|
const container = document.getElementById('subreddit-editor');
|
||||||
|
if (!container) return;
|
||||||
|
const tones = currentConfig?.tones || ['default'];
|
||||||
|
container.appendChild(buildSubredditRow({ tone: 'default', type: 'image', weight: 1 }, tones, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSubredditEditor() {
|
||||||
|
const rows = document.querySelectorAll('#subreddit-editor .subreddit-editor-row');
|
||||||
|
const out = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = row.querySelector('[data-field="name"]').value.trim().replace(/^\/?r\//i, '');
|
||||||
|
if (!name) continue;
|
||||||
|
const weightStr = row.querySelector('[data-field="weight"]').value.trim();
|
||||||
|
const weight = weightStr === '' ? undefined : Number(weightStr);
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
type: row.querySelector('[data-field="type"]').value,
|
||||||
|
tone: row.querySelector('[data-field="tone"]').value,
|
||||||
|
...(Number.isFinite(weight) ? { weight } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSubredditStatus(text, kind) {
|
||||||
|
const el = document.getElementById('subreddit-editor-status');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || '';
|
||||||
|
el.className = 'subreddit-editor-status' + (kind ? ' ' + kind : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSubreddits() {
|
||||||
|
const btn = document.getElementById('save-subreddits-btn');
|
||||||
|
const subreddits = readSubredditEditor();
|
||||||
|
if (subreddits.length === 0) {
|
||||||
|
setSubredditStatus('At least one subreddit required.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
setSubredditStatus('Saving…');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ subreddits }),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (!res.ok || !result.success) {
|
||||||
|
setSubredditStatus('Save failed: ' + (result.error || res.statusText), 'error');
|
||||||
|
} else {
|
||||||
|
currentConfig.subreddits = result.subreddits;
|
||||||
|
renderSubredditEditor();
|
||||||
|
setSubredditStatus('Saved.', 'success');
|
||||||
|
setTimeout(() => setSubredditStatus(''), 3000);
|
||||||
|
fetchStats();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSubredditStatus('Save failed: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function populateSettingsForm() {
|
function populateSettingsForm() {
|
||||||
if (!currentConfig) return;
|
if (!currentConfig) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import YAML from 'yaml';
|
||||||
|
import type { SubredditConfig } from './index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes an updated subreddit list back to `config.yaml`, preserving the
|
||||||
|
* surrounding YAML structure, comment blocks, and per-row inline comments
|
||||||
|
* for entries that survive the update.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - Parse with `YAML.parseDocument` so the CST (including comments) is
|
||||||
|
* intact.
|
||||||
|
* - For each updated entry that matches an existing row by name, mutate
|
||||||
|
* the existing node's fields in place — its `.comment` (the trailing
|
||||||
|
* `# ...` annotation on the line) is preserved.
|
||||||
|
* - New entries become fresh flow-style maps appended in the caller's
|
||||||
|
* order; entries no longer in the list are dropped (and their comments
|
||||||
|
* go with them, since the comment is attached to the row).
|
||||||
|
* - Atomic write via tmp file + rename so a crash mid-write can't leave
|
||||||
|
* a half-written config on disk.
|
||||||
|
*/
|
||||||
|
export function persistSubreddits(
|
||||||
|
subreddits: SubredditConfig[],
|
||||||
|
configPath: string = path.resolve(process.cwd(), 'config.yaml'),
|
||||||
|
): void {
|
||||||
|
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
const doc = YAML.parseDocument(raw);
|
||||||
|
|
||||||
|
const seq = doc.getIn(['reddit', 'subreddits']) as YAML.YAMLSeq | undefined;
|
||||||
|
if (!seq || !YAML.isSeq(seq)) {
|
||||||
|
throw new Error('reddit.subreddits is not a sequence in config.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map existing items by their `name` so we can reuse YAMLMap nodes
|
||||||
|
// (and the trailing comments attached to them).
|
||||||
|
const existingByName = new Map<string, YAML.YAMLMap>();
|
||||||
|
for (const item of seq.items) {
|
||||||
|
if (!YAML.isMap(item)) continue;
|
||||||
|
const name = item.get('name');
|
||||||
|
if (typeof name === 'string') existingByName.set(name, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems: YAML.YAMLMap[] = [];
|
||||||
|
for (const sub of subreddits) {
|
||||||
|
const existing = existingByName.get(sub.name);
|
||||||
|
const node = existing ?? new YAML.YAMLMap();
|
||||||
|
if (!existing) {
|
||||||
|
// Match the on-disk style: flow-mapped, single-quoted strings.
|
||||||
|
node.flow = true;
|
||||||
|
node.set('name', sub.name);
|
||||||
|
}
|
||||||
|
setOrDelete(node, 'type', sub.type === 'image' ? undefined : sub.type);
|
||||||
|
setOrDelete(node, 'weight', sub.weight);
|
||||||
|
setOrDelete(node, 'tone', sub.tone === 'default' ? undefined : sub.tone);
|
||||||
|
setOrDelete(node, 'scripted', sub.scripted);
|
||||||
|
nextItems.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
seq.items = nextItems;
|
||||||
|
|
||||||
|
const out = doc.toString();
|
||||||
|
const tmp = configPath + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, out, 'utf-8');
|
||||||
|
fs.renameSync(tmp, configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOrDelete(node: YAML.YAMLMap, key: string, value: unknown): void {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
if (node.has(key)) node.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.set(key, value);
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { WebContext } from '../context.js';
|
import type { WebContext } from '../context.js';
|
||||||
|
import { SubredditConfigSchema, type SubredditConfig } from '../../config/index.js';
|
||||||
|
import { TONE_KEYS } from '../../config/tone-profiles.js';
|
||||||
|
import { persistSubreddits } from '../../config/persistence.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const SubredditsUpdateSchema = z.array(SubredditConfigSchema).min(1);
|
||||||
|
|
||||||
export function createConfigRouter(ctx: WebContext): Router {
|
export function createConfigRouter(ctx: WebContext): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -9,6 +15,8 @@ export function createConfigRouter(ctx: WebContext): Router {
|
|||||||
rateLimit: ctx.runtimeConfig.rateLimit,
|
rateLimit: ctx.runtimeConfig.rateLimit,
|
||||||
browser: { dryRun: ctx.runtimeConfig.browser.dryRun },
|
browser: { dryRun: ctx.runtimeConfig.browser.dryRun },
|
||||||
reddit: ctx.runtimeConfig.reddit,
|
reddit: ctx.runtimeConfig.reddit,
|
||||||
|
subreddits: ctx.config.reddit.subreddits,
|
||||||
|
tones: TONE_KEYS,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,12 +65,36 @@ export function createConfigRouter(ctx: WebContext): Router {
|
|||||||
ctx.config.reddit.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts;
|
ctx.config.reddit.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts;
|
||||||
ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes;
|
ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes;
|
||||||
|
|
||||||
|
if (updates.subreddits !== undefined) {
|
||||||
|
const parsed = SubredditsUpdateSchema.safeParse(updates.subreddits);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid subreddits payload', detail: parsed.error.format() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next: SubredditConfig[] = parsed.data;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const s of next) {
|
||||||
|
const key = s.name.toLowerCase();
|
||||||
|
if (seen.has(key)) {
|
||||||
|
res.status(400).json({ error: `Duplicate subreddit: ${s.name}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
persistSubreddits(next);
|
||||||
|
ctx.config.reddit.subreddits = next;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.getOnConfigChange()?.(ctx.runtimeConfig);
|
ctx.getOnConfigChange()?.(ctx.runtimeConfig);
|
||||||
|
|
||||||
ctx.activity.push('info', 'Config updated via dashboard');
|
ctx.activity.push('info', 'Config updated via dashboard');
|
||||||
ctx.broadcaster.push('info', 'Configuration updated via dashboard', updates);
|
ctx.broadcaster.push('info', 'Configuration updated via dashboard', updates);
|
||||||
|
|
||||||
res.json({ success: true, config: ctx.runtimeConfig });
|
res.json({
|
||||||
|
success: true,
|
||||||
|
config: ctx.runtimeConfig,
|
||||||
|
subreddits: ctx.config.reddit.subreddits,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.logger.error('Failed to update config:', error);
|
ctx.logger.error('Failed to update config:', error);
|
||||||
res.status(400).json({ error: String(error) });
|
res.status(400).json({ error: String(error) });
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import YAML from 'yaml';
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { persistSubreddits } from '../src/config/persistence.js';
|
||||||
|
|
||||||
|
const SAMPLE = `# top-level note that must survive
|
||||||
|
reddit:
|
||||||
|
# weights bias picking
|
||||||
|
subreddits:
|
||||||
|
- { name: AverageNudes, weight: 4, tone: 'direct' } # validation seekers
|
||||||
|
- { name: gonewild40plus, weight: 4, tone: 'gentle' } # mature
|
||||||
|
- { name: normalnudes, weight: 3 } # real bodies
|
||||||
|
- { name: tightandtoned, weight: 1 }
|
||||||
|
|
||||||
|
pollIntervalMs: 30000
|
||||||
|
userAgent: "ua"
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('persistSubreddits', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let configPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rb-config-'));
|
||||||
|
configPath = path.join(tmpDir, 'config.yaml');
|
||||||
|
fs.writeFileSync(configPath, SAMPLE, 'utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mutates an existing row in place and preserves its inline comment', () => {
|
||||||
|
persistSubreddits(
|
||||||
|
[
|
||||||
|
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'playful' },
|
||||||
|
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
|
||||||
|
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
|
||||||
|
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
|
||||||
|
],
|
||||||
|
configPath,
|
||||||
|
);
|
||||||
|
const out = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
expect(out).toMatch(/tone:\s*['"]?playful['"]?/);
|
||||||
|
expect(out).toContain('# validation seekers');
|
||||||
|
expect(out).toContain('# top-level note that must survive');
|
||||||
|
expect(out).toContain('# weights bias picking');
|
||||||
|
expect(out).toContain('# mature');
|
||||||
|
expect(out).toContain('pollIntervalMs: 30000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops removed rows (and their attached comments)', () => {
|
||||||
|
persistSubreddits(
|
||||||
|
[
|
||||||
|
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
|
||||||
|
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
|
||||||
|
],
|
||||||
|
configPath,
|
||||||
|
);
|
||||||
|
const out = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
expect(out).not.toContain('gonewild40plus');
|
||||||
|
expect(out).not.toContain('normalnudes');
|
||||||
|
expect(out).not.toContain('# mature');
|
||||||
|
expect(out).toContain('AverageNudes');
|
||||||
|
expect(out).toContain('tightandtoned');
|
||||||
|
expect(out).toContain('# validation seekers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends new rows in caller order', () => {
|
||||||
|
persistSubreddits(
|
||||||
|
[
|
||||||
|
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
|
||||||
|
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
|
||||||
|
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
|
||||||
|
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
|
||||||
|
{ name: 'NewSub', type: 'chat', weight: 2, tone: 'playful' },
|
||||||
|
],
|
||||||
|
configPath,
|
||||||
|
);
|
||||||
|
const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||||
|
const subs = parsed.reddit.subreddits;
|
||||||
|
expect(subs[subs.length - 1]).toMatchObject({
|
||||||
|
name: 'NewSub',
|
||||||
|
type: 'chat',
|
||||||
|
weight: 2,
|
||||||
|
tone: 'playful',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits 'default' tone and 'image' type on write to match on-disk style", () => {
|
||||||
|
persistSubreddits(
|
||||||
|
[{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'default' }],
|
||||||
|
configPath,
|
||||||
|
);
|
||||||
|
const out = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
expect(out).not.toMatch(/tone:\s*default/);
|
||||||
|
expect(out).not.toMatch(/type:\s*image/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('atomic write — tmp file not left behind on success', () => {
|
||||||
|
persistSubreddits(
|
||||||
|
[{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'direct' }],
|
||||||
|
configPath,
|
||||||
|
);
|
||||||
|
expect(fs.existsSync(configPath + '.tmp')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders rows according to input order', () => {
|
||||||
|
persistSubreddits(
|
||||||
|
[
|
||||||
|
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
|
||||||
|
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
|
||||||
|
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
|
||||||
|
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
|
||||||
|
],
|
||||||
|
configPath,
|
||||||
|
);
|
||||||
|
const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(parsed.reddit.subreddits.map((s: { name: string }) => s.name)).toEqual([
|
||||||
|
'tightandtoned',
|
||||||
|
'AverageNudes',
|
||||||
|
'gonewild40plus',
|
||||||
|
'normalnudes',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user