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