linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs
Kjell Tore Guttormsen 90cf98d287 fix(linkedin-studio): S12 — close S11 re-review + render-chain-propagation lint guard
Closes the 2 grep-verified findings from the S11 cold full-brief re-review
(docs/remediation/review.md, BLOCK 1/0/1/0, 0 dropped). Both were the NEXT
RING of the meta-class S10/S11 converged: a propagation miss — the fix had
landed where the SC named the file, not in the render-source it depends on.

BLOCKER (command->reference propagation): references/ab-testing-framework.md:166
still shipped the banned A/B "Significant? (>20%)" Yes/No verdict column while
commands/ab-test.md (which RENDERS from it, inlined at :30, presented at :69)
had been cleaned to the honest "Directional?" framing. Re-framed the reference
result template to match the command verbatim (header + the directional note)
and retuned :38 "20% significance threshold" -> "minimum-meaningful-difference
threshold". The whole render chain is now significance-verdict-free.

MINOR ($-replacement, class-closed not line-patched): the newest-first section
appends/rewrites in hooks/scripts/state-updater.mjs passed a replacement STRING
embedding untrusted user content to String.replace, so dollar-sequences
($1 / $& / dollar-backtick / dollar-apostrophe / $$) in a topic/hook/partner
(e.g. "$100 budget cut") re-injected the captured heading and dropped
characters, silently corrupting state. Converted all 5 content-bearing sites
(Recent Posts, prune, Milestone Log, First-Hour, Outreach) to replacement
FUNCTIONS; the 3 remaining $1 sites only interpolate date scalars. +4
$-bearing regression tests — incl. the prune fixture, which itself had to
switch to a function (the bug bit the fixture as it was being written).

META (generalize the lint to the propagation-miss class): new test-runner.sh
Section 11 — render-chain propagation guard. Forbids the significance-verdict
column (Significant? adjacent to "(" or a table pipe) across the WHOLE render
chain (commands + every inlined reference + adjacent surfaces), with a
permanent non-vacuity self-test (3 verdict forms caught, 6 legitimate
Significant/significance/Directional? forms ignored) and an e2e mutation-proof.
Generalizes S10/S11's "fix the class, not the line" to command->reference.

Pre-patch render-chain sweep confirmed ab-testing-framework.md was the SOLE
propagation survivor (so a 6th review finds no 3rd). test-runner.sh 70/0/0;
node --test 98/98. CLAUDE.md lint enumeration synced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:42:25 +02:00

660 lines
28 KiB
JavaScript

import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact } 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');
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);
});
});
describe('pruneContentHistory', () => {
test('removes entries older than 90 days', () => {
const today = new Date();
const old = new Date(today);
old.setDate(old.getDate() - 100);
const oldDate = old.toISOString().slice(0, 10);
const recent = new Date(today);
recent.setDate(recent.getDate() - 10);
const recentDate = recent.toISOString().slice(0, 10);
const stateWithOld = SAMPLE_STATE.replace(
'## Recent Posts\n\n',
`## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n`
);
const result = pruneContentHistory(stateWithOld, 90);
assert.notEqual(result, null);
assert.equal(result.pruned, 1);
assert.ok(!result.content.includes(oldDate));
assert.ok(result.content.includes(recentDate));
});
test('preserves entries within 90 days', () => {
const today = new Date();
const recent = new Date(today);
recent.setDate(recent.getDate() - 30);
const recentDate = recent.toISOString().slice(0, 10);
const stateWithRecent = SAMPLE_STATE.replace(
'## Recent Posts\n\n',
`## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n`
);
const result = pruneContentHistory(stateWithRecent, 90);
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);
assert.equal(result, null);
});
test('handles custom maxAgeDays', () => {
const today = new Date();
const old = new Date(today);
old.setDate(old.getDate() - 40);
const oldDate = old.toISOString().slice(0, 10);
const stateWithOld = SAMPLE_STATE.replace(
'## Recent Posts\n\n',
`## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n`
);
const result = pruneContentHistory(stateWithOld, 30);
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 today = new Date();
const old = new Date(today); old.setDate(old.getDate() - 100);
const oldDate = old.toISOString().slice(0, 10);
const recent = new Date(today); recent.setDate(recent.getDate() - 10);
const recentDate = recent.toISOString().slice(0, 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',
() => `## Recent Posts\n\n- [${oldDate}] "Old..." (1000) - drop me\n- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget\n`
);
const result = pruneContentHistory(stateWithMix, 90);
assert.notEqual(result, null);
assert.equal(result.pruned, 1);
assert.ok(!result.content.includes(oldDate), 'old entry pruned');
assert.ok(result.content.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]'));
});
});