coaching-yjs-survival.test.ts3.8 KBView on GitHub
/**
 * Phase 1 of the coaching-agent design (apps/server/docs/coaching-agent.md).
 *
 * The coaching documents are Y.js-backed like every other Cedar Doc, and they carry two things
 * whose state is easy to lose in a round trip: a GFM pipe table (structure) and a GFM task list
 * (the `checked` attribute). If `- [x]` degrades to `- [ ]` — or a table flattens to paragraphs —
 * through a Y.Doc round trip, a rep's checklist silently resets every time the doc syncs.
 *
 * This drives the same `prosemirrorJSONToYDoc` / `yDocToProsemirrorJSON` pair the collaboration
 * layer uses (see `playbookNodeSurvival.test.ts`, which pins the analogous bug for playbook nodes).
 */
import { Editor, getSchema } from '@tiptap/core';
import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { prosemirrorJSONToYDoc, yDocToProsemirrorJSON } from 'y-prosemirror';

const extensions = [
  StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
  Markdown,
  Table.configure({ resizable: false }),
  TableRow,
  TableHeader,
  TableCell,
  TaskList,
  TaskItem.configure({ nested: true }),
];

const DAY_ENTRY = [
  '# Keenan — coaching',
  '',
  '## 2026-08-20',
  '',
  '| Discovery | Score | Why |',
  '| --- | --- | --- |',
  '| D1 Knew the must-asks | 4 | Got detail count and build-vs-grow. |',
  '| D2 Genuinely curious | 2 | 6 pains voiced, 4 with no follow-up. |',
  '',
  '- [x] Built rapport before starting discovery',
  '- [x] Asked "what piqued your interest"',
  '- [ ] Asked for the decision-maker’s name',
].join('\n');

function toJSON(markdown: string) {
  const editor = new Editor({ extensions, content: markdown, contentType: 'markdown' as never });
  try {
    return editor.getJSON();
  } finally {
    editor.destroy();
  }
}

function collectTaskChecked(json: unknown, acc: boolean[] = []): boolean[] {
  if (!json || typeof json !== 'object') return acc;
  const node = json as { type?: string; attrs?: { checked?: boolean }; content?: unknown[] };
  if (node.type === 'taskItem') acc.push(Boolean(node.attrs?.checked));
  for (const child of node.content ?? []) collectTaskChecked(child, acc);
  return acc;
}

function countType(json: unknown, type: string, acc = { n: 0 }): number {
  if (!json || typeof json !== 'object') return acc.n;
  const node = json as { type?: string; content?: unknown[] };
  if (node.type === type) acc.n += 1;
  for (const child of node.content ?? []) countType(child, type, acc);
  return acc.n;
}

describe('coaching document survives a Y.js round trip', () => {
  const schema = getSchema(extensions);
  const before = toJSON(DAY_ENTRY);
  const ydoc = prosemirrorJSONToYDoc(schema, before, 'prosemirror');
  const after = yDocToProsemirrorJSON(ydoc, 'prosemirror');

  it('keeps the scorecard table intact', () => {
    expect(countType(before, 'table')).toBe(1);
    expect(countType(after, 'table')).toBe(1);
    expect(countType(after, 'tableRow')).toBe(countType(before, 'tableRow'));
    expect(countType(after, 'tableCell')).toBe(countType(before, 'tableCell'));
  });

  it('keeps every checklist item and its checked state', () => {
    const beforeChecked = collectTaskChecked(before);
    const afterChecked = collectTaskChecked(after);
    expect(beforeChecked).toEqual([true, true, false]);
    expect(afterChecked).toEqual(beforeChecked);
  });

  it('keeps the day heading, so newest-first ordering is not lost', () => {
    expect(countType(after, 'heading')).toBe(countType(before, 'heading'));
    expect(JSON.stringify(after)).toContain('2026-08-20');
  });
});