Files
docmd-io--docmd/packages/plugins/threads/tests/parser.test.js
T
wehub-resource-sync 6db8fca185
docmd CI verification / verify (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:55 +08:00

305 lines
9.5 KiB
JavaScript

/**
* Tests for threads parser - serialize/deserialize ::: threads block
*
* Run: node packages/plugins/threads/tests/parser.test.js
*/
const {
parseThreadsFromContent,
serializeThreadsBlock,
replaceThreadsBlock,
} = require('../src/plugin/parser.js');
let passed = 0;
let total = 0;
function assert(condition, msg) {
total++;
if (!condition) {
console.error(`FAIL: ${msg}`);
process.exit(1);
}
passed++;
console.log(` PASS: ${msg}`);
}
function assertDeepEqual(actual, expected, msg) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
assert(a === e, `${msg}\n expected: ${e}\n actual: ${a}`);
}
// ─── Test 1: Parse threads from content with 2 threads, comments, reactions, resolved status ───
console.log('\nTest 1: Parse full threads block');
const fullContent = `# My Document
Some content here.
::: threads
::: thread t-abc123
::: comment "alice" "2026-03-07"
This is the first comment
:::
::: comment "bob" "2026-03-08" edited "2026-03-09"
I agree, let's reword it
::: reactions
- 👍 charlie, dave
- 🎉 alice
:::
:::
:::
::: thread t-def456 resolved "charlie" "2026-03-08"
::: comment "charlie" "2026-03-08"
Typo in this section
:::
:::
:::
Some trailing content.
`;
const threads = parseThreadsFromContent(fullContent);
assert(Array.isArray(threads), 'parseThreadsFromContent returns an array');
assert(threads.length === 2, 'parses 2 threads');
// Thread 1
const t1 = threads[0];
assert(t1.id === 't-abc123', 'thread 1 id');
assert(t1.resolved === false, 'thread 1 not resolved');
assert(t1.resolved_by === null, 'thread 1 resolved_by is null');
assert(t1.resolved_at === null, 'thread 1 resolved_at is null');
assert(t1.comments.length === 2, 'thread 1 has 2 comments');
// Comment 1 of thread 1
const c1 = t1.comments[0];
assert(c1.author === 'alice', 'comment 1 author');
assert(c1.date === '2026-03-07', 'comment 1 date');
assert(c1.edited_at === null, 'comment 1 not edited');
assert(c1.body === 'This is the first comment', 'comment 1 body');
assert(c1.reactions.length === 0, 'comment 1 no reactions');
assert(c1.id.startsWith('c-'), 'comment 1 id starts with c-');
assert(c1.id.length === 10, 'comment 1 id is c- + 8 hex chars');
assert(c1.thread_id === 't-abc123', 'comment 1 thread_id matches');
// Comment 2 of thread 1
const c2 = t1.comments[1];
assert(c2.author === 'bob', 'comment 2 author');
assert(c2.date === '2026-03-08', 'comment 2 date');
assert(c2.edited_at === '2026-03-09', 'comment 2 edited_at');
assert(c2.body === "I agree, let's reword it", 'comment 2 body (no reactions content)');
assert(c2.reactions.length === 2, 'comment 2 has 2 reactions');
assert(c2.reactions[0].emoji === '👍', 'reaction 1 emoji');
assertDeepEqual(c2.reactions[0].authors, ['charlie', 'dave'], 'reaction 1 authors');
assert(c2.reactions[1].emoji === '🎉', 'reaction 2 emoji');
assertDeepEqual(c2.reactions[1].authors, ['alice'], 'reaction 2 authors');
// Thread 2
const t2 = threads[1];
assert(t2.id === 't-def456', 'thread 2 id');
assert(t2.resolved === true, 'thread 2 is resolved');
assert(t2.resolved_by === 'charlie', 'thread 2 resolved_by');
assert(t2.resolved_at === '2026-03-08', 'thread 2 resolved_at');
assert(t2.comments.length === 1, 'thread 2 has 1 comment');
assert(t2.comments[0].author === 'charlie', 'thread 2 comment author');
assert(t2.comments[0].body === 'Typo in this section', 'thread 2 comment body');
// ─── Test 2: Parse content with no threads block → empty array ───
console.log('\nTest 2: No threads block');
const noThreads = `# Just a document\n\nNo threads here.\n`;
const emptyResult = parseThreadsFromContent(noThreads);
assert(Array.isArray(emptyResult), 'returns array for no threads');
assert(emptyResult.length === 0, 'returns empty array');
// ─── Test 3: Serialize and re-parse (round-trip) ───
console.log('\nTest 3: Round-trip serialize → parse');
const threadData = [
{
id: 't-111111',
resolved: false,
resolved_by: null,
resolved_at: null,
comments: [
{
id: 'c-aabbccdd',
thread_id: 't-111111',
author: 'eve',
date: '2026-01-01',
edited_at: null,
body: 'Hello world',
reactions: [],
},
],
},
{
id: 't-222222',
resolved: true,
resolved_by: 'frank',
resolved_at: '2026-02-15',
comments: [
{
id: 'c-11223344',
thread_id: 't-222222',
author: 'frank',
date: '2026-02-10',
edited_at: '2026-02-12',
body: 'Multi-line\ncontent here',
reactions: [
{ emoji: '👍', authors: ['eve'] },
],
},
],
},
];
const serialized = serializeThreadsBlock(threadData);
assert(serialized.startsWith('::: threads\n'), 'serialized starts with ::: threads');
assert(serialized.trimEnd().endsWith(':::'), 'serialized ends with :::');
// Re-parse
const reparsed = parseThreadsFromContent(serialized);
assert(reparsed.length === 2, 'round-trip: 2 threads');
assert(reparsed[0].id === 't-111111', 'round-trip: thread 1 id');
assert(reparsed[0].resolved === false, 'round-trip: thread 1 not resolved');
assert(reparsed[0].comments[0].author === 'eve', 'round-trip: comment author');
assert(reparsed[0].comments[0].body === 'Hello world', 'round-trip: comment body');
assert(reparsed[1].id === 't-222222', 'round-trip: thread 2 id');
assert(reparsed[1].resolved === true, 'round-trip: thread 2 resolved');
assert(reparsed[1].resolved_by === 'frank', 'round-trip: resolved_by');
assert(reparsed[1].resolved_at === '2026-02-15', 'round-trip: resolved_at');
assert(reparsed[1].comments[0].author === 'frank', 'round-trip: comment 2 author');
assert(reparsed[1].comments[0].edited_at === '2026-02-12', 'round-trip: edited_at');
assert(reparsed[1].comments[0].body === 'Multi-line\ncontent here', 'round-trip: multi-line body');
assert(reparsed[1].comments[0].reactions.length === 1, 'round-trip: reactions count');
assert(reparsed[1].comments[0].reactions[0].emoji === '👍', 'round-trip: reaction emoji');
assertDeepEqual(reparsed[1].comments[0].reactions[0].authors, ['eve'], 'round-trip: reaction authors');
// ─── Test 4: Replace threads block in existing file ───
console.log('\nTest 4: Replace threads block');
const existingContent = `# Document
Some text.
::: threads
::: thread t-old
::: comment "olduser" "2025-01-01"
Old comment
:::
:::
:::
Footer text.
`;
const newThreads = [
{
id: 't-new',
resolved: false,
resolved_by: null,
resolved_at: null,
comments: [
{
id: 'c-newid000',
thread_id: 't-new',
author: 'newuser',
date: '2026-06-01',
edited_at: null,
body: 'New comment',
reactions: [],
},
],
},
];
const replaced = replaceThreadsBlock(existingContent, newThreads);
assert(replaced.includes('# Document'), 'replace preserves header');
assert(replaced.includes('Footer text.'), 'replace preserves footer');
assert(replaced.includes('t-new'), 'replace includes new thread');
assert(!replaced.includes('t-old'), 'replace removes old thread');
assert(!replaced.includes('olduser'), 'replace removes old content');
// Verify the replaced content re-parses correctly
const reparsedReplaced = parseThreadsFromContent(replaced);
assert(reparsedReplaced.length === 1, 'replaced content has 1 thread');
assert(reparsedReplaced[0].id === 't-new', 'replaced thread id correct');
// ─── Test 5: Add threads block to file that doesn't have one ───
console.log('\nTest 5: Add threads block to file without one');
const plainContent = `# My Page
Just some content.
`;
const withThreads = replaceThreadsBlock(plainContent, newThreads);
assert(withThreads.includes('# My Page'), 'add preserves original content');
assert(withThreads.includes('::: threads'), 'add includes threads block');
assert(withThreads.includes('t-new'), 'add includes thread id');
const reparsedAdded = parseThreadsFromContent(withThreads);
assert(reparsedAdded.length === 1, 'added content has 1 thread');
assert(reparsedAdded[0].id === 't-new', 'added thread id correct');
// ─── Test 6: Replace with empty threads removes the block ───
console.log('\nTest 6: Replace with empty threads removes block');
const clearedContent = replaceThreadsBlock(existingContent, []);
assert(!clearedContent.includes('::: threads'), 'empty threads removes block');
assert(clearedContent.includes('# Document'), 'preserves header after clearing');
assert(clearedContent.includes('Footer text.'), 'preserves footer after clearing');
// ─── Test 7: Comment body with multiple paragraphs ───
console.log('\nTest 7: Multi-paragraph comment body');
const multiParaContent = `::: threads
::: thread t-mp
::: comment "alice" "2026-03-01"
First paragraph.
Second paragraph.
Third paragraph.
:::
:::
:::
`;
const mpThreads = parseThreadsFromContent(multiParaContent);
assert(mpThreads.length === 1, 'multi-para: 1 thread');
assert(
mpThreads[0].comments[0].body === 'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.',
'multi-para: preserves paragraphs in body'
);
// ─── Test 8: Comment IDs are unique ───
console.log('\nTest 8: Comment IDs are unique');
const idSet = new Set();
for (const thread of threads) {
for (const comment of thread.comments) {
assert(!idSet.has(comment.id), `comment id ${comment.id} is unique`);
idSet.add(comment.id);
}
}
// ─── Done ───
console.log(`\n✓ All ${passed}/${total} tests passed.\n`);