chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
`;
|
||||
77
blocksuite/framework/store/src/__tests__/assets.unit.spec.ts
Normal file
77
blocksuite/framework/store/src/__tests__/assets.unit.spec.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
249
blocksuite/framework/store/src/__tests__/block.unit.spec.ts
Normal file
249
blocksuite/framework/store/src/__tests__/block.unit.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
954
blocksuite/framework/store/src/__tests__/collection.unit.spec.ts
Normal file
954
blocksuite/framework/store/src/__tests__/collection.unit.spec.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
272
blocksuite/framework/store/src/__tests__/doc.unit.spec.ts
Normal file
272
blocksuite/framework/store/src/__tests__/doc.unit.spec.ts
Normal 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();
|
||||
});
|
||||
133
blocksuite/framework/store/src/__tests__/jsx.unit.spec.ts
Normal file
133
blocksuite/framework/store/src/__tests__/jsx.unit.spec.ts
Normal 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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
133
blocksuite/framework/store/src/__tests__/schema.unit.spec.ts
Normal file
133
blocksuite/framework/store/src/__tests__/schema.unit.spec.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
88
blocksuite/framework/store/src/__tests__/test-schema.ts
Normal file
88
blocksuite/framework/store/src/__tests__/test-schema.ts
Normal 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: [],
|
||||
},
|
||||
});
|
||||
122
blocksuite/framework/store/src/__tests__/test-utils-dom.ts
Normal file
122
blocksuite/framework/store/src/__tests__/test-utils-dom.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
177
blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts
Normal file
177
blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user