Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

134 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { createEditor, $getRoot } from 'lexical';
import { MentionNode } from '../../../src/components/composer/MentionNode';
import { serializeComposer } from '../../../src/components/composer/serialize';
import { setComposerFromText } from '../../../src/components/composer/deserialize';
import type { InlineMentionEntity } from '../../../src/utils/inlineMentions';
// Headless Lexical editor — no DOM required. `createEditor` runs purely in
// memory, so these specs stay in the default `node` test environment.
function makeEditor() {
return createEditor({
namespace: 'serialize-test',
nodes: [MentionNode],
onError(err) {
throw err;
},
});
}
const FILE_ENTITY: InlineMentionEntity = {
id: 'designs/landing.html',
kind: 'file',
label: 'designs/landing.html',
token: '@designs/landing.html',
title: 'File: designs/landing.html',
};
const MCP_ENTITY: InlineMentionEntity = {
id: 'slack',
kind: 'mcp',
label: 'Slack MCP',
token: '@Slack MCP',
title: 'MCP: Slack MCP',
};
const SKILL_ENTITY: InlineMentionEntity = {
id: 'deck-builder',
kind: 'skill',
label: 'Deck Builder',
token: '@Deck Builder',
};
describe('serializeComposer / setComposerFromText round-trip', () => {
it('keeps a plain prompt with no mentions byte-identical', () => {
const editor = makeEditor();
setComposerFromText(editor, 'hello world', []);
const result = serializeComposer(editor.getEditorState());
expect(result.text).toBe('hello world');
expect(result.present).toHaveLength(0);
});
it('round-trips a known file mention as a single @token plus trailing text', () => {
const editor = makeEditor();
const text = 'Use @designs/landing.html now';
setComposerFromText(editor, text, [FILE_ENTITY]);
const result = serializeComposer(editor.getEditorState());
expect(result.text).toBe(text);
expect(result.present).toHaveLength(1);
expect(result.present[0]?.id).toBe('designs/landing.html');
expect(result.present[0]?.kind).toBe('file');
});
it('preserves a Shift+Enter newline as a single \\n (no \\n\\n gap)', () => {
const editor = makeEditor();
// The exact shape a Shift+Enter produces: one LineBreakNode inside the
// single paragraph. It must serialize back to a single `\n`.
const text = '@designs/landing.html hi\nworld';
setComposerFromText(editor, text, [FILE_ENTITY]);
expect(serializeComposer(editor.getEditorState()).text).toBe(text);
});
it('preserves multi-newline spacing around a mention (no \\n collapse)', () => {
const editor = makeEditor();
const text = 'Plan:\n\n@designs/landing.html \n\nKeep spacing';
setComposerFromText(editor, text, [FILE_ENTITY]);
expect(serializeComposer(editor.getEditorState()).text).toBe(text);
});
it('emits the MCP token text verbatim including the space inside the label', () => {
const editor = makeEditor();
setComposerFromText(editor, '@Slack MCP ', [MCP_ENTITY]);
const result = serializeComposer(editor.getEditorState());
expect(result.text).toBe('@Slack MCP ');
expect(result.present[0]?.id).toBe('slack');
expect(result.present[0]?.kind).toBe('mcp');
});
it('round-trips a skill mention carrying its real id', () => {
const editor = makeEditor();
setComposerFromText(editor, 'Plan with @Deck Builder please', [SKILL_ENTITY]);
const result = serializeComposer(editor.getEditorState());
expect(result.text).toBe('Plan with @Deck Builder please');
expect(result.present).toHaveLength(1);
expect(result.present[0]?.id).toBe('deck-builder');
expect(result.present[0]?.kind).toBe('skill');
});
it('keeps unknown @tokens as plain text (not a MentionNode)', () => {
const editor = makeEditor();
setComposerFromText(editor, 'hi @nobody there', [FILE_ENTITY]);
const result = serializeComposer(editor.getEditorState());
expect(result.text).toBe('hi @nobody there');
expect(result.present).toHaveLength(0);
});
it('builds a single root paragraph (no \\n\\n block join)', () => {
const editor = makeEditor();
setComposerFromText(editor, 'line one\nline two', []);
editor.getEditorState().read(() => {
expect($getRoot().getChildrenSize()).toBe(1);
});
expect(serializeComposer(editor.getEditorState()).text).toBe('line one\nline two');
});
});
describe('MentionNode atomic behaviour', () => {
it('reports token text as its node text content and stays token mode', () => {
const editor = makeEditor();
setComposerFromText(editor, '@designs/landing.html', [FILE_ENTITY]);
editor.getEditorState().read(() => {
const para = $getRoot().getFirstChild();
const mention =
para && 'getFirstChild' in para
? (para as unknown as { getFirstChild: () => unknown }).getFirstChild()
: null;
expect(mention).toBeInstanceOf(MentionNode);
expect((mention as MentionNode).getTextContent()).toBe('@designs/landing.html');
expect((mention as MentionNode).getToken()).toBe('@designs/landing.html');
expect((mention as MentionNode).getMode()).toBe('token');
});
});
});