chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions

View File

@@ -0,0 +1,58 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`model to snapshot 1`] = `
{
"flavour": "page",
"id": "0",
"props": {
"count": 3,
"items": [
{
"content": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "item 1",
},
],
},
"id": 0,
},
{
"content": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "item 2",
},
],
},
"id": 1,
},
{
"content": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "item 3",
},
],
},
"id": 2,
},
],
"style": {
"color": "red",
},
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "doc title",
},
],
},
},
"version": 1,
}
`;

View File

@@ -0,0 +1,77 @@
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { describe, expect, test } from 'vitest';
import { getAssetName } from '../adapter/assets.js';
describe('getAssetName', () => {
test('should return the name if it exists', () => {
const assets = new Map<string, Blob>([
['blobId', new File([], 'image.png', { type: 'image/png' })],
['blobId2', new File([], 'image', { type: 'image/png' })],
// inconsistent name with type
['blobId3', new File([], 'image.jpg', { type: 'image/png' })],
// empty name
['blobId4', new File([], '', { type: 'image/png' })],
]);
expect(getAssetName(assets, 'blobId')).toBe('image.png');
expect(getAssetName(assets, 'blobId2')).toBe('image.png');
// respect the original name
expect(getAssetName(assets, 'blobId3')).toBe('image.jpg');
expect(getAssetName(assets, 'blobId4')).toBe('blobId4.png');
});
test('should return blobId with extension if name does not exist', () => {
const assets = new Map<string, Blob>([
['blobId', new Blob([], { type: 'image/jpeg' })],
]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('blobId.jpeg');
});
test('should return the name if it exists but type is empty', () => {
const assets = new Map<string, Blob>([
['blobId', new File([], 'document.test', { type: '' })],
]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('document.test');
});
test('should return the original name even not ext found', () => {
const assets = new Map<string, Blob>([['blobId', new File([], 'blob.')]]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('blob.');
});
test('should return blobId with "blob" extension if type is empty', () => {
const assets = new Map<string, Blob>([
['blobId', new Blob([])],
['blobId2', new Blob([], { type: '' })],
]);
expect(getAssetName(assets, 'blobId')).toBe('blobId.blob');
expect(getAssetName(assets, 'blobId2')).toBe('blobId2.blob');
});
test('should return blobId with last part of mime type if extension is not found', () => {
const assets = new Map<string, Blob>([
['blobId', new Blob([], { type: 'application/unknown' })],
]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('blobId.unknown');
});
test('should return blobId with bin if type is octet-stream', () => {
const assets = new Map<string, Blob>([
['blobId', new Blob([], { type: 'application/octet-stream' })],
]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('blobId.bin');
});
test('should throw BlockSuiteError if blob is not found', () => {
const assets = new Map<string, Blob>();
expect(() => getAssetName(assets, 'blobId')).toThrow(BlockSuiteError);
expect(() => getAssetName(assets, 'blobId')).toThrowError(
'blob not found for blobId: blobId'
);
});
});

View File

@@ -0,0 +1,249 @@
import { computed, effect } from '@preact/signals-core';
import { describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import {
defineBlockSchema,
internalPrimitives,
Schema,
type SchemaToModel,
} from '../schema/index.js';
import { Block, type YBlock } from '../store/doc/block/index.js';
import { DocCollection, IdGeneratorType } from '../store/index.js';
const pageSchema = defineBlockSchema({
flavour: 'page',
props: internal => ({
title: internal.Text(),
count: 0,
toggle: false,
style: {} as Record<string, unknown>,
boxed: internal.Boxed(new Y.Map()),
}),
metadata: {
role: 'root',
version: 1,
},
});
type RootModel = SchemaToModel<typeof pageSchema>;
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register([pageSchema]);
return { id: 'test-collection', idGenerator, schema };
}
const defaultDocId = 'doc:home';
function createTestDoc(docId = defaultDocId) {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: docId });
doc.load();
return doc;
}
test('init block without props should add default props', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const block = new Block(doc.schema, yBlock, doc);
const model = block.model as RootModel;
expect(yBlock.get('prop:count')).toBe(0);
expect(model.count).toBe(0);
expect(model.style).toEqual({});
});
describe('block model should has signal props', () => {
test('atom', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const block = new Block(doc.schema, yBlock, doc);
const model = block.model as RootModel;
const isOdd = computed(() => model.count$.value % 2 === 1);
expect(model.count$.value).toBe(0);
expect(isOdd.peek()).toBe(false);
// set prop
model.count = 1;
expect(model.count$.value).toBe(1);
expect(isOdd.peek()).toBe(true);
expect(yBlock.get('prop:count')).toBe(1);
// set signal
model.count$.value = 2;
expect(model.count).toBe(2);
expect(isOdd.peek()).toBe(false);
expect(yBlock.get('prop:count')).toBe(2);
// set prop
yBlock.set('prop:count', 3);
expect(model.count).toBe(3);
expect(model.count$.value).toBe(3);
expect(isOdd.peek()).toBe(true);
const toggleEffect = vi.fn();
effect(() => {
toggleEffect(model.toggle$.value);
});
expect(toggleEffect).toHaveBeenCalledTimes(1);
const runToggle = () => {
const next = !model.toggle;
model.toggle = next;
expect(model.toggle$.value).toBe(next);
};
const times = 10;
for (let i = 0; i < times; i++) {
runToggle();
}
expect(toggleEffect).toHaveBeenCalledTimes(times + 1);
const runToggleReverse = () => {
const next = !model.toggle;
model.toggle$.value = next;
expect(model.toggle).toBe(next);
};
for (let i = 0; i < times; i++) {
runToggleReverse();
}
expect(toggleEffect).toHaveBeenCalledTimes(times * 2 + 1);
});
test('nested', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const block = new Block(doc.schema, yBlock, doc);
const model = block.model as RootModel;
expect(model.style).toEqual({});
model.style = { color: 'red' };
expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({
color: 'red',
});
expect(model.style$.value).toEqual({ color: 'red' });
model.style.color = 'yellow';
expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({
color: 'yellow',
});
expect(model.style$.value).toEqual({ color: 'yellow' });
model.style$.value = { color: 'blue' };
expect(model.style.color).toBe('blue');
expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({
color: 'blue',
});
const map = new Y.Map();
map.set('color', 'green');
yBlock.set('prop:style', map);
expect(model.style.color).toBe('green');
expect(model.style$.value).toEqual({ color: 'green' });
});
test('with stash and pop', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const block = new Block(doc.schema, yBlock, doc);
const model = block.model as RootModel;
expect(model.count).toBe(0);
model.stash('count');
model.count = 1;
expect(model.count$.value).toBe(1);
expect(yBlock.get('prop:count')).toBe(0);
model.count$.value = 2;
expect(model.count).toBe(2);
expect(yBlock.get('prop:count')).toBe(0);
model.pop('count');
expect(yBlock.get('prop:count')).toBe(2);
expect(model.count).toBe(2);
expect(model.count$.value).toBe(2);
model.stash('count');
yBlock.set('prop:count', 3);
expect(model.count).toBe(3);
expect(model.count$.value).toBe(3);
model.count$.value = 4;
expect(yBlock.get('prop:count')).toBe(3);
expect(model.count).toBe(4);
model.pop('count');
expect(yBlock.get('prop:count')).toBe(4);
});
});
test('on change', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const onPropsUpdated = vi.fn();
const block = new Block(doc.schema, yBlock, doc, {
onChange: onPropsUpdated,
});
const model = block.model as RootModel;
model.title = internalPrimitives.Text('abc');
expect(onPropsUpdated).toHaveBeenCalledWith(
expect.anything(),
'title',
expect.anything()
);
expect(model.title$.value.toDelta()).toEqual([{ insert: 'abc' }]);
onPropsUpdated.mockClear();
model.title.insert('d', 1);
expect(onPropsUpdated).toHaveBeenCalledWith(
expect.anything(),
'title',
expect.anything()
);
expect(model.title$.value.toDelta()).toEqual([{ insert: 'adbc' }]);
onPropsUpdated.mockClear();
model.boxed.getValue()!.set('foo', 0);
expect(onPropsUpdated).toHaveBeenCalledWith(
expect.anything(),
'boxed',
expect.anything()
);
expect(onPropsUpdated.mock.calls[0][2].toJSON().value).toMatchObject({
foo: 0,
});
expect(model.boxed$.value.getValue()!.toJSON()).toEqual({
foo: 0,
});
});

View File

@@ -0,0 +1,954 @@
// checkout https://vitest.dev/guide/debugging.html for debugging tests
import type { Slot } from '@blocksuite/global/utils';
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { COLLECTION_VERSION, PAGE_VERSION } from '../consts.js';
import type { BlockModel, BlockSchemaType, Doc } from '../index.js';
import { DocCollection, IdGeneratorType, Schema } from '../index.js';
import type { DocMeta } from '../store/index.js';
import type { BlockSuiteDoc } from '../yjs/index.js';
import {
NoteBlockSchema,
ParagraphBlockSchema,
RootBlockSchema,
} from './test-schema.js';
import { assertExists } from './test-utils-dom.js';
export const BlockSchemas = [
ParagraphBlockSchema,
RootBlockSchema,
NoteBlockSchema,
] as BlockSchemaType[];
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register(BlockSchemas);
return { id: 'test-collection', idGenerator, schema };
}
const defaultDocId = 'doc:home';
const spaceId = defaultDocId;
const spaceMetaId = 'meta';
function serializCollection(doc: BlockSuiteDoc): Record<string, any> {
const spaces = {};
doc.spaces.forEach((subDoc, key) => {
// @ts-expect-error FIXME: ts error
spaces[key] = subDoc.toJSON();
});
const json = doc.toJSON();
delete json.spaces;
return {
...json,
spaces,
};
}
function waitOnce<T>(slot: Slot<T>) {
return new Promise<T>(resolve => slot.once(val => resolve(val)));
}
function createRoot(doc: Doc) {
doc.addBlock('affine:page');
if (!doc.root) throw new Error('root not found');
return doc.root;
}
function createTestDoc(docId = defaultDocId) {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: docId });
doc.load();
return doc;
}
function requestIdleCallbackPolyfill(
callback: IdleRequestCallback,
options?: IdleRequestOptions
) {
const timeout = options?.timeout ?? 1000;
const start = Date.now();
return setTimeout(function () {
callback({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, timeout - (Date.now() - start));
},
});
}, timeout) as unknown as number;
}
beforeEach(() => {
if (globalThis.requestIdleCallback === undefined) {
globalThis.requestIdleCallback = requestIdleCallbackPolyfill;
}
});
describe('basic', () => {
it('can init collection', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
assert.equal(collection.isEmpty, true);
const doc = collection.createDoc({ id: 'doc:home' });
doc.load();
const actual = serializCollection(collection.doc);
const actualDoc = actual[spaceMetaId].pages[0] as DocMeta;
assert.equal(collection.isEmpty, false);
assert.equal(typeof actualDoc.createDate, 'number');
// @ts-expect-error FIXME: ts error
delete actualDoc.createDate;
assert.deepEqual(actual, {
[spaceMetaId]: {
pages: [
{
id: 'doc:home',
title: '',
tags: [],
},
],
workspaceVersion: COLLECTION_VERSION,
pageVersion: PAGE_VERSION,
blockVersions: {
'affine:note': 1,
'affine:page': 2,
'affine:paragraph': 1,
},
},
spaces: {
[spaceId]: {
blocks: {},
},
},
});
});
it('init collection with custom id generator', () => {
const options = createTestOptions();
let id = 100;
const collection = new DocCollection({
...options,
idGenerator: () => {
return String(id++);
},
});
collection.meta.initialize();
{
const doc = collection.createDoc();
assert.equal(doc.id, '100');
}
{
const doc = collection.createDoc();
assert.equal(doc.id, '101');
}
});
it('doc ready lifecycle', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({
id: 'space:0',
});
const readyCallback = vi.fn();
const rootAddedCallback = vi.fn();
doc.slots.ready.on(readyCallback);
doc.slots.rootAdded.on(rootAddedCallback);
doc.load(() => {
expect(doc.ready).toBe(false);
const rootId = doc.addBlock('affine:page', {
title: new doc.Text(),
});
expect(rootAddedCallback).toBeCalledTimes(1);
expect(doc.ready).toBe(false);
doc.addBlock('affine:note', {}, rootId);
});
expect(doc.ready).toBe(true);
expect(readyCallback).toBeCalledTimes(1);
});
it('collection docs with yjs applyUpdate', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const collection2 = new DocCollection(options);
const doc = collection.createDoc({
id: 'space:0',
});
doc.load(() => {
doc.addBlock('affine:page', {
title: new doc.Text(),
});
});
{
const subdocsTester = vi.fn(({ added }) => {
expect(added.size).toBe(1);
});
// only apply root update
collection2.doc.once('subdocs', subdocsTester);
expect(subdocsTester).toBeCalledTimes(0);
expect(collection2.docs.size).toBe(0);
const update = encodeStateAsUpdate(collection.doc);
applyUpdate(collection2.doc, update);
expect(collection2.doc.toJSON()['spaces']).toEqual({
'space:0': {
blocks: {},
},
});
expect(collection2.docs.size).toBe(1);
expect(subdocsTester).toBeCalledTimes(1);
}
{
// apply doc update
const update = encodeStateAsUpdate(doc.spaceDoc);
expect(collection2.docs.size).toBe(1);
const doc2 = collection2.getDoc('space:0');
assertExists(doc2);
applyUpdate(doc2.spaceDoc, update);
expect(collection2.doc.toJSON()['spaces']).toEqual({
'space:0': {
blocks: {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': [],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
},
},
});
const fn = vi.fn(({ loaded }) => {
expect(loaded.size).toBe(1);
});
collection2.doc.once('subdocs', fn);
expect(fn).toBeCalledTimes(0);
doc2.load();
expect(fn).toBeCalledTimes(1);
}
});
});
describe('addBlock', () => {
it('can add single model', () => {
const doc = createTestDoc();
doc.addBlock('affine:page', {
title: new doc.Text(),
});
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': [],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
});
});
it('can add model with props', () => {
const doc = createTestDoc();
doc.addBlock('affine:page', { title: new doc.Text('hello') });
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'sys:children': [],
'sys:flavour': 'affine:page',
'sys:id': '0',
'prop:title': 'hello',
'sys:version': 2,
},
});
});
it('can add multi models', () => {
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {
title: new doc.Text(),
});
const noteId = doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlocks(
[
{ flavour: 'affine:paragraph', blockProps: { type: 'h1' } },
{ flavour: 'affine:paragraph', blockProps: { type: 'h2' } },
],
noteId
);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'prop:title': '',
'sys:version': 2,
},
'1': {
'sys:children': ['2', '3', '4'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'prop:text': '',
'prop:type': 'text',
'sys:version': 1,
},
'3': {
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'prop:text': '',
'prop:type': 'h1',
'sys:version': 1,
},
'4': {
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'prop:text': '',
'prop:type': 'h2',
'sys:version': 1,
},
});
});
it('can observe slot events', async () => {
const doc = createTestDoc();
queueMicrotask(() =>
doc.addBlock('affine:page', {
title: new doc.Text(),
})
);
const blockId = await waitOnce(doc.slots.rootAdded);
const block = doc.getBlockById(blockId) as BlockModel;
assert.equal(block.flavour, 'affine:page');
});
it('can add block to root', async () => {
const doc = createTestDoc();
let noteId: string;
queueMicrotask(() => {
const rootId = doc.addBlock('affine:page');
noteId = doc.addBlock('affine:note', {}, rootId);
});
await waitOnce(doc.slots.rootAdded);
const { root } = doc;
if (!root) throw new Error('root is null');
assert.equal(root.flavour, 'affine:page');
doc.addBlock('affine:paragraph', {}, noteId!);
assert.equal(root.children[0].flavour, 'affine:note');
assert.equal(root.children[0].children[0].flavour, 'affine:paragraph');
assert.equal(root.childMap.value.get('1'), 0);
const serializedChildren = serializCollection(doc.rootDoc).spaces[spaceId]
.blocks['0']['sys:children'];
assert.deepEqual(serializedChildren, ['1']);
assert.equal(root.children[0].id, '1');
});
it('can add and remove multi docs', async () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc0 = collection.createDoc({ id: 'doc:home' });
const doc1 = collection.createDoc({ id: 'space:doc1' });
await Promise.all([doc0.load(), doc1.load()]);
assert.equal(collection.docs.size, 2);
doc0.addBlock('affine:page', {
title: new doc0.Text(),
});
collection.removeDoc(doc0.id);
assert.equal(collection.docs.size, 1);
assert.equal(
serializCollection(doc0.rootDoc).spaces['doc:home'],
undefined
);
collection.removeDoc(doc1.id);
assert.equal(collection.docs.size, 0);
});
it('can remove doc that has not been loaded', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc0 = collection.createDoc({ id: 'doc:home' });
collection.removeDoc(doc0.id);
assert.equal(collection.docs.size, 0);
});
it('can set doc state', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
collection.createDoc({ id: 'doc:home' });
assert.deepEqual(
collection.meta.docMetas.map(({ id, title }) => ({
id,
title,
})),
[
{
id: 'doc:home',
title: '',
},
]
);
let called = false;
collection.meta.docMetaUpdated.on(() => {
called = true;
});
collection.setDocMeta('doc:home', { favorite: true });
assert.deepEqual(
collection.meta.docMetas.map(({ id, title, favorite }) => ({
id,
title,
favorite,
})),
[
{
id: 'doc:home',
title: '',
favorite: true,
},
]
);
assert.ok(called);
});
it('can set collection common meta fields', async () => {
const options = createTestOptions();
const collection = new DocCollection(options);
queueMicrotask(() => collection.meta.setName('hello'));
await waitOnce(collection.meta.commonFieldsUpdated);
assert.deepEqual(collection.meta.name, 'hello');
queueMicrotask(() => collection.meta.setAvatar('gengar.jpg'));
await waitOnce(collection.meta.commonFieldsUpdated);
assert.deepEqual(collection.meta.avatar, 'gengar.jpg');
});
});
describe('deleteBlock', () => {
it('delete children recursively by default', () => {
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['2', '3'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
});
const deletedModel = doc.getBlockById('1') as BlockModel;
doc.deleteBlock(deletedModel);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': [],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
});
});
it('bring children to parent', () => {
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
const p1 = doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, p1);
doc.addBlock('affine:paragraph', {}, p1);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['2'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'prop:text': '',
'prop:type': 'text',
'sys:children': ['3', '4'],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
'4': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'sys:version': 1,
},
});
const deletedModel = doc.getBlockById('2') as BlockModel;
const deletedModelParent = doc.getBlockById('1') as BlockModel;
doc.deleteBlock(deletedModel, {
bringChildrenTo: deletedModelParent,
});
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['3', '4'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
'4': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'sys:version': 1,
},
});
});
it('bring children to other block', () => {
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
const p1 = doc.addBlock('affine:paragraph', {}, noteId);
const p2 = doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, p1);
doc.addBlock('affine:paragraph', {}, p1);
doc.addBlock('affine:paragraph', {}, p2);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['2', '3'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'prop:text': '',
'prop:type': 'text',
'sys:children': ['4', '5'],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': ['6'],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
'4': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'sys:version': 1,
},
'5': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '5',
'sys:version': 1,
},
'6': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '6',
'sys:version': 1,
},
});
const deletedModel = doc.getBlockById('2') as BlockModel;
const moveToModel = doc.getBlockById('3') as BlockModel;
doc.deleteBlock(deletedModel, {
bringChildrenTo: moveToModel,
});
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['3'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': ['6', '4', '5'],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
'4': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'sys:version': 1,
},
'5': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '5',
'sys:version': 1,
},
'6': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '6',
'sys:version': 1,
},
});
});
it('can delete model with parent', () => {
const doc = createTestDoc();
const rootModel = createRoot(doc);
const noteId = doc.addBlock('affine:note', {}, rootModel.id);
doc.addBlock('affine:paragraph', {}, noteId);
// before delete
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['2'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'prop:text': '',
'prop:type': 'text',
'sys:version': 1,
},
});
doc.deleteBlock(rootModel.children[0].children[0]);
// after delete
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': [],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
});
assert.equal(rootModel.children.length, 1);
});
});
describe('getBlock', () => {
it('can get block by id', () => {
const doc = createTestDoc();
const rootModel = createRoot(doc);
const noteId = doc.addBlock('affine:note', {}, rootModel.id);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
const text = doc.getBlockById('3') as BlockModel;
assert.equal(text.flavour, 'affine:paragraph');
assert.equal(rootModel.children[0].children.indexOf(text), 1);
const invalid = doc.getBlockById('😅');
assert.equal(invalid, null);
});
it('can get parent', () => {
const doc = createTestDoc();
const rootModel = createRoot(doc);
const noteId = doc.addBlock('affine:note', {}, rootModel.id);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
const result = doc.getParent(
rootModel.children[0].children[1]
) as BlockModel;
assert.equal(result, rootModel.children[0]);
const invalid = doc.getParent(rootModel);
assert.equal(invalid, null);
});
it('can get previous sibling', () => {
const doc = createTestDoc();
const rootModel = createRoot(doc);
const noteId = doc.addBlock('affine:note', {}, rootModel.id);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
const result = doc.getPrev(rootModel.children[0].children[1]) as BlockModel;
assert.equal(result, rootModel.children[0].children[0]);
const invalid = doc.getPrev(rootModel.children[0].children[0]);
assert.equal(invalid, null);
});
});
// Inline snapshot is not supported under describe.parallel config
describe('collection.exportJSX works', () => {
it('collection matches snapshot', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'doc:home' });
doc.addBlock('affine:page', { title: new doc.Text('hello') });
expect(collection.exportJSX()).toMatchInlineSnapshot(`
<affine:page
prop:count={0}
prop:items={[]}
prop:style={{}}
prop:title="hello"
/>
`);
});
it('empty collection matches snapshot', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
collection.createDoc({ id: 'doc:home' });
expect(collection.exportJSX()).toMatchInlineSnapshot('null');
});
it('collection with multiple blocks children matches snapshot', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'doc:home' });
doc.load(() => {
const rootId = doc.addBlock('affine:page', {
title: new doc.Text(),
});
const noteId = doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
});
expect(collection.exportJSX()).toMatchInlineSnapshot(/* xml */ `
<affine:page
prop:count={0}
prop:items={[]}
prop:style={{}}
>
<affine:note>
<affine:paragraph
prop:type="text"
/>
<affine:paragraph
prop:type="text"
/>
</affine:note>
</affine:page>
`);
});
});
describe('flags', () => {
it('update flags', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const awareness = collection.awarenessStore;
awareness.setFlag('enable_lasso_tool', false);
expect(awareness.getFlag('enable_lasso_tool')).toBe(false);
awareness.setFlag('enable_lasso_tool', true);
expect(awareness.getFlag('enable_lasso_tool')).toBe(true);
});
});
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:page': BlockModel;
'affine:paragraph': BlockModel;
'affine:note': BlockModel;
}
}
}

View File

@@ -0,0 +1,272 @@
import { expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import { Schema } from '../schema/index.js';
import {
BlockViewType,
DocCollection,
IdGeneratorType,
} from '../store/index.js';
import {
DividerBlockSchema,
ListBlockSchema,
NoteBlockSchema,
ParagraphBlockSchema,
type RootBlockModel,
RootBlockSchema,
} from './test-schema.js';
const BlockSchemas = [
RootBlockSchema,
ParagraphBlockSchema,
ListBlockSchema,
NoteBlockSchema,
DividerBlockSchema,
];
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register(BlockSchemas);
return { id: 'test-collection', idGenerator, schema };
}
test('trigger props updated', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('affine:page');
const rootModel = doc.root as RootBlockModel;
expect(rootModel).not.toBeNull();
const onPropsUpdated = vi.fn();
rootModel.propsUpdated.on(onPropsUpdated);
const getColor = () =>
(rootModel.yBlock.get('prop:style') as Y.Map<string>).get('color');
const getItems = () => rootModel.yBlock.get('prop:items') as Y.Array<unknown>;
const getCount = () => rootModel.yBlock.get('prop:count');
rootModel.count = 1;
expect(onPropsUpdated).toBeCalledTimes(1);
expect(onPropsUpdated).toHaveBeenNthCalledWith(1, { key: 'count' });
expect(getCount()).toBe(1);
rootModel.count = 2;
expect(onPropsUpdated).toBeCalledTimes(2);
expect(onPropsUpdated).toHaveBeenNthCalledWith(2, { key: 'count' });
expect(getCount()).toBe(2);
rootModel.style.color = 'blue';
expect(onPropsUpdated).toBeCalledTimes(3);
expect(onPropsUpdated).toHaveBeenNthCalledWith(3, { key: 'style' });
expect(getColor()).toBe('blue');
rootModel.style = { color: 'red' };
expect(onPropsUpdated).toBeCalledTimes(4);
expect(onPropsUpdated).toHaveBeenNthCalledWith(4, { key: 'style' });
expect(getColor()).toBe('red');
rootModel.style.color = 'green';
expect(onPropsUpdated).toBeCalledTimes(5);
expect(onPropsUpdated).toHaveBeenNthCalledWith(5, { key: 'style' });
expect(getColor()).toBe('green');
rootModel.items.push(1);
expect(onPropsUpdated).toBeCalledTimes(6);
expect(onPropsUpdated).toHaveBeenNthCalledWith(6, { key: 'items' });
expect(getItems().get(0)).toBe(1);
rootModel.items[0] = { id: '1' };
expect(onPropsUpdated).toBeCalledTimes(7);
expect(onPropsUpdated).toHaveBeenNthCalledWith(7, { key: 'items' });
expect(getItems().get(0)).toBeInstanceOf(Y.Map);
expect((getItems().get(0) as Y.Map<unknown>).get('id')).toBe('1');
});
test('stash and pop', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('affine:page');
const rootModel = doc.root as RootBlockModel;
expect(rootModel).not.toBeNull();
const onPropsUpdated = vi.fn();
rootModel.propsUpdated.on(onPropsUpdated);
const getCount = () => rootModel.yBlock.get('prop:count');
const getColor = () =>
(rootModel.yBlock.get('prop:style') as Y.Map<string>).get('color');
rootModel.count = 1;
expect(onPropsUpdated).toBeCalledTimes(1);
expect(onPropsUpdated).toHaveBeenNthCalledWith(1, { key: 'count' });
expect(getCount()).toBe(1);
rootModel.stash('count');
rootModel.count = 2;
expect(onPropsUpdated).toBeCalledTimes(3);
expect(onPropsUpdated).toHaveBeenNthCalledWith(3, { key: 'count' });
expect(rootModel.yBlock.get('prop:count')).toBe(1);
rootModel.pop('count');
expect(onPropsUpdated).toBeCalledTimes(4);
expect(onPropsUpdated).toHaveBeenNthCalledWith(4, { key: 'count' });
expect(rootModel.yBlock.get('prop:count')).toBe(2);
rootModel.style.color = 'blue';
expect(getColor()).toBe('blue');
expect(onPropsUpdated).toBeCalledTimes(5);
expect(onPropsUpdated).toHaveBeenNthCalledWith(5, { key: 'style' });
rootModel.stash('style');
rootModel.style = {
color: 'red',
};
expect(getColor()).toBe('blue');
expect(onPropsUpdated).toBeCalledTimes(7);
expect(onPropsUpdated).toHaveBeenNthCalledWith(7, { key: 'style' });
rootModel.pop('style');
expect(getColor()).toBe('red');
expect(onPropsUpdated).toBeCalledTimes(8);
expect(onPropsUpdated).toHaveBeenNthCalledWith(8, { key: 'style' });
rootModel.stash('style');
expect(onPropsUpdated).toBeCalledTimes(9);
expect(onPropsUpdated).toHaveBeenNthCalledWith(9, { key: 'style' });
rootModel.style.color = 'green';
expect(onPropsUpdated).toBeCalledTimes(10);
expect(onPropsUpdated).toHaveBeenNthCalledWith(10, { key: 'style' });
expect(getColor()).toBe('red');
rootModel.pop('style');
expect(getColor()).toBe('green');
expect(onPropsUpdated).toBeCalledTimes(11);
expect(onPropsUpdated).toHaveBeenNthCalledWith(11, { key: 'style' });
});
test('always get latest value in onChange', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('affine:page');
const rootModel = doc.root as RootBlockModel;
expect(rootModel).not.toBeNull();
let value: unknown;
rootModel.propsUpdated.on(({ key }) => {
// @ts-expect-error FIXME: ts error
value = rootModel[key];
});
rootModel.count = 1;
expect(value).toBe(1);
rootModel.stash('count');
rootModel.count = 2;
expect(value).toBe(2);
rootModel.pop('count');
rootModel.count = 3;
expect(value).toBe(3);
rootModel.style.color = 'blue';
expect(value).toEqual({ color: 'blue' });
rootModel.stash('style');
rootModel.style = { color: 'red' };
expect(value).toEqual({ color: 'red' });
rootModel.style.color = 'green';
expect(value).toEqual({ color: 'green' });
rootModel.pop('style');
rootModel.style.color = 'yellow';
expect(value).toEqual({ color: 'yellow' });
});
test('query', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc1 = collection.createDoc({ id: 'home' });
doc1.load();
const doc2 = collection.getDoc('home');
const doc3 = collection.getDoc('home', {
query: {
mode: 'loose',
match: [
{
flavour: 'affine:list',
viewType: BlockViewType.Hidden,
},
],
},
});
expect(doc1).toBe(doc2);
expect(doc1).not.toBe(doc3);
const page = doc1.addBlock('affine:page');
const note = doc1.addBlock('affine:note', {}, page);
const paragraph1 = doc1.addBlock('affine:paragraph', {}, note);
const list1 = doc1.addBlock('affine:list' as never, {}, note);
expect(doc2?.getBlock(paragraph1)?.blockViewType).toBe(BlockViewType.Display);
expect(doc2?.getBlock(list1)?.blockViewType).toBe(BlockViewType.Display);
expect(doc3?.getBlock(list1)?.blockViewType).toBe(BlockViewType.Hidden);
const list2 = doc1.addBlock('affine:list' as never, {}, note);
expect(doc2?.getBlock(list2)?.blockViewType).toBe(BlockViewType.Display);
expect(doc3?.getBlock(list2)?.blockViewType).toBe(BlockViewType.Hidden);
});
test('local readonly', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc1 = collection.createDoc({ id: 'home' });
doc1.load();
const doc2 = collection.getDoc('home', { readonly: true });
const doc3 = collection.getDoc('home', { readonly: false });
expect(doc1.readonly).toBeFalsy();
expect(doc2?.readonly).toBeTruthy();
expect(doc3?.readonly).toBeFalsy();
collection.awarenessStore.setReadonly(doc1.blockCollection, true);
expect(doc1.readonly).toBeTruthy();
expect(doc2?.readonly).toBeTruthy();
expect(doc3?.readonly).toBeTruthy();
collection.awarenessStore.setReadonly(doc1.blockCollection, false);
expect(doc1.readonly).toBeFalsy();
expect(doc2?.readonly).toBeTruthy();
expect(doc3?.readonly).toBeFalsy();
});

View File

@@ -0,0 +1,133 @@
// checkout https://vitest.dev/guide/debugging.html for debugging tests
import { describe, expect, it } from 'vitest';
import { yDocToJSXNode } from '../utils/jsx.js';
describe('basic', () => {
it('serialized doc match snapshot', () => {
expect(
yDocToJSXNode(
{
'0': {
'sys:id': '0',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
},
'1': {
'sys:id': '1',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'prop:text': [],
'prop:type': 'text',
},
},
'0'
)
).toMatchInlineSnapshot(`
<affine:page>
<affine:paragraph
prop:type="text"
/>
</affine:page>
`);
});
it('block with plain text should match snapshot', () => {
expect(
yDocToJSXNode(
{
'0': {
'sys:id': '0',
'sys:flavour': 'affine:page',
'sys:children': ['1'],
'prop:title': 'this is title',
},
'1': {
'sys:id': '2',
'sys:flavour': 'affine:paragraph',
'sys:children': [],
'prop:type': 'text',
'prop:text': [{ insert: 'just plain text' }],
},
},
'0'
)
).toMatchInlineSnapshot(`
<affine:page
prop:title="this is title"
>
<affine:paragraph
prop:text="just plain text"
prop:type="text"
/>
</affine:page>
`);
});
it('doc record match snapshot', () => {
expect(
yDocToJSXNode(
{
'0': {
'sys:id': '0',
'sys:flavour': 'affine:page',
'sys:children': ['1'],
'prop:title': 'this is title',
},
'1': {
'sys:id': '2',
'sys:flavour': 'affine:paragraph',
'sys:children': [],
'prop:type': 'text',
'prop:text': [
{ insert: 'this is ' },
{
insert: 'a ',
attributes: { link: 'http://www.example.com' },
},
{
insert: 'link',
attributes: { link: 'http://www.example.com', bold: true },
},
{ insert: ' with', attributes: { bold: true } },
{ insert: ' bold' },
],
},
},
'0'
)
).toMatchInlineSnapshot(`
<affine:page
prop:title="this is title"
>
<affine:paragraph
prop:text={
<>
<text
insert="this is "
/>
<text
insert="a "
link="http://www.example.com"
/>
<text
bold={true}
insert="link"
link="http://www.example.com"
/>
<text
bold={true}
insert=" with"
/>
<text
insert=" bold"
/>
</>
}
prop:type="text"
/>
</affine:page>
`);
});
});

View File

@@ -0,0 +1,133 @@
import { literal } from 'lit/static-html.js';
import { describe, expect, it, vi } from 'vitest';
// import some blocks
import { type BlockModel, defineBlockSchema } from '../schema/base.js';
import { SchemaValidateError } from '../schema/error.js';
import { Schema } from '../schema/index.js';
import { DocCollection, IdGeneratorType } from '../store/index.js';
import {
DividerBlockSchema,
ListBlockSchema,
NoteBlockSchema,
ParagraphBlockSchema,
RootBlockSchema,
} from './test-schema.js';
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register(BlockSchemas);
return { id: 'test-collection', idGenerator, schema };
}
const TestCustomNoteBlockSchema = defineBlockSchema({
flavour: 'affine:note-block-video',
props: internal => ({
text: internal.Text(),
}),
metadata: {
version: 1,
role: 'content',
tag: literal`affine-note-block-video`,
parent: ['affine:note'],
},
});
const TestInvalidNoteBlockSchema = defineBlockSchema({
flavour: 'affine:note-invalid-block-video',
props: internal => ({
text: internal.Text(),
}),
metadata: {
version: 1,
role: 'content',
tag: literal`affine-invalid-note-block-video`,
parent: ['affine:note'],
},
});
const BlockSchemas = [
RootBlockSchema,
ParagraphBlockSchema,
ListBlockSchema,
NoteBlockSchema,
DividerBlockSchema,
TestCustomNoteBlockSchema,
TestInvalidNoteBlockSchema,
];
const defaultDocId = 'doc0';
function createTestDoc(docId = defaultDocId) {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: docId });
doc.load();
return doc;
}
describe('schema', () => {
it('should be able to validate schema by role', () => {
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
const paragraphId = doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:note', {});
expect(consoleMock.mock.calls[0]).toSatisfy((call: unknown[]) => {
return typeof call[0] === 'string';
});
expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => {
return call[0] instanceof SchemaValidateError;
});
consoleMock.mockClear();
// add paragraph to root should throw
doc.addBlock('affine:paragraph', {}, rootId);
expect(consoleMock.mock.calls[0]).toSatisfy((call: unknown[]) => {
return typeof call[0] === 'string';
});
expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => {
return call[0] instanceof SchemaValidateError;
});
consoleMock.mockClear();
doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, paragraphId);
expect(consoleMock).not.toBeCalled();
});
it('should glob match works', () => {
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:note-block-video', {}, noteId);
expect(consoleMock).not.toBeCalled();
doc.addBlock('affine:note-invalid-block-video', {}, noteId);
expect(consoleMock.mock.calls[0]).toSatisfy((call: unknown[]) => {
return typeof call[0] === 'string';
});
expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => {
return call[0] instanceof SchemaValidateError;
});
});
});
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:note-block-video': BlockModel;
'affine:note-invalid-block-video': BlockModel;
}
}
}

View File

@@ -0,0 +1,88 @@
import { defineBlockSchema, type SchemaToModel } from '../schema/index.js';
export const RootBlockSchema = defineBlockSchema({
flavour: 'affine:page',
props: internal => ({
title: internal.Text(),
count: 0,
style: {} as Record<string, unknown>,
items: [] as unknown[],
}),
metadata: {
version: 2,
role: 'root',
},
});
export type RootBlockModel = SchemaToModel<typeof RootBlockSchema>;
export const NoteBlockSchema = defineBlockSchema({
flavour: 'affine:note',
props: () => ({}),
metadata: {
version: 1,
role: 'hub',
parent: ['affine:page'],
children: [
'affine:paragraph',
'affine:list',
'affine:code',
'affine:divider',
'affine:database',
'affine:data-view',
'affine:image',
'affine:note-block-*',
'affine:bookmark',
'affine:attachment',
'affine:surface-ref',
'affine:embed-*',
],
},
});
export const ParagraphBlockSchema = defineBlockSchema({
flavour: 'affine:paragraph',
props: internal => ({
type: 'text',
text: internal.Text(),
}),
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:database',
'affine:paragraph',
'affine:list',
],
},
});
export const ListBlockSchema = defineBlockSchema({
flavour: 'affine:list',
props: internal => ({
type: 'bulleted',
text: internal.Text(),
checked: false,
collapsed: false,
}),
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:database',
'affine:list',
'affine:paragraph',
],
},
});
export const DividerBlockSchema = defineBlockSchema({
flavour: 'affine:divider',
metadata: {
version: 1,
role: 'content',
children: [],
},
});

View File

@@ -0,0 +1,122 @@
import type { DocCollection } from '../store/index.js';
declare global {
interface WindowEventMap {
'test-result': CustomEvent<TestResult>;
}
interface Window {
collection: DocCollection;
}
}
export interface TestResult {
success: boolean;
messages: string[];
}
const testResult: TestResult = {
success: true,
messages: [],
};
interface TestCase {
name: string;
callback: () => Promise<boolean>;
}
let testCases: TestCase[] = [];
function reportTestResult() {
const event = new CustomEvent<TestResult>('test-result', {
detail: testResult,
});
window.dispatchEvent(event);
}
function addMessage(message: string) {
console.log(message);
testResult.messages.push(message);
}
function reject(message: string) {
testResult.success = false;
addMessage(`❌ ${message}`);
}
export function testSerial(name: string, callback: () => Promise<boolean>) {
testCases.push({ name, callback });
}
function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function runOnce() {
await wait(50); // for correct event sequence
for (const testCase of testCases) {
const { name, callback } = testCase;
const result = await callback();
if (result) addMessage(`✅ ${name}`);
else reject(name);
}
reportTestResult();
testCases = [];
}
// XXX: workaround typing issue in blobs/__tests__/test-entry.ts
export function assertExists<T>(val: T | null | undefined): asserts val is T {
if (val === null || val === undefined) {
throw new Error('val does not exist');
}
}
export async function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
// Test image source: https://en.wikipedia.org/wiki/Test_card
export async function loadTestImageBlob(name: string): Promise<Blob> {
const resp = await fetch(`/${name}.png`);
return resp.blob();
}
export async function loadImage(blobUrl: string) {
const img = new Image();
img.src = blobUrl;
return new Promise<HTMLImageElement>(resolve => {
img.onload = () => resolve(img);
});
}
export function assertColor(
img: HTMLImageElement,
x: number,
y: number,
color: [number, number, number]
): boolean {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(x, y, 1, 1).data;
const r = data[0];
const g = data[1];
const b = data[2];
return r === color[0] && g === color[1] && b === color[2];
}
// prevent redundant test runs
export function disableButtonsAfterClick() {
const buttons = document.querySelectorAll('button');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
buttons.forEach(button => {
button.disabled = true;
});
});
});
}

View File

@@ -0,0 +1,143 @@
import { expect, test } from 'vitest';
import * as Y from 'yjs';
import { MemoryBlobCRUD } from '../adapter/index.js';
import { Text } from '../reactive/index.js';
import {
type BlockModel,
defineBlockSchema,
Schema,
type SchemaToModel,
} from '../schema/index.js';
import { DocCollection, IdGeneratorType } from '../store/index.js';
import { AssetsManager, BaseBlockTransformer } from '../transformer/index.js';
const docSchema = defineBlockSchema({
flavour: 'page',
props: internal => ({
title: internal.Text('doc title'),
count: 3,
style: {
color: 'red',
},
items: [
{
id: 0,
content: internal.Text('item 1'),
},
{
id: 1,
content: internal.Text('item 2'),
},
{
id: 2,
content: internal.Text('item 3'),
},
],
}),
metadata: {
role: 'root',
version: 1,
},
});
type RootBlockModel = SchemaToModel<typeof docSchema>;
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register([docSchema]);
return { id: 'test-collection', idGenerator, schema };
}
const transformer = new BaseBlockTransformer();
const blobCRUD = new MemoryBlobCRUD();
const assets = new AssetsManager({ blob: blobCRUD });
test('model to snapshot', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('page');
const rootModel = doc.root as RootBlockModel;
expect(rootModel).not.toBeNull();
const snapshot = transformer.toSnapshot({
model: rootModel,
assets,
});
expect(snapshot).toMatchSnapshot();
});
test('snapshot to model', async () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('page');
const rootModel = doc.root as RootBlockModel;
const tempDoc = new Y.Doc();
const map = tempDoc.getMap('temp');
expect(rootModel).not.toBeNull();
const snapshot = transformer.toSnapshot({
model: rootModel,
assets,
});
const model = await transformer.fromSnapshot({
json: snapshot,
assets,
children: [],
});
expect(model.flavour).toBe(rootModel.flavour);
// @ts-expect-error FIXME: ts error
expect(model.props.title).toBeInstanceOf(Text);
// @ts-expect-error FIXME: ts error
map.set('title', model.props.title.yText);
// @ts-expect-error FIXME: ts error
expect(model.props.title.toString()).toBe('doc title');
// @ts-expect-error FIXME: ts error
expect(model.props.style).toEqual({
color: 'red',
});
// @ts-expect-error FIXME: ts error
expect(model.props.count).toBe(3);
// @ts-expect-error FIXME: ts error
expect(model.props.items).toMatchObject([
{
id: 0,
},
{
id: 1,
},
{
id: 2,
},
]);
// @ts-expect-error FIXME: ts error
model.props.items.forEach((item, index) => {
expect(item.content).toBeInstanceOf(Text);
const key = `item:${index}:content`;
map.set(key, item.content.yText);
expect(item.content.toString()).toBe(`item ${index + 1}`);
});
});
declare global {
namespace BlockSuite {
interface BlockModels {
page: BlockModel;
}
}
}

View File

@@ -0,0 +1,177 @@
import { describe, expect, test } from 'vitest';
import * as Y from 'yjs';
import type { Text } from '../reactive/index.js';
import { Boxed, createYProxy, popProp, stashProp } from '../reactive/index.js';
describe('blocksuite yjs', () => {
describe('array', () => {
test('proxy', () => {
const ydoc = new Y.Doc();
const arr = ydoc.getArray('arr');
arr.push([0]);
const proxy = createYProxy(arr) as unknown[];
expect(arr.get(0)).toBe(0);
proxy.push(1);
expect(arr.get(1)).toBe(1);
expect(arr.length).toBe(2);
proxy.splice(1, 1);
expect(arr.length).toBe(1);
proxy[0] = 2;
expect(arr.length).toBe(1);
});
});
describe('object', () => {
test('deep', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const obj = new Y.Map();
obj.set('foo', 1);
map.set('obj', obj);
map.set('num', 0);
const map2 = new Y.Map();
obj.set('map', map2);
map2.set('foo', 40);
const proxy = createYProxy<Record<string, any>>(map);
expect(proxy.num).toBe(0);
expect(proxy.obj.foo).toBe(1);
expect(proxy.obj.map.foo).toBe(40);
proxy.obj.bar = 100;
expect(obj.get('bar')).toBe(100);
proxy.obj2 = { foo: 2, bar: { num: 3 } };
expect(map.get('obj2')).toBeInstanceOf(Y.Map);
// @ts-expect-error FIXME: ts error
expect(map.get('obj2').get('bar').get('num')).toBe(3);
proxy.obj2.bar.str = 'hello';
// @ts-expect-error FIXME: ts error
expect(map.get('obj2').get('bar').get('str')).toBe('hello');
proxy.obj3 = {};
const { obj3 } = proxy;
obj3.id = 'obj3';
expect((map.get('obj3') as Y.Map<string>).get('id')).toBe('obj3');
proxy.arr = [];
expect(map.get('arr')).toBeInstanceOf(Y.Array);
proxy.arr.push({ counter: 1 });
expect((map.get('arr') as Y.Array<Y.Map<number>>).get(0)).toBeInstanceOf(
Y.Map
);
expect(
(map.get('arr') as Y.Array<Y.Map<number>>).get(0).get('counter')
).toBe(1);
});
test('with y text', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const inner = new Y.Map();
map.set('inner', inner);
const text = new Y.Text('hello');
inner.set('text', text);
const proxy = createYProxy<{ inner: { text: Text } }>(map);
proxy.inner = { ...proxy.inner };
expect(proxy.inner.text.yText).toBeInstanceOf(Y.Text);
expect(proxy.inner.text.yText.toJSON()).toBe('hello');
});
test('with native wrapper', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const inner = new Y.Map();
map.set('inner', inner);
const native = new Boxed(['hello', 'world']);
inner.set('native', native.yMap);
const proxy = createYProxy<{
inner: {
native: Boxed<string[]>;
native2: Boxed<number>;
};
}>(map);
expect(proxy.inner.native.getValue()).toEqual(['hello', 'world']);
proxy.inner.native.setValue(['hello', 'world', 'foo']);
expect(native.getValue()).toEqual(['hello', 'world', 'foo']);
// @ts-expect-error FIXME: ts error
expect(map.get('inner').get('native').get('value')).toEqual([
'hello',
'world',
'foo',
]);
const native2 = new Boxed(0);
proxy.inner.native2 = native2;
// @ts-expect-error FIXME: ts error
expect(map.get('inner').get('native2').get('value')).toBe(0);
native2.setValue(1);
// @ts-expect-error FIXME: ts error
expect(map.get('inner').get('native2').get('value')).toBe(1);
});
});
describe('stash and pop', () => {
test('object', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
map.set('num', 0);
const proxy = createYProxy<Record<string, any>>(map);
expect(proxy.num).toBe(0);
stashProp(map, 'num');
proxy.num = 1;
expect(proxy.num).toBe(1);
expect(map.get('num')).toBe(0);
proxy.num = 2;
popProp(map, 'num');
expect(map.get('num')).toBe(2);
});
test('array', () => {
const ydoc = new Y.Doc();
const arr = ydoc.getArray('arr');
arr.push([0]);
const proxy = createYProxy<Record<string, any>>(arr);
expect(proxy[0]).toBe(0);
stashProp(arr, 0);
proxy[0] = 1;
expect(proxy[0]).toBe(1);
expect(arr.get(0)).toBe(0);
popProp(arr, 0);
expect(arr.get(0)).toBe(1);
});
test('nested', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const arr = new Y.Array();
map.set('arr', arr);
arr.push([0]);
const proxy = createYProxy<Record<string, any>>(map);
expect(proxy.arr[0]).toBe(0);
stashProp(arr, 0);
proxy.arr[0] = 1;
expect(proxy.arr[0]).toBe(1);
expect(arr.get(0)).toBe(0);
popProp(arr, 0);
expect(arr.get(0)).toBe(1);
});
});
});