mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-31 00:59:57 +08:00
feat(core): markdown-diff & patch apply (#12844)
## New Features - **Markdown diff**: - Introduced block-level diffing for markdown content, enabling detection of insertions, deletions, and replacements between document versions. - Generate patch operations from markdown diff. - **Patch Renderer**: Transfer patch operations to a render diff which can be rendered into page body. - **Patch apply**:Added functionality to apply patch operations to documents, supporting block insertion, deletion, and content replacement. ## Refactors * Move `affine/shared/__tests__/utils` to `blocksuite/affine-shared/test-utils` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced utilities for declarative creation and testing of document structures using template literals. * Added new functions and types for block-level markdown diffing and patch application. * Provided a utility to generate structured render diffs for markdown blocks. * Added a unified test-utils entry point for easier access to testing helpers. * **Bug Fixes** * Updated import paths in test files to use the new test-utils location. * **Documentation** * Improved example usage in documentation to reflect the new import paths for test utilities. * **Tests** * Added comprehensive test suites for markdown diffing, patch application, and render diff utilities. * **Chores** * Updated package dependencies and export maps to expose new test utilities. * Refactored internal test utilities organization for clarity and maintainability. <!-- end of auto-generated comment: release notes by coderabbit.ai --> > CLOSE AI-271 AI-272 AI-273
This commit is contained in:
@@ -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 '@blocksuite/affine-shared/test-utils';
|
||||
|
||||
// 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`
|
||||
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
CodeBlockSchemaExtension,
|
||||
DatabaseBlockSchemaExtension,
|
||||
ImageBlockSchemaExtension,
|
||||
ListBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
ParagraphBlockSchemaExtension,
|
||||
RootBlockSchemaExtension,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { Container } from '@blocksuite/global/di';
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import {
|
||||
type Block,
|
||||
type ExtensionType,
|
||||
type Store,
|
||||
Text,
|
||||
} from '@blocksuite/store';
|
||||
import { TestWorkspace } from '@blocksuite/store/test';
|
||||
|
||||
import { createTestHost } from './create-test-host';
|
||||
|
||||
const DEFAULT_EXTENSIONS = [
|
||||
RootBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
ParagraphBlockSchemaExtension,
|
||||
ListBlockSchemaExtension,
|
||||
ImageBlockSchemaExtension,
|
||||
DatabaseBlockSchemaExtension,
|
||||
CodeBlockSchemaExtension,
|
||||
];
|
||||
|
||||
// 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',
|
||||
'affine-database': 'affine:database',
|
||||
'affine-code': 'affine:code',
|
||||
};
|
||||
|
||||
interface SelectionInfo {
|
||||
anchorBlockId?: string;
|
||||
anchorOffset?: number;
|
||||
focusBlockId?: string;
|
||||
focusOffset?: number;
|
||||
cursorBlockId?: string;
|
||||
cursorOffset?: number;
|
||||
}
|
||||
|
||||
export function createAffineTemplate(
|
||||
extensions: ExtensionType[] = DEFAULT_EXTENSIONS
|
||||
) {
|
||||
/**
|
||||
* 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<anchor /></affine-paragraph>
|
||||
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
|
||||
* </affine-note>
|
||||
* </affine-page>
|
||||
* `;
|
||||
* ```
|
||||
*/
|
||||
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('test-doc');
|
||||
const container = new Container();
|
||||
extensions.forEach(extension => {
|
||||
extension.setup(container);
|
||||
});
|
||||
const store = doc.getStore({ extensions, provider: container.provider() });
|
||||
let selectionInfo: SelectionInfo = {};
|
||||
|
||||
// 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(store, root, null, selectionInfo);
|
||||
});
|
||||
|
||||
// Create host object
|
||||
const host = createTestHost(store);
|
||||
|
||||
// Set selection if needed
|
||||
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
|
||||
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
|
||||
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
|
||||
const focusOffset = selectionInfo.focusOffset ?? 0;
|
||||
const anchorOffset = selectionInfo.anchorOffset ?? 0;
|
||||
|
||||
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.anchorBlockId,
|
||||
index: anchorOffset,
|
||||
length: focusOffset,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
} else {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.anchorBlockId,
|
||||
index: anchorOffset,
|
||||
length: anchorTextLength - anchorOffset,
|
||||
},
|
||||
to: {
|
||||
blockId: selectionInfo.focusBlockId,
|
||||
index: 0,
|
||||
length: focusOffset,
|
||||
},
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
}
|
||||
} else if (selectionInfo.cursorBlockId) {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.cursorBlockId,
|
||||
index: selectionInfo.cursorOffset ?? 0,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single block from template string
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* const block = block`<affine-note />`
|
||||
* ```
|
||||
*/
|
||||
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('temp-doc');
|
||||
const store = doc.getStore({ extensions });
|
||||
|
||||
let blockId: string | null = null;
|
||||
const selectionInfo: SelectionInfo = {};
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Create a root block if needed
|
||||
const flavour = tagToFlavour[root.tagName.toLowerCase()];
|
||||
if (
|
||||
flavour === 'affine:paragraph' ||
|
||||
flavour === 'affine:list' ||
|
||||
flavour === 'affine:code'
|
||||
) {
|
||||
const pageId = store.addBlock('affine:page', {});
|
||||
const noteId = store.addBlock('affine:note', {}, pageId);
|
||||
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
|
||||
} else {
|
||||
blockId = buildDocFromElement(store, root, null, selectionInfo);
|
||||
}
|
||||
});
|
||||
|
||||
// Return the created block
|
||||
return blockId ? (store.getBlock(blockId) ?? null) : null;
|
||||
}
|
||||
|
||||
return {
|
||||
affine,
|
||||
block,
|
||||
};
|
||||
}
|
||||
|
||||
export const { affine, block } = createAffineTemplate();
|
||||
|
||||
/**
|
||||
* Recursively build document structure
|
||||
* @param doc
|
||||
* @param element
|
||||
* @param parentId
|
||||
* @param selectionInfo
|
||||
* @returns
|
||||
*/
|
||||
function buildDocFromElement(
|
||||
doc: Store,
|
||||
element: Element,
|
||||
parentId: string | null,
|
||||
selectionInfo: SelectionInfo
|
||||
): string {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
// Handle selection tags
|
||||
if (tagName === 'anchor') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.anchorBlockId = parentId;
|
||||
selectionInfo.anchorOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
} else if (tagName === 'focus') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.focusBlockId = parentId;
|
||||
selectionInfo.focusOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
} else if (tagName === 'cursor') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.cursorBlockId = parentId;
|
||||
selectionInfo.cursorOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Process all child nodes, including text nodes
|
||||
Array.from(element.children).forEach(child => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
// Handle element nodes
|
||||
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
|
||||
} else if (child.nodeType === Node.TEXT_NODE) {
|
||||
// Handle text nodes
|
||||
console.log('buildDocFromElement text node:', child.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
return blockId;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { BlockModel, Store } from '@blocksuite/store';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
declare module 'vitest' {
|
||||
interface Assertion<T = any> {
|
||||
toEqualDoc(expected: Store, options?: { compareId?: boolean }): T;
|
||||
}
|
||||
}
|
||||
|
||||
const COMPARE_PROPERTIES = new Set(['id']);
|
||||
|
||||
function blockToTemplate(block: BlockModel, indent: string = ''): string {
|
||||
const props = Object.entries(block.props)
|
||||
.filter(([key]) => COMPARE_PROPERTIES.has(key))
|
||||
.map(([key, value]) => `${key}="${value}"`)
|
||||
.join(' ');
|
||||
|
||||
const text = block.text ? block.text.toString() : '';
|
||||
const children = block.children
|
||||
.map(child => blockToTemplate(child, indent + ' '))
|
||||
.join('\n');
|
||||
|
||||
const tagName = `affine-${block.flavour}`;
|
||||
const propsStr = props ? ` ${props}` : '';
|
||||
const content = text
|
||||
? `>${text}</${tagName}>`
|
||||
: children
|
||||
? `>\n${children}\n${indent}</${tagName}>`
|
||||
: `></${tagName}>`;
|
||||
|
||||
return `${indent}<${tagName}${propsStr}${content}`;
|
||||
}
|
||||
|
||||
function docToTemplate(doc: Store): string {
|
||||
if (!doc.root) return 'null';
|
||||
const rootBlock = doc.getBlock(doc.root.id);
|
||||
if (!rootBlock) return 'null';
|
||||
return blockToTemplate(rootBlock.model);
|
||||
}
|
||||
|
||||
function compareBlocks(
|
||||
actual: BlockModel,
|
||||
expected: BlockModel,
|
||||
compareId: boolean = false
|
||||
): boolean {
|
||||
if (actual.flavour !== expected.flavour) return false;
|
||||
if (compareId && actual.id !== expected.id) return false;
|
||||
if (actual.children.length !== expected.children.length) return false;
|
||||
|
||||
const actualText = actual.text;
|
||||
const expectedText = expected.text;
|
||||
if (
|
||||
actualText &&
|
||||
expectedText &&
|
||||
actualText.toString() !== expectedText.toString()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actualProps = { ...actual.props };
|
||||
const expectedProps = { ...expected.props };
|
||||
|
||||
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
|
||||
return false;
|
||||
|
||||
for (const [i, child] of actual.children.entries()) {
|
||||
if (!compareBlocks(child, expected.children[i], compareId)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function compareDocs(
|
||||
actual: Store,
|
||||
expected: Store,
|
||||
compareId: boolean = false
|
||||
): boolean {
|
||||
if (!actual.root || !expected.root) return false;
|
||||
|
||||
const actualRoot = actual.getBlock(actual.root.id);
|
||||
const expectedRoot = expected.getBlock(expected.root.id);
|
||||
|
||||
if (!actualRoot || !expectedRoot) return false;
|
||||
|
||||
return compareBlocks(actualRoot.model, expectedRoot.model, compareId);
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toEqualDoc(
|
||||
received: Store,
|
||||
expected: Store,
|
||||
options: { compareId?: boolean } = { compareId: false }
|
||||
) {
|
||||
const compareId = options.compareId;
|
||||
const pass = compareDocs(received, expected, compareId);
|
||||
|
||||
if (pass) {
|
||||
return {
|
||||
message: () => 'expected documents to be different',
|
||||
pass: true,
|
||||
};
|
||||
} else {
|
||||
const actualTemplate = docToTemplate(received);
|
||||
const expectedTemplate = docToTemplate(expected);
|
||||
|
||||
return {
|
||||
message: () =>
|
||||
`Documents are not equal.\n\nActual:\n${actualTemplate}\n\nExpected:\n${expectedTemplate}`,
|
||||
pass: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { CommandManager, type EditorHost } from '@blocksuite/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 = {
|
||||
store: doc,
|
||||
std: std as any,
|
||||
selection: undefined as any,
|
||||
};
|
||||
host.store = doc;
|
||||
host.std = std as any;
|
||||
|
||||
std.host = host;
|
||||
std.selection = new MockSelectionStore();
|
||||
|
||||
std.command = new CommandManager(std as any);
|
||||
// @ts-expect-error dev-only
|
||||
host.command = std.command;
|
||||
host.selection = std.selection;
|
||||
|
||||
return host as EditorHost;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './affine-template';
|
||||
export * from './affine-test-utils';
|
||||
export * from './create-test-host';
|
||||
Reference in New Issue
Block a user