mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
79
blocksuite/affine/shared/src/__tests__/helpers/README.md
Normal file
79
blocksuite/affine/shared/src/__tests__/helpers/README.md
Normal 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`
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user