sprotty
Version:
A next-gen framework for graphical views
578 lines (551 loc) • 21.1 kB
text/typescript
/********************************************************************************
* Copyright (c) 2017-2020 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
import 'reflect-metadata';
import { expect, describe, it } from 'vitest';
import { Container } from 'inversify';
import { TYPES } from '../../base/types';
import { ConsoleLogger } from '../../utils/logging';
import { EMPTY_ROOT, IModelFactory } from '../../base/model/smodel-factory';
import { SModelElementImpl, SModelRootImpl } from '../../base/model/smodel';
import { CommandExecutionContext } from '../../base/commands/command';
import { AnimationFrameSyncer } from '../../base/animations/animation-frame-syncer';
import { CompoundAnimation, Animation } from '../../base/animations/animation';
import { SEdgeImpl, SGraphImpl, SNodeImpl } from '../../graph/sgraph';
import { FadeAnimation } from '../../features/fade/fade';
import { MoveAnimation, MorphEdgesAnimation } from '../../features/move/move';
import { UpdateModelCommand } from './update-model';
import { ModelMatcher } from './model-matching';
import { ManhattanEdgeRouter } from '../routing/manhattan-edge-router';
import defaultModule from '../../base/di.config';
import { EdgeRouterRegistry } from '../routing/routing';
import { AnchorComputerRegistry } from '../routing/anchor';
import { ManhattanRectangularAnchor } from '../routing/manhattan-anchors';
import { Point, SEdge, SGraph, SModelElement, SModelRoot,SNode, UpdateModelAction } from 'sprotty-protocol';
import { registerModelElement } from '../../base/model/smodel-utils';
function compare(expected: SModelElement, actual: SModelElementImpl) {
for (const p in expected) {
if (Object.prototype.hasOwnProperty.call(expected, p)) {
const expectedProp = (expected as any)[p];
const actualProp = (actual as any)[p];
if (p === 'children') {
for (const i in expectedProp) {
if (Object.prototype.hasOwnProperty.call(expectedProp, i)) {
compare(expectedProp[i], actualProp[i]);
}
}
} else {
expect(actualProp).to.deep.equal(expectedProp);
}
}
}
}
describe('UpdateModelCommand', () => {
const container = new Container();
container.load(defaultModule);
registerModelElement(container, 'graph', SGraphImpl);
registerModelElement(container, 'node', SNodeImpl);
registerModelElement(container, 'edge', SEdgeImpl);
const graphFactory = container.get<IModelFactory>(TYPES.IModelFactory);
const emptyRoot = graphFactory.createRoot(EMPTY_ROOT);
const context: CommandExecutionContext = {
root: emptyRoot,
modelFactory: graphFactory,
duration: 0,
modelChanged: undefined!,
logger: new ConsoleLogger(),
syncer: new AnimationFrameSyncer()
};
const model1 = graphFactory.createRoot({
id: 'model',
type: 'graph',
children: []
});
const model2: SModelRoot = {
id: 'model',
type: 'graph2',
children: []
};
const command1 = new UpdateModelCommand({
kind: UpdateModelCommand.KIND,
newRoot: model2,
animate: false
});
it('replaces the model if animation is suppressed', () => {
context.root = model1; /* the old model */
const newModel = command1.execute(context);
compare(model2, newModel as SModelRootImpl);
expect(model1).to.equal(command1.oldRoot);
expect(newModel).to.equal(command1.newRoot);
});
it('undo() returns the previous model', () => {
context.root = graphFactory.createRoot(model2);
expect(model1).to.equal(command1.undo(context));
});
it('redo() returns the new model', () => {
context.root = model1; /* the old model */
const newModel = command1.redo(context);
compare(model2, newModel as SModelRootImpl);
});
class TestUpdateModelCommand extends UpdateModelCommand {
constructor(action: UpdateModelAction, edgeRouterRegistry?: EdgeRouterRegistry) {
super(action);
this.edgeRouterRegistry = edgeRouterRegistry;
}
testAnimation(root: SModelRootImpl, execContext: CommandExecutionContext) {
this.oldRoot = root;
this.newRoot = execContext.modelFactory.createRoot(this.action.newRoot!);
const matcher = new ModelMatcher();
const matchResult = matcher.match(root, this.newRoot);
return this.computeAnimation(this.newRoot, matchResult, execContext);
}
}
it('fades in new elements', () => {
const command2 = new TestUpdateModelCommand({
kind: UpdateModelCommand.KIND,
animate: true,
newRoot: {
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'child1'
},
{
type: 'node',
id: 'child2'
}
]
}
});
const animation = command2.testAnimation(model1, context);
expect(animation).to.be.an.instanceOf(FadeAnimation);
const fades = (animation as FadeAnimation).elementFades;
expect(fades).to.have.lengthOf(2);
for (const fade of fades) {
expect(fade.type).to.equal('in');
expect(fade.element.type).to.equal('node');
expect(fade.element.id).to.be.oneOf(['child1', 'child2']);
}
});
it('fades out deleted elements', () => {
const model3 = graphFactory.createRoot({
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'child1'
},
{
type: 'node',
id: 'child2'
}
]
});
const command2 = new TestUpdateModelCommand({
kind: UpdateModelCommand.KIND,
animate: true,
newRoot: {
type: 'graph',
id: 'model',
children: []
}
});
const animation = command2.testAnimation(model3, context);
expect(animation).to.be.an.instanceOf(FadeAnimation);
const fades = (animation as FadeAnimation).elementFades;
expect(fades).to.have.lengthOf(2);
for (const fade of fades) {
expect(fade.type).to.equal('out');
expect(fade.element.type).to.equal('node');
expect(fade.element.id).to.be.oneOf(['child1', 'child2']);
}
});
it('moves relocated elements', () => {
const model3 = graphFactory.createRoot({
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'child1',
position: { x: 100, y: 100 }
} as SNode
]
});
const command2 = new TestUpdateModelCommand({
kind: UpdateModelCommand.KIND,
animate: true,
newRoot: {
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'child1',
position: { x: 150, y: 200 }
} as SNode
]
}
});
const animation = command2.testAnimation(model3, context);
expect(animation).to.be.an.instanceOf(MoveAnimation);
const moves = (animation as MoveAnimation).elementMoves;
const child1Move = moves.get('child1')!;
expect(child1Move.element.id).to.equal('child1');
expect(child1Move.fromPosition).to.deep.equal({ x: 100, y: 100 });
expect(child1Move.toPosition).to.deep.equal({ x: 150, y: 200 });
});
it('combines fade and move animations', () => {
const model3 = graphFactory.createRoot({
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'child1',
position: { x: 100, y: 100 }
} as SNode,
{
type: 'node',
id: 'child2'
}
]
});
const command2 = new TestUpdateModelCommand({
kind: UpdateModelCommand.KIND,
animate: true,
newRoot: {
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'child1',
position: { x: 150, y: 200 }
} as SNode,
{
type: 'node',
id: 'child3'
}
]
}
});
const animation = command2.testAnimation(model3, context);
expect(animation).to.be.an.instanceOf(CompoundAnimation);
const components = (animation as CompoundAnimation).components;
expect(components).to.have.lengthOf(2);
const fadeAnimation = components[0] as FadeAnimation;
expect(fadeAnimation).to.be.an.instanceOf(FadeAnimation);
expect(fadeAnimation.elementFades).to.have.lengthOf(2);
for (const fade of fadeAnimation.elementFades) {
if (fade.type === 'in')
expect(fade.element.id).to.equal('child3');
else if (fade.type === 'out')
expect(fade.element.id).to.equal('child2');
}
const moveAnimation = components[1] as MoveAnimation;
expect(moveAnimation).to.be.an.instanceOf(MoveAnimation);
const child1Move = moveAnimation.elementMoves.get('child1')!;
expect(child1Move.element.id).to.equal('child1');
expect(child1Move.fromPosition).to.deep.equal({ x: 100, y: 100 });
expect(child1Move.toPosition).to.deep.equal({ x: 150, y: 200 });
});
it('applies a given model diff', () => {
context.root = graphFactory.createRoot({
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'child1',
position: { x: 100, y: 100 }
} as SNode,
{
type: 'node',
id: 'child2'
}
]
});
const command2 = new TestUpdateModelCommand({
kind: UpdateModelCommand.KIND,
animate: false,
matches: [
{
right: {
type: 'node',
id: 'child3'
},
rightParentId: 'model'
},
{
left: {
type: 'node',
id: 'child2'
},
leftParentId: 'model'
},
{
left: {
type: 'node',
id: 'child1',
position: { x: 100, y: 100 }
} as SNode,
leftParentId: 'model',
right: {
type: 'node',
id: 'child1',
position: { x: 150, y: 200 }
} as SNode,
rightParentId: 'model',
}
]
});
const newModel = command2.execute(context) as SModelRootImpl;
expect(newModel.children).to.have.lengthOf(2);
const expected: SGraph = {
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'child3'
},
{
type: 'node',
id: 'child1',
position: { x: 150, y: 200 }
} as SNode
]
};
compare(expected, newModel);
});
it('morphs edge', () => {
const edgeId = 'edge';
const edgeRouterRegistry = createEdgeRouterRegistry();
context.root = graphFactory.createRoot(
newModelWithEdge(edgeId, [{ x: 64, y: 0 }, { x: 64, y: 128 }]));
// connects node1 left with node2 right at respective midpoints
const command3 = new TestUpdateModelCommand({
kind: UpdateModelCommand.KIND,
animate: false,
newRoot: newModelWithEdge(edgeId, [{ x: 136, y: 0 }])
}, edgeRouterRegistry);
const animation1 = command3.testAnimation(context.root, context);
expect(animation1).to.be.instanceof(MorphEdgesAnimation);
if (animation1 instanceof Animation) {
const newRoot = animation1.tween(0, context);
const edge = newRoot.index.getById(edgeId) as SEdgeImpl;
expect(edge.routingPoints).to.be.eql([
{ x: 64, y: 0 },
{ x: 64, y: 128 }
]);
animation1.tween(0.5, context);
expect(edge.routingPoints).to.be.eql([
{ x: 100, y: 0 }, // midpoint between 64:0 and 136:0
{ x: 100, y: 94 } // midpoint between 64:128 and the interpolated 136:60
]);
animation1.tween(1, context);
expect(edge.routingPoints).to.be.eql([
{ x: 136, y: 0 }
]);
}
const command2 = new TestUpdateModelCommand({
kind: UpdateModelCommand.KIND,
animate: false,
newRoot: newModelWithEdge(edgeId, [{ x: 32, y: 0 }, { x: 32, y: 32 }, { x: 64, y: 32 }, { x: 64, y: 128 }])
}, edgeRouterRegistry);
const animation2 = command2.testAnimation(command3.newRoot, context);
expect(animation2).to.be.instanceof(MorphEdgesAnimation);
if (animation2 instanceof Animation) {
const newRoot = animation2.tween(0, context);
const edge = newRoot.index.getById(edgeId) as SEdgeImpl;
expect(edge.routingPoints).to.be.eql([
{ x: 68, y: 0 }, // interpolated between 0:0 and 136:0
{ x: 136, y: 0 }, // original
{ x: 136, y: 40 }, // interpolated between 136:0 and 136:120 (33%)
{ x: 136, y: 80 }, // interpolated between 136:0 and 136:120 (66%)
]);
animation2.tween(0.5, context);
expect(edge.routingPoints).to.be.eql([
{ x: 50, y: 0 }, // midpoint between 32:0 and 68:0
{ x: 84, y: 16 }, // midpoint between 32:32 and 136:0
{ x: 100, y: 36 }, // midpoint between 64:32 and 136:40
{ x: 100, y: 104 }, // midpoint between 64:128 and 136:80
]);
animation2.tween(1, context);
expect(edge.routingPoints).to.be.eql([
{ x: 32, y: 0 },
{ x: 32, y: 32 },
{ x: 64, y: 32 },
{ x: 64, y: 128 }
]);
}
});
it('#190 removes relocated elements before adding them { animate: false }', async () => {
return removesRelocatedElementsBeforeAddingThem(false);
});
it('#190 removes relocated elements before adding them { animate: true }', async () => {
return removesRelocatedElementsBeforeAddingThem(true);
});
it('#190 removes container element and adds contained element { animate: false }', async () => {
return removesContainerElementAndAddsContainedElement(false);
});
it('#190 removes container element and adds contained element { animate: true }', async () => {
return removesContainerElementAndAddsContainedElement(true);
});
async function removesRelocatedElementsBeforeAddingThem(animate: boolean): Promise<void> {
context.root = graphFactory.createRoot({
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'a',
children: [
{
type: 'node',
id: 'b'
}
]
}
]
});
const flattenCommand = new UpdateModelCommand({
kind: UpdateModelCommand.KIND,
animate,
matches: [
{
right: {
type: 'node',
id: 'b'
},
rightParentId: 'model'
},
{
left: {
type: 'node',
id: 'b'
},
leftParentId: 'a'
}
]
});
const actual = await flattenCommand.execute(context);
const expected: SModelElement = {
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'a'
},
{
type: 'node',
id: 'b'
}
]
};
compare(expected, actual as SModelRootImpl);
}
async function removesContainerElementAndAddsContainedElement(animate: boolean): Promise<void> {
context.root = graphFactory.createRoot({
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'a',
children: [
{
type: 'node',
id: 'b'
}
]
}
]
});
const flattenCommand = new UpdateModelCommand({
kind: UpdateModelCommand.KIND,
animate,
matches: [
{
right: {
type: 'node',
id: 'b'
},
rightParentId: 'model'
},
{
left: {
type: 'node',
id: 'a'
},
leftParentId: 'model'
}
]
});
const actual = await flattenCommand.execute(context);
const expected: SModelElement = {
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'b'
}
]
};
compare(expected, actual as SModelRootImpl);
}
function newModelWithEdge(edgeId: string, routingPoints: Point[]): SModelRoot {
return {
type: 'graph',
id: 'model',
children: [
{
type: 'node',
id: 'node1',
position: { x: -16, y: -8 },
size: { width: 16, height: 16 }
// mid-right is at 0:0
} as SNode,
{
type: 'node',
id: 'node2',
position: { x: 128, y: 120 },
size: { width: 16, height: 16 }
// mid-left is at 128:128
} as SNode,
{
type: 'edge',
id: edgeId,
routerKind: ManhattanEdgeRouter.KIND,
routingPoints,
sourceId: 'node1',
targetId: 'node2'
} as SEdge
]
};
}
function createEdgeRouterRegistry(): EdgeRouterRegistry {
const anchorRegistry = new AnchorComputerRegistry([new ManhattanRectangularAnchor()]);
const router = new ManhattanEdgeRouter();
router.anchorRegistry = anchorRegistry;
return new EdgeRouterRegistry([router]);
}
});