task-line.test.ts5.6 KBView on GitHub
/**
 * The task-line grammar: `<headline> — <detail>`, plain text, one string.
 *
 * Two consumers have to agree on the split — `TaskLine` (every non-editor surface) and
 * the agenda's decoration plugin — so the split itself is tested once, here, and the
 * decoration is tested against real ProseMirror positions rather than string offsets:
 * a row can carry an inline chip, which is one position wide and no characters long,
 * and that is exactly where naive index arithmetic goes wrong.
 */

import { getSchema } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';

import { AgendaTaskNode } from '@/modules/agentCanvas/extensions/AgendaTaskNode';
import { ConversationNode } from '@/modules/agentCanvas/extensions/ConversationNode';
import {
  buildTaskLineDecorations,
  DETAIL_CLASS,
  HEADLINE_CLASS,
} from '@/modules/agentCanvas/extensions/TaskLineDecorations';
import { parseTaskLine } from '@/modules/userTasks/utils/task-line';

describe('parseTaskLine', () => {
  it('splits on the em dash', () => {
    expect(parseTaskLine('Send Simon the recap — you promised it before the 4:30')).toEqual({
      headline: 'Send Simon the recap',
      detail: 'you promised it before the 4:30',
      splitIndex: 20,
      emphasized: true,
    });
  });

  it('accepts the en dash and the hyphen the agent might reach for instead', () => {
    expect(parseTaskLine('Book the call – Natalie confirmed Friday').detail).toBe(
      'Natalie confirmed Friday',
    );
    expect(parseTaskLine('Book the call - Natalie confirmed Friday').detail).toBe(
      'Natalie confirmed Friday',
    );
  });

  it('splits on the FIRST separator, so a dash in the detail stays in the detail', () => {
    const parsed = parseTaskLine('Reply to Amie — she asked about doc-heavy carriers — twice');
    expect(parsed.headline).toBe('Reply to Amie');
    expect(parsed.detail).toBe('she asked about doc-heavy carriers — twice');
  });

  it('does not treat a hyphen inside a word or a time range as a separator', () => {
    expect(parseTaskLine('Confirm the 2-3pm follow-up slot').detail).toBeNull();
  });

  // A page of fully-bold sentences is heavier than the plain text it replaced, so a
  // long line that predates the convention keeps rendering the way it does today.
  it('emphasizes a short unsplit line but leaves a long one plain', () => {
    expect(parseTaskLine('Chase the SOC2 answer').emphasized).toBe(true);
    expect(
      parseTaskLine(
        'Send Amie the eDocs naming comparison she asked for, including the two doc-heavy carriers she named on the call',
      ).emphasized,
    ).toBe(false);
  });

  it('treats a trailing dash with nothing after it as text, not a split', () => {
    const parsed = parseTaskLine('Send the recap —');
    expect(parsed.detail).toBeNull();
    expect(parsed.splitIndex).toBeNull();
  });

  it('renders an empty description as nothing to emphasize', () => {
    expect(parseTaskLine('   ')).toEqual({
      headline: '',
      detail: null,
      splitIndex: null,
      emphasized: false,
    });
  });
});

const schema = getSchema([Document, Paragraph, Text, AgendaTaskNode, ConversationNode]);

function docWith(content: Record<string, unknown>[]) {
  return schema.nodeFromJSON({ type: 'doc', content });
}

function task(content: unknown[], attrs: Record<string, unknown> = {}) {
  return {
    type: 'agendaTask',
    attrs: { conversationId: null, taskId: null, indentLevel: 0, ...attrs },
    content,
  };
}

const text = (value: string) => ({ type: 'text', text: value });

function decorations(doc: ReturnType<typeof docWith>) {
  return buildTaskLineDecorations(doc)
    .find()
    .map((d) => ({
      from: d.from,
      to: d.to,
      class: (d as unknown as { type: { attrs: { class: string } } }).type.attrs.class,
    }));
}

describe('buildTaskLineDecorations', () => {
  it('bolds the headline and mutes the detail', () => {
    const doc = docWith([task([text('Send the recap — before the 4:30')])]);

    // The row starts at 0, its text at 1: headline is [1, 15), detail [15, 33).
    expect(decorations(doc)).toEqual([
      { from: 1, to: 15, class: HEADLINE_CLASS },
      { from: 15, to: 33, class: DETAIL_CLASS },
    ]);
  });

  it('counts an inline chip as a position, not as characters', () => {
    const doc = docWith([
      task([
        { type: 'conversationNode', attrs: { conversationId: 'abc' } },
        text('Send the recap — before the 4:30'),
      ]),
    ]);

    // The chip occupies position 1, so every text offset shifts by one.
    expect(decorations(doc)).toEqual([
      { from: 2, to: 16, class: HEADLINE_CLASS },
      { from: 16, to: 34, class: DETAIL_CLASS },
    ]);
  });

  it('bolds the whole of a short unsplit row', () => {
    const doc = docWith([task([text('Chase the SOC2 answer')])]);
    expect(decorations(doc)).toEqual([{ from: 1, to: 22, class: HEADLINE_CLASS }]);
  });

  it('leaves a completed row alone — it is already struck through as a whole', () => {
    const doc = docWith([task([text('Send the recap — before the 4:30')], { checked: true })]);
    expect(decorations(doc)).toEqual([]);
  });

  it('leaves a group header alone — it is a chip, not a sentence', () => {
    const doc = docWith([task([text('Acme Corp')], { groupHeader: true })]);
    expect(decorations(doc)).toEqual([]);
  });

  it('decorates every row in a document independently', () => {
    const doc = docWith([
      task([text('A — one')]),
      task([text('B — two')]),
    ]);
    expect(decorations(doc)).toHaveLength(4);
  });
});