This commit is contained in:
xsxx03-art
2026-06-03 10:06:48 +08:00
parent cf28676756
commit 9b4f43f0b1
202 changed files with 14111 additions and 10107 deletions
+52
View File
@@ -0,0 +1,52 @@
/*
* Description: Static and helper regression tests for MXPIC EDA frontend/backend integration contracts.
* Inside functions: N/A - assertion-based test/module script.
* Developer : Qin Yue @ 2026
* Organization : OptiHK Limited
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const backend = path.join(root, 'backend');
const databasePy = fs.readFileSync(path.join(backend, 'database.py'), 'utf8');
const serverPy = fs.readFileSync(path.join(backend, 'server.py'), 'utf8');
const dashboardHtml = fs.readFileSync(path.join(root, 'frontend', 'dashboard.html'), 'utf8');
assert(
databasePy.includes('user_group'),
'database migration should add users.user_group'
);
assert(
databasePy.includes("'admin'") && databasePy.includes("'manager'"),
'admin should be migrated/seeded as manager'
);
assert(
databasePy.includes("'engineer'") && databasePy.includes("'developers'"),
'engineer should be migrated/seeded as developers'
);
assert(
serverPy.includes("session['user_group']") || serverPy.includes('session["user_group"]'),
'login should store user_group in the session'
);
assert(
serverPy.includes('"user_group"'),
'/api/profile should return user_group'
);
assert(
fs.existsSync(path.join(backend, 'pdk_access.py')),
'backend/pdk_access.py should resolve role-based PDK roots'
);
assert(
serverPy.includes('pdk_root_for_session'),
'server should resolve PDK root per logged-in user group'
);
assert(
dashboardHtml.includes('profile-group'),
'dashboard should show read-only profile group information'
);
assert(
!dashboardHtml.includes('profile-group"></select'),
'profile group must not be editable'
);
+65
View File
@@ -0,0 +1,65 @@
/*
* Description: Static and helper regression tests for MXPIC EDA frontend/backend integration contracts.
* Inside functions: N/A - assertion-based test/module script.
* Developer : Qin Yue @ 2026
* Organization : OptiHK Limited
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
assert(
canvasHtml.includes('buildInstancesYaml'),
'canvas.html should use buildInstancesYaml for layout instance export'
);
assert(
canvasHtml.includes('buildCanvasPinsYaml(page.nodes)'),
'canvas.html should export pins from active canvas port nodes'
);
assert(
canvasHtml.includes('buildPageComponentPorts(page.port, page.nodes)'),
'canvas library entries should expose ports from their page-port data'
);
assert(
!canvasHtml.includes("activePage.nodes.filter(n => n.type === 'rotatableNode' && n.data?.type === 'composite')"),
'project layout export should not filter out regular PDK instances'
);
assert(
canvasHtml.includes('Cells: cellEntries') &&
canvasHtml.includes('Basic: basicEntries') &&
canvasHtml.includes('PDK: library || {}'),
'library tree should expose top-level Cells, Basic, and PDK groups'
);
assert(
canvasHtml.includes("__name__: 'Port'") && canvasHtml.includes("__name__: 'Anchor'"),
'Basic folder should expose Port and Anchor as separate virtual components'
);
assert(
canvasHtml.includes('isDirectLeafGrid') &&
canvasHtml.includes('childData.__cell__ === true || childData.__element__ === true || childData.__basic__ === true') &&
canvasHtml.includes('<div className="category-grid">'),
'Cells and Basic folders should bypass category grouping and render direct draggable leaves in a 2D grid'
);
assert(
canvasHtml.includes('element-card-icon port-icon') && canvasHtml.includes('element-card-icon anchor-icon'),
'virtual element cards should render distinct generated icons for Port and Anchor'
);
assert(
canvasHtml.includes('const selectedIsVirtualElement = selectedNode?.data?.elementType ==='),
'right inspector should classify virtual elements separately from PDK/forge components'
);
assert(
canvasHtml.includes('const canChooseComponent = !selectedIsVirtualElement && availableComponentsFromNode.length > 0;'),
'virtual elements should not show PDK or generate_with_forge component selection'
);
assert(
canvasHtml.includes('buildElementsYaml(page.nodes)'),
'canvas layout export should include an elements section'
);
assert(
!canvasHtml.includes("activePage.nodes.filter(n => n.selected && n.id !== 'page-port')"),
'copy/delete should not exclude port nodes'
);
+746
View File
@@ -0,0 +1,746 @@
/*
* Description: Static and helper regression tests for MXPIC EDA frontend/backend integration contracts.
* Inside functions: N/A - assertion-based test/module script.
* Developer : Qin Yue @ 2026
* Organization : OptiHK Limited
*/
const assert = require('assert');
const helpers = require('../frontend/canvas-helpers.js');
const handles = helpers.buildPortHandles({
a0: { x: 0, y: 0, a: 180 },
b0: { x: 0, y: 0, a: 0 },
a1: { x: -91.7, y: 4.475, a: 180 },
a2: { x: -91.7, y: -4.475, a: 180 },
b1: { x: 91.7, y: 4.475, a: 0 },
b2: { x: 91.7, y: -4.475, a: 0 },
ep2a: { x: -37.8, y: -20, a: 270 },
ep2b: { x: -37.8, y: 20, a: 90 },
});
assert.deepStrictEqual(handles.map(handle => handle.name), ['a1', 'a2', 'b1', 'b2', 'ep2b', 'ep2a']);
assert.deepStrictEqual(handles.filter(handle => handle.position === 'left').map(handle => handle.name), ['a1', 'a2']);
assert.deepStrictEqual(handles.filter(handle => handle.position === 'right').map(handle => handle.name), ['b1', 'b2']);
assert.deepStrictEqual(handles.find(handle => handle.name === 'ep2b').position, 'top');
assert.deepStrictEqual(handles.find(handle => handle.name === 'ep2a').position, 'bottom');
assert.strictEqual(handles.find(handle => handle.name === 'a1').style.top, '15%');
assert.strictEqual(handles.find(handle => handle.name === 'a1').style.left, 0);
assert.strictEqual(handles.find(handle => handle.name === 'a2').style.top, '85%');
assert.strictEqual(handles.find(handle => handle.name === 'b1').style.left, '100%');
assert.strictEqual(handles.find(handle => handle.name === 'ep2b').style.left, '50%');
assert.strictEqual(handles.find(handle => handle.name === 'ep2b').style.top, 0);
assert.strictEqual(handles.find(handle => handle.name === 'ep2a').style.top, '100%');
const uniformLeftHandles = helpers.buildPortHandles({
p_top: { x: -10, y: 300, a: 180 },
p_mid: { x: -10, y: 20, a: 180 },
p_bottom: { x: -10, y: -5, a: 180 },
});
assert.deepStrictEqual(
uniformLeftHandles.map(handle => handle.style.top),
['15%', '50%', '85%'],
'ports on the same side should be uniformly spaced after sorting'
);
const exactCenteredHandles = helpers.buildPortHandles({
a1: { x: -91.7, y: 4.475, a: 180 },
a2: { x: -91.7, y: -4.475, a: 180 },
b1: { x: 91.7, y: 4.475, a: 0 },
b2: { x: 91.7, y: -4.475, a: 0 },
}, { boxSize: { width: 183.4, height: 29.65 } });
assert.strictEqual(
exactCenteredHandles.find(handle => handle.name === 'a1').style.top,
'34.907%',
'box_size-aware handles should use exact centered PDK y coordinates instead of uniform spacing'
);
assert.strictEqual(exactCenteredHandles.find(handle => handle.name === 'a2').style.top, '65.093%');
assert.strictEqual(exactCenteredHandles.find(handle => handle.name === 'b1').style.left, '100%');
const exactPositiveHandles = helpers.buildPortHandles({
a1: { x: 0, y: 10, a: 180 },
b1: { x: 150.4, y: 10, a: 0 },
a2: { x: 0, y: -10, a: 180 },
b2: { x: 150.4, y: -10, a: 0 },
}, { boxSize: { width: 150.4, height: 40 } });
assert.strictEqual(exactPositiveHandles.find(handle => handle.name === 'a1').style.top, '25%');
assert.strictEqual(exactPositiveHandles.find(handle => handle.name === 'a2').style.top, '75%');
const exactPortObjectHandles = helpers.buildPortHandles({
port_1: { x: 0, y: 10, a: 0 },
port_2: { x: 0, y: 0, a: 0 },
port_3: { x: 0, y: -10, a: 0 },
}, { boxSize: { width: 47, height: 58 } });
assert.deepStrictEqual(
exactPortObjectHandles.map(handle => handle.style.top),
['32.759%', '50%', '67.241%'],
'standalone Port handles should use their actual y offsets inside the Port body'
);
const exactAnchorHandles = helpers.buildPortHandles(
helpers.buildElementPorts('anchor', { portNumber: 3, pitch: 10 }),
{ boxSize: { width: 16, height: 58 } }
);
assert.deepStrictEqual(
exactAnchorHandles.filter(handle => handle.name.startsWith('a')).map(handle => handle.style.top),
['32.759%', '50%', '67.241%'],
'standalone Anchor handles should be centered around the Anchor body'
);
assert.deepStrictEqual(
helpers.normalizeBoxSize({ box_size: [946, 75] }),
{ width: 946, height: 75 },
'component box size should load from YAML box_size arrays'
);
assert.deepStrictEqual(
helpers.normalizeBoxSize({ box_size: ['946.0', '75.0'] }),
{ width: 946, height: 75 },
'component box size should accept numeric strings from YAML/JSON metadata'
);
assert.deepStrictEqual(
helpers.normalizeBoxSize({ box_sz: { width: 1200, height: 85 } }),
{ width: 1200, height: 85 },
'component box size should also accept box_sz objects'
);
assert.strictEqual(
helpers.PORT_NODE_SIZE,
30,
'Port and Anchor virtual elements should use the same 30 um canvas footprint'
);
assert.deepStrictEqual(
helpers.calculateLayoutBounds({
nodes: [{
position: { x: 100, y: 200 },
data: { componentName: 'rotated_component', boxSize: { width: 50, height: 20 }, rotation: 90 }
}]
}),
{
minX: 80,
minY: 200,
maxX: 100,
maxY: 250,
width: 20,
height: 50,
bottomLeft: { x: 80, y: 200 },
topRight: { x: 100, y: 250 }
},
'layout preview bounds should use component box_size and rotation to find device corners'
);
assert.strictEqual(
helpers.chooseCategoryComponent('generate with mxpic_forge', [
'generate with mxpic_forge',
'EC_SiN400_1310_0p5dB_L935_A0_QY_202604'
], 'edge_couplers'),
'EC_SiN400_1310_0p5dB_L935_A0_QY_202604',
'dropping an EC category should prefer the real PDK component so its YAML box_size is loaded'
);
assert.deepStrictEqual(
helpers.clampPositionToCanvas({ x: 4990, y: 5010 }, { width: 5000, height: 5000 }, { width: 946, height: 75 }),
{ x: 4054, y: 4925 },
'component drag position should keep the full component box inside the canvas boundary'
);
const rulerMeasurement = helpers.createRulerMeasurement({ x: 10, y: 20 }, { x: 40, y: 60 });
assert.deepStrictEqual(
rulerMeasurement,
{
start: { x: 10, y: 20 },
end: { x: 40, y: 60 },
dx: 30,
dy: 40,
distance: 50,
midpoint: { x: 25, y: 40 },
label: '50.000 um dx 30.000 dy 40.000'
},
'ruler measurement should calculate point-to-point distance in canvas um coordinates'
);
assert.strictEqual(
helpers.createRulerMeasurement({ x: 1 }, null),
null,
'ruler measurement should wait until both points are available'
);
assert.deepStrictEqual(
helpers.createComponentSymbolMetrics({ width: 946, height: 75 }),
{ width: 898.7, height: 51 },
'large edge-coupler symbols should scale close to the YAML box width instead of being capped near 300 um'
);
assert.deepStrictEqual(
helpers.createComponentSymbolMetrics({ width: 132, height: 82 }),
{ width: 118.8, height: 55.76 },
'default symbols should still scale proportionally inside normal component boxes'
);
assert.deepStrictEqual(
helpers.calculateCompositeBoxSize({
nodes: [
{
type: 'portNode',
position: { x: 10, y: 5 },
data: { componentDisplayName: 'input', elementType: 'port', portNumber: 1, pitch: 10, width: 0.5 }
},
{
type: 'rotatableNode',
position: { x: 50, y: 20 },
data: { componentName: 'MMI_1', boxSize: { width: 80, height: 30 } }
},
{
type: 'rotatableNode',
position: { x: 160, y: 70 },
data: { componentName: 'MMI_2', boxSize: { width: 20, height: 10 } }
}
]
}),
{ width: 170, height: 75 },
'composite canvas symbols should use the bounds of exported ports and internal instance boxes'
);
assert.deepStrictEqual(
helpers.calculateCompositeBoxSize({
nodes: [{
type: 'portNode',
position: { x: 10, y: 5 },
data: { componentDisplayName: 'input', elementType: 'port', portNumber: 1, pitch: 10 }
}]
}),
helpers.DEFAULT_COMPONENT_BOX_SIZE,
'single-port empty canvases should keep the default component footprint'
);
const rotatedHandles = helpers.buildPortHandles({
left_port: { x: -50, y: 0, a: 180 },
top_port: { x: 0, y: 20, a: 90 },
}, { rotation: 180 });
assert.strictEqual(
rotatedHandles.find(handle => handle.name === 'left_port').position,
'right',
'rotating a component should rotate the React Flow handle side'
);
assert.strictEqual(
rotatedHandles.find(handle => handle.name === 'top_port').position,
'bottom',
'rotating a component should rotate vertical port handle sides'
);
const quarterTurnHandles = helpers.buildPortHandles({
out: { x: 50, y: 0, a: 0 },
up: { x: 0, y: 20, a: 90 },
}, { rotation: 90 });
assert.strictEqual(
quarterTurnHandles.find(handle => handle.name === 'out').position,
'bottom',
'90 degree canvas rotation should move a right-facing port direction to the bottom side'
);
assert.strictEqual(
quarterTurnHandles.find(handle => handle.name === 'up').position,
'right',
'90 degree canvas rotation should move a top-facing port direction to the right side'
);
const negativeQuarterTurnHandles = helpers.buildPortHandles({
out: { x: 50, y: 0, a: 0 },
down: { x: 0, y: -20, a: -90 },
}, { rotation: -90 });
assert.strictEqual(
negativeQuarterTurnHandles.find(handle => handle.name === 'out').position,
'top',
'-90 degree canvas rotation should move a right-facing port direction to the top side'
);
assert.strictEqual(
negativeQuarterTurnHandles.find(handle => handle.name === 'down').position,
'right',
'-90 degree canvas rotation should move a bottom-facing port direction to the right side'
);
const args = helpers.createForgeArguments();
assert(Object.keys(args).length >= 10);
assert.strictEqual(helpers.isForgeComponent('generate with mxpic_forge'), true);
assert.strictEqual(helpers.isBasicComponent('waveguide'), true);
assert.strictEqual(helpers.isBasicComponent('circle'), true);
assert.strictEqual(helpers.isBasicComponent('cricle'), true);
assert.strictEqual(
helpers.buildElementPorts('port').port.a,
0,
'Port objects should default to 0 degree angle'
);
assert.deepStrictEqual(
{
a1: helpers.buildElementPorts('anchor').a1.a,
b1: helpers.buildElementPorts('anchor').b1.a,
},
{ a1: 180, b1: 0 },
'Anchor objects should default to a1 for the left port and b1 for the right port'
);
assert.deepStrictEqual(
{
a1: helpers.buildElementPorts('anchor').a1,
b1: helpers.buildElementPorts('anchor').b1,
},
{
a1: { x: 0, y: -15, a: 180, width: 0.5 },
b1: { x: 0, y: -15, a: 0, width: 0.5 }
},
'Anchor a/b port pairs should share coordinates and keep opposite directions'
);
assert.deepStrictEqual(
helpers.buildBasicComponentPorts('waveguide', { length: 120, width: 0.6 }).b1,
{ x: 120, y: 0, a: 0, width: 0.6, xsection: 'strip', description: 'Optical power output' },
'basic waveguide ports should be generated from editable settings'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('waveguide', { length: 120, width: 0.5 }).box_size,
[120, 20],
'basic waveguide symbol should use a height that is two port-circle diameters'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('90 bend', { radius: 15 }).box_size,
[25, 25],
'90 bend symbol should not shrink below the radius-25 canvas size'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('90 bend', { radius: 30 }).box_size,
[30, 30],
'90 bend symbol should still scale above radius 25'
);
assert.deepStrictEqual(
helpers.buildBasicComponentPorts('90 bend', { radius: 15, width: 0.6 }),
{
a1: { x: 0, y: 12.5, a: 180, width: 0.6, xsection: 'strip', description: 'Optical power input' },
b1: { x: 12.5, y: 0, a: 90, width: 0.6, xsection: 'strip', description: 'Optical power output' }
},
'90 bend ports should sit at the middle of the left and top sides of the square'
);
const ninetyBendHandles = helpers.buildPortHandles(helpers.buildBasicComponentPorts('90 bend', { radius: 15 }));
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'a1').position, 'left');
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'a1').style.top, '50%');
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'a1').style.left, 0);
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'b1').position, 'top');
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'b1').style.left, '50%');
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'b1').style.top, 0);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('180 bend', { radius: 15 }).box_size,
[25, 50],
'180 bend symbol should not shrink below the radius-25 canvas size'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('180 bend', { radius: 30 }).box_size,
[30, 60],
'180 bend symbol should still scale above radius 25'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('taper', { length: 80, width1: 0.4, width2: 1.2 }).box_size,
[80, 20],
'basic taper symbol should use a height that is two port-circle diameters'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('taper', { length: 80, width1: 0.4, width2: 1.2 }).ports.a1.description,
'Optical power input',
'basic component metadata should include human-readable port descriptions'
);
const yaml = helpers.buildInstanceYaml({
instanceName: 'component_1',
componentName: 'generate with mxpic_forge',
componentPath: 'ignored/path',
position: { x: 12.34, y: -5 },
rotation: 90,
flip: true,
flop: true,
forgeArguments: { function_name: 'mmi1x2', length: 25.5, include_heater: true }
});
assert(yaml.includes('component: generate_with_forge'));
assert(yaml.includes('flip: 1'));
assert(yaml.includes('flop: 1'));
assert(yaml.includes('function_name: "mmi1x2"'));
assert(yaml.includes('length: 25.5'));
assert(yaml.includes('include_heater: true'));
const basicYaml = helpers.buildInstanceYaml({
instanceName: 'wg_1',
componentName: 'waveguide',
componentPath: 'ignored',
position: { x: 0, y: 0 },
rotation: 0,
flip: false,
flop: false,
basicArguments: { length: 88, width: 0.7, xsection: 'strip' }
});
assert(basicYaml.includes('component: waveguide'));
assert(basicYaml.includes('length: 88'));
assert(basicYaml.includes('width: 0.7'));
const projectInstancesYaml = helpers.buildInstancesYaml({
nodes: [
{
id: 'node-1',
type: 'rotatableNode',
position: { x: 10, y: 20 },
data: {
componentDisplayName: 'component_1',
componentName: 'PDK_A',
rotation: 0
}
},
{
id: 'node-2',
type: 'rotatableNode',
position: { x: 30, y: 40 },
data: {
componentDisplayName: 'cell_1',
componentName: 'canvas_1',
type: 'composite',
rotation: 90
}
}
],
resolveComponentPath: name => name === 'PDK_A' ? 'foundry/path/PDK_A' : name
});
assert(projectInstancesYaml.includes('component_1:'));
assert(projectInstancesYaml.includes('component: foundry/path/PDK_A'));
assert(projectInstancesYaml.includes('cell_1:'));
assert(projectInstancesYaml.includes('component: canvas_1'));
const pagePortsYaml = helpers.buildPortsYaml({ x: 50, y: 150, a: 90 });
assert(pagePortsYaml.includes('pins:'));
assert(!pagePortsYaml.includes('ports:'));
assert(pagePortsYaml.includes('- name: port_io1'));
assert(pagePortsYaml.includes('pin: io1'));
assert(pagePortsYaml.includes('x: 50.0'));
assert(pagePortsYaml.includes('y: -150.0'));
assert(pagePortsYaml.includes('angle: -90.0'));
const componentPorts = helpers.buildPageComponentPorts({ x: 12, y: -6, a: 180 });
assert.deepStrictEqual(componentPorts, {
port_io1: { element: 'port', pin: 'io1', x: 12, y: -6, a: 0, width: 0.5 }
});
const elementNodes = [
{
id: 'port-1',
type: 'portNode',
position: { x: 10, y: 20 },
data: {
componentDisplayName: 'in0',
elementType: 'port',
angle: 180,
width: 0.7,
layer: 'WG_CORE',
description: 'input port'
}
},
{
id: 'anchor-1',
type: 'rotatableNode',
position: { x: 30, y: 40 },
data: {
componentDisplayName: 'anchor_1',
componentName: 'Anchor',
elementType: 'anchor',
rotation: 0
}
},
{
id: 'mmi-1',
type: 'rotatableNode',
position: { x: 50, y: 60 },
data: {
componentDisplayName: 'component_1',
componentName: 'MMI',
rotation: 0
}
}
];
assert.deepStrictEqual(helpers.buildElementPorts('port', { angle: 90, width: 0.8 }), {
port: { x: 0, y: 0, a: 90, width: 0.8 }
});
assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('anchor')), ['a1', 'b1']);
assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('port', { portNumber: 3, pitch: 10 })), ['port_1', 'port_2', 'port_3']);
assert.deepStrictEqual(helpers.buildElementPorts('port', { portNumber: 3, pitch: 10 }).port_1, { x: 0, y: 10, a: 0, width: 0.5 });
assert.deepStrictEqual(helpers.buildElementPorts('port', { portNumber: 3 }).port_1, { x: 0, y: 10, a: 0, width: 0.5 });
assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 })), ['a1', 'b1', 'a2', 'b2']);
assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).a1, { x: 0, y: 6, a: 180, width: 0.5 });
assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).b2, { x: 0, y: -6, a: 0, width: 0.5 });
assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).a2, { x: 0, y: -6, a: 180, width: 0.5 });
assert.deepStrictEqual(
helpers.getNodePortCanvasPoint({
id: 'anchor-rotated',
type: 'anchorNode',
position: { x: 100, y: 200 },
data: {
elementType: 'anchor',
rotation: 90,
portNumber: 1,
pitch: 10,
ports: helpers.buildElementPorts('anchor', { portNumber: 1, pitch: 10 })
}
}, 'a1'),
{ x: 115, y: 200 },
'Anchor port endpoint coordinates should rotate with the anchor body'
);
assert.deepStrictEqual(
helpers.getNodePortCanvasPoint({
id: 'port-rotated',
type: 'portNode',
position: { x: 100, y: 200 },
data: {
elementType: 'port',
angle: 180,
portNumber: 3,
pitch: 10,
ports: helpers.buildElementPorts('port', { angle: 180, portNumber: 3, pitch: 10 })
}
}, 'port_1'),
{ x: 100, y: 210 },
'Port pin endpoint coordinates should rotate with the Port body'
);
assert.deepStrictEqual(helpers.buildElementBoxSize({ portNumber: 1 }), { width: 47, height: 30 });
assert.deepStrictEqual(helpers.buildElementBoxSize({ elementType: 'anchor', portNumber: 1 }), { width: 16, height: 30 });
assert.deepStrictEqual(helpers.buildElementBoxSize({ elementType: 'anchor', portNumber: 4, pitch: 10 }), { width: 16, height: 72 });
assert.deepStrictEqual(helpers.buildElementBoxSize({ portNumber: 4, pitch: 10 }), { width: 47, height: 72 });
assert.deepStrictEqual(
helpers.buildElementBoxSize({ elementType: 'port', portName: 'input_port' }),
{ width: 82, height: 30 },
'Port object width should grow to fit the displayed port instance name'
);
assert.deepStrictEqual(
helpers.buildPageComponentPorts(null, [{
id: 'port-array',
type: 'portNode',
position: { x: 100, y: 200 },
data: { componentDisplayName: 'array', elementType: 'port', portNumber: 3, pitch: 10, width: 0.6 }
}]),
{
array_io1: { element: 'array', pin: 'io1', x: 100, y: 190, a: 180, width: 0.6 },
array_io2: { element: 'array', pin: 'io2', x: 100, y: 200, a: 180, width: 0.6 },
array_io3: { element: 'array', pin: 'io3', x: 100, y: 210, a: 180, width: 0.6 }
}
);
assert.deepStrictEqual(
helpers.buildPageComponentPorts(null, [{
id: 'port-array-rotated-90',
type: 'portNode',
position: { x: 100, y: 200 },
data: { componentDisplayName: 'array', elementType: 'port', angle: 90, portNumber: 2, pitch: 10, width: 0.6 }
}]),
{
array_io1: { element: 'array', pin: 'io1', x: 95, y: 200, a: -90, width: 0.6 },
array_io2: { element: 'array', pin: 'io2', x: 105, y: 200, a: -90, width: 0.6 }
}
);
assert.deepStrictEqual(
helpers.buildPageComponentPorts(null, [{
id: 'port-array-rotated',
type: 'portNode',
position: { x: 100, y: 200 },
data: { componentDisplayName: 'array', elementType: 'port', angle: 180, portNumber: 3, pitch: 10, width: 0.6 }
}]),
{
array_io1: { element: 'array', pin: 'io1', x: 100, y: 210, a: 0, width: 0.6 },
array_io2: { element: 'array', pin: 'io2', x: 100, y: 200, a: 0, width: 0.6 },
array_io3: { element: 'array', pin: 'io3', x: 100, y: 190, a: 0, width: 0.6 }
},
'Rotated Port object pins should export rotated coordinates along with their rotated angle'
);
const canvasPortsYaml = helpers.buildCanvasPortsYaml(elementNodes);
assert(canvasPortsYaml.includes('pins:'));
assert(!canvasPortsYaml.includes('ports:'));
assert(canvasPortsYaml.includes('name: in0_io1'));
assert(canvasPortsYaml.includes('element: in0'));
assert(canvasPortsYaml.includes('pin: io1'));
assert(canvasPortsYaml.includes('description: "input port"'));
assert(canvasPortsYaml.includes('width: 0.7'));
assert(canvasPortsYaml.includes('y: -20.0'));
assert(canvasPortsYaml.includes('angle: 0.0'));
const elementsYaml = helpers.buildElementsYaml(elementNodes);
assert(elementsYaml.includes('in0:'));
assert(elementsYaml.includes('type: port'));
assert(elementsYaml.includes('anchor_1:'));
assert(elementsYaml.includes('type: anchor'));
assert(elementsYaml.includes('y: -20.0'));
assert(elementsYaml.includes('pin_number: 1'));
assert(elementsYaml.includes('name: in0_io1'));
assert(elementsYaml.includes('role: io1'));
assert(elementsYaml.includes('name: anchor_1_a1'));
assert(elementsYaml.includes('role: a1'));
assert(elementsYaml.includes('pitch: 10'));
const instancesWithoutElements = helpers.buildInstancesYaml({
nodes: elementNodes,
resolveComponentPath: name => name
});
assert(!instancesWithoutElements.includes('anchor_1:'));
assert(!instancesWithoutElements.includes('in0:'));
assert(instancesWithoutElements.includes('component_1:'));
assert(instancesWithoutElements.includes('y: -60.0'));
const multiPortComponentPorts = helpers.buildPageComponentPorts(null, elementNodes);
assert.deepStrictEqual(multiPortComponentPorts.in0_io1, { element: 'in0', pin: 'io1', x: 10, y: 20, a: 0, width: 0.7 });
const technologyManifest = {
defaults: { xsection: 'strip', width: 0.45, radius: 10, routing_type: 'euler_bend' },
xsections: {
strip: { family: 'optical', default_width: 0.45 },
rib_low: { family: 'optical', default_width: 0.5 },
metal_1: { family: 'electrical', default_width: 5 },
metal_2: { family: 'electrical', default_width: 6 }
},
routing_types: ['euler_bend', 'standard_bend']
};
const routeDefaults = helpers.createRouteSettings(technologyManifest);
assert.deepStrictEqual(routeDefaults, {
xsection: 'strip',
family: 'optical',
width: 0.45,
radius: 10,
routing_type: 'euler_bend',
widthEdited: false
});
const metalRoute = helpers.updateRouteXsection(routeDefaults, 'metal_1', technologyManifest);
assert.strictEqual(metalRoute.family, 'electrical');
assert.strictEqual(metalRoute.width, 5);
const manuallyEditedWidth = helpers.updateRouteField(routeDefaults, 'width', 0.62, technologyManifest);
const changedXsection = helpers.updateRouteXsection(manuallyEditedWidth, 'rib_low', technologyManifest);
assert.strictEqual(changedXsection.width, 0.62);
assert.strictEqual(changedXsection.family, 'optical');
const styledStrip = helpers.routeStyleForSettings({ xsection: 'strip', family: 'optical' }, false);
const styledMetal = helpers.routeStyleForSettings({ xsection: 'metal_1', family: 'electrical' }, true);
assert.notStrictEqual(styledStrip.style.stroke, styledMetal.style.stroke);
assert(styledMetal.style.strokeDasharray, 'electrical routes should use a visibly different line treatment');
assert(styledMetal.style.strokeWidth > styledStrip.style.strokeWidth);
const routeYaml = helpers.buildBundlesYaml({
nodes: [
{ id: 'a', data: { componentDisplayName: 'inst_a' } },
{ id: 'b', data: { componentDisplayName: 'inst_b' } }
],
edges: [{
id: 'edge-a-b',
source: 'a',
target: 'b',
sourceHandle: 'out',
targetHandle: 'in',
data: {
route: { xsection: 'metal_1', family: 'electrical', width: 5, radius: 20, routing_type: 'standard_bend' },
points: [{ x: 0, y: 0 }, { x: 40, y: 20 }]
}
}]
}, technologyManifest);
assert(routeYaml.includes('xsection: metal_1'));
assert(routeYaml.includes('family: electrical'));
assert(routeYaml.includes('radius: 20'));
assert(routeYaml.includes('routing_type: standard_bend'));
assert(routeYaml.includes('points:'));
assert(routeYaml.includes('x: 40.0'));
assert(routeYaml.includes('y: -20.0'));
const anchoredRouteYaml = helpers.buildBundlesYaml({
nodes: [
{
id: 'src-node',
type: 'rotatableNode',
position: { x: 10, y: 20 },
data: {
componentDisplayName: 'src_inst',
boxSize: [100, 40],
ports: { out: { x: -10, y: 0, a: 180 } }
}
},
{
id: 'dst-node',
type: 'rotatableNode',
position: { x: 120, y: 20 },
data: {
componentDisplayName: 'dst_inst',
boxSize: [100, 40],
ports: { in: { x: 10, y: 0, a: 0 } }
}
}
],
edges: [{
id: 'edge-src-dst',
source: 'src-node',
target: 'dst-node',
sourceHandle: 'out',
targetHandle: 'in',
data: {
route: { xsection: 'strip', family: 'optical', width: 0.45, radius: 10, routing_type: 'euler_bend' },
points: [{ x: 0, y: 0 }, { x: 80, y: 0 }, { x: 80, y: 60 }]
}
}]
}, technologyManifest);
assert(anchoredRouteYaml.includes('from: src_inst:out'));
assert(anchoredRouteYaml.includes('to: dst_inst:in'));
assert(anchoredRouteYaml.includes('x: 0.0'));
assert(anchoredRouteYaml.includes('y: -20.0'));
assert(anchoredRouteYaml.includes('x: 130.0'));
const freeRouteYaml = helpers.buildBundlesYaml({
nodes: [],
edges: [{
id: 'route-free-1',
source: '__free_route_route-free-1_start__',
target: '__free_route_route-free-1_end__',
data: {
freeRoute: true,
route: { xsection: 'strip', family: 'optical', width: 0.45, radius: 10, routing_type: 'euler_bend' },
points: [{ x: 10, y: 20 }, { x: 80, y: 20 }, { x: 80, y: 120 }]
}
}]
}, technologyManifest);
assert(freeRouteYaml.includes('id: "route-free-1"'));
assert(!freeRouteYaml.includes('from:'));
assert(!freeRouteYaml.includes('to:'));
assert(freeRouteYaml.includes('points:'));
assert(freeRouteYaml.includes('x: 80.0'));
assert(freeRouteYaml.includes('y: -120.0'));
const edgeA = {
id: 'edge-a-b',
source: 'a',
target: 'b',
data: { route: { family: 'optical' } }
};
const edgeB = {
id: 'edge-c-d',
source: 'c',
target: 'd',
data: { route: { family: 'optical' } }
};
const edgeC = {
id: 'edge-e-f',
source: 'e',
target: 'f',
data: { route: { family: 'electrical' } }
};
const crossingNodes = {
a: { position: { x: 0, y: 0 } },
b: { position: { x: 100, y: 100 } },
c: { position: { x: 0, y: 100 } },
d: { position: { x: 100, y: 0 } },
e: { position: { x: 0, y: 100 } },
f: { position: { x: 100, y: 0 } }
};
edgeA.data.route.xsection = 'strip';
edgeB.data.route.xsection = 'strip';
edgeC.data.route.xsection = 'metal_1';
const edgeD = {
id: 'edge-g-h',
source: 'e',
target: 'f',
data: { route: { xsection: 'rib_low', family: 'optical' } }
};
const edgeE = {
id: 'edge-metal-alias',
source: 'e',
target: 'f',
data: { route: { xsection: 'metal1', family: 'electrical' } }
};
const edgeF = {
id: 'edge-metal-underscore',
source: 'a',
target: 'b',
data: { route: { xsection: 'metal_1', family: 'electrical' } }
};
assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeB, [edgeA], crossingNodes).conflictEdge.id, 'edge-a-b');
assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeC, [edgeA], crossingNodes), null);
assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeD, [edgeA], crossingNodes), null);
assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeE, [edgeF], crossingNodes).conflictEdge.id, 'edge-metal-underscore');
assert.strictEqual(helpers.findSameFamilyRouteCrossing(edgeB, [edgeA], crossingNodes).conflictEdge.id, 'edge-a-b');
+35
View File
@@ -0,0 +1,35 @@
/*
* Description: Static and helper regression tests for MXPIC EDA frontend/backend integration contracts.
* Inside functions: N/A - assertion-based test/module script.
* Developer : Qin Yue @ 2026
* Organization : OptiHK Limited
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
const serverPy = fs.readFileSync(path.join(root, 'backend', 'server.py'), 'utf8');
assert(
canvasHtml.includes('src="/canvas-helpers.js"'),
'canvas.html should request the canvas helper script from /canvas-helpers.js'
);
assert(
serverPy.includes("@app.route('/canvas-helpers.js')"),
'backend/server.py should serve /canvas-helpers.js'
);
assert(
serverPy.includes("send_from_directory(FRONTEND_DIR, 'canvas-helpers.js')") ||
serverPy.includes('send_from_directory(FRONTEND_DIR, "canvas-helpers.js")'),
'the /canvas-helpers.js route should serve frontend/canvas-helpers.js'
);
assert(
serverPy.includes('def no_cache_response(response):'),
'canvas assets should use an explicit no-cache response helper'
);
assert(
serverPy.includes('Cache-Control') && serverPy.includes('no-store'),
'canvas routes should prevent stale browser caches while the editor is changing'
);
+38
View File
@@ -0,0 +1,38 @@
/*
* Description: Static and helper regression tests for MXPIC EDA frontend/backend integration contracts.
* Inside functions: N/A - assertion-based test/module script.
* Developer : Qin Yue @ 2026
* Organization : OptiHK Limited
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const serverPy = fs.readFileSync(path.join(root, 'backend', 'server.py'), 'utf8');
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
assert(
serverPy.includes('EXPORT_ROOT'),
'server should build GDS exports into a temporary export root'
);
assert(
serverPy.includes("@app.route('/api/exports/<export_id>/<filename>'"),
'server should expose an authenticated export download route'
);
assert(
serverPy.includes('download_url'),
'Build GDS response should include download_url'
);
assert(
serverPy.includes('cleanup_expired_exports'),
'server should clean exports older than the retention period'
);
assert(
canvasHtml.includes('download_url'),
'frontend should read download_url from Build GDS response'
);
assert(
canvasHtml.includes('document.createElement') && canvasHtml.includes('.download'),
'frontend should trigger a browser download for generated GDS'
);
+179
View File
@@ -0,0 +1,179 @@
/*
* Description: Static and helper regression tests for MXPIC EDA frontend/backend integration contracts.
* Inside functions: N/A - assertion-based test/module script.
* Developer : Qin Yue @ 2026
* Organization : OptiHK Limited
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const backendDir = path.join(root, 'backend');
const serverPy = fs.readFileSync(path.join(backendDir, 'server.py'), 'utf8');
const removedInternalPdkPath = ['mxpic', 'PDKs'].join('/');
const removedEdaPdkRootName = 'EDA_' + 'PDK_ROOT';
const removedYmlPathName = 'YML_' + 'PATH';
assert(
!fs.existsSync(path.join(backendDir, 'layout_preview.py')),
'EDA backend should not keep the legacy local GDS/SVG preview builder'
);
assert(
!fs.existsSync(path.join(backendDir, 'pdk_registry.py')),
'EDA backend should not keep the legacy local PDK GDS registry'
);
assert(
fs.existsSync(path.join(backendDir, 'gds_builder.py')),
'backend/gds_builder.py should build hierarchical GDS from saved project YAML'
);
assert(
fs.existsSync(path.join(backendDir, 'routed_layout_preview.py')),
'backend/routed_layout_preview.py should create routed SVG previews through mxpic_router'
);
assert(
fs.existsSync(path.join(backendDir, 'router_dependency.py')),
'backend/router_dependency.py should validate build-time mxpic_router runtime dependencies'
);
assert(
!serverPy.includes('from router_dependency import require_router_stack') &&
!serverPy.includes('Verified mxpic_router stack') &&
!serverPy.includes('require_router_stack()'),
'server startup should not require mxpic_router; only build actions should validate the router stack'
);
assert(
!serverPy.includes('create_layout_svg_from_gds'),
'save-layout route should not use the legacy gdstk-only preview builder'
);
assert(
serverPy.includes('create_routed_layout_svg') &&
!serverPy.includes('if layout_has_links(content):'),
'save-layout route should always use routed preview generation through mxpic_router'
);
assert(
serverPy.includes('cell_routes_path') &&
serverPy.includes('write_route_points_sidecar') &&
serverPy.includes('.routes.yml'),
'save-layout should write a sidecar route-points file for manually drawn link guidance'
);
assert(
serverPy.includes('svg_url'),
'save-layout response should include an svg_url for the new layout tab'
);
assert(
serverPy.includes('RouterStackUnavailable') &&
serverPy.includes('except RouterStackUnavailable as e') &&
serverPy.includes('"preview_status": preview_status') &&
serverPy.includes('"preview_error": preview_error'),
'save-layout should still return success and explain skipped SVG preview when mxpic_router is unavailable'
);
assert(
serverPy.includes("@app.route('/api/projects/<project_name>/cells/<cell_name>/layout.svg')"),
'server should expose a route for saved cell SVG previews'
);
assert(
serverPy.includes("@app.route('/api/build-gds'"),
'server should expose a Build GDS API route'
);
assert(
serverPy.includes("@app.route('/api/technologies/<foundry>/<technology>/manifest'"),
'server should expose a technology manifest API route'
);
assert(
fs.existsSync(path.join(backendDir, 'technology_manifest.py')),
'backend/technology_manifest.py should read generated technology manifests'
);
assert(
!serverPy.includes(removedEdaPdkRootName) &&
!serverPy.includes(removedYmlPathName) &&
!serverPy.includes("'mxpic', 'PDKs'") &&
!serverPy.includes(removedInternalPdkPath),
'server should not use an internal EDA PDK copy as a separate technology source of truth'
);
assert(
serverPy.includes('pdks_root = current_pdk_root()') &&
serverPy.includes('os.path.exists(os.path.join(technology_path, "technology.yml"))'),
'technology list should scan the active role PDK root and only expose folders containing technology.yml'
);
assert(
serverPy.includes('read_technology_manifest(current_pdk_root()'),
'technology manifest API should read technology.yml from the active role PDK root'
);
assert(
!fs.existsSync(path.join(root, 'mxpic')),
'mxpic_EDA should not contain a redundant internal technology copy'
);
const gdsBuilderPy = fs.readFileSync(path.join(backendDir, 'gds_builder.py'), 'utf8');
assert(
gdsBuilderPy.includes('return _build_with_mxpic_router(') &&
!gdsBuilderPy.includes('return _build_with_gdstk') &&
!gdsBuilderPy.includes('return _build_with_nazca'),
'Build GDS should use mxpic_router as the only production builder'
);
assert(
gdsBuilderPy.includes('from router_dependency import require_router_stack') &&
gdsBuilderPy.includes('require_router_stack()'),
'Build GDS API wrapper should validate mxpic_router only when a GDS build is requested'
);
const routedPreviewPy = fs.readFileSync(path.join(backendDir, 'routed_layout_preview.py'), 'utf8');
assert(
routedPreviewPy.includes('from router_dependency import require_router_stack') &&
routedPreviewPy.includes('require_router_stack(require_gdstk=True)'),
'Build Layout preview should validate mxpic_router and gdstk only when preview generation is requested'
);
const routerDependencyPy = fs.readFileSync(path.join(backendDir, 'router_dependency.py'), 'utf8');
assert(
routerDependencyPy.includes('def require_router_stack') &&
routerDependencyPy.includes('class RouterStackUnavailable') &&
routerDependencyPy.includes('require_gdstk: bool = False') &&
routerDependencyPy.includes('importlib.import_module("nazca")') &&
routerDependencyPy.includes('mxpic_router.builder') &&
routerDependencyPy.includes('_import_mxpic_forge_route'),
'router dependency gate should validate mxpic_router, Nazca, optional gdstk, and route backend imports'
);
const routerDir = path.resolve(root, '..', 'mxpic_router', 'mxpic_router');
if (fs.existsSync(routerDir)) {
const routerLoaderPy = fs.readFileSync(path.join(routerDir, 'eda_loader.py'), 'utf8');
const routerBuilderPy = fs.readFileSync(path.join(routerDir, 'builder.py'), 'utf8');
assert(
routerLoaderPy.includes('pins: Dict[str, PinSpec]') &&
routerLoaderPy.includes('pin_number') &&
routerLoaderPy.includes('pitch: float = 10.0') &&
routerLoaderPy.includes('pins=_pins(element.get("pins"))'),
'mxpic_router loader should parse pins-only layout metadata from exported elements'
);
assert(
routerBuilderPy.includes('_port_element_pin_entries') &&
routerBuilderPy.includes('_anchor_element_pin_entries') &&
routerBuilderPy.includes('_metadata_pins') &&
routerBuilderPy.includes('link.src_pin'),
'mxpic_router builder should register named element pins and route through pin endpoints'
);
}
assert(
serverPy.includes('def scoped_pdk_root_for_project') &&
serverPy.includes('read_project_meta(project_name).get("technology")') &&
serverPy.includes('os.path.join(base_root, foundry, technology)'),
'backend should resolve a project-scoped PDK root from selected foundry/technology'
);
assert(
serverPy.includes('request.args.get(\'project\')') &&
serverPy.includes('scoped_pdk_root_for_project(project)'),
'library/component APIs should accept ?project= and search inside the selected technology folder'
);
assert(
serverPy.includes('__path__') &&
serverPy.includes('os.path.relpath(root, path_root)'),
'library tree leaves should preserve component paths relative to the role PDK root'
);
assert(
serverPy.includes('f.lower() != "technology.yml"') &&
serverPy.includes('component_yml_files'),
'component library scanning should ignore technology.yml so it can descend into technology folders'
);
+538
View File
@@ -0,0 +1,538 @@
/*
* Description: Static and helper regression tests for MXPIC EDA frontend/backend integration contracts.
* Inside functions: N/A - assertion-based test/module script.
* Developer : Qin Yue @ 2026
* Organization : OptiHK Limited
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
const canvasHelpers = fs.readFileSync(path.join(root, 'frontend', 'canvas-helpers.js'), 'utf8');
assert(
canvasHtml.includes('Build GDS'),
'Project Tree header should include a Build GDS button'
);
assert(
canvasHtml.includes('Save YAML for all canvases') && canvasHtml.includes('handleSaveProjectLayouts'),
'Project Tree should include a save button that writes YAML for all canvases'
);
assert(
canvasHtml.includes('/api/build-gds'),
'Build GDS button should call the backend build-gds API'
);
assert(
canvasHtml.includes(':layout'),
'Build Layout should open an SVG preview tab named like canvas_1:layout'
);
assert(
canvasHtml.includes('svg_url'),
'Build Layout should use the backend svg_url response'
);
assert(
canvasHtml.includes('result.preview_error') &&
canvasHtml.includes('Preview skipped: '),
'Build Layout should log when the backend saves YAML but skips SVG preview because the router stack is unavailable'
);
assert(
canvasHtml.includes('layoutPreview'),
'canvas pages should support a layoutPreview tab type'
);
assert(
canvasHtml.includes('LayoutSvgPreview'),
'layout preview tabs should use the auto-scaling SVG viewer'
);
assert(
canvasHtml.includes('layoutScale'),
'layout SVG preview should expose an editable scale value'
);
assert(
canvasHtml.includes('objectFit: \'contain\''),
'100% layout preview scale should fit the full SVG within the screen'
);
assert(
canvasHtml.includes('className="build-gds-btn"'),
'Build GDS should use a dedicated polished button class'
);
assert(
canvasHtml.includes('buildGdsBusy'),
'Build GDS should expose an in-progress state to prevent duplicate requests'
);
assert(
canvasHtml.includes('Build GDS network error'),
'Build GDS fetch failures should produce a specific network diagnostic'
);
assert(
canvasHtml.includes('className="build-layout-btn"'),
'Build Layout should use the polished primary action class'
);
assert(
canvasHtml.includes('Route Editor'),
'Selecting an edge should expose a route editor'
);
assert(
canvasHtml.includes('selectedEdge'),
'canvas should track selected edges separately from selected nodes'
);
assert(
canvasHtml.includes('technologyManifest'),
'canvas should load the selected technology manifest'
);
assert(
canvasHtml.includes('standard_bend'),
'route editor should offer standard_bend as a routing type'
);
assert(
canvasHtml.includes('findSameTypeRouteCrossing'),
'canvas should validate same-xsection route crossings'
);
assert(
canvasHtml.includes('link-mode-tabs') &&
canvasHtml.includes('link-mode-summary') &&
canvasHtml.includes('link-mode-menu') &&
canvasHtml.includes('currentLinkXsection') &&
canvasHtml.includes('setCurrentLinkXsection') &&
canvasHtml.includes("['strip', 'rib_low', 'metal_1', 'metal_2']"),
'canvas should expose a collapsed route-type selector for new links'
);
assert(
canvasHtml.includes('handleBasicConnection') &&
canvasHtml.includes('onConnect={handleBasicConnection}') &&
canvasHtml.includes('nodesConnectable={true}') &&
canvasHtml.includes('connectionMode="loose"') &&
canvasHtml.includes('const conflict = findSameTypeRouteCrossing(candidate, activePage.edges, nodeMap, technologyManifest);') &&
canvasHtml.includes('Connection rejected:') &&
canvasHtml.includes('data: { route }') &&
canvasHtml.includes('addEdge(candidate, p.edges)'),
'canvas should use React Flow native pin-to-pin connections and reject same-xsection crossings for new links'
);
assert(
!canvasHtml.includes('linkDraft') &&
!canvasHtml.includes('routingMode') &&
!canvasHtml.includes('toggleRoutingMode') &&
!canvasHtml.includes('handleLinkCanvasMouseDown') &&
!canvasHtml.includes('handleLinkCanvasMouseMove') &&
!canvasHtml.includes('finalizeLinkDraft') &&
!canvasHtml.includes('__link_draft_edge__') &&
!canvasHtml.includes('Routing mode: click anywhere to start a point route.'),
'current interactive point-link drawing mode should be removed from the canvas'
);
assert(
canvasHtml.includes('<Handle') &&
canvasHtml.includes('type="source"') &&
canvasHtml.includes('type="target"') &&
!canvasHtml.includes('nodesConnectable={false}'),
'component and port handles should remain connectable for basic pin linking'
);
assert(
canvasHtml.includes("selectable: true") &&
canvasHtml.includes("type: 'parallelRoute'") &&
canvasHtml.includes('hasRoutePoints') &&
canvasHtml.includes("hasRoutePoints ? 'parallelRoute' : view.type") &&
canvasHtml.includes('data-route-edge-id') &&
canvasHtml.includes('handleRouteEdgeMouseDown') &&
canvasHtml.includes('selectEdgeById') &&
canvasHtml.includes('vectorEffect="non-scaling-stroke"') &&
canvasHtml.includes('onEdgeMouseDown={handleReactFlowEdgeMouseDown}'),
'saved manual routes should remain selectable through a zoom-independent custom SVG hit path and React Flow edge mouse handling'
);
assert(
canvasHtml.includes('panOnDrag={false}'),
'left mouse drag should not pan the canvas'
);
assert(
canvasHtml.includes('selectionOnDrag={true}') &&
canvasHtml.includes('selectionMode={FULL_SELECTION_MODE}') &&
canvasHtml.includes('SelectionMode'),
'left mouse drag should draw a full-coverage selection area instead of panning'
);
assert(
canvasHtml.includes('const path = points.length >= 2') &&
canvasHtml.includes('points.slice(1).map(point => `L ${point.x},${point.y}`)') &&
canvasHtml.includes('position: { x: first.x - 6, y: first.y - 6 }') &&
canvasHtml.includes('position: { x: last.x - 6, y: last.y - 6 }'),
'free routes should render all saved point-to-point line segments and keep hidden endpoints aligned'
);
assert(
!canvasHtml.includes('buildOrthogonalPoints') &&
!canvasHtml.includes('buildManhattanRoutePoints') &&
!canvasHtml.includes('findNearestPort') &&
!canvasHtml.includes('linkPreviewSnapPort'),
'custom link drawing geometry helpers should be deleted from the current canvas code'
);
const routePointNodeBlock = canvasHtml.slice(
canvasHtml.indexOf('const RulerPointNode'),
canvasHtml.indexOf('const RulerMeasurementNode')
);
assert(
routePointNodeBlock.includes('<Handle') &&
routePointNodeBlock.includes('type="source"') &&
routePointNodeBlock.includes('type="target"') &&
routePointNodeBlock.includes('id="route"'),
'route point nodes should expose hidden source and target handles so route edges can attach and render'
);
assert(
canvasHtml.includes('build-progress') && canvasHtml.includes('buildProgress'),
'Build Layout and Build GDS should show progress while running'
);
assert(
canvasHtml.includes('port-name-label'),
'component nodes should render visible port name labels'
);
assert(
canvasHtml.includes('multiSelectionKeyCode="Shift"'),
'ReactFlow should allow shift multi-select for links'
);
assert(
canvasHtml.includes("deleteKeyCode={['Backspace', 'Delete']}"),
'ReactFlow should allow selected links to be deleted with the Delete key'
);
assert(
canvasHtml.includes('selectedEdges'),
'canvas should track multiple selected links for batch route editing'
);
assert(
canvasHtml.includes('__mixed__'),
'multi-link route editor should show mixed values as --'
);
assert(
canvasHtml.includes('Flip X') && canvasHtml.includes('Mirror Y'),
'component inspector should expose flip/flop controls'
);
assert(
canvasHtml.includes('onNodeMouseDown') && canvasHtml.includes('spaceRotateNodeIdRef'),
'holding a component and pressing Space should rotate it by 90 degrees'
);
assert(
canvasHtml.includes('getSpaceRotationTarget') &&
canvasHtml.includes('selectedSpaceNode') &&
canvasHtml.includes('node.type !== \'rotatableNode\' && node.type !== \'portNode\' && node.type !== \'anchorNode\'') &&
canvasHtml.includes('node.type === \'portNode\' || node.data?.elementType === \'port\'') &&
canvasHtml.includes('angle: normalizeAngle(Number(node.data?.angle || 0) + 90)'),
'Space rotation should also rotate selected Port and Anchor elements'
);
assert(
canvasHtml.includes('const anchorRotation = data.rotation || 0') &&
canvasHtml.includes('const anchorVisualRotation = -Number(anchorRotation || 0)') &&
canvasHtml.includes('transform: `rotate(${anchorVisualRotation}deg)`') &&
canvasHtml.includes('buildPortHandles(localAnchorHandlePorts, { rotation: 0, boxSize: elementSize') &&
canvasHtml.includes('anchorDirectionHandles') &&
canvasHtml.includes('rotation: -Number(anchorRotation || 0)') &&
canvasHtml.includes('anchorHandleVisualStyle(portHandle') &&
canvasHtml.includes('anchorPortVisualSide') &&
canvasHtml.includes('portHandle.name') &&
canvasHtml.includes('visualSide === \'left\' ? 0 : elementSize.width') &&
canvasHtml.includes('transform: \'translate(-50%, -50%)\'') &&
canvasHtml.includes("portHandle.style?.top || '50%'") &&
canvasHtml.includes('localLeft') &&
canvasHtml.includes('localTop') &&
canvasHtml.includes('handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]') &&
canvasHtml.includes('getAnchorHandleRouteDirection') &&
canvasHtml.includes('rotation: -Number(node.data?.rotation || 0)') &&
canvasHtml.includes('directionToReactFlowPosition') &&
canvasHtml.includes('sourcePosition: directionToReactFlowPosition(sourceDirection)') &&
canvasHtml.includes('targetPosition: directionToReactFlowPosition(targetDirection)') &&
!canvasHtml.includes('type: \'parallelRoute\',\n data: {\n ...(edge.data || {}),\n parallelOffset: offset,\n sourceDirection,\n targetDirection') &&
!canvasHtml.includes('rotatedAnchorHandlePositions'),
'Anchor port circles should split into side columns and spread across the full anchor body while built-in rectangular links use rotated directions'
);
assert(
canvasHtml.includes('Port Number') &&
canvasHtml.includes('Pitch') &&
canvasHtml.includes('portNumber') &&
canvasHtml.includes('pitch') &&
canvasHtml.includes('DEFAULT_ELEMENT_PITCH') &&
canvasHtml.includes('buildElementBoxSize') &&
canvasHtml.includes('height: elementSize.height') &&
canvasHtml.includes('elementType: \'anchor\'') &&
canvasHtml.includes('pitch: DEFAULT_ELEMENT_PITCH') &&
canvasHtml.includes('ports: buildElementPorts'),
'Port and Anchor inspectors should expose port number and pitch, default to 10 um pitch, and grow in height'
);
assert(
canvasHtml.includes('const componentIndexesByPrefixRef = useRef({});') &&
canvasHtml.includes('const usedIndexes = componentIndexesByPrefixRef.current[prefix] || new Set();') &&
canvasHtml.includes('while (usedIndexes.has(nextIndex)) nextIndex += 1;') &&
canvasHtml.includes('usedIndexes.add(nextIndex);') &&
canvasHtml.includes('const name = `${prefix}_${nextIndex}`;') &&
canvasHtml.includes('releaseComponentDisplayNames(selectedNodes);') &&
canvasHtml.includes('releaseComponentDisplayName(oldDisplayName);') &&
canvasHtml.includes('reserveComponentDisplayName(newComponentDisplayName);') &&
!canvasHtml.includes('componentCounterRef.current') &&
!canvasHtml.includes('componentCountersByPrefixRef') &&
canvasHtml.includes('COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS') &&
canvasHtml.includes("directional_coupler: 'DC'") &&
canvasHtml.includes("multimode_interferometers: 'MMI'") &&
canvasHtml.includes("photodetectors: 'PD'") &&
canvasHtml.includes("waveguides: 'WG'") &&
canvasHtml.includes("transitions: 'TRX'") &&
canvasHtml.includes("Mach_Zender_modulators: 'MZM'") &&
canvasHtml.includes("bendings: 'BD'") &&
canvasHtml.includes("edge_couplers: 'EC'") &&
canvasHtml.includes("grating_couplers: 'GC'") &&
canvasHtml.includes("terminations: 'TERM'") &&
canvasHtml.includes('abbreviate: Boolean(parsedData.category)') &&
canvasHtml.includes('abbreviate: Boolean(copyCategory)'),
'new PDK component instances should use their component category abbreviation as the display-name prefix'
);
assert(
canvasHtml.includes('normalizeAngle,') && canvasHtml.includes('normalizeAngle(Number(node.data?.rotation || 0) + 90)'),
'Space rotation should import normalizeAngle before using it'
);
assert(
canvasHtml.includes('component-floating-label') && canvasHtml.includes('component-visual-body'),
'component labels should float outside the rotated body'
);
assert(
!canvasHtml.includes('const visualPortHandles = useMemo(') &&
canvasHtml.includes('buildPortHandles(data.ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop), boxSize: componentSize })') &&
canvasHtml.includes('const portDirectionMap = useMemo(') &&
canvasHtml.includes('position: \'absolute\', inset: 0') &&
canvasHtml.includes('pointerEvents: \'none\'') &&
canvasHtml.includes('pointerEvents: \'all\'') &&
canvasHtml.includes('handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]'),
'component port circles should use transformed pin positions so rendered sides follow pin angles'
);
assert(
canvasHtml.includes('canvasTextVisible') &&
canvasHtml.includes('toggleCanvasText') &&
canvasHtml.includes('Text On') &&
canvasHtml.includes('Text Off') &&
canvasHtml.includes('canvas-text-hidden') &&
canvasHtml.includes('.canvas-text-hidden .component-floating-label'),
'canvas toolbar should toggle instance name and PDK text above components'
);
assert(
canvasHtml.includes('--floating-label-bg') && canvasHtml.includes('--port-label-bg') && canvasHtml.includes('--mini-button-bg'),
'theme variables should keep labels, port chips, and header buttons readable in light and dark modes'
);
assert(
canvasHtml.includes('className="site-nav-actions"') &&
canvasHtml.includes('className="canvas-toolbar"') &&
canvasHtml.includes('grid-snap-label') &&
canvasHtml.includes('body.light-mode .canvas-toolbar'),
'dashboard/logout should move to a site-level top-right action group and the canvas toolbar should keep readable grid text in light mode'
);
assert(
canvasHtml.includes('body.light-mode .component-floating-label') &&
canvasHtml.includes('body.light-mode .port-name-label') &&
canvasHtml.includes('body.light-mode .mini-btn'),
'light mode should override dark translucent label and button surfaces'
);
assert(
canvasHtml.includes('Canvas Size') && canvasHtml.includes('canvasSize') && canvasHtml.includes('DEFAULT_CANVAS_SIZE'),
'project tree should expose a canvas size control with a 5000 um default'
);
assert(
canvasHtml.includes('CanvasBoundaryNode') && canvasHtml.includes('canvas-boundary-node'),
'canvas should render a bold boundary rectangle in flow coordinates'
);
assert(
canvasHtml.includes('renderNodes') && canvasHtml.includes('nodeExtent={canvasNodeExtent}'),
'ReactFlow should render the boundary node and constrain draggable nodes to the canvas extent'
);
assert(
canvasHtml.includes('minZoom={0.02}') && canvasHtml.includes('defaultViewport={{ x: 80, y: 80, zoom: 0.12 }}'),
'large 5000 um canvases should zoom out far enough to fit on one screen'
);
assert(
canvasHtml.includes('onWheel={handleWheel}') &&
canvasHtml.includes('calculateLayoutBounds(activePage)') &&
canvasHtml.includes('layoutBounds') &&
canvasHtml.includes('stageWidth') &&
canvasHtml.includes('stageHeight'),
'layout preview should mouse-wheel zoom and size 100% from calculated box_size layout bounds'
);
assert(
canvasHtml.includes('reactFlowInstance.fitBounds') &&
canvasHtml.includes('width: activeCanvasSize.width') &&
canvasHtml.includes('height: activeCanvasSize.height'),
'switching canvases should fit the full canvas boundary instead of resetting to 100% zoom'
);
assert(
canvasHtml.includes('boxSize') && canvasHtml.includes('normalizeBoxSize'),
'component metadata box_size should drive rendered component box dimensions'
);
assert(
canvasHtml.includes('chooseCategoryComponent') && canvasHtml.includes('[id, data.ports, data.componentName, data.boxSize]'),
'category drops and metadata refreshes should remeasure components with YAML box sizes'
);
assert(
canvasHtml.includes('Background color="#334155" gap={10} size={1}'),
'default grid spacing should be 10 um'
);
assert(
canvasHtml.includes('Ruler') &&
canvasHtml.includes('rulerMode') &&
canvasHtml.includes('onPaneClick={handleCanvasPaneClick}') &&
canvasHtml.includes('onNodeClick={handleCanvasNodeClick}') &&
canvasHtml.includes('onPaneMouseMove={handleCanvasMouseMove}'),
'canvas should expose a ruler mode controlled from the top toolbar, allow measuring on component bodies, and preview to the mouse'
);
assert(
canvasHtml.includes('mouseCanvasPoint') &&
canvasHtml.includes('canvasOrigin') &&
canvasHtml.includes('originPickMode') &&
canvasHtml.includes('displayMousePoint') &&
canvasHtml.includes('toggleOriginPickMode') &&
canvasHtml.includes('onMouseMoveCapture={handleCanvasMouseMove}') &&
canvasHtml.includes('handleCanvasPaneClick') &&
canvasHtml.includes('handleCanvasNodeClick') &&
canvasHtml.includes('origin-select-btn') &&
canvasHtml.includes('Select canvas origin') &&
canvasHtml.includes('className="coordinate-readout"') &&
canvasHtml.includes('className="origin-crosshair"'),
'canvas should show live mouse coordinates and support one-click origin selection with a crosshair preview'
);
assert(
canvasHtml.includes('createRulerMeasurement') &&
canvasHtml.includes('rulerPointNode') &&
canvasHtml.includes('rulerMeasurementNode') &&
canvasHtml.includes('rulerPreviewPoint') &&
canvasHtml.includes('strokeDasharray: rulerPreviewMeasurement ? undefined') &&
canvasHtml.includes('renderEdges'),
'ruler mode should render temporary point nodes, a live solid preview edge, and a measurement label'
);
assert(
canvasHtml.includes('Box Size') &&
canvasHtml.includes('selectedNodeBoxSize') &&
canvasHtml.includes('box-size-readout'),
'component inspector should show the selected component YAML box size'
);
assert(
canvasHtml.includes('coordinate-grid'),
'selected component coordinates should be displayed horizontally in the right panel'
);
assert(
canvasHtml.includes('selectedPositionNodes.length > 1') &&
canvasHtml.includes("const MIXED_VALUE = '--'") &&
canvasHtml.includes('getSharedNumericDisplay') &&
canvasHtml.includes('clearMixedInput') &&
canvasHtml.includes('event.currentTarget.value === MIXED_VALUE') &&
canvasHtml.includes('editingTransformField') &&
canvasHtml.includes('if (editingTransformField) return;') &&
canvasHtml.includes('commitTransformInput') &&
canvasHtml.includes('event.currentTarget.value') &&
canvasHtml.includes('updatePosition(selectedNode.id, \'x\', val)') &&
canvasHtml.includes('selectedPositionNodes.forEach(node => {') &&
canvasHtml.includes('position: { [axis]: val }') &&
canvasHtml.includes('selectedNodes={selectedNodes}'),
'multi-selected components should show mixed values as -- and set all selected X/Y values absolutely from the inspector'
);
assert(
canvasHtml.includes('portInfo.description') || canvasHtml.includes('port.description'),
'port information should append optional human-readable port descriptions'
);
assert(
canvasHtml.includes('BASIC_COMPONENTS') &&
canvasHelpers.includes('BASIC_COMPONENTS') &&
canvasHelpers.includes('waveguide') &&
canvasHelpers.includes('90 bend') &&
canvasHelpers.includes('180 bend') &&
canvasHelpers.includes('circle') &&
canvasHelpers.includes('taper'),
'component library should expose basic Nazca primitives'
);
assert(
canvasHtml.includes('Cells: cellEntries') &&
canvasHtml.includes('Basic: basicEntries') &&
canvasHtml.includes('PDK: library || {}'),
'component library should keep top-level Cells, Basic, and PDK folders'
);
assert(
canvasHtml.includes('isDirectLeafGrid ? (') &&
canvasHtml.includes('<div className="category-grid">') &&
canvasHtml.includes("isUserCell ? 'compact-tree-card' : ''"),
'Basic, Port, and Anchor entries should render as consistent 2D cards instead of compact list rows'
);
assert(
canvasHtml.includes("key === 'xsection'") &&
canvasHtml.includes('<select') &&
canvasHtml.includes('xsections.map(xsection =>') &&
canvasHtml.includes('updateBasicArgument(key, event.target.value)'),
'Basic component xsection should be selected from technology manifest xsections instead of free text'
);
assert(
canvasHtml.indexOf('const xsections = Object.keys') <
canvasHtml.indexOf('if (selectedRouteEdges.length > 0)'),
'Basic and route property panels should share the same xsection list from RightPanel scope'
);
assert(
canvasHtml.includes("['waveguide', 'taper', '90 bend'].includes(data.componentName)") &&
canvasHtml.includes('minHeight: visualSize.height') &&
canvasHtml.includes('isBasicCompactComponent ?'),
'waveguide, taper, and 90 bend nodes should override the default component min-height and padding on the canvas'
);
assert(
canvasHtml.includes('font-size: 0.3rem;') &&
canvasHtml.includes('font-size: 0.4rem;') &&
canvasHtml.includes('font-size: 0.32rem;') &&
canvasHtml.includes("font: 600 0.5rem/1.35") &&
canvasHtml.includes('width: 6, height: 6') &&
canvasHtml.includes("border: '1px solid var(--accent)'") &&
canvasHtml.includes('width: 5,') &&
canvasHtml.includes('fontSize: 8'),
'canvas labels and port circles should render smaller than the previous sizing'
);
assert(
canvasHtml.includes('const portDisplayName = data.portName || data.componentDisplayName || data.label || \'port\';') &&
canvasHtml.includes('const canvasAngle = -Number(angle || 0);') &&
canvasHtml.includes('const pinLabelFromPortName =') &&
canvasHtml.includes('buildPortHandles(localHandlePorts, { rotation: canvasAngle })') &&
canvasHtml.includes('buildPortHandles(localHandlePorts, { rotation: 0, boxSize: elementSize })') &&
canvasHtml.includes('style={{ ...baseHandleStyle, ...portHandle.style }}') &&
canvasHtml.includes('borderRadius: 7') &&
canvasHtml.includes('boxSizing: \'border-box\'') &&
canvasHtml.includes('width: elementSize.width, height: elementSize.height, position: \'relative\'') &&
canvasHtml.includes('className="component-floating-label"') &&
canvasHtml.includes('className="port-pin-label"') &&
canvasHtml.includes('pinLabelFromPortName(portHandle.name)') &&
canvasHtml.includes('pinLabelStyle(portHandle') &&
canvasHtml.includes('transform: `rotate(${canvasAngle}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`') &&
canvasHtml.includes('{portDisplayName}'),
'standalone Port nodes should render pin labels at their circles while the port instance name floats outside the rotated body'
);
assert(
canvasHtml.includes('const anchorDisplayName = data.componentDisplayName || data.label || \'anchor\';') &&
canvasHtml.includes('className="anchor-node-shell"') &&
canvasHtml.includes('className="anchor-visual-body"') &&
canvasHtml.includes("const localLeft = visualSide === 'left' ? 0 : elementSize.width") &&
canvasHtml.includes("transform: 'translate(-50%, -50%)'") &&
canvasHtml.includes('pinLabelFromPortName(portHandle.name)') &&
canvasHtml.includes('pinLabelStyle(portHandle') &&
canvasHtml.includes('className="port-pin-label"') &&
canvasHtml.includes('{anchorDisplayName}'),
'standalone Anchor nodes should use the same outside name label and per-pin labels as Port nodes'
);
assert(
canvasHtml.includes('ParallelRouteEdge') &&
canvasHtml.includes('parallelOffset') &&
canvasHtml.includes("type: 'parallelRoute'") &&
canvasHtml.includes('edgeTypes={edgeTypes}'),
'overlapped links should render with separated parallel edge paths'
);
assert(
canvasHtml.includes('data: { route, points: routePoints }') &&
canvasHtml.includes('normalizeRoutePoints(link.points'),
'manual link route points should be stored on edges and restored from saved YAML'
);
assert(
canvasHtml.includes('createComponentSymbolMetrics') &&
!canvasHtml.includes('Math.min(128') &&
!canvasHtml.includes('Math.min(64'),
'component icon/symbol dimensions should scale with component box size without old hard caps'
);
assert(
canvasHtml.includes('/api/library?project=') &&
canvasHtml.includes('/api/component/${encodeURIComponent(componentName)}?project=') &&
canvasHtml.includes('/api/component/${encodeURIComponent(compName)}?project='),
'canvas should pass project context when loading library and component metadata'
);
assert(
canvasHtml.includes('/api/component/${encodeURIComponent(componentData.name)}/image?project=') &&
canvasHtml.includes('return obj.__path__.split'),
'component images and saved component paths should use technology-scoped library metadata'
);
+49
View File
@@ -0,0 +1,49 @@
/*
* Description: Static and helper regression tests for MXPIC EDA frontend/backend integration contracts.
* Inside functions: N/A - assertion-based test/module script.
* Developer : Qin Yue @ 2026
* Organization : OptiHK Limited
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
assert(
canvasHtml.includes('loadedProjectPage'),
'project loading should reuse the saved project canvas instead of creating a duplicate empty project tab'
);
assert(
canvasHtml.includes('nonProjectPages'),
'project composite map should exclude the saved project page itself'
);
assert(
canvasHtml.includes('layoutToCanvasY'),
'loading saved layout YAML should convert GDS/layout Y coordinates back to canvas coordinates'
);
assert(
canvasHtml.includes('buildElementNodesFromYaml'),
'project loading should rebuild saved anchor/port element nodes from YAML elements'
);
assert(
canvasHtml.includes('Object.entries(doc.elements || {})'),
'project loading should read doc.elements, not only doc.instances'
);
assert(
canvasHtml.includes('nodeNameMap[elementName] = nodeId'),
'loaded element names should be registered so saved links can reconnect to anchors and ports'
);
assert(
canvasHtml.includes('getAvailableComponentsForLoadedComponent'),
'project loading should reconstruct PDK component selection options for saved instances'
);
assert(
canvasHtml.includes('availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents'),
'loaded PDK instances should keep availableComponents so the right panel can show the PDK selector'
);
assert(
canvasHtml.includes('Array.from(new Set([FORGE_COMPONENT_LABEL, ...sameCategoryComponents'),
'loaded PDK selector choices should include forge and same-category library components'
);