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');
  });
});