test: add std gfx test (#11442)

### Changed
- Move some intergraion tests to std as they are more like basic tests
- Add some basic gfx-related tests
This commit is contained in:
doouding
2025-04-08 16:20:35 +00:00
parent e4e3d8ef59
commit d7268ce04c
19 changed files with 641 additions and 265 deletions

View File

@@ -11,6 +11,7 @@ import {
HeadingBlockSchemaExtension,
NoteBlockSchemaExtension,
RootBlockSchemaExtension,
SurfaceBlockSchemaExtension,
} from './test-schema.js';
import { testSpecs } from './test-spec.js';
@@ -20,6 +21,7 @@ const extensions = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
HeadingBlockSchemaExtension,
SurfaceBlockSchemaExtension,
];
function createTestOptions() {

View File

@@ -0,0 +1,358 @@
import {
createAutoIncrementIdGenerator,
TestWorkspace,
} from '@blocksuite/store/test';
import { describe, expect, test, vi } from 'vitest';
import { effects } from '../../effects.js';
import type { TestShapeElement } from '../test-gfx-element.js';
import {
RootBlockSchemaExtension,
type SurfaceBlockModel,
SurfaceBlockSchemaExtension,
} from '../test-schema.js';
effects();
const extensions = [RootBlockSchemaExtension, SurfaceBlockSchemaExtension];
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
return { id: 'test-collection', idGenerator };
}
const commonSetup = () => {
const collection = new TestWorkspace(createTestOptions());
collection.meta.initialize();
const doc = collection.createDoc('home');
const store = doc.getStore({ extensions });
doc.load();
const rootId = store.addBlock('test:page');
const surfaceId = store.addBlock('test:surface', {}, rootId);
const surfaceBlock = store.getBlock(surfaceId)!;
return {
surfaceId,
surfaceModel: surfaceBlock.model as SurfaceBlockModel,
};
};
describe('surface basic', () => {
test('addElement should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
expect(model.elementModels[0].id).toBe(id);
});
test('removeElement should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
model.deleteElement(id);
expect(model.elementModels.length).toBe(0);
});
test('updateElement should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
model.updateElement(id, { xywh: '[10,10,200,200]' });
expect(model.elementModels[0].xywh).toBe('[10,10,200,200]');
});
test('getElementById should return element', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
expect(model.getElementById(id)).not.toBeNull();
});
test('getElementById should return null if not found', () => {
const { surfaceModel: model } = commonSetup();
expect(model.getElementById('not-found')).toBeNull();
});
test('created observer should be called', () => {
const { surfaceModel } = commonSetup();
let expectPayload;
const elementAddedCallback = vi.fn(payload => (expectPayload = payload));
surfaceModel.elementAdded.subscribe(elementAddedCallback);
const shapeId = surfaceModel.addElement({
type: 'testShape',
rotate: 0,
xywh: '[0, 0, 10, 10]',
});
expect(elementAddedCallback).toHaveBeenCalled();
expect(expectPayload).toMatchObject({
id: shapeId,
});
});
test('update and props observer should be called', () => {
const { surfaceModel } = commonSetup();
const shapeId = surfaceModel.addElement({
type: 'testShape',
rotate: 0,
xywh: '[0, 0, 10, 10]',
});
const shapeModel = surfaceModel.getElementById(shapeId)!;
let expectPayload;
const elementUpdatedCallback = vi.fn(payload => (expectPayload = payload));
let propsUpdatedPayload;
const propsUpdatedCallback = vi.fn(payload => {
propsUpdatedPayload = payload;
});
surfaceModel.elementUpdated.subscribe(elementUpdatedCallback);
shapeModel.propsUpdated.subscribe(propsUpdatedCallback);
surfaceModel.updateElement(shapeId, {
rotate: 10,
});
expect(elementUpdatedCallback).toHaveBeenCalled();
expect(propsUpdatedCallback).toHaveBeenCalled();
expect(expectPayload).toMatchObject({
id: shapeId,
props: {
rotate: 10,
},
oldValues: {
rotate: 0,
},
});
expect(propsUpdatedPayload).toMatchObject({
key: 'rotate',
});
});
test('delete observer should be called', () => {
const { surfaceModel } = commonSetup();
const shapeId = surfaceModel.addElement({
type: 'testShape',
rotate: 0,
xywh: '[0, 0, 10, 10]',
});
let expectPayload;
const deletedCallback = vi.fn(payload => (expectPayload = payload));
surfaceModel.elementRemoved.subscribe(deletedCallback);
surfaceModel.deleteElement(shapeId);
expect(deletedCallback).toHaveBeenCalled();
expect(expectPayload).toMatchObject({
id: shapeId,
type: 'testShape',
});
});
});
describe('element model', () => {
test('default value should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const element = model.getElementById(id)! as TestShapeElement;
expect(element.rotate).toBe(0);
expect(element.xywh).toBe('[0,0,10,10]');
});
test('defined prop should not be overwritten by default value', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
rotate: 20,
});
const element = model.getElementById(id)! as TestShapeElement;
expect(element.rotate).toBe(20);
});
test('assign value to model property should update ymap directly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const element = model.getElementById(id)! as TestShapeElement;
expect(element.yMap.get('rotate')).toBe(0);
element.rotate = 30;
expect(element.yMap.get('rotate')).toBe(30);
});
});
describe('stash/pop', () => {
const { surfaceModel: model } = commonSetup();
test('stash and pop should work correctly', () => {
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
expect(elementModel.rotate).toBe(0);
elementModel.stash('rotate');
elementModel.rotate = 10;
expect(elementModel.rotate).toBe(10);
expect(elementModel.yMap.get('rotate')).toBe(0);
elementModel.pop('rotate');
expect(elementModel.rotate).toBe(10);
expect(elementModel.yMap.get('rotate')).toBe(10);
elementModel.rotate = 6;
expect(elementModel.rotate).toBe(6);
expect(elementModel.yMap.get('rotate')).toBe(6);
});
test('assign stashed property should emit event', () => {
const id = model.addElement({
type: 'testShape',
rotate: 4,
});
const elementModel = model.getElementById(id)! as TestShapeElement;
elementModel.stash('rotate');
const onchange = vi.fn();
const subscription = model.elementUpdated.subscribe(({ id }) => {
subscription.unsubscribe();
onchange(id);
});
elementModel.rotate = 10;
expect(onchange).toHaveBeenCalledWith(id);
});
test('stashed property should also trigger derive decorator', () => {
const id = model.addElement({
type: 'testShape',
rotate: 20,
});
const elementModel = model.getElementById(id)! as TestShapeElement;
elementModel.stash('shapeType');
elementModel.shapeType = 'triangle';
// rotation should be 0 'cause of derive decorator
expect(elementModel.rotate).toBe(0);
});
test('non-field property should not allow stash/pop, and should fail silently ', () => {
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
// opacity is a local property, so it should not be stashed
elementModel.stash('opacity');
expect(elementModel['_stashed'].has('opacity')).toBe(false);
// pop the `opacity` should not affect yMap
elementModel.opacity = 0.5;
elementModel.pop('opacity');
expect(elementModel.yMap.has('opacity')).toBe(false);
});
});
describe('derive decorator', () => {
test('derived decorator should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
rotate: 20,
});
const elementModel = model.getElementById(id)! as TestShapeElement;
elementModel.shapeType = 'triangle';
expect(elementModel.rotate).toBe(0);
});
});
describe('local decorator', () => {
test('local decorator should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
expect(elementModel.display).toBe(true);
elementModel.display = false;
expect(elementModel.display).toBe(false);
elementModel.opacity = 0.5;
expect(elementModel.opacity).toBe(0.5);
});
test('assign local property should emit event', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
const onchange = vi.fn();
const subscription = model.elementUpdated.subscribe(({ id }) => {
subscription.unsubscribe();
onchange(id);
});
const onPropChange = vi.fn();
elementModel.propsUpdated.subscribe(({ key }) => {
onPropChange(key);
});
elementModel.display = false;
expect(elementModel.display).toBe(false);
expect(onchange).toHaveBeenCalledWith(id);
expect(onPropChange).toHaveBeenCalledWith('display');
});
});
describe('convert decorator', () => {
test('convert decorator', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
// @ts-expect-error test needed
elementModel.shapeType = 'otherImpossibleType';
expect(elementModel.shapeType).toBe('rect');
});
});

View File

@@ -0,0 +1,134 @@
import {
createAutoIncrementIdGenerator,
TestWorkspace,
} from '@blocksuite/store/test';
import { describe, expect, test } from 'vitest';
import { effects } from '../../effects.js';
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
import { TestEditorContainer } from '../test-editor.js';
import { TestLocalElement } from '../test-gfx-element.js';
import {
RootBlockSchemaExtension,
type SurfaceBlockModel,
SurfaceBlockSchemaExtension,
TestGfxBlockSchemaExtension,
} from '../test-schema.js';
import { testSpecs } from '../test-spec.js';
effects();
const extensions = [
RootBlockSchemaExtension,
SurfaceBlockSchemaExtension,
TestGfxBlockSchemaExtension,
];
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
return { id: 'test-collection', idGenerator };
}
const commonSetup = async () => {
const collection = new TestWorkspace(createTestOptions());
collection.meta.initialize();
const doc = collection.createDoc('home');
const store = doc.getStore({ extensions });
doc.load();
const rootId = store.addBlock('test:page');
const surfaceId = store.addBlock('test:surface', {}, rootId);
const surfaceBlock = store.getBlock(surfaceId)!;
const editorContainer = new TestEditorContainer();
editorContainer.doc = store;
editorContainer.specs = testSpecs;
document.body.append(editorContainer);
await editorContainer.updateComplete;
const gfx = editorContainer.std.get(GfxControllerIdentifier);
return {
gfx,
surfaceId,
rootId,
surfaceModel: surfaceBlock.model as SurfaceBlockModel,
};
};
describe('gfx element view basic', () => {
test('view should be created', async () => {
const { gfx, surfaceModel } = await commonSetup();
const id = surfaceModel.addElement({
type: 'testShape',
});
const shapeView = gfx.view.get(id);
expect(shapeView).not.toBeNull();
expect(shapeView!.model.id).toBe(id);
expect(shapeView!.isConnected).toBe(true);
});
test('view should be removed', async () => {
const { gfx, surfaceModel } = await commonSetup();
const id = surfaceModel.addElement({
type: 'testShape',
});
const shapeView = gfx.view.get(id);
expect(shapeView).not.toBeNull();
expect(shapeView!.model.id).toBe(id);
surfaceModel.deleteElement(id);
expect(gfx.view.get(id)).toBeNull();
expect(shapeView!.isConnected).toBe(false);
});
test('query gfx block view should work', async () => {
const { gfx, surfaceId, rootId } = await commonSetup();
const waitGfxViewConnected = (id: string) => {
const { promise, resolve } = Promise.withResolvers<void>();
const subscription = gfx.std.view.viewUpdated.subscribe(payload => {
if (
payload.id === id &&
payload.type === 'block' &&
payload.method === 'add'
) {
subscription.unsubscribe();
resolve();
}
});
return promise;
};
const id = gfx.std.store.addBlock('test:gfx-block', undefined, surfaceId);
await waitGfxViewConnected(id);
const gfxBlockView = gfx.view.get(id);
expect(gfxBlockView).not.toBeNull();
const rootView = gfx.view.get(rootId);
// root is not a gfx block, so it should be null
expect(rootView).toBeNull();
});
test('local element view should be created', async () => {
const { gfx, surfaceModel } = await commonSetup();
const localElement = new TestLocalElement(surfaceModel);
localElement.id = 'test-local-element';
surfaceModel.addLocalElement(localElement);
const localView = gfx.view.get(localElement);
expect(localView).not.toBeNull();
expect(localView!.isConnected).toBe(true);
surfaceModel.deleteLocalElement(localElement);
expect(localView!.isConnected).toBe(false);
});
});

View File

@@ -1,11 +1,13 @@
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { BlockComponent } from '../view/index.js';
import { BlockComponent, GfxBlockComponent } from '../view/index.js';
import type {
HeadingBlockModel,
NoteBlockModel,
RootBlockModel,
SurfaceBlockModel,
TestGfxBlockModel,
} from './test-schema.js';
@customElement('test-root-block')
@@ -39,3 +41,21 @@ export class HeadingH2BlockComponent extends BlockComponent<HeadingBlockModel> {
return html` <div class="test-heading-block h2">${this.model.text}</div> `;
}
}
@customElement('test-surface-block')
export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
override renderBlock() {
return html`
<div class="test-surface-block">${this.renderChildren(this.model)}</div>
`;
}
}
@customElement('test-gfx-block')
export class TestGfxBlockComponent extends GfxBlockComponent<TestGfxBlockModel> {
override renderGfxBlock() {
return html`
<div class="test-gfx-block">${this.renderChildren(this.model)}</div>
`;
}
}

View File

@@ -0,0 +1,44 @@
import type { SerializedXYWH } from '@blocksuite/global/gfx';
import {
convert,
derive,
field,
GfxLocalElementModel,
GfxPrimitiveElementModel,
} from '../gfx/index.js';
export class TestShapeElement extends GfxPrimitiveElementModel {
get type() {
return 'testShape';
}
@field()
accessor rotate: number = 0;
@field()
accessor xywh: SerializedXYWH = '[0,0,10,10]';
@convert(val => {
if (['rect', 'triangle'].includes(val)) {
return val;
}
return 'rect';
})
@derive(val => {
if (val === 'triangle') {
return {
rotate: 0,
};
}
return {};
})
@field()
accessor shapeType: 'rect' | 'triangle' = 'rect';
}
export class TestLocalElement extends GfxLocalElementModel {
override type: string = 'testLocal';
}

View File

@@ -1,8 +1,17 @@
import type { SerializedXYWH } from '@blocksuite/global/gfx';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import * as Y from 'yjs';
import { SurfaceBlockModel as BaseSurfaceModel } from '../gfx/index.js';
import {
GfxCompatibleBlockModel,
type GfxCompatibleProps,
} from '../gfx/model/gfx-block-model.js';
import { TestShapeElement } from './test-gfx-element.js';
export const RootBlockSchema = defineBlockSchema({
flavour: 'test:page',
@@ -15,7 +24,7 @@ export const RootBlockSchema = defineBlockSchema({
metadata: {
version: 2,
role: 'root',
children: ['test:note'],
children: ['test:note', 'test:surface'],
},
});
@@ -61,3 +70,58 @@ export const HeadingBlockSchemaExtension =
export class HeadingBlockModel extends BlockModel<
ReturnType<(typeof HeadingBlockSchema)['model']['props']>
> {}
export const SurfaceBlockSchema = defineBlockSchema({
flavour: 'test:surface',
props: internal => ({
elements: internal.Boxed<Y.Map<Y.Map<unknown>>>(new Y.Map()),
}),
metadata: {
version: 1,
role: 'hub',
parent: ['test:page'],
},
toModel: () => new SurfaceBlockModel(),
});
export const SurfaceBlockSchemaExtension =
BlockSchemaExtension(SurfaceBlockSchema);
export class SurfaceBlockModel extends BaseSurfaceModel {
override _init() {
this._extendElement({
testShape: TestShapeElement,
});
super._init();
}
}
type GfxTestBlockProps = {
xywh: SerializedXYWH;
rotate: number;
index: string;
} & GfxCompatibleProps;
export const TestGfxBlockSchema = defineBlockSchema({
flavour: 'test:gfx-block',
props: () =>
({
xywh: '[0,0,10,10]' as SerializedXYWH,
rotate: 0,
index: 'a0',
lockedBySelf: false,
}) as GfxTestBlockProps,
metadata: {
version: 1,
role: 'content',
parent: ['test:surface'],
},
toModel: () => new TestGfxBlockModel(),
});
export const TestGfxBlockSchemaExtension =
BlockSchemaExtension(TestGfxBlockSchema);
export class TestGfxBlockModel extends GfxCompatibleBlockModel<GfxTestBlockProps>(
BlockModel
) {}

View File

@@ -20,4 +20,8 @@ export const testSpecs: ExtensionType[] = [
return literal`test-h2-block`;
}),
BlockViewExtension('test:surface', literal`test-surface-block`),
BlockViewExtension('test:gfx-block', literal`test-gfx-block`),
];