composite-merge.test.ts10.1 KBView on GitHub /**
* Tests for composite playbook merge/split transforms.
*/
import {
mergeComposite,
splitComposite,
COMPOSITE_SECTION,
SCOPE_SECTION,
type PMNode,
} from '@/modules/documents/playbook/composite-merge';
const para = (text?: string): PMNode =>
text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' };
const stage = (id: string, label: string, extra: PMNode[] = []): PMNode => ({
type: 'stageSection',
attrs: { id, label },
content: [
{ type: 'stageEntry', content: [para(`enter ${id}`)] },
{ type: 'stageExit', content: [para(`exit ${id}`)] },
{ type: 'stageInstructions', content: [para(`do ${id}`)] },
...extra,
],
});
const userDoc: PMNode = {
type: 'doc',
content: [
{ type: 'alwaysLoadedSection', content: [para('user always')] },
{ type: 'globalSection', content: [para('user global')] },
stage('discovery', 'Discovery'),
],
};
const orgDoc: PMNode = {
type: 'doc',
content: [
{ type: 'alwaysLoadedSection', content: [para('org always')] },
{ type: 'globalSection', content: [para('org global')] },
stage('discovery', 'Discovery'),
stage('won', 'Won'),
],
};
// A trigger block wrapping a subagent `<ref>` (fileLink) — the exact shape the
// "/subagent" insert produces. Used to assert refs distribute to the right doc.
const triggerWithRef = (documentId: string): PMNode => ({
type: 'triggerNode',
attrs: { config: JSON.stringify({ type: 'event_occurred' }) },
content: [{ type: 'paragraph', content: [{ type: 'fileLink', attrs: { documentId } }] }],
});
const userDocRef: PMNode = {
type: 'doc',
content: [
{ type: 'alwaysLoadedSection', content: [para('user always')] },
{ type: 'globalSection', content: [triggerWithRef('user-sub-id')] },
stage('discovery', 'Discovery'),
],
};
const orgDocRef: PMNode = {
type: 'doc',
content: [
{ type: 'alwaysLoadedSection', content: [para('org always')] },
{ type: 'globalSection', content: [triggerWithRef('org-sub-id')] },
stage('discovery', 'Discovery'),
],
};
describe('mergeComposite', () => {
it('groups each section into a composite section with org then user cards', () => {
const merged = mergeComposite(userDoc, orgDoc);
const keys = (merged.content ?? []).map((n) => n.attrs?.sectionKey);
// always-loaded, global, then stages (org order: discovery, won)
expect(keys).toEqual(['always-loaded', 'global', 'stage', 'stage']);
const global = (merged.content ?? []).find((n) => n.attrs?.sectionKey === 'global')!;
expect(global.type).toBe(COMPOSITE_SECTION);
const scopes = (global.content ?? []).map((s) => s.attrs?.scope);
expect(scopes).toEqual(['org', 'user']);
expect(global.content?.[0].type).toBe(SCOPE_SECTION);
});
it('removes entry/exit from the user stage card but keeps them on the org card', () => {
const merged = mergeComposite(userDoc, orgDoc);
const discovery = (merged.content ?? []).find(
(n) => n.attrs?.sectionKey === 'stage' && n.attrs?.stageId === 'discovery',
)!;
const orgScope = discovery.content!.find((s) => s.attrs?.scope === 'org')!;
const userScope = discovery.content!.find((s) => s.attrs?.scope === 'user')!;
const orgTypes = (orgScope.content ?? []).map((n) => n.type);
const userTypes = (userScope.content ?? []).map((n) => n.type);
expect(orgTypes).toContain('stageEntry');
expect(orgTypes).toContain('stageExit');
expect(userTypes).not.toContain('stageEntry');
expect(userTypes).not.toContain('stageExit');
expect(userTypes).toContain('stageInstructions');
});
it('includes org-only stages (won) with only an org card', () => {
const merged = mergeComposite(userDoc, orgDoc);
const won = (merged.content ?? []).find((n) => n.attrs?.stageId === 'won')!;
expect((won.content ?? []).map((s) => s.attrs?.scope)).toEqual(['org']);
});
it('handles a missing org doc — user-only cards', () => {
const merged = mergeComposite(userDoc, null);
for (const group of merged.content ?? []) {
expect((group.content ?? []).map((s) => s.attrs?.scope)).toEqual(['user']);
}
});
it('marks every scope after the first with the divider flag', () => {
const global = (mergeComposite(userDoc, orgDoc).content ?? []).find(
(n) => n.attrs?.sectionKey === 'global',
)!;
expect((global.content ?? []).map((s) => s.attrs?.divider)).toEqual([false, true]);
// A single-scope section gets no divider.
const won = (mergeComposite(userDoc, orgDoc).content ?? []).find((n) => n.attrs?.stageId === 'won')!;
expect((won.content ?? []).map((s) => s.attrs?.divider)).toEqual([false]);
});
it('strips decorative horizontal rules and drops the emptied cross-cutting', () => {
const orgWithRule: PMNode = {
type: 'doc',
content: [
{
type: 'globalSection',
content: [{ type: 'crossCuttingSection', content: [{ type: 'horizontalRule' }] }],
},
],
};
const merged = mergeComposite({ type: 'doc', content: [] }, orgWithRule);
const global = (merged.content ?? []).find((n) => n.attrs?.sectionKey === 'global')!;
const orgScope = global.content!.find((s) => s.attrs?.scope === 'org')!;
// No horizontalRule survives, and the now-empty cross-cutting is gone — the
// card falls back to the empty placeholder paragraph.
expect(JSON.stringify(orgScope)).not.toContain('horizontalRule');
expect(orgScope.content).toEqual([{ type: 'paragraph' }]);
});
});
describe('splitComposite', () => {
it('round-trips the org doc faithfully through merge → split', () => {
const { orgJson } = splitComposite(mergeComposite(userDoc, orgDoc), userDoc);
expect(orgJson).toEqual(orgDoc);
});
it('round-trips the user doc faithfully — hidden entry/exit are restored, not deleted', () => {
const { userJson } = splitComposite(mergeComposite(userDoc, orgDoc), userDoc);
expect(userJson).toEqual(userDoc);
});
it('drops user entry/exit only when the original is not provided', () => {
const { userJson } = splitComposite(mergeComposite(userDoc, orgDoc));
const discovery = (userJson.content ?? []).find((n) => n.attrs?.id === 'discovery')!;
expect((discovery.content ?? []).map((n) => n.type)).not.toContain('stageEntry');
});
it('routes org and user cards back to their own docs', () => {
const { userJson, orgJson } = splitComposite(mergeComposite(userDoc, orgDoc), userDoc);
// org keeps the won stage; user never had it
expect(orgJson.content?.some((n) => n.attrs?.id === 'won')).toBe(true);
expect(userJson.content?.some((n) => n.attrs?.id === 'won')).toBe(false);
});
it('produces an empty-doc placeholder when a scope has no sections', () => {
const { orgJson } = splitComposite(mergeComposite(userDoc, null), userDoc);
expect(orgJson).toEqual({ type: 'doc', content: [{ type: 'paragraph' }] });
});
// The save path's correctness for the "/subagent" feature: a ref dropped into a
// scope card must end up only in that scope's doc on split (→ written to that
// doc's PLAYBOOK.md by saveCompositePlaybook).
it('routes a subagent <ref> in each scope back to its own doc', () => {
const { userJson, orgJson } = splitComposite(mergeComposite(userDocRef, orgDocRef), userDocRef);
const userStr = JSON.stringify(userJson);
const orgStr = JSON.stringify(orgJson);
expect(userStr).toContain('user-sub-id');
expect(userStr).not.toContain('org-sub-id');
expect(orgStr).toContain('org-sub-id');
expect(orgStr).not.toContain('user-sub-id');
});
it('round-trips trigger blocks with subagent refs faithfully in both scopes', () => {
const { userJson, orgJson } = splitComposite(mergeComposite(userDocRef, orgDocRef), userDocRef);
expect(orgJson).toEqual(orgDocRef);
expect(userJson).toEqual(userDocRef);
});
// Regression guard for the "playbook opened empty" report: the merge layer
// must never collapse a populated user+org playbook to a blank composite. The
// blank render was client-side (hydration), not the data pipeline — this pins
// that the pipeline stays non-empty for a realistic shape.
it('produces a non-empty composite with a section per key when both scopes have content', () => {
const composite = mergeComposite(userDoc, orgDoc);
expect(composite.type).toBe('doc');
const groups = composite.content ?? [];
// always-loaded, global, stage:discovery, stage:won → 4 groups.
expect(groups).toHaveLength(4);
expect(groups.every((g) => g.type === COMPOSITE_SECTION)).toBe(true);
// Every group carries at least one scope card — never an empty group.
expect(groups.every((g) => (g.content ?? []).some((s) => s.type === SCOPE_SECTION))).toBe(true);
// The composite is not the single-placeholder-paragraph "empty doc".
const isEmptyDoc =
groups.length === 1 && groups[0].type === 'paragraph';
expect(isEmptyDoc).toBe(false);
});
it('falls back to a single placeholder only when BOTH scopes are empty', () => {
const composite = mergeComposite(null, null);
expect(composite.content).toEqual([{ type: 'paragraph' }]);
});
// Empty text nodes (from empty markdown leaves like an empty ``` fence) make
// ProseMirror's setContent throw "Empty text nodes are not allowed", blanking
// the editor. mergeComposite must strip them.
it('strips empty text nodes from the merged composite', () => {
const withEmptyText: PMNode = {
type: 'doc',
content: [
{
type: 'globalSection',
content: [
{
type: 'crossCuttingSection',
content: [
{ type: 'codeBlock', content: [{ type: 'text', text: '' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'real' }, { type: 'text', text: '' }] },
],
},
],
},
],
};
const composite = mergeComposite(withEmptyText, null);
let emptyTextCount = 0;
const walk = (n: PMNode) => {
if (n.type === 'text' && !(n.text && n.text.length > 0)) emptyTextCount++;
n.content?.forEach(walk);
};
walk(composite);
expect(emptyTextCount).toBe(0);
// Real text is preserved.
const flat = JSON.stringify(composite);
expect(flat).toContain('real');
});
});