Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions backend/src/__tests__/routes/interoperability.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { errorHandler } from '../../middleware';

const siriusInteropServiceMock = {
validate: jest.fn(),
validateAirdView: jest.fn(),
importOdesign: jest.fn(),
importAird: jest.fn(),
exportOdesign: jest.fn(),
};

Expand Down Expand Up @@ -66,6 +68,51 @@ describe('interoperability Sirius routes', () => {
);
});

it('routes .aird validation requests to validateAirdView', async () => {
siriusInteropServiceMock.validateAirdView.mockResolvedValue({
diagrams: [],
report: { supported: true, warnings: [], droppedFeatures: [], unresolvedReferences: [] },
});

const res = await request(buildApp())
.post('/api/interoperability/sirius/validate')
.send({ content: '<viewpoint:DAnalysis/>', sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' });

expect(res.status).toBe(200);
expect(siriusInteropServiceMock.validateAirdView).toHaveBeenCalledWith(
expect.objectContaining({ sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' }),
'user-1'
);
expect(siriusInteropServiceMock.validate).not.toHaveBeenCalled();
});

it('imports Sirius .aird views', async () => {
siriusInteropServiceMock.importAird.mockResolvedValue({
diagrams: [{ id: 'diagram-1' }],
report: { supported: true, warnings: [], droppedFeatures: [], unresolvedReferences: [] },
});

const res = await request(buildApp())
.post('/api/interoperability/sirius/aird/import')
.send({ content: '<viewpoint:DAnalysis/>', modelId: 'model-1', viewpointId: 'viewpoint-1' });

expect(res.status).toBe(201);
expect(siriusInteropServiceMock.importAird).toHaveBeenCalledWith(
expect.objectContaining({ modelId: 'model-1', viewpointId: 'viewpoint-1' }),
'user-1',
'DSL_DESIGNER'
);
});

it('rejects .aird import requests without a modelId', async () => {
const res = await request(buildApp())
.post('/api/interoperability/sirius/aird/import')
.send({ content: '<viewpoint:DAnalysis/>' });

expect(res.status).toBe(400);
expect(siriusInteropServiceMock.importAird).not.toHaveBeenCalled();
});

it('imports Sirius .odesign content', async () => {
siriusInteropServiceMock.importOdesign.mockResolvedValue({
viewpoints: [{ id: 'viewpoint-1' }],
Expand Down
223 changes: 216 additions & 7 deletions backend/src/__tests__/services/sirius-interop.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ const viewpointServiceMock = {
getAll: jest.fn(),
};

const modelServiceMock = {
getById: jest.fn(),
};

const diagramServiceMock = {
create: jest.fn(),
};

jest.mock('../../services/metamodel.service', () => ({
metamodelService: metamodelServiceMock,
}));
Expand All @@ -17,9 +25,19 @@ jest.mock('../../services/viewpoint.service', () => ({
viewpointService: viewpointServiceMock,
}));

jest.mock('../../services/model.service', () => ({
modelService: modelServiceMock,
}));

jest.mock('../../services/diagram.service', () => ({
diagramService: diagramServiceMock,
}));

import { siriusInteropService } from '../../services/sirius-interop.service';
import { Metamodel, Viewpoint } from '../../../../shared/types';
import { Metamodel, Model, Viewpoint } from '../../../../shared/types';
import JSZip from 'jszip';
import * as fs from 'fs';
import * as path from 'path';

const mockMetamodel: Metamodel = {
id: 'metamodel-1',
Expand Down Expand Up @@ -159,10 +177,12 @@ describe('SiriusInteropService', () => {
).rejects.toThrow(ApiError);
});

it('recognizes .aird files as deferred Sirius session data', async () => {
const preview = await siriusInteropService.validate(
it('recognizes a Sirius .aird DAnalysis but reports when it has no diagram representations', async () => {
modelServiceMock.getById.mockResolvedValue(mockModel);
const preview = await siriusInteropService.validateAirdView(
{
sourceFormat: 'aird',
modelId: 'model-1',
content: `<?xml version="1.0" encoding="UTF-8"?>
<viewpoint:DAnalysis xmi:id="analysis-1"
xmlns:xmi="http://www.omg.org/XMI"
Expand All @@ -171,10 +191,9 @@ describe('SiriusInteropService', () => {
'user-1'
);

expect(preview.viewpoints).toHaveLength(0);
expect(preview.report.supported).toBe(true);
expect(preview.report.droppedFeatures).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'SIRIUS_DEFERRED_AIRD' }),
expect(preview.diagrams).toHaveLength(0);
expect(preview.report.unresolvedReferences).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'SIRIUS_AIRD_NO_DIAGRAMS' }),
]));
});

Expand Down Expand Up @@ -383,3 +402,193 @@ describe('SiriusInteropService', () => {
).rejects.toThrow(ApiError);
});
});

const mockModel: Model = {
id: 'model-1',
name: 'Demo Model',
metamodelId: 'metamodel-1',
conformsTo: 'metamodel-1',
elements: [
{ id: 'component-api', name: 'API', modelElementId: 'cls-root', style: {}, references: {} },
{ id: 'component-db', name: 'Database', modelElementId: 'cls-child', style: {}, references: {} },
],
};

const mockAirdViewpoint: Viewpoint = {
id: 'viewpoint-1',
name: 'Minimal Viewpoint',
metamodelId: 'metamodel-1',
sharedConcreteSyntaxByMetaClassId: {},
isDefault: true,
representationDescriptions: [
{
id: 'sirius-diag-main',
name: 'Minimal Diagram',
viewpointId: 'viewpoint-1',
kind: 'diagram',
visibleMetaClassIds: ['cls-root', 'cls-child'],
creatableMetaClassIds: ['cls-root', 'cls-child'],
edgeMappings: [{ id: 'sirius-edge-children', referenceName: 'children' }],
isDefault: true,
},
],
};

const airdWithLayout = `<?xml version="1.0" encoding="UTF-8"?>
<xmi:XMI xmi:version="2.0"
xmlns:xmi="http://www.omg.org/XMI"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:viewpoint="http://www.eclipse.org/sirius/1.1.0"
xmlns:diagram="http://www.eclipse.org/sirius/diagram/1.1.0"
xmlns:notation="http://www.eclipse.org/gmf/runtime/1.0.2/notation">
<viewpoint:DAnalysis xmi:id="analysis-1">
<ownedViews xmi:id="view-1" viewpoint="description/minimal.odesign#viewpoint-1">
<ownedRepresentations xsi:type="diagram:DSemanticDiagram" xmi:id="rep-1" name="Demo System"
target="model/sample.xmi#system-demo" description="description/minimal.odesign#diag.main">
<ownedDiagramElements xmi:id="dde-api" name="API" target="model/sample.xmi#component-api"
mapping="description/minimal.odesign#map.root"/>
<ownedDiagramElements xmi:id="dde-db" name="Database" target="model/sample.xmi#component-db"
mapping="description/minimal.odesign#map.root"/>
<ownedDiagramElements xmi:id="dde-dep" name="API to Database" target="model/sample.xmi#component-api"
mapping="description/minimal.odesign#edge.children" sourceNode="#dde-api" targetNode="#dde-db"/>
</ownedRepresentations>
</ownedViews>
</viewpoint:DAnalysis>
<notation:Diagram xmi:id="gmf-1" element="#rep-1">
<children xsi:type="notation:Node" xmi:id="gn-api" element="#dde-api">
<layoutConstraint xsi:type="notation:Bounds" x="100" y="50" width="160" height="90"/>
</children>
<children xsi:type="notation:Node" xmi:id="gn-db" element="#dde-db">
<layoutConstraint xsi:type="notation:Bounds" x="400" y="260" width="160" height="90"/>
</children>
<edges xsi:type="notation:Edge" xmi:id="ge-dep" element="#dde-dep" source="gn-api" target="gn-db">
<points x="180" y="140"/>
<points x="480" y="260"/>
</edges>
</notation:Diagram>
</xmi:XMI>`;

describe('SiriusInteropService .aird import', () => {
beforeEach(() => {
modelServiceMock.getById.mockResolvedValue(mockModel);
viewpointServiceMock.getAll.mockResolvedValue([mockAirdViewpoint]);
diagramServiceMock.create.mockImplementation(async (data: any) => ({ id: `diagram-${data.name}`, ...data }));
});

it('validates a Sirius .aird into a SpatialDSL view with resolved targets and GMF layout', async () => {
const preview = await siriusInteropService.validateAirdView(
{ content: airdWithLayout, sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' },
'user-1'
);

expect(preview.report.supported).toBe(true);
expect(preview.diagrams).toHaveLength(1);

const diagram = preview.diagrams[0];
expect(diagram).toEqual(expect.objectContaining({
name: 'Demo System',
modelId: 'model-1',
viewpointId: 'viewpoint-1',
representationDescriptionId: 'sirius-diag-main',
}));

const nodes = diagram.elements.filter(element => element.type === 'node');
const edges = diagram.elements.filter(element => element.type === 'edge');
expect(nodes).toHaveLength(2);
expect(edges).toHaveLength(1);

const apiNode = nodes.find(node => node.modelElementId === 'component-api');
expect(apiNode).toEqual(expect.objectContaining({ x: 100, y: 50, width: 160, height: 90 }));

const edge = edges[0];
expect(edge.modelElementId).toBe('component-api');
expect(edge.sourceId).toBe(nodes.find(node => node.modelElementId === 'component-api')!.id);
expect(edge.targetId).toBe(nodes.find(node => node.modelElementId === 'component-db')!.id);
expect(edge.points).toEqual([{ x: 180, y: 140 }, { x: 480, y: 260 }]);
});

it('imports a Sirius .aird by creating views through the diagram service', async () => {
const result = await siriusInteropService.importAird(
{ content: airdWithLayout, modelId: 'model-1', viewpointId: 'viewpoint-1' },
'user-1',
'DSL_DESIGNER'
);

expect(diagramServiceMock.create).toHaveBeenCalledTimes(1);
expect(diagramServiceMock.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Demo System', modelId: 'model-1', viewpointId: 'viewpoint-1' }),
'user-1',
'DSL_DESIGNER'
);
expect(result.diagrams).toHaveLength(1);
expect(result.report.supported).toBe(true);
});

it('fails .aird import when the model is missing', async () => {
modelServiceMock.getById.mockResolvedValue(null);
await expect(
siriusInteropService.importAird(
{ content: airdWithLayout, modelId: 'missing', viewpointId: 'viewpoint-1' },
'user-1',
'DSL_DESIGNER'
)
).rejects.toThrow(ApiError);
expect(diagramServiceMock.create).not.toHaveBeenCalled();
});

it('warns and drops elements whose semantic target cannot be resolved', async () => {
const airdUnresolved = airdWithLayout.replace(
'name="Database" target="model/sample.xmi#component-db"',
'name="Ghost" target="model/sample.xmi#missing-component"'
);

const preview = await siriusInteropService.validateAirdView(
{ content: airdUnresolved, sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' },
'user-1'
);

const diagram = preview.diagrams[0];
// The DB node drops, which also drops the edge (its target endpoint is gone).
expect(diagram.elements.filter(element => element.type === 'node')).toHaveLength(1);
expect(diagram.elements.filter(element => element.type === 'edge')).toHaveLength(0);
expect(preview.report.unresolvedReferences.some(warning => warning.code === 'SIRIUS_AIRD_TARGET_UNRESOLVED')).toBe(true);
});

it('parses the aird-layout fixture from disk and preserves GMF node bounds', async () => {
const fixturePath = path.resolve(__dirname, '../../../../fixtures/sirius/aird-layout/representations.aird');
const content = fs.readFileSync(fixturePath, 'utf8');
const fixtureViewpoint: Viewpoint = {
...mockAirdViewpoint,
representationDescriptions: [
{ ...mockAirdViewpoint.representationDescriptions[0], id: 'sirius-diagram-minimal' },
],
};
viewpointServiceMock.getAll.mockResolvedValue([fixtureViewpoint]);

const preview = await siriusInteropService.validateAirdView(
{ content, sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' },
'user-1'
);

expect(preview.report.supported).toBe(true);
expect(preview.report.unresolvedReferences).toHaveLength(0);
const diagram = preview.diagrams[0];
expect(diagram.representationDescriptionId).toBe('sirius-diagram-minimal');
expect(diagram.elements.filter(element => element.type === 'node')).toHaveLength(2);
expect(diagram.elements.filter(element => element.type === 'edge')).toHaveLength(1);
expect(diagram.elements.find(element => element.modelElementId === 'component-api')).toEqual(
expect.objectContaining({ x: 80, y: 60, width: 160, height: 90 })
);
});

it('errors when no model is supplied for an .aird import', async () => {
const preview = await siriusInteropService.validateAirdView(
{ content: airdWithLayout, sourceFormat: 'aird' },
'user-1'
);

expect(preview.report.supported).toBe(false);
expect(preview.diagrams).toHaveLength(0);
expect(preview.report.unresolvedReferences.some(warning => warning.code === 'SIRIUS_AIRD_MODEL_REQUIRED')).toBe(true);
});
});
20 changes: 19 additions & 1 deletion backend/src/routes/interoperability.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,32 @@ router.post(
body('content').isString().notEmpty().withMessage('content is required'),
body('sourceFormat').optional().isIn(['ecore', 'xmi', 'odesign', 'aird', 'project-zip']),
body('metamodelId').optional().isString().withMessage('metamodelId must be a string'),
body('modelId').optional().isString().withMessage('modelId must be a string'),
body('viewpointId').optional().isString().withMessage('viewpointId must be a string'),
body('options').optional().isObject().withMessage('options must be an object'),
]),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const preview = await siriusInteropService.validate(req.body, req.user!.userId);
const preview = req.body.sourceFormat === 'aird'
? await siriusInteropService.validateAirdView(req.body, req.user!.userId)
: await siriusInteropService.validate(req.body, req.user!.userId);
res.json({ success: true, data: preview });
})
);

router.post(
'/sirius/aird/import',
validate([
body('content').isString().notEmpty().withMessage('content is required'),
body('modelId').isString().notEmpty().withMessage('modelId is required'),
body('viewpointId').optional().isString().withMessage('viewpointId must be a string'),
body('options').optional().isObject().withMessage('options must be an object'),
]),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const result = await siriusInteropService.importAird(req.body, req.user!.userId, req.user!.role);
res.status(201).json({ success: true, data: result });
})
);

router.post(
'/sirius/import',
validate([
Expand Down
Loading
Loading