Måling som ikke endrer neste utgave er teater: report/analyze/ab-test/48h-monitor endte alle i chat (grep do.next = 0 i hele repoet). N15 gjør kanalen til en kontrakt. - recordDoNext i hooks/scripts/state-updater.mjs (TDD, 17 nye tester): seksjonen Do-Next Directives, rader "[dato] (kilde) direktiv — evidence: peker". Levetid = erstatt-per-kilde + 60-dagers aldersgulv, maks 3 per skriv, additiv scalar last_donext_date, $-sikre replacement-funksjoner. CLI: --record-do-next. - Skrivere: report Step 7b, analyze Step 6b, ab-test 2c.6 (kun Adopt), post-feedback-monitor (kun 48h-sjekken); analytics-interpreter emitterer nå anbefalinger på do-next-form (imperativ + evidens). - Lesere: post/quick/batch/create Step 0 + newsletter Step 1, som i tillegg kalibrerer på forrige utgaves MÅLTE tall via queue-id-match (queue.json → analytics/posts) med eksplisitt honest-miss — aldri estimert. - Guards: test-runner Section 16v (15 ubetingede sjekker, floor 198 → 213), $-safety-batteriet dekker recordDoNext (coverage-gate fanget den nye eksporten). Suiter: test-runner 232/0, hooks 191/0, trends 300/0, brain 134/0, editions 72/0, specifics-bank 45/0, contract-gate 33/0, tests 35/0, render 60/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
910 lines
41 KiB
JavaScript
910 lines
41 KiB
JavaScript
import { describe, test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact, recordDoNext } from '../state-updater.mjs';
|
|
|
|
const SAMPLE_STATE = `---
|
|
last_post_date: "2026-04-05"
|
|
first_post_date: "2026-01-15"
|
|
last_post_topic: "AI strategy"
|
|
posts_this_week: 2
|
|
weekly_goal: 3
|
|
current_streak: 5
|
|
longest_streak: 12
|
|
current_week: "2026-W14"
|
|
last_import_date: "2026-04-01"
|
|
last_import_week: "2026-W14"
|
|
follower_count: 850
|
|
follower_target: 10000
|
|
target_date: "2026-12-31"
|
|
monthly_growth: []
|
|
projected_10k_date: ""
|
|
growth_rate_needed: 0
|
|
---
|
|
|
|
# LinkedIn Session State
|
|
|
|
## Recent Posts
|
|
|
|
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
|
|
- [2026-04-03] "Three things I learned..." (1200) - leadership
|
|
- [2026-03-28] "Why most teams fail at..." (1350) - team building
|
|
|
|
## Session Notes
|
|
|
|
## Planned Content
|
|
|
|
## Milestone Log
|
|
`;
|
|
|
|
// Mirrors config/state-file.template.md: every template-initialized state file
|
|
// ships the ## First-Hour Plans and ## Outreach Pipeline sections pre-created,
|
|
// each already containing its two <!-- --> format comments. Real
|
|
// /linkedin:firsthour and /linkedin:outreach invocations therefore hit the
|
|
// section-APPEND branch — not the section-CREATE branch that SAMPLE_STATE (which
|
|
// omits the sections) exercises. These fixtures cover the production code path.
|
|
const TEMPLATE_STATE = `---
|
|
last_post_date: "2026-04-05"
|
|
first_post_date: "2026-01-15"
|
|
last_post_topic: "AI strategy"
|
|
posts_this_week: 2
|
|
weekly_goal: 3
|
|
current_streak: 5
|
|
longest_streak: 12
|
|
follower_count: 850
|
|
follower_target: 10000
|
|
target_date: "2026-12-31"
|
|
---
|
|
|
|
# LinkedIn Session State
|
|
|
|
## Recent Posts
|
|
|
|
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
|
|
|
|
## First-Hour Plans
|
|
|
|
<!-- First-hour / reply-loop plans, newest first. Written by /linkedin:firsthour. -->
|
|
<!-- Format: ### [YYYY-MM-DD HH:MM] topic -->
|
|
|
|
## Outreach Pipeline
|
|
|
|
<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->
|
|
<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->
|
|
`;
|
|
|
|
// A hand-corrupted / pre-migration state missing EVERY date anchor
|
|
// (last_post_date, last_firsthour_date, last_outreach_date). No production
|
|
// template ships like this — every template carries last_post_date — but it is
|
|
// the exact else-fall-through the S8 date-scalar gate protects: the scalar must
|
|
// NOT be inserted and must NOT be reported in `changes`, while the section append
|
|
// still runs and the function still returns non-null (log-accuracy branch).
|
|
const NO_ANCHOR_STATE = `---
|
|
first_post_date: "2026-01-15"
|
|
last_post_topic: "AI strategy"
|
|
posts_this_week: 2
|
|
weekly_goal: 3
|
|
follower_count: 850
|
|
follower_target: 10000
|
|
target_date: "2026-12-31"
|
|
---
|
|
|
|
# LinkedIn Session State
|
|
|
|
## Recent Posts
|
|
|
|
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
|
|
`;
|
|
|
|
describe('updatePostTracking', () => {
|
|
test('sets last_post_date to provided date', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-07',
|
|
postTopic: 'AI governance',
|
|
hookText: 'The real problem with AI governance...',
|
|
charCount: 1500,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^last_post_date: "2026-04-07"$/m);
|
|
});
|
|
|
|
test('sets last_post_topic', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-07',
|
|
postTopic: 'AI governance',
|
|
hookText: 'The real problem...',
|
|
charCount: 1500,
|
|
format: 'post'
|
|
});
|
|
assert.match(result.content, /^last_post_topic: "AI governance"$/m);
|
|
});
|
|
|
|
test('increments posts_this_week when same week', () => {
|
|
// 2026-04-06 is a Monday, ISO W15. current_week is W14.
|
|
// Use a date that stays in W14: 2026-04-05 is Sunday W14 — but last_post_date is already 04-05.
|
|
// Let's use a state with current_week matching the post date week.
|
|
const w15State = SAMPLE_STATE.replace('current_week: "2026-W14"', 'current_week: "2026-W15"');
|
|
const result = updatePostTracking(w15State, {
|
|
postDate: '2026-04-07', // Tuesday W15
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^posts_this_week: 3$/m); // was 2, incremented
|
|
});
|
|
|
|
test('increments streak when gap <= 2 days', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-06', // 1 day after last_post_date 2026-04-05
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^current_streak: 6$/m); // was 5, incremented
|
|
});
|
|
|
|
test('resets streak to 1 when gap > 2 days', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-09', // 4 days after 2026-04-05
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^current_streak: 1$/m);
|
|
});
|
|
|
|
test('sets first_post_date when null', () => {
|
|
const nullFirstPost = SAMPLE_STATE.replace(
|
|
'first_post_date: "2026-01-15"',
|
|
'first_post_date: null'
|
|
);
|
|
const result = updatePostTracking(nullFirstPost, {
|
|
postDate: '2026-04-07',
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^first_post_date: "2026-04-07"$/m);
|
|
});
|
|
|
|
test('does NOT overwrite existing first_post_date', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-07',
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^first_post_date: "2026-01-15"$/m);
|
|
});
|
|
|
|
test('triggers week rollover when ISO week changes', () => {
|
|
// 2026-04-14 is W16, current_week is W14
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-14',
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
// After rollover, posts_this_week resets to 0 then increments to 1
|
|
assert.match(result.content, /^posts_this_week: 1$/m);
|
|
assert.match(result.content, /^current_week: "2026-W16"$/m);
|
|
});
|
|
|
|
test('appends to Recent Posts section', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-06',
|
|
postTopic: 'AI governance',
|
|
hookText: 'The real problem with AI governance today...',
|
|
charCount: 1500,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('- [2026-04-06] "The real problem with AI governance today..." (1500) - AI governance'));
|
|
// Existing entries should still be there
|
|
assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."'));
|
|
});
|
|
|
|
test('appends a $-bearing topic/hook verbatim — special replacement patterns are not interpreted', () => {
|
|
// Regression (S12): the append used a replacement *string*, so `$1`/`$&`/`$\``/
|
|
// `$'`/`$$` in user content were interpreted — a "$100 budget" topic re-injected
|
|
// the captured `## Recent Posts` heading and dropped characters. The replacement
|
|
// function inserts the content verbatim.
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-09',
|
|
postTopic: '$100 budget — $& and $1 rule',
|
|
hookText: 'We cut $1 of $5',
|
|
charCount: 1200,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('$100 budget — $& and $1 rule'), 'topic with $-tokens must be inserted verbatim');
|
|
assert.ok(result.content.includes('"We cut $1 of $5"'), 'hook with $-tokens must be inserted verbatim');
|
|
// S13: the SCALAR path (replaceField → state-updater.mjs:58) must ALSO insert
|
|
// verbatim. The S12 test only checked the Recent Posts section entry (a function
|
|
// append), so a `$&`-corrupted `last_post_topic` scalar shipped green: a
|
|
// replacement *string* expands `$&` to the whole matched line, rewriting the
|
|
// field to `last_post_topic: "$100 budget — last_post_topic: "AI strategy" …"`.
|
|
// This assertion fails until replaceField is a replacement function.
|
|
assert.match(result.content, /^last_post_topic: "\$100 budget — \$& and \$1 rule"$/m,
|
|
'last_post_topic scalar must carry the $-bearing topic verbatim (no $&/$1 expansion)');
|
|
const headings = result.content.match(/^## Recent Posts$/gm) || [];
|
|
assert.equal(headings.length, 1, 'heading must not be re-injected by a $1/$& expansion');
|
|
});
|
|
|
|
test('updates longest_streak when current exceeds it', () => {
|
|
const highStreak = SAMPLE_STATE.replace('current_streak: 5', 'current_streak: 12');
|
|
const result = updatePostTracking(highStreak, {
|
|
postDate: '2026-04-06', // 1 day gap, streak increments to 13
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^current_streak: 13$/m);
|
|
assert.match(result.content, /^longest_streak: 13$/m);
|
|
});
|
|
|
|
test('does not update longest_streak when current is lower', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-06',
|
|
postTopic: 'test',
|
|
hookText: 'Hook',
|
|
charCount: 1000,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^current_streak: 6$/m);
|
|
assert.match(result.content, /^longest_streak: 12$/m); // unchanged
|
|
});
|
|
|
|
test('returns changes array describing what changed', () => {
|
|
const result = updatePostTracking(SAMPLE_STATE, {
|
|
postDate: '2026-04-06',
|
|
postTopic: 'AI governance',
|
|
hookText: 'Hook',
|
|
charCount: 1500,
|
|
format: 'post'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(Array.isArray(result.changes));
|
|
assert.ok(result.changes.length > 0);
|
|
});
|
|
});
|
|
|
|
// Fixed synthetic "today" for prune tests: far from every hardcoded SAMPLE_STATE
|
|
// date, so fixture dates computed relative to it can never collide with them.
|
|
const FIXED_TODAY = new Date('2027-01-01T00:00:00Z');
|
|
|
|
// Days before FIXED_TODAY as YYYY-MM-DD.
|
|
function daysBeforeFixedToday(days) {
|
|
const d = new Date(FIXED_TODAY);
|
|
d.setDate(d.getDate() - days);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
// Extract only the Recent Posts section body so assertions cannot accidentally
|
|
// match dates in the frontmatter or other sections.
|
|
function recentPostsSection(content) {
|
|
const match = content.match(/## Recent Posts\n([\s\S]*?)\n## /);
|
|
assert.ok(match, 'Recent Posts section present');
|
|
return match[1];
|
|
}
|
|
|
|
describe('pruneContentHistory', () => {
|
|
test('prunes an old entry that sits BELOW a fresh entry (regression: /m flag truncated the capture to the first line)', () => {
|
|
// Production prepends new entries at the top, so old entries always sit
|
|
// BELOW fresh ones. With the /m flag, the $ alternative in the lookahead
|
|
// matched the end of the FIRST entry line, so only that line was captured
|
|
// and older entries below were never scanned -> never pruned.
|
|
const oldDate = daysBeforeFixedToday(400);
|
|
const freshDate = daysBeforeFixedToday(10);
|
|
|
|
const state = SAMPLE_STATE.replace(
|
|
/## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
|
|
`## Recent Posts\n\n- [${freshDate}] "Fresh post..." (1200) - fresh topic\n- [${oldDate}] "Old post..." (1000) - old topic\n\n`
|
|
);
|
|
|
|
const result = pruneContentHistory(state, 90, FIXED_TODAY);
|
|
assert.notEqual(result, null, 'an old entry below a fresh one must be pruned');
|
|
assert.equal(result.pruned, 1);
|
|
const section = recentPostsSection(result.content);
|
|
assert.ok(!section.includes(oldDate), 'old entry pruned');
|
|
assert.ok(section.includes(freshDate), 'fresh entry kept');
|
|
});
|
|
|
|
test('removes entries older than 90 days', () => {
|
|
const oldDate = daysBeforeFixedToday(100);
|
|
const recentDate = daysBeforeFixedToday(10);
|
|
|
|
const stateWithOld = SAMPLE_STATE.replace(
|
|
/## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
|
|
`## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n\n`
|
|
);
|
|
|
|
const result = pruneContentHistory(stateWithOld, 90, FIXED_TODAY);
|
|
assert.notEqual(result, null);
|
|
assert.equal(result.pruned, 1);
|
|
const section = recentPostsSection(result.content);
|
|
assert.ok(!section.includes(oldDate));
|
|
assert.ok(section.includes(recentDate));
|
|
});
|
|
|
|
test('preserves entries within 90 days', () => {
|
|
const recentDate = daysBeforeFixedToday(30);
|
|
|
|
const stateWithRecent = SAMPLE_STATE.replace(
|
|
/## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
|
|
`## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n\n`
|
|
);
|
|
|
|
const result = pruneContentHistory(stateWithRecent, 90, FIXED_TODAY);
|
|
assert.equal(result, null); // nothing to prune
|
|
});
|
|
|
|
test('returns null when no entries exist', () => {
|
|
const emptyRecent = SAMPLE_STATE.replace(
|
|
/## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
|
|
'## Recent Posts\n\n'
|
|
);
|
|
const result = pruneContentHistory(emptyRecent, 90, FIXED_TODAY);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test('handles custom maxAgeDays', () => {
|
|
const oldDate = daysBeforeFixedToday(40);
|
|
|
|
const stateWithOld = SAMPLE_STATE.replace(
|
|
/## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
|
|
`## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n\n`
|
|
);
|
|
|
|
const result = pruneContentHistory(stateWithOld, 30, FIXED_TODAY);
|
|
assert.notEqual(result, null);
|
|
assert.equal(result.pruned, 1);
|
|
});
|
|
|
|
test('preserves a $-bearing kept entry verbatim while pruning — special replacement patterns are not interpreted', () => {
|
|
// Regression (S12): the rewrite used `.replace(section, newSectionString)`; a
|
|
// string search has no $1 group, but `$&` still expands to the whole matched
|
|
// section and `$$` collapses to `$`, so a kept post like "$$ and $& budget"
|
|
// corrupted state. The replacement function inserts newSection verbatim.
|
|
const oldDate = daysBeforeFixedToday(100);
|
|
const recentDate = daysBeforeFixedToday(10);
|
|
|
|
// Build the fixture with a replacement FUNCTION too — a string replacement here
|
|
// would itself interpret the `$$`/`$&` we are trying to plant (the very bug under
|
|
// test), corrupting the fixture before pruneContentHistory ever sees it.
|
|
const stateWithMix = SAMPLE_STATE.replace(
|
|
/## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
|
|
() => `## Recent Posts\n\n- [${oldDate}] "Old..." (1000) - drop me\n- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget\n\n`
|
|
);
|
|
|
|
const result = pruneContentHistory(stateWithMix, 90, FIXED_TODAY);
|
|
assert.notEqual(result, null);
|
|
assert.equal(result.pruned, 1);
|
|
const section = recentPostsSection(result.content);
|
|
assert.ok(!section.includes(oldDate), 'old entry pruned');
|
|
assert.ok(section.includes(`- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget`), 'kept $-bearing entry survives verbatim');
|
|
const headings = result.content.match(/^## Recent Posts$/gm) || [];
|
|
assert.equal(headings.length, 1, 'section must not be duplicated by a $& expansion');
|
|
});
|
|
});
|
|
|
|
describe('updateFollowerCount', () => {
|
|
test('updates follower_count', () => {
|
|
const result = updateFollowerCount(SAMPLE_STATE, {
|
|
count: 920,
|
|
month: '2026-04'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^follower_count: 920$/m);
|
|
});
|
|
|
|
test('recalculates growth_rate_needed', () => {
|
|
const result = updateFollowerCount(SAMPLE_STATE, {
|
|
count: 920,
|
|
month: '2026-04'
|
|
});
|
|
assert.notEqual(result, null);
|
|
const match = result.content.match(/^growth_rate_needed: (\d+)$/m);
|
|
assert.ok(match, 'growth_rate_needed should be present');
|
|
const rate = parseInt(match[1], 10);
|
|
assert.ok(rate > 0, 'growth_rate_needed should be positive');
|
|
});
|
|
|
|
test('appends to Milestone Log section', () => {
|
|
const result = updateFollowerCount(SAMPLE_STATE, {
|
|
count: 920,
|
|
month: '2026-04'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('[2026-04] 920 (+70)'));
|
|
});
|
|
});
|
|
|
|
describe('recordFirstHourPlan', () => {
|
|
const PLAN_OPTS = {
|
|
planDate: '2026-05-30 09:00',
|
|
postTopic: 'AI governance',
|
|
targets: ['Whale: @bigvoice (within 30 min)', 'Inner circle: @peer'],
|
|
draftComments: ['Your point about model-cards resonates — we found...'],
|
|
plan: ['09:00 — Post goes live', '09:10 — Add value self-comment', '09:30 — Reply to every comment']
|
|
};
|
|
|
|
test('creates a non-R-initial First-Hour Plans section when absent', () => {
|
|
// SAMPLE_STATE has no ## First-Hour Plans section → must be created (additive)
|
|
assert.ok(!SAMPLE_STATE.includes('## First-Hour Plans'));
|
|
const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('## First-Hour Plans'));
|
|
});
|
|
|
|
test('records topic, targets, draft comments and timeline in the entry', () => {
|
|
const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('[2026-05-30 09:00]'));
|
|
assert.ok(result.content.includes('AI governance'));
|
|
assert.ok(result.content.includes('Whale: @bigvoice (within 30 min)'));
|
|
assert.ok(result.content.includes('Your point about model-cards resonates'));
|
|
assert.ok(result.content.includes('09:10 — Add value self-comment'));
|
|
});
|
|
|
|
test('is additive — inserts last_firsthour_date when the field is absent', () => {
|
|
assert.ok(!/^last_firsthour_date:/m.test(SAMPLE_STATE));
|
|
const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^last_firsthour_date: "2026-05-30 09:00"$/m);
|
|
});
|
|
|
|
test('updates an existing last_firsthour_date without duplicating it', () => {
|
|
const withField = SAMPLE_STATE.replace(
|
|
'last_post_date: "2026-04-05"',
|
|
'last_post_date: "2026-04-05"\nlast_firsthour_date: null'
|
|
);
|
|
const result = recordFirstHourPlan(withField, { ...PLAN_OPTS, planDate: '2026-05-30 11:00' });
|
|
assert.notEqual(result, null);
|
|
const hits = result.content.match(/^last_firsthour_date:/gm) || [];
|
|
assert.equal(hits.length, 1, 'field must not be duplicated');
|
|
assert.match(result.content, /^last_firsthour_date: "2026-05-30 11:00"$/m);
|
|
});
|
|
|
|
test('leaves existing fields and sections untouched (round-trip)', () => {
|
|
const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^last_post_date: "2026-04-05"$/m);
|
|
assert.match(result.content, /^follower_count: 850$/m);
|
|
assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."'));
|
|
});
|
|
|
|
test('gracefully defaults empty targets/comments/plan', () => {
|
|
const result = recordFirstHourPlan(SAMPLE_STATE, {
|
|
planDate: '2026-05-30 09:00',
|
|
postTopic: 'minimal'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('## First-Hour Plans'));
|
|
assert.ok(result.content.includes('minimal'));
|
|
});
|
|
|
|
test('returns a changes array describing what changed', () => {
|
|
const result = recordFirstHourPlan(SAMPLE_STATE, PLAN_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(Array.isArray(result.changes));
|
|
assert.ok(result.changes.length > 0);
|
|
});
|
|
|
|
test('appends into the pre-existing First-Hour Plans section (production path) without duplicating it or dropping its format comments', () => {
|
|
// TEMPLATE_STATE ships the section + its two <!-- --> comments → the
|
|
// section-APPEND branch must fire (the path every real invocation takes),
|
|
// NOT section creation.
|
|
assert.ok(TEMPLATE_STATE.includes('## First-Hour Plans'));
|
|
const result = recordFirstHourPlan(TEMPLATE_STATE, PLAN_OPTS);
|
|
assert.notEqual(result, null);
|
|
// Heading not duplicated (the create branch did not also run)
|
|
const headings = result.content.match(/^## First-Hour Plans$/gm) || [];
|
|
assert.equal(headings.length, 1, 'section must not be duplicated');
|
|
// Both template format comments survive the append
|
|
assert.ok(result.content.includes('<!-- First-hour / reply-loop plans, newest first. Written by /linkedin:firsthour. -->'));
|
|
assert.ok(result.content.includes('<!-- Format: ### [YYYY-MM-DD HH:MM] topic -->'));
|
|
// Entry lands INSIDE the section (between its heading and the next section)
|
|
const section = result.content.slice(
|
|
result.content.indexOf('## First-Hour Plans'),
|
|
result.content.indexOf('## Outreach Pipeline')
|
|
);
|
|
assert.ok(section.includes('[2026-05-30 09:00]'), 'entry must land inside the First-Hour Plans section');
|
|
assert.ok(section.includes('AI governance'));
|
|
});
|
|
|
|
test('records $-bearing topic/targets/comments verbatim — special replacement patterns are not interpreted', () => {
|
|
// Regression (S12): the section append used a replacement *string*; `$&`/`$1`/`$$`
|
|
// in the topic/targets/comments were interpreted. The function inserts verbatim.
|
|
const result = recordFirstHourPlan(TEMPLATE_STATE, {
|
|
planDate: '2026-05-30 09:00',
|
|
postTopic: '$100 launch & $& spend',
|
|
targets: ['Whale: @big$voice ($1 ask)'],
|
|
draftComments: ['Loved the $$ point and the $& follow-up'],
|
|
plan: ['09:00 — live']
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('$100 launch & $& spend'), 'topic with $-tokens verbatim');
|
|
assert.ok(result.content.includes('Whale: @big$voice ($1 ask)'), 'target with $-tokens verbatim');
|
|
assert.ok(result.content.includes('Loved the $$ point and the $& follow-up'), 'comment with $-tokens verbatim');
|
|
const headings = result.content.match(/^## First-Hour Plans$/gm) || [];
|
|
assert.equal(headings.length, 1, 'section must not be re-injected by a $1/$& expansion');
|
|
});
|
|
|
|
test('no-anchor fall-through: neither last_firsthour_date nor last_post_date — scalar not written, not reported; section still appended', () => {
|
|
// Exercises the else-fall-through of the date-scalar gate
|
|
// (state-updater.mjs:225-231): with NO anchor field, the scalar cannot be
|
|
// inserted and must NOT be reported in `changes` (the exact branch the S8
|
|
// fix protects) — yet the section append always runs, so result is non-null.
|
|
assert.ok(!/^last_firsthour_date:/m.test(NO_ANCHOR_STATE));
|
|
assert.ok(!/^last_post_date:/m.test(NO_ANCHOR_STATE));
|
|
const result = recordFirstHourPlan(NO_ANCHOR_STATE, PLAN_OPTS);
|
|
assert.notEqual(result, null);
|
|
// Scalar NOT inserted (no anchor to insert after)
|
|
assert.ok(!/^last_firsthour_date:/m.test(result.content), 'scalar must not be inserted without an anchor');
|
|
// ...and NOT reported as changed
|
|
assert.ok(!result.changes.some((c) => /last_firsthour_date/.test(c)), 'no date-scalar change entry without an anchor');
|
|
// The section entry IS still written
|
|
assert.ok(result.changes.some((c) => /^First-Hour Plans \+=/.test(c)), 'section entry must still be recorded');
|
|
assert.ok(result.content.includes('## First-Hour Plans'));
|
|
assert.ok(result.content.includes('[2026-05-30 09:00]'));
|
|
});
|
|
});
|
|
|
|
describe('recordOutreachContact', () => {
|
|
const CONTACT_OPTS = {
|
|
contactDate: '2026-05-30 14:00',
|
|
track: 'collab',
|
|
partner: '@bigvoice',
|
|
stage: 'pitched',
|
|
nextAction: 'follow up if no reply',
|
|
dueDate: '2026-06-06'
|
|
};
|
|
|
|
test('creates a non-R-initial Outreach Pipeline section when absent', () => {
|
|
// SAMPLE_STATE has no ## Outreach Pipeline section → must be created (additive)
|
|
assert.ok(!SAMPLE_STATE.includes('## Outreach Pipeline'));
|
|
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('## Outreach Pipeline'));
|
|
});
|
|
|
|
test('records track, partner, stage, next action and due in the entry', () => {
|
|
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('[2026-05-30 14:00]'));
|
|
assert.ok(result.content.includes('@bigvoice'));
|
|
assert.ok(result.content.includes('collab'));
|
|
assert.ok(result.content.includes('pitched'));
|
|
assert.ok(result.content.includes('follow up if no reply'));
|
|
assert.ok(result.content.includes('2026-06-06'));
|
|
});
|
|
|
|
test('is additive — inserts last_outreach_date when the field is absent', () => {
|
|
assert.ok(!/^last_outreach_date:/m.test(SAMPLE_STATE));
|
|
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^last_outreach_date: "2026-05-30 14:00"$/m);
|
|
});
|
|
|
|
test('updates an existing last_outreach_date without duplicating it', () => {
|
|
const withField = SAMPLE_STATE.replace(
|
|
'last_post_date: "2026-04-05"',
|
|
'last_post_date: "2026-04-05"\nlast_outreach_date: null'
|
|
);
|
|
const result = recordOutreachContact(withField, { ...CONTACT_OPTS, contactDate: '2026-05-31 09:00' });
|
|
assert.notEqual(result, null);
|
|
const hits = result.content.match(/^last_outreach_date:/gm) || [];
|
|
assert.equal(hits.length, 1, 'field must not be duplicated');
|
|
assert.match(result.content, /^last_outreach_date: "2026-05-31 09:00"$/m);
|
|
});
|
|
|
|
test('leaves existing fields and sections untouched (round-trip)', () => {
|
|
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^last_post_date: "2026-04-05"$/m);
|
|
assert.match(result.content, /^follower_count: 850$/m);
|
|
assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."'));
|
|
});
|
|
|
|
test('gracefully defaults empty optional fields', () => {
|
|
const result = recordOutreachContact(SAMPLE_STATE, {
|
|
contactDate: '2026-05-30 14:00',
|
|
partner: 'minimal-contact'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('## Outreach Pipeline'));
|
|
assert.ok(result.content.includes('minimal-contact'));
|
|
});
|
|
|
|
test('returns a changes array describing what changed', () => {
|
|
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(Array.isArray(result.changes));
|
|
assert.ok(result.changes.length > 0);
|
|
});
|
|
|
|
test('appends into the pre-existing Outreach Pipeline section (production path) without duplicating it or dropping its format comments', () => {
|
|
// TEMPLATE_STATE ships the section + its two <!-- --> comments → the
|
|
// section-APPEND branch must fire, NOT section creation.
|
|
assert.ok(TEMPLATE_STATE.includes('## Outreach Pipeline'));
|
|
const result = recordOutreachContact(TEMPLATE_STATE, CONTACT_OPTS);
|
|
assert.notEqual(result, null);
|
|
// Heading not duplicated (the create branch did not also run)
|
|
const headings = result.content.match(/^## Outreach Pipeline$/gm) || [];
|
|
assert.equal(headings.length, 1, 'section must not be duplicated');
|
|
// Both template format comments survive the append
|
|
assert.ok(result.content.includes('<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->'));
|
|
assert.ok(result.content.includes('<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->'));
|
|
// Entry lands INSIDE the section (it is the last section in the template)
|
|
const section = result.content.slice(result.content.indexOf('## Outreach Pipeline'));
|
|
assert.ok(section.includes('[2026-05-30 14:00]'), 'entry must land inside the Outreach Pipeline section');
|
|
assert.ok(section.includes('@bigvoice'));
|
|
});
|
|
|
|
test('records $-bearing partner/stage/nextAction verbatim — special replacement patterns are not interpreted', () => {
|
|
// Regression (S12): same class as the other section appends — replacement
|
|
// function, not string, so `$&`/`$1`/`$$` in user content are inserted verbatim.
|
|
const result = recordOutreachContact(TEMPLATE_STATE, {
|
|
contactDate: '2026-05-30 14:00',
|
|
track: 'collab',
|
|
partner: '@big$voice & $&co',
|
|
stage: 'pitched $100 deal',
|
|
nextAction: 'send $$ quote, ref $1',
|
|
dueDate: '2026-06-06'
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('@big$voice & $&co'), 'partner with $-tokens verbatim');
|
|
assert.ok(result.content.includes('pitched $100 deal'), 'stage with $-tokens verbatim');
|
|
assert.ok(result.content.includes('send $$ quote, ref $1'), 'nextAction with $-tokens verbatim');
|
|
const headings = result.content.match(/^## Outreach Pipeline$/gm) || [];
|
|
assert.equal(headings.length, 1, 'section must not be re-injected by a $1/$& expansion');
|
|
});
|
|
|
|
test('no-anchor fall-through: none of last_outreach_date/last_firsthour_date/last_post_date — scalar not written, not reported; section still appended', () => {
|
|
// Exercises the else-fall-through of the date-scalar gate
|
|
// (state-updater.mjs:284-293): with NONE of the three anchors, the scalar
|
|
// cannot be inserted and must NOT be reported — the section append still
|
|
// runs, so result is non-null.
|
|
assert.ok(!/^last_outreach_date:/m.test(NO_ANCHOR_STATE));
|
|
assert.ok(!/^last_firsthour_date:/m.test(NO_ANCHOR_STATE));
|
|
assert.ok(!/^last_post_date:/m.test(NO_ANCHOR_STATE));
|
|
const result = recordOutreachContact(NO_ANCHOR_STATE, CONTACT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(!/^last_outreach_date:/m.test(result.content), 'scalar must not be inserted without an anchor');
|
|
assert.ok(!result.changes.some((c) => /last_outreach_date/.test(c)), 'no date-scalar change entry without an anchor');
|
|
assert.ok(result.changes.some((c) => /^Outreach Pipeline \+=/.test(c)), 'section entry must still be recorded');
|
|
assert.ok(result.content.includes('## Outreach Pipeline'));
|
|
assert.ok(result.content.includes('[2026-05-30 14:00]'));
|
|
});
|
|
});
|
|
|
|
// Mirrors the ## Do-Next Directives block config/state-file.template.md ships
|
|
// (own fixture rather than an edit to TEMPLATE_STATE, so the recordOutreachContact
|
|
// "entry lands inside the last section" assertion keeps testing exactly what it
|
|
// tested before). Every template-initialized state file therefore hits the
|
|
// section-APPEND branch; SAMPLE_STATE (no section) exercises section-CREATE.
|
|
const DONEXT_TEMPLATE_STATE = `---
|
|
last_post_date: "2026-04-05"
|
|
last_post_topic: "AI strategy"
|
|
follower_count: 850
|
|
---
|
|
|
|
# LinkedIn Session State
|
|
|
|
## Recent Posts
|
|
|
|
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
|
|
|
|
## Do-Next Directives
|
|
|
|
<!-- Measurement -> next action. Written by /linkedin:report, /linkedin:analyze, /linkedin:ab-test (Adopt) and the 48h post-feedback-monitor; read by the create surfaces at Step 0. -->
|
|
<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->
|
|
`;
|
|
|
|
describe('recordDoNext', () => {
|
|
// "Now" for the write tests: days — not months — after the 2026-05-3x record dates,
|
|
// so the age floor is NOT what those assertions end up measuring. (Reusing
|
|
// FIXED_TODAY here made the supersede test pass for the wrong reason: every row
|
|
// was already past the 60-day floor, so "replaced" and "expired" were
|
|
// indistinguishable.) The prune tests below deliberately use FIXED_TODAY with
|
|
// dates computed relative to it.
|
|
const DONEXT_TODAY = new Date('2026-06-10T00:00:00Z');
|
|
const REPORT_OPTS = {
|
|
recordDate: '2026-05-30',
|
|
source: 'report',
|
|
directives: [
|
|
{ directive: 'Double down on AI-agent posts', evidence: 'weekly 2026-W22: 3 posts avg 2400 impressions vs 1200 baseline' },
|
|
{ directive: 'Move Tuesday posts to 08:00', evidence: 'weekly 2026-W22: Tuesday -40% impressions' }
|
|
],
|
|
today: DONEXT_TODAY
|
|
};
|
|
|
|
test('creates a non-R-initial Do-Next Directives section when absent', () => {
|
|
assert.ok(!SAMPLE_STATE.includes('## Do-Next Directives'));
|
|
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('## Do-Next Directives'));
|
|
});
|
|
|
|
test('records date, source tag, directive text and evidence pointer in each row', () => {
|
|
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^- \[2026-05-30\] \(report\) Double down on AI-agent posts — evidence: weekly 2026-W22: 3 posts avg 2400 impressions vs 1200 baseline$/m);
|
|
assert.match(result.content, /^- \[2026-05-30\] \(report\) Move Tuesday posts to 08:00 — evidence: weekly 2026-W22: Tuesday -40% impressions$/m);
|
|
});
|
|
|
|
test('accepts a plain-string directive and marks the missing evidence pointer as none', () => {
|
|
const result = recordDoNext(SAMPLE_STATE, {
|
|
recordDate: '2026-05-30',
|
|
source: 'report',
|
|
directives: ['Ship one carousel this week'],
|
|
today: DONEXT_TODAY
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^- \[2026-05-30\] \(report\) Ship one carousel this week — evidence: _\(none\)_$/m);
|
|
});
|
|
|
|
test('caps a single write at 3 directives (2-3 is the contract; a report dump is not a backlog)', () => {
|
|
const result = recordDoNext(SAMPLE_STATE, {
|
|
recordDate: '2026-05-30',
|
|
source: 'report',
|
|
directives: ['one', 'two', 'three', 'four', 'five'],
|
|
today: DONEXT_TODAY
|
|
});
|
|
assert.notEqual(result, null);
|
|
const rows = result.content.match(/^- \[2026-05-30\] \(report\)/gm) || [];
|
|
assert.equal(rows.length, 3, 'at most 3 rows per write');
|
|
assert.ok(!result.content.includes('(report) four'));
|
|
});
|
|
|
|
test('replaces the SAME source on the next write — directives have a lifetime, they do not accumulate', () => {
|
|
const first = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
|
|
const second = recordDoNext(first.content, {
|
|
recordDate: '2026-06-06',
|
|
source: 'report',
|
|
directives: ['Test a poll format'],
|
|
today: DONEXT_TODAY
|
|
});
|
|
assert.notEqual(second, null);
|
|
assert.ok(!second.content.includes('Double down on AI-agent posts'), 'superseded report directive must be gone');
|
|
assert.ok(!second.content.includes('Move Tuesday posts to 08:00'), 'superseded report directive must be gone');
|
|
assert.match(second.content, /^- \[2026-06-06\] \(report\) Test a poll format /m);
|
|
});
|
|
|
|
test('leaves OTHER sources untouched when one source rewrites its slot', () => {
|
|
const withAb = recordDoNext(SAMPLE_STATE, {
|
|
recordDate: '2026-05-28',
|
|
source: 'ab-test',
|
|
directives: [{ directive: 'Adopt question hooks', evidence: 'test hook-form: B +38% comments' }],
|
|
today: DONEXT_TODAY
|
|
});
|
|
const withReport = recordDoNext(withAb.content, REPORT_OPTS);
|
|
assert.notEqual(withReport, null);
|
|
assert.ok(withReport.content.includes('(ab-test) Adopt question hooks'), 'a report write must not evict the A/B slot');
|
|
assert.ok(withReport.content.includes('(report) Double down on AI-agent posts'));
|
|
});
|
|
|
|
test('prunes rows older than maxAgeDays regardless of source (bounded section)', () => {
|
|
const stale = daysBeforeFixedToday(90);
|
|
const fresh = daysBeforeFixedToday(10);
|
|
const seeded = SAMPLE_STATE.replace(
|
|
'## Milestone Log',
|
|
`## Do-Next Directives\n\n- [${stale}] (48h-monitor) Old learning — evidence: post 2026-01\n- [${fresh}] (48h-monitor) Recent learning — evidence: post 2026-12\n\n## Milestone Log`
|
|
);
|
|
const result = recordDoNext(seeded, { ...REPORT_OPTS, recordDate: daysBeforeFixedToday(0), maxAgeDays: 60, today: FIXED_TODAY });
|
|
assert.notEqual(result, null);
|
|
assert.ok(!result.content.includes('Old learning'), 'row past the age window must be dropped');
|
|
assert.ok(result.content.includes('Recent learning'), 'row inside the age window must survive');
|
|
});
|
|
|
|
test('prunes stale rows even when the write itself carries no directives', () => {
|
|
const stale = daysBeforeFixedToday(120);
|
|
const seeded = SAMPLE_STATE.replace(
|
|
'## Milestone Log',
|
|
`## Do-Next Directives\n\n- [${stale}] (report) Ancient directive — evidence: none\n\n## Milestone Log`
|
|
);
|
|
const result = recordDoNext(seeded, { recordDate: daysBeforeFixedToday(0), source: 'report', directives: [], maxAgeDays: 60, today: FIXED_TODAY });
|
|
assert.notEqual(result, null);
|
|
assert.ok(!result.content.includes('Ancient directive'));
|
|
});
|
|
|
|
test('returns null when there is nothing to record and nothing to prune', () => {
|
|
const result = recordDoNext(SAMPLE_STATE, { recordDate: '2026-05-30', source: 'report', directives: [], today: FIXED_TODAY });
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test('is additive — inserts last_donext_date when the field is absent', () => {
|
|
assert.ok(!/^last_donext_date:/m.test(SAMPLE_STATE));
|
|
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^last_donext_date: "2026-05-30"$/m);
|
|
});
|
|
|
|
test('updates an existing last_donext_date without duplicating it', () => {
|
|
const withField = SAMPLE_STATE.replace(
|
|
'last_post_date: "2026-04-05"',
|
|
'last_post_date: "2026-04-05"\nlast_donext_date: null'
|
|
);
|
|
const result = recordDoNext(withField, { ...REPORT_OPTS, recordDate: '2026-06-06' });
|
|
assert.notEqual(result, null);
|
|
const hits = result.content.match(/^last_donext_date:/gm) || [];
|
|
assert.equal(hits.length, 1, 'field must not be duplicated');
|
|
assert.match(result.content, /^last_donext_date: "2026-06-06"$/m);
|
|
});
|
|
|
|
test('no-anchor fall-through: scalar not written, not reported; section still written', () => {
|
|
assert.ok(!/^last_post_date:/m.test(NO_ANCHOR_STATE));
|
|
const result = recordDoNext(NO_ANCHOR_STATE, REPORT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(!/^last_donext_date:/m.test(result.content), 'scalar must not be inserted without an anchor');
|
|
assert.ok(!result.changes.some((c) => /last_donext_date/.test(c)), 'no date-scalar change entry without an anchor');
|
|
assert.ok(result.changes.some((c) => /^Do-Next Directives/.test(c)), 'section write must still be recorded');
|
|
assert.ok(result.content.includes('(report) Double down on AI-agent posts'));
|
|
});
|
|
|
|
test('writes into the pre-existing section (production path) without duplicating it or dropping its format comments', () => {
|
|
assert.ok(DONEXT_TEMPLATE_STATE.includes('## Do-Next Directives'));
|
|
const result = recordDoNext(DONEXT_TEMPLATE_STATE, REPORT_OPTS);
|
|
assert.notEqual(result, null);
|
|
const headings = result.content.match(/^## Do-Next Directives$/gm) || [];
|
|
assert.equal(headings.length, 1, 'section must not be duplicated');
|
|
assert.ok(result.content.includes('<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->'), 'template format comment survives');
|
|
const section = result.content.slice(result.content.indexOf('## Do-Next Directives'));
|
|
assert.ok(section.includes('(report) Double down on AI-agent posts'), 'rows must land inside the section');
|
|
});
|
|
|
|
test('records $-bearing directive/evidence verbatim — special replacement patterns are not interpreted', () => {
|
|
const result = recordDoNext(DONEXT_TEMPLATE_STATE, {
|
|
recordDate: '2026-05-30',
|
|
source: 'report',
|
|
directives: [{ directive: 'Price the $&-offer at $99', evidence: 'ref $1, spend $$40' }],
|
|
today: DONEXT_TODAY
|
|
});
|
|
assert.notEqual(result, null);
|
|
assert.ok(result.content.includes('Price the $&-offer at $99'), 'directive with $-tokens verbatim');
|
|
assert.ok(result.content.includes('ref $1, spend $$40'), 'evidence with $-tokens verbatim');
|
|
const headings = result.content.match(/^## Do-Next Directives$/gm) || [];
|
|
assert.equal(headings.length, 1, 'section must not be re-injected by a $1/$& expansion');
|
|
});
|
|
|
|
test('leaves existing fields and sections untouched (round-trip)', () => {
|
|
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.match(result.content, /^last_post_date: "2026-04-05"$/m);
|
|
assert.match(result.content, /^follower_count: 850$/m);
|
|
assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."'));
|
|
assert.ok(result.content.includes('## Milestone Log'));
|
|
});
|
|
|
|
test('returns a changes array describing what changed', () => {
|
|
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
|
|
assert.notEqual(result, null);
|
|
assert.ok(Array.isArray(result.changes));
|
|
assert.ok(result.changes.some((c) => /Do-Next Directives/.test(c)));
|
|
});
|
|
|
|
test('a do-next section does not break pruneContentHistory (non-R-initial section boundary)', () => {
|
|
// pruneContentHistory captures "## Recent Posts ... (?=\n## [^R])": the do-next
|
|
// heading must terminate that window, not extend it into the directive rows.
|
|
const withDoNext = recordDoNext(SAMPLE_STATE, REPORT_OPTS).content;
|
|
const pruned = pruneContentHistory(withDoNext, 30, FIXED_TODAY);
|
|
assert.notEqual(pruned, null, 'old Recent Posts entries must still be prunable');
|
|
assert.ok(pruned.content.includes('(report) Double down on AI-agent posts'), 'directives must survive a content-history prune');
|
|
});
|
|
});
|