test(editor): getFirstContentBlock & getLastContentBlock & isNothingSelected command (#10757)

### TL;DR

Added unit tests for block and selection commands, along with a new test helper system for creating test documents.

### What changed?

- Added unit tests for several commands:
  - `getFirstContentBlockCommand`
  - `getLastContentBlockCommand`
  - `isNothingSelectedCommand`
- Created a new test helpers make it easier to create structured test documents with a html-like syntax:

```typescript
import { describe, expect, it } from 'vitest';
import { affine } from '../__tests__/utils/affine-template';
describe('My Test', () => {
  it('should correctly handle document structure', () => {
    const doc = affine`
      <affine-page>
        <affine-note>
          <affine-paragraph>Test content</affine-paragraph>
        </affine-note>
      </affine-page>
    `;
    // Get blocks
    const pages = doc.getBlocksByFlavour('affine:page');
    const notes = doc.getBlocksByFlavour('affine:note');
    const paragraphs = doc.getBlocksByFlavour('affine:paragraph');
    expect(pages.length).toBe(1);
    expect(notes.length).toBe(1);
    expect(paragraphs.length).toBe(1);
    // Perform more tests here...
  });
});
```
This commit is contained in:
yoyoyohamapi
2025-03-14 02:35:21 +00:00
parent 04efca362e
commit d3aae962bc
9 changed files with 1040 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it } from 'vitest';
import { getFirstContentBlockCommand } from '../../../commands/block-crud/get-first-content-block';
import { affine } from '../../helpers/affine-template';
describe('commands/block-crud', () => {
describe('getFirstContentBlockCommand', () => {
it('should return null when root is not provided and no note block exists', () => {
const host = affine`<affine-page></affine-page>`;
const [_, { firstBlock }] = host.command.exec(
getFirstContentBlockCommand,
{
root: undefined,
std: {
host,
} as any,
}
);
expect(firstBlock).toBeNull();
});
it('should return first content block when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">Second Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const [_, { firstBlock }] = host.command.exec(
getFirstContentBlockCommand,
{
root: undefined,
}
);
expect(firstBlock?.id).toBe('paragraph-1');
});
it('should return null when no content blocks are found in children', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
</affine-note>
</affine-page>
`;
const [_, { firstBlock }] = host.command.exec(
getFirstContentBlockCommand,
{}
);
expect(firstBlock).toBeNull();
});
it('should return first content block within specified root subtree', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1-1">1-1 Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-1-2">1-2 Paragraph</affine-paragraph>
</affine-note>
<affine-note id="note-2">
<affine-paragraph id="paragraph-2-1">2-1 Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2-2">2-2 Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const noteBlock = host.doc.getBlock('note-2')?.model;
const [_, { firstBlock }] = host.command.exec(
getFirstContentBlockCommand,
{
root: noteBlock,
}
);
expect(firstBlock?.id).toBe('paragraph-2-1');
});
});
});

View File

@@ -0,0 +1,80 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it } from 'vitest';
import { getLastContentBlockCommand } from '../../../commands/block-crud/get-last-content-block';
import { affine } from '../../helpers/affine-template';
describe('commands/block-crud', () => {
describe('getLastContentBlockCommand', () => {
it('should return null when root is not provided and no note block exists', () => {
const host = affine`<affine-page></affine-page>`;
const [_, { lastBlock }] = host.command.exec(getLastContentBlockCommand, {
root: undefined,
std: {
host,
} as any,
});
expect(lastBlock).toBeNull();
});
it('should return last content block when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">Second Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const [_, { lastBlock }] = host.command.exec(getLastContentBlockCommand, {
root: undefined,
});
expect(lastBlock?.id).toBe('paragraph-2');
});
it('should return null when no content blocks are found in children', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
</affine-note>
</affine-page>
`;
const [_, { lastBlock }] = host.command.exec(
getLastContentBlockCommand,
{}
);
expect(lastBlock).toBeNull();
});
it('should return last content block within specified root subtree', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1-1">1-1 Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-1-2">1-2 Paragraph</affine-paragraph>
</affine-note>
<affine-note id="note-2">
<affine-paragraph id="paragraph-2-1">2-1 Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2-2">2-2 Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const noteBlock = host.doc.getBlock('note-2')?.model;
const [_, { lastBlock }] = host.command.exec(getLastContentBlockCommand, {
root: noteBlock,
});
expect(lastBlock?.id).toBe('paragraph-2-2');
});
});
});

View File

@@ -0,0 +1,147 @@
/**
* @vitest-environment happy-dom
*/
import { BlockSelection, TextSelection } from '@blocksuite/block-std';
import { describe, expect, it, vi } from 'vitest';
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
import { ImageSelection } from '../../../selection';
import { affine } from '../../helpers/affine-template';
describe('commands/selection', () => {
describe('isNothingSelectedCommand', () => {
it('should return true when nothing is selected', () => {
const host = affine`<affine-page></affine-page>`;
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
{}
);
expect(isNothingSelected).toBe(true);
});
it('should return false when text selection exists', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Test paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
// Mock text selection
const textSelection = new TextSelection({
from: {
blockId: 'paragraph-1',
index: 0,
length: 0,
},
to: {
blockId: 'paragraph-1',
index: 4,
length: 0,
},
});
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
{
currentTextSelection: textSelection,
}
);
expect(isNothingSelected).toBe(false);
});
it('should return false when block selection exists', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Test paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
// Mock block selection
const blockSelection = new BlockSelection({
blockId: 'paragraph-1',
});
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
{
currentBlockSelections: [blockSelection],
}
);
expect(isNothingSelected).toBe(false);
});
it('should return false when image selection exists', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-image id="image-1">Test paragraph</affine-image>
</affine-note>
</affine-page>
`;
// Mock image selection
const imageSelection = new ImageSelection({
blockId: 'image-1',
});
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
{
currentImageSelections: [imageSelection],
}
);
expect(isNothingSelected).toBe(false);
});
it('should return false when no selection is provided but selection is found in context', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Test paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
// Mock selection behavior via vi.spyOn before executing the command
const mockTextSelection = new TextSelection({
from: {
blockId: 'paragraph-1',
index: 0,
length: 0,
},
to: null,
});
const mockContext = {
// No explicit `currentTextSelection provided
std: {
selection: {
find: vi.fn().mockImplementation(type => {
if (type === TextSelection) {
return mockTextSelection;
}
return null;
}),
filter: vi.fn().mockReturnValue([]),
},
},
};
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
mockContext as any
);
expect(isNothingSelected).toBe(false);
});
});
});

View File

@@ -0,0 +1,79 @@
# AFFiNE Test Tools
## Structured Document Creation
`affine-template.ts` provides a concise way to create test documents, using a html-like syntax.
### Basic Usage
```typescript
import { affine } from '../__tests__/utils/affine-template';
// Create a simple document
const doc = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hello, World!</affine-paragraph>
</affine-note>
</affine-page>
`;
```
### Complex Structure Example
```typescript
// Create a document with multiple notes and paragraphs
const doc = affine`
<affine-page title="My Test Page">
<affine-note>
<affine-paragraph>First paragraph</affine-paragraph>
<affine-paragraph>Second paragraph</affine-paragraph>
</affine-note>
<affine-note>
<affine-paragraph>Another note</affine-paragraph>
</affine-note>
</affine-page>
`;
```
### Application in Tests
This tool is particularly suitable for creating documents with specific structures in test cases:
```typescript
import { describe, expect, it } from 'vitest';
import { affine } from '../__tests__/utils/affine-template';
describe('My Test', () => {
it('should correctly handle document structure', () => {
const doc = affine`
<affine-page>
<affine-note>
<affine-paragraph>Test content</affine-paragraph>
</affine-note>
</affine-page>
`;
// Get blocks
const pages = doc.getBlocksByFlavour('affine:page');
const notes = doc.getBlocksByFlavour('affine:note');
const paragraphs = doc.getBlocksByFlavour('affine:paragraph');
expect(pages.length).toBe(1);
expect(notes.length).toBe(1);
expect(paragraphs.length).toBe(1);
// Perform more tests here...
});
});
```
### Supported Block Types
Currently supports the following block types:
- `affine-page``affine:page`
- `affine-note``affine:note`
- `affine-paragraph``affine:paragraph`
- `affine-list``affine:list`
- `affine-image``affine:image`

View File

@@ -0,0 +1,179 @@
import {
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { type Block, type Store } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host';
// Extensions array
const extensions = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
ImageBlockSchemaExtension,
];
// Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = {
'affine-page': 'affine:page',
'affine-note': 'affine:note',
'affine-paragraph': 'affine:paragraph',
'affine-list': 'affine:list',
'affine-image': 'affine:image',
};
/**
* Parse template strings and build BlockSuite document structure,
* then create a host object with the document
*
* Example:
* ```
* const host = affine`
* <affine-page id="page">
* <affine-note id="note">
* <affine-paragraph id="paragraph-1">Hello, world</affine-paragraph>
* </affine-note>
* </affine-page>
* `;
* ```
*/
export function affine(strings: TemplateStringsArray, ...values: any[]) {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a new doc
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc({ id: 'test-doc', extensions });
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
buildDocFromElement(doc, root, null);
});
// Create and return a host object with the document
return createTestHost(doc);
}
/**
* Create a single block from template string
*
* Example:
* ```
* const block = block`<affine-note />`
* ```
*/
export function block(
strings: TemplateStringsArray,
...values: any[]
): Block | null {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a temporary doc to hold the block
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc({ id: 'temp-doc', extensions });
let blockId: string | null = null;
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
blockId = buildDocFromElement(doc, root, null);
});
// Return the created block
return blockId ? (doc.getBlock(blockId) ?? null) : null;
}
/**
* Recursively build document structure
* @param doc
* @param element
* @param parentId
* @returns
*/
function buildDocFromElement(
doc: Store,
element: Element,
parentId: string | null
): string {
const tagName = element.tagName.toLowerCase();
const flavour = tagToFlavour[tagName];
if (!flavour) {
throw new Error(`Unknown tag name: ${tagName}`);
}
const props: Record<string, any> = {};
const customId = element.getAttribute('id');
// If ID is specified, add it to props
if (customId) {
props.id = customId;
}
// Process element attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name !== 'id') {
// Skip id attribute, we already handled it
props[attr.name] = attr.value;
}
});
// Special handling for different block types based on their flavours
switch (flavour) {
case 'affine:paragraph':
case 'affine:list':
if (element.textContent) {
props.text = new Text(element.textContent);
}
break;
}
// Create block
const blockId = doc.addBlock(flavour, props, parentId);
// Recursively process child elements
Array.from(element.children).forEach(child => {
buildDocFromElement(doc, child, blockId);
});
return blockId;
}

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { affine } from './affine-template';
describe('helpers/affine-template', () => {
it('should create a basic document structure from template', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello, world</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.doc).toBeDefined();
const pageBlock = host.doc.getBlock('page');
expect(pageBlock).toBeDefined();
expect(pageBlock?.flavour).toBe('affine:page');
const noteBlock = host.doc.getBlock('note');
expect(noteBlock).toBeDefined();
expect(noteBlock?.flavour).toBe('affine:note');
const paragraphBlock = host.doc.getBlock('paragraph-1');
expect(paragraphBlock).toBeDefined();
expect(paragraphBlock?.flavour).toBe('affine:paragraph');
});
it('should handle nested blocks correctly', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph>First paragraph</affine-paragraph>
<affine-list>List item</affine-list>
<affine-paragraph>Second paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const noteBlocks = host.doc.getBlocksByFlavour('affine:note');
const paragraphBlocks = host.doc.getBlocksByFlavour('affine:paragraph');
const listBlocks = host.doc.getBlocksByFlavour('affine:list');
expect(noteBlocks.length).toBe(1);
expect(paragraphBlocks.length).toBe(2);
expect(listBlocks.length).toBe(1);
const noteBlock = noteBlocks[0];
const noteChildren = host.doc.getBlock(noteBlock.id)?.model.children || [];
expect(noteChildren.length).toBe(3);
expect(noteChildren[0].flavour).toBe('affine:paragraph');
expect(noteChildren[1].flavour).toBe('affine:list');
expect(noteChildren[2].flavour).toBe('affine:paragraph');
});
it('should handle empty blocks correctly', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph></affine-paragraph>
</affine-note>
</affine-page>
`;
const paragraphBlocks = host.doc.getBlocksByFlavour('affine:paragraph');
expect(paragraphBlocks.length).toBe(1);
const paragraphBlock = host.doc.getBlock(paragraphBlocks[0].id);
const paragraphText = paragraphBlock?.model.text?.toString() || '';
expect(paragraphText).toBe('');
});
it('should throw error on invalid template', () => {
expect(() => {
affine`
<unknown-tag></unknown-tag>
`;
}).toThrow();
});
});

View File

@@ -0,0 +1,135 @@
import {
BlockSchemaExtension,
defineBlockSchema,
type Store,
Text,
} from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { type Element as HappyDOMElement, Window } from 'happy-dom';
// Define schema
const PageBlockSchema = defineBlockSchema({
flavour: 'affine:page',
props: () => ({}),
metadata: {
version: 1,
role: 'root',
children: ['affine:note'],
},
});
const NoteBlockSchema = defineBlockSchema({
flavour: 'affine:note',
props: () => ({}),
metadata: {
version: 1,
role: 'hub',
parent: ['affine:page'],
children: ['affine:paragraph'],
},
});
const ParagraphBlockSchema = defineBlockSchema({
flavour: 'affine:paragraph',
props: internal => ({
text: internal.Text(),
}),
metadata: {
version: 1,
role: 'content',
parent: ['affine:note'],
},
});
// Create schema extensions
const PageBlockSchemaExtension = BlockSchemaExtension(PageBlockSchema);
const NoteBlockSchemaExtension = BlockSchemaExtension(NoteBlockSchema);
const ParagraphBlockSchemaExtension =
BlockSchemaExtension(ParagraphBlockSchema);
// Extensions array
const extensions = [
PageBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
];
/**
* Parse HTML string and create document block structure
* @param node Current DOM node
* @param doc Document object
* @param parentId Parent block ID
* @returns Created block ID
*/
function processNode(
node: HappyDOMElement,
doc: Store,
parentId?: string
): string | undefined {
// Skip text nodes and comments
if (node.nodeType !== 1) {
return undefined;
}
const tagName = node.tagName.toLowerCase();
let blockId: string | undefined = undefined;
// Create appropriate block based on tag name
if (tagName === 'affine-page') {
blockId = doc.addBlock('affine:page', {}, parentId);
} else if (tagName === 'affine-note') {
blockId = doc.addBlock('affine:note', {}, parentId);
} else if (tagName === 'affine-paragraph') {
// Get paragraph text content
const textContent = node.textContent || '';
// Get attributes
const props: Record<string, any> = { text: new Text(textContent) };
// Process custom attributes
for (const attr of Array.from(node.attributes)) {
if (attr.name === 'type') {
props.type = attr.value;
} else if (attr.name === 'checked' && attr.value === 'true') {
props.checked = true;
}
}
blockId = doc.addBlock('affine:paragraph', props, parentId);
} else {
console.warn(`Unknown tag name: ${tagName}`);
return undefined;
}
// Process child nodes
for (const childNode of Array.from(node.children) as HappyDOMElement[]) {
processNode(childNode, doc, blockId);
}
return blockId;
}
/**
* Create document from HTML string
* @param template HTML template string
* @returns Created document object
*/
export function createDocFromHTML(template: string) {
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc({ id: 'test-doc', extensions });
doc.load(() => {
const window = new Window();
const document = window.document;
const container = document.createElement('div');
container.innerHTML = template;
// Process each child node of the root
for (const childNode of Array.from(container.children)) {
processNode(childNode, doc);
}
});
return doc;
}

View File

@@ -0,0 +1,246 @@
import { CommandManager, type EditorHost } from '@blocksuite/block-std';
import type { Block, Store } from '@blocksuite/store';
import { Subject } from 'rxjs';
interface MockBlockComponent {
id: string;
model: Block;
flavour: string;
role: string;
parentElement: MockBlockComponent | null;
closest: (selector: string) => MockBlockComponent | null;
querySelector: (selector: string) => MockBlockComponent | null;
querySelectorAll: (selector: string) => MockBlockComponent[];
children: MockBlockComponent[];
}
type ViewUpdateMethod = 'delete' | 'add';
type ViewUpdatePayload = {
id: string;
method: ViewUpdateMethod;
type: 'block';
view: MockBlockComponent;
};
/**
* Mock selection class for testing
*/
class MockSelectionStore {
private _selections: any[] = [];
constructor() {}
get value() {
return this._selections;
}
create(selectionClass: any, ...args: any[]) {
return new selectionClass(...args);
}
setGroup(group: string, selections: any[]) {
this._selections = this._selections.filter(s => s.group !== group);
this._selections.push(...selections);
return this;
}
set(selections: any[]) {
this._selections = selections;
return this;
}
find(type: any) {
return this._selections.find(s => s instanceof type);
}
filter(type: any) {
return this._selections.filter(s => s instanceof type);
}
clear() {
this._selections = [];
return this;
}
slots = {
changed: {
emit: () => {},
},
remoteChanged: {
emit: () => {},
},
};
dispose() {
this._selections = [];
}
}
class MockViewStore {
private readonly _blockMap = new Map<string, MockBlockComponent>();
viewUpdated = new Subject<ViewUpdatePayload>();
constructor(private readonly doc: Store) {}
get views() {
return Array.from(this._blockMap.values());
}
deleteBlock(node: MockBlockComponent) {
this._blockMap.delete(node.model.id);
this.viewUpdated.next({
id: node.model.id,
method: 'delete',
type: 'block',
view: node,
});
}
getBlock(id: string): MockBlockComponent | null {
if (this._blockMap.has(id)) {
return this._blockMap.get(id) || null;
}
const block = this.doc.getBlock(id);
if (!block) return null;
const mockComponent = this._createMockBlockComponent(block);
this._blockMap.set(id, mockComponent);
return mockComponent;
}
setBlock(node: MockBlockComponent) {
if (this._blockMap.has(node.model.id)) {
this.deleteBlock(node);
}
this._blockMap.set(node.model.id, node);
this.viewUpdated.next({
id: node.model.id,
method: 'add',
type: 'block',
view: node,
});
}
private _createMockBlockComponent(block: Block): MockBlockComponent {
const role = this._determineBlockRole(block);
const mockComponent: MockBlockComponent = {
id: block.id,
model: block,
flavour: block.flavour,
role,
parentElement: null,
children: [],
closest: () => null,
querySelector: () => null,
querySelectorAll: () => [],
};
this._setupParentChildRelationships(mockComponent);
return mockComponent;
}
private _determineBlockRole(block: Block): string {
if (
block.flavour.includes('paragraph') ||
block.flavour.includes('list') ||
block.flavour.includes('list-item') ||
block.flavour.includes('text')
) {
return 'content';
}
return 'root';
}
private _setupParentChildRelationships(component: MockBlockComponent) {
const parentId = (component.model as any).parentId;
if (parentId) {
const parentComponent = this.getBlock(parentId);
if (parentComponent) {
component.parentElement = parentComponent;
if (
!parentComponent.children.find(child => child.id === component.id)
) {
parentComponent.children.push(component);
}
}
}
try {
const childIds =
(component.model as any).children?.map((child: any) =>
typeof child === 'string' ? child : child.id
) || [];
for (const childId of childIds) {
const childBlock = this.doc.getBlock(childId);
if (childBlock) {
const childComponent =
this.getBlock(childId) ||
this._createMockBlockComponent(childBlock);
if (
!component.children.find(child => child.id === childComponent.id)
) {
component.children.push(childComponent);
childComponent.parentElement = component;
}
}
}
} catch {
// ignore
}
}
dispose() {
this._blockMap.clear();
}
}
/**
* Create a test host object
*
* This function creates a mock host object that includes doc and command properties,
* which can be used for testing command execution.
*
* Usage:
* ```typescript
* const doc = affine`<affine-page></affine-page>`;
* const host = createTestHost(doc);
*
* // Use host.command.exec to execute commands
* const [_, result] = host.command.exec(someCommand, {
* // command params
* });
* ```
*
* @param doc Document object
* @returns Host object containing doc and command
*/
export function createTestHost(doc: Store): EditorHost {
const std = {
host: undefined as any,
view: new MockViewStore(doc),
command: undefined as any,
selection: undefined as any,
};
const host = {
doc: doc,
std: std as any,
};
host.doc = doc;
host.std = std as any;
std.host = host;
std.selection = new MockSelectionStore();
std.command = new CommandManager(std as any);
// @ts-expect-error
host.command = std.command;
return host as EditorHost;
}

View File

@@ -1,5 +1,7 @@
export {
getBlockIndexCommand,
getFirstContentBlockCommand,
getLastContentBlockCommand,
getNextBlockCommand,
getPrevBlockCommand,
getSelectedBlocksCommand,
@@ -21,5 +23,6 @@ export {
getRangeRects,
getSelectionRectsCommand,
getTextSelectionCommand,
isNothingSelectedCommand,
type SelectionRect,
} from './selection/index.js';