test: cover domain, engine and controller scenarios; add headless mode
Add Vitest suites for relations, properties, catalogue, symbols, edge styles, flow solver, settings, JointJS shapes/serialization and the controller lifecycle (add/connect/edit/delete, undo-redo, copy-paste, flow, save-load). Introduce headless DiagramController (Paper-less) so the controller is testable outside a browser, and fix the flow direction sign. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e4783c31f2
commit
e70ec822c9
@ -69,7 +69,7 @@ export function App(): JSX.Element {
|
|||||||
|
|
||||||
const onAddCenter = useCallback(
|
const onAddCenter = useCallback(
|
||||||
(symbolId: string) => {
|
(symbolId: string) => {
|
||||||
if (!ctrl) return
|
if (!ctrl || !ctrl.paper) return
|
||||||
const box = ctrl.paper.el.getBoundingClientRect()
|
const box = ctrl.paper.el.getBoundingClientRect()
|
||||||
ctrl.addNodeAtClient(symbolId, box.left + box.width / 2, box.top + box.height / 2)
|
ctrl.addNodeAtClient(symbolId, box.left + box.width / 2, box.top + box.height / 2)
|
||||||
},
|
},
|
||||||
@ -88,7 +88,7 @@ export function App(): JSX.Element {
|
|||||||
}, [ctrl])
|
}, [ctrl])
|
||||||
|
|
||||||
const onExport = useCallback(() => {
|
const onExport = useCallback(() => {
|
||||||
if (!ctrl) return
|
if (!ctrl || !ctrl.paper) return
|
||||||
const svg = ctrl.paper.svg.cloneNode(true) as SVGSVGElement
|
const svg = ctrl.paper.svg.cloneNode(true) as SVGSVGElement
|
||||||
const blob = new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' })
|
const blob = new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
|
|||||||
@ -30,7 +30,8 @@ type Emit = 'selection' | 'change' | 'settings' | 'flow'
|
|||||||
|
|
||||||
export class DiagramController {
|
export class DiagramController {
|
||||||
readonly graph: dia.Graph
|
readonly graph: dia.Graph
|
||||||
readonly paper: dia.Paper
|
/** Undefined in headless mode (no DOM host, e.g. tests). */
|
||||||
|
readonly paper?: dia.Paper
|
||||||
settings: DiagramSettings
|
settings: DiagramSettings
|
||||||
private history = new History()
|
private history = new History()
|
||||||
private selection = new Set<string>()
|
private selection = new Set<string>()
|
||||||
@ -45,33 +46,32 @@ export class DiagramController {
|
|||||||
flow: new Set()
|
flow: new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(el: HTMLElement, settings: DiagramSettings = DEFAULT_SETTINGS) {
|
constructor(el: HTMLElement | null, settings: DiagramSettings = DEFAULT_SETTINGS) {
|
||||||
this.settings = { ...settings }
|
this.settings = { ...settings }
|
||||||
this.graph = new dia.Graph({}, { cellNamespace })
|
this.graph = new dia.Graph({}, { cellNamespace })
|
||||||
this.paper = new dia.Paper({
|
if (el) {
|
||||||
el,
|
this.paper = new dia.Paper({
|
||||||
model: this.graph,
|
el,
|
||||||
width: '100%',
|
model: this.graph,
|
||||||
height: '100%',
|
width: '100%',
|
||||||
gridSize: this.settings.gridSize,
|
height: '100%',
|
||||||
drawGrid: this.settings.showGrid ? { name: 'mesh', args: { color: '#2a2c36' } } : false,
|
gridSize: this.settings.gridSize,
|
||||||
background: { color: this.settings.theme === 'dark' ? '#181920' : '#f4f5f7' },
|
drawGrid: this.settings.showGrid ? { name: 'mesh', args: { color: '#2a2c36' } } : false,
|
||||||
async: true,
|
background: { color: this.settings.theme === 'dark' ? '#181920' : '#f4f5f7' },
|
||||||
cellViewNamespace: cellNamespace,
|
async: true,
|
||||||
defaultRouter: routerConfig(this.settings),
|
cellViewNamespace: cellNamespace,
|
||||||
defaultConnector: connectorConfig(this.settings),
|
defaultRouter: routerConfig(this.settings),
|
||||||
snapLinks: { radius: 20 },
|
defaultConnector: connectorConfig(this.settings),
|
||||||
linkPinning: false,
|
snapLinks: { radius: 20 },
|
||||||
markAvailable: true,
|
linkPinning: false,
|
||||||
defaultLink: () => {
|
markAvailable: true,
|
||||||
const link = createLink({ id: '' }, { id: '' })
|
defaultLink: () => createLink({ id: '' }, { id: '' }),
|
||||||
return link
|
validateConnection: this.validateConnection,
|
||||||
},
|
validateMagnet: (_view, magnet) => magnet.getAttribute('magnet') !== 'passive'
|
||||||
validateConnection: this.validateConnection,
|
})
|
||||||
validateMagnet: (_view, magnet) => magnet.getAttribute('magnet') !== 'passive'
|
this.bindEvents()
|
||||||
})
|
}
|
||||||
this.animator = new FlowAnimator(this.graph)
|
this.animator = new FlowAnimator(this.graph)
|
||||||
this.bindEvents()
|
|
||||||
this.snapshot()
|
this.snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,26 +86,28 @@ export class DiagramController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bindEvents(): void {
|
private bindEvents(): void {
|
||||||
this.paper.on('element:pointerclick', (view, evt) => {
|
const paper = this.paper
|
||||||
|
if (!paper) return
|
||||||
|
paper.on('element:pointerclick', (view, evt) => {
|
||||||
this.select(view.model.id as string, Boolean(evt.shiftKey || evt.ctrlKey || evt.metaKey))
|
this.select(view.model.id as string, Boolean(evt.shiftKey || evt.ctrlKey || evt.metaKey))
|
||||||
})
|
})
|
||||||
this.paper.on('link:pointerclick', (view, evt) => {
|
paper.on('link:pointerclick', (view, evt) => {
|
||||||
this.select(view.model.id as string, Boolean(evt.shiftKey || evt.ctrlKey || evt.metaKey))
|
this.select(view.model.id as string, Boolean(evt.shiftKey || evt.ctrlKey || evt.metaKey))
|
||||||
})
|
})
|
||||||
this.paper.on('blank:pointerclick', () => this.clearSelection())
|
paper.on('blank:pointerclick', () => this.clearSelection())
|
||||||
|
|
||||||
// Snap nodes to the grid on drop and record history.
|
// Snap nodes to the grid on drop and record history.
|
||||||
this.paper.on('element:pointerup', (view) => {
|
paper.on('element:pointerup', (view) => {
|
||||||
const el = view.model
|
const el = view.model
|
||||||
const p = el.position()
|
const p = el.position()
|
||||||
el.position(snap(p.x, this.settings), snap(p.y, this.settings))
|
el.position(snap(p.x, this.settings), snap(p.y, this.settings))
|
||||||
this.snapshot()
|
this.snapshot()
|
||||||
})
|
})
|
||||||
this.paper.on('link:connect', () => this.snapshot())
|
paper.on('link:connect', () => this.snapshot())
|
||||||
|
|
||||||
// Reveal ports of hovered node.
|
// Reveal ports of hovered node.
|
||||||
this.paper.on('element:mouseenter', (view) => this.setPortsVisible(view.model as dia.Element, true))
|
paper.on('element:mouseenter', (view) => this.setPortsVisible(view.model as dia.Element, true))
|
||||||
this.paper.on('element:mouseleave', (view) => {
|
paper.on('element:mouseleave', (view) => {
|
||||||
if (!this.selection.has(view.model.id as string)) {
|
if (!this.selection.has(view.model.id as string)) {
|
||||||
this.setPortsVisible(view.model as dia.Element, false)
|
this.setPortsVisible(view.model as dia.Element, false)
|
||||||
}
|
}
|
||||||
@ -136,6 +138,7 @@ export class DiagramController {
|
|||||||
// ---- Node / edge creation ----------------------------------------------
|
// ---- Node / edge creation ----------------------------------------------
|
||||||
|
|
||||||
addNodeAtClient(symbolId: string, clientX: number, clientY: number): dia.Element {
|
addNodeAtClient(symbolId: string, clientX: number, clientY: number): dia.Element {
|
||||||
|
if (!this.paper) return this.addNode(symbolId, clientX, clientY)
|
||||||
const local = this.paper.clientToLocalPoint(new g.Point(clientX, clientY))
|
const local = this.paper.clientToLocalPoint(new g.Point(clientX, clientY))
|
||||||
return this.addNode(symbolId, local.x, local.y)
|
return this.addNode(symbolId, local.x, local.y)
|
||||||
}
|
}
|
||||||
@ -185,23 +188,25 @@ export class DiagramController {
|
|||||||
private refreshHighlights(): void {
|
private refreshHighlights(): void {
|
||||||
for (const id of this.selection) {
|
for (const id of this.selection) {
|
||||||
const cell = this.graph.getCell(id)
|
const cell = this.graph.getCell(id)
|
||||||
const view = cell && this.paper.findViewByModel(cell)
|
if (!cell) continue
|
||||||
|
if (!cell.isLink()) this.setPortsVisible(cell as dia.Element, true)
|
||||||
|
const view = this.paper?.findViewByModel(cell)
|
||||||
if (!view) continue
|
if (!view) continue
|
||||||
highlighters.mask.add(view, cell.isLink() ? 'line' : 'body', 'sel', {
|
highlighters.mask.add(view, cell.isLink() ? 'line' : 'body', 'sel', {
|
||||||
deep: false,
|
deep: false,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
attrs: { stroke: '#f0c000', 'stroke-width': 2 }
|
attrs: { stroke: '#f0c000', 'stroke-width': 2 }
|
||||||
})
|
})
|
||||||
if (!cell.isLink()) this.setPortsVisible(cell as dia.Element, true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearHighlights(): void {
|
private clearHighlights(): void {
|
||||||
for (const id of this.selection) {
|
for (const id of this.selection) {
|
||||||
const cell = this.graph.getCell(id)
|
const cell = this.graph.getCell(id)
|
||||||
const view = cell && this.paper.findViewByModel(cell)
|
if (!cell) continue
|
||||||
|
if (!cell.isLink()) this.setPortsVisible(cell as dia.Element, false)
|
||||||
|
const view = this.paper?.findViewByModel(cell)
|
||||||
if (view) highlighters.mask.remove(view, 'sel')
|
if (view) highlighters.mask.remove(view, 'sel')
|
||||||
if (cell && !cell.isLink()) this.setPortsVisible(cell as dia.Element, false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,18 +344,20 @@ export class DiagramController {
|
|||||||
setSettings(patch: Partial<DiagramSettings>): void {
|
setSettings(patch: Partial<DiagramSettings>): void {
|
||||||
this.settings = { ...this.settings, ...patch }
|
this.settings = { ...this.settings, ...patch }
|
||||||
const s = this.settings
|
const s = this.settings
|
||||||
// Some grid runtime methods are undeclared in the shipped d.ts.
|
if (this.paper) {
|
||||||
const paper = this.paper as unknown as {
|
// Some grid runtime methods are undeclared in the shipped d.ts.
|
||||||
drawGrid: (opt: unknown) => void
|
const paper = this.paper as unknown as {
|
||||||
clearGrid: () => void
|
drawGrid: (opt: unknown) => void
|
||||||
|
clearGrid: () => void
|
||||||
|
}
|
||||||
|
this.paper.options.gridSize = s.gridSize
|
||||||
|
this.paper.setGridSize(s.gridSize)
|
||||||
|
if (s.showGrid) paper.drawGrid({ name: 'mesh', args: { color: '#2a2c36' } })
|
||||||
|
else paper.clearGrid()
|
||||||
|
this.paper.drawBackground({ color: s.theme === 'dark' ? '#181920' : '#f4f5f7' })
|
||||||
|
this.paper.options.defaultRouter = routerConfig(s)
|
||||||
|
this.paper.options.defaultConnector = connectorConfig(s)
|
||||||
}
|
}
|
||||||
this.paper.options.gridSize = s.gridSize
|
|
||||||
this.paper.setGridSize(s.gridSize)
|
|
||||||
if (s.showGrid) paper.drawGrid({ name: 'mesh', args: { color: '#2a2c36' } })
|
|
||||||
else paper.clearGrid()
|
|
||||||
this.paper.drawBackground({ color: s.theme === 'dark' ? '#181920' : '#f4f5f7' })
|
|
||||||
this.paper.options.defaultRouter = routerConfig(s)
|
|
||||||
this.paper.options.defaultConnector = connectorConfig(s)
|
|
||||||
for (const link of this.graph.getLinks()) {
|
for (const link of this.graph.getLinks()) {
|
||||||
link.router(routerConfig(s).name, routerConfig(s).args)
|
link.router(routerConfig(s).name, routerConfig(s).args)
|
||||||
link.connector(connectorConfig(s).name, connectorConfig(s).args)
|
link.connector(connectorConfig(s).name, connectorConfig(s).args)
|
||||||
@ -362,14 +369,17 @@ export class DiagramController {
|
|||||||
// ---- Zoom / fit ---------------------------------------------------------
|
// ---- Zoom / fit ---------------------------------------------------------
|
||||||
|
|
||||||
zoom(factor: number): void {
|
zoom(factor: number): void {
|
||||||
|
if (!this.paper) return
|
||||||
const next = Math.min(3, Math.max(0.2, this.paper.scale().sx * factor))
|
const next = Math.min(3, Math.max(0.2, this.paper.scale().sx * factor))
|
||||||
this.paper.scale(next, next)
|
this.paper.scale(next, next)
|
||||||
}
|
}
|
||||||
resetZoom(): void {
|
resetZoom(): void {
|
||||||
|
if (!this.paper) return
|
||||||
this.paper.scale(1, 1)
|
this.paper.scale(1, 1)
|
||||||
this.paper.translate(0, 0)
|
this.paper.translate(0, 0)
|
||||||
}
|
}
|
||||||
fitContent(): void {
|
fitContent(): void {
|
||||||
|
if (!this.paper) return
|
||||||
this.paper.transformToFitContent({ padding: 40, minScale: 0.2, maxScale: 2 })
|
this.paper.transformToFitContent({ padding: 40, minScale: 0.2, maxScale: 2 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -494,6 +504,6 @@ export class DiagramController {
|
|||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.animator.stop()
|
this.animator.stop()
|
||||||
this.paper.remove()
|
this.paper?.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -95,8 +95,9 @@ export function solveFlow(nodes: FlowNodeInput[], edges: FlowEdgeInput[]): FlowS
|
|||||||
const s = subtreeNet.get(node)!
|
const s = subtreeNet.get(node)!
|
||||||
subtreeNet.set(parent, subtreeNet.get(parent)! + s)
|
subtreeNet.set(parent, subtreeNet.get(parent)! + s)
|
||||||
const edge = parentEdge.get(node)!
|
const edge = parentEdge.get(node)!
|
||||||
// Positive subtree surplus flows node → parent.
|
// Positive subtree surplus (s > 0) flows out of the subtree: node → parent.
|
||||||
const forwardIsSourceToTarget = edge.source === node ? s < 0 : s > 0
|
// "forward" means the flow direction matches the edge's source → target.
|
||||||
|
const forwardIsSourceToTarget = edge.source === node ? s > 0 : s < 0
|
||||||
result.set(edge.id, { id: edge.id, magnitude: Math.abs(s), forward: forwardIsSourceToTarget })
|
result.set(edge.id, { id: edge.id, magnitude: Math.abs(s), forward: forwardIsSourceToTarget })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
tests/catalogue.test.ts
Normal file
25
tests/catalogue.test.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { CATALOGUES, getCatalogue, getCatalogueItem, seedsFromItem } from '@diagram/catalogue'
|
||||||
|
|
||||||
|
describe('catalogue', () => {
|
||||||
|
it('exposes catalogues by id', () => {
|
||||||
|
expect(getCatalogue('pipeMaterial')?.items.length).toBeGreaterThan(0)
|
||||||
|
expect(getCatalogue('missing')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves items', () => {
|
||||||
|
expect(getCatalogueItem('pipeMaterial', 'pvc')?.label).toMatch(/PVC/)
|
||||||
|
expect(getCatalogueItem('pipeMaterial', 'nope')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns physics seeds for a chosen item', () => {
|
||||||
|
expect(seedsFromItem('pipeMaterial', 'steel')).toEqual({ roughness: 0.045 })
|
||||||
|
expect(seedsFromItem('pumpModel', 'grundfos-cr10')).toEqual({ flowRate: 10 })
|
||||||
|
expect(seedsFromItem('valveType', 'ball')).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every catalogue item has fields', () => {
|
||||||
|
for (const cat of Object.values(CATALOGUES))
|
||||||
|
for (const it of cat.items) expect(typeof it.fields).toBe('object')
|
||||||
|
})
|
||||||
|
})
|
||||||
83
tests/controller.test.ts
Normal file
83
tests/controller.test.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DiagramController } from '@diagram/controller'
|
||||||
|
|
||||||
|
// Headless controller (no DOM host / Paper) exercises the graph-level logic.
|
||||||
|
let ctrl: DiagramController
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ctrl = new DiagramController(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
ctrl.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('node lifecycle', () => {
|
||||||
|
it('adds a node and selects it', () => {
|
||||||
|
const node = ctrl.addNode('pump', 100, 100)
|
||||||
|
expect(ctrl.graph.getElements().length).toBe(1)
|
||||||
|
expect(ctrl.getSelection().map((c) => c.id)).toContain(node.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits properties through applyValues', () => {
|
||||||
|
const node = ctrl.addNode('valve', 40, 40)
|
||||||
|
ctrl.applyValues(node.id as string, { ...(node.get('values') as object), title: 'V-9', opening: 50 })
|
||||||
|
expect(node.attr('label/text')).toBe('V-9')
|
||||||
|
expect((node.get('values') as Record<string, unknown>).opening).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes the selection', () => {
|
||||||
|
const node = ctrl.addNode('tank', 40, 40)
|
||||||
|
ctrl.select(node.id as string, false)
|
||||||
|
ctrl.deleteSelected()
|
||||||
|
expect(ctrl.graph.getElements().length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('undo / redo', () => {
|
||||||
|
it('reverts and reapplies an add', () => {
|
||||||
|
ctrl.addNode('pump', 0, 0)
|
||||||
|
expect(ctrl.graph.getElements().length).toBe(1)
|
||||||
|
ctrl.undo()
|
||||||
|
expect(ctrl.graph.getElements().length).toBe(0)
|
||||||
|
ctrl.redo()
|
||||||
|
expect(ctrl.graph.getElements().length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('copy / paste', () => {
|
||||||
|
it('duplicates selected nodes', () => {
|
||||||
|
const node = ctrl.addNode('valve', 60, 60)
|
||||||
|
ctrl.select(node.id as string, false)
|
||||||
|
ctrl.duplicateSelected()
|
||||||
|
expect(ctrl.graph.getElements().length).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('flow', () => {
|
||||||
|
it('solves and animates a seeded network, then stops', () => {
|
||||||
|
ctrl.seedSample()
|
||||||
|
const sol = ctrl.runFlow()
|
||||||
|
expect(sol.edges.size).toBeGreaterThan(0)
|
||||||
|
expect(sol.maxMagnitude).toBeGreaterThan(0)
|
||||||
|
expect(ctrl.isFlowing()).toBe(true)
|
||||||
|
ctrl.stopFlow()
|
||||||
|
expect(ctrl.isFlowing()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serialization', () => {
|
||||||
|
it('round-trips the whole diagram including settings', () => {
|
||||||
|
ctrl.seedSample()
|
||||||
|
ctrl.setSettings({ jumpover: false, colorScheme: 'colorblind' })
|
||||||
|
const json = ctrl.toJSON()
|
||||||
|
const nElements = ctrl.graph.getElements().length
|
||||||
|
|
||||||
|
const ctrl2 = new DiagramController(null)
|
||||||
|
ctrl2.loadJSON(json)
|
||||||
|
expect(ctrl2.graph.getElements().length).toBe(nElements)
|
||||||
|
expect(ctrl2.settings.colorScheme).toBe('colorblind')
|
||||||
|
expect(ctrl2.settings.jumpover).toBe(false)
|
||||||
|
ctrl2.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
34
tests/edges.test.ts
Normal file
34
tests/edges.test.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { marker, dashArray, lineAttrs } from '@diagram/edges'
|
||||||
|
|
||||||
|
describe('edge markers', () => {
|
||||||
|
it('returns null for none', () => {
|
||||||
|
expect(marker('none', '#000')).toBeNull()
|
||||||
|
})
|
||||||
|
it('builds filled arrow and diamond paths', () => {
|
||||||
|
expect(marker('arrow', '#f00')).toMatchObject({ type: 'path', fill: '#f00' })
|
||||||
|
expect(marker('diamond', '#f00')?.d).toContain('Z')
|
||||||
|
})
|
||||||
|
it('builds a circle marker', () => {
|
||||||
|
expect(marker('circle', '#0f0')).toMatchObject({ type: 'circle', r: 5 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dash patterns', () => {
|
||||||
|
it('scales with stroke width', () => {
|
||||||
|
expect(dashArray('solid', 3)).toBe('none')
|
||||||
|
expect(dashArray('dashed', 2)).toBe('6 4')
|
||||||
|
expect(dashArray('dotted', 2)).toBe('2 3')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('line attrs', () => {
|
||||||
|
it('composes stroke, dash and markers', () => {
|
||||||
|
const attrs = lineAttrs({ color: '#123456', lineStyle: 'dashed', strokeWidth: 4, headMarker: 'arrow', tailMarker: 'none' })
|
||||||
|
expect(attrs.stroke).toBe('#123456')
|
||||||
|
expect(attrs.strokeWidth).toBe(4)
|
||||||
|
expect(attrs.strokeDasharray).toBe('12 8')
|
||||||
|
expect(attrs.targetMarker).toMatchObject({ type: 'path' })
|
||||||
|
expect(attrs.sourceMarker).toEqual({ d: '' })
|
||||||
|
})
|
||||||
|
})
|
||||||
82
tests/flow.test.ts
Normal file
82
tests/flow.test.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { solveFlow, balanceSupplies, type FlowNodeInput, type FlowEdgeInput } from '@diagram/flow'
|
||||||
|
|
||||||
|
describe('balanceSupplies', () => {
|
||||||
|
it('scales demand to match supply', () => {
|
||||||
|
const b = balanceSupplies([
|
||||||
|
{ id: 's', supply: 10 },
|
||||||
|
{ id: 'a', supply: -5 },
|
||||||
|
{ id: 'b', supply: -5 }
|
||||||
|
])
|
||||||
|
expect(b.get('s')).toBe(10)
|
||||||
|
expect((b.get('a') ?? 0) + (b.get('b') ?? 0)).toBeCloseTo(-10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales up when demand exceeds supply', () => {
|
||||||
|
const b = balanceSupplies([
|
||||||
|
{ id: 's', supply: 4 },
|
||||||
|
{ id: 'a', supply: -8 }
|
||||||
|
])
|
||||||
|
expect(b.get('a')).toBeCloseTo(-4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('solveFlow (tree)', () => {
|
||||||
|
// source(10) -> j -> two outlets(5 each)
|
||||||
|
const nodes: FlowNodeInput[] = [
|
||||||
|
{ id: 'src', supply: 10 },
|
||||||
|
{ id: 'j', supply: 0 },
|
||||||
|
{ id: 'o1', supply: -5 },
|
||||||
|
{ id: 'o2', supply: -5 }
|
||||||
|
]
|
||||||
|
const edges: FlowEdgeInput[] = [
|
||||||
|
{ id: 'e1', source: 'src', target: 'j' },
|
||||||
|
{ id: 'e2', source: 'j', target: 'o1' },
|
||||||
|
{ id: 'e3', source: 'j', target: 'o2' }
|
||||||
|
]
|
||||||
|
|
||||||
|
it('conserves mass (zero residual on a tree)', () => {
|
||||||
|
expect(solveFlow(nodes, edges).residual).toBeCloseTo(0, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries full supply on the trunk and splits on branches', () => {
|
||||||
|
const sol = solveFlow(nodes, edges)
|
||||||
|
expect(sol.edges.get('e1')!.magnitude).toBeCloseTo(10)
|
||||||
|
expect(sol.edges.get('e2')!.magnitude).toBeCloseTo(5)
|
||||||
|
expect(sol.edges.get('e3')!.magnitude).toBeCloseTo(5)
|
||||||
|
expect(sol.maxMagnitude).toBeCloseTo(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('orients flow from source toward sinks', () => {
|
||||||
|
const sol = solveFlow(nodes, edges)
|
||||||
|
// e1 is src->j, flow should go forward (source to target)
|
||||||
|
expect(sol.edges.get('e1')!.forward).toBe(true)
|
||||||
|
// e2 is j->o1, forward means j -> outlet
|
||||||
|
expect(sol.edges.get('e2')!.forward).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reverses direction when an edge is drawn against the flow', () => {
|
||||||
|
const reversed: FlowEdgeInput[] = [{ id: 'e1', source: 'j', target: 'src' }, edges[1], edges[2]]
|
||||||
|
const sol = solveFlow(nodes, reversed)
|
||||||
|
expect(sol.edges.get('e1')!.forward).toBe(false)
|
||||||
|
expect(sol.edges.get('e1')!.magnitude).toBeCloseTo(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('solveFlow (loop)', () => {
|
||||||
|
it('still conserves mass with a cycle present', () => {
|
||||||
|
const nodes: FlowNodeInput[] = [
|
||||||
|
{ id: 'src', supply: 6 },
|
||||||
|
{ id: 'a', supply: 0 },
|
||||||
|
{ id: 'b', supply: 0 },
|
||||||
|
{ id: 'sink', supply: -6 }
|
||||||
|
]
|
||||||
|
const edges: FlowEdgeInput[] = [
|
||||||
|
{ id: 'e1', source: 'src', target: 'a' },
|
||||||
|
{ id: 'e2', source: 'a', target: 'b' },
|
||||||
|
{ id: 'e3', source: 'b', target: 'sink' },
|
||||||
|
{ id: 'e4', source: 'a', target: 'sink' } // extra loop edge
|
||||||
|
]
|
||||||
|
expect(solveFlow(nodes, edges).residual).toBeCloseTo(0, 6)
|
||||||
|
})
|
||||||
|
})
|
||||||
61
tests/properties.test.ts
Normal file
61
tests/properties.test.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { coerce, validate, defaultsFor, schemaIndex, type PropertyDef, type PropertySchema } from '@diagram/properties'
|
||||||
|
|
||||||
|
const intDef: PropertyDef = { key: 'n', label: 'N', type: 'int', group: 'g', min: 0, max: 10 }
|
||||||
|
const floatDef: PropertyDef = { key: 'f', label: 'F', type: 'float', group: 'g', min: 0 }
|
||||||
|
const enumDef: PropertyDef = { key: 'e', label: 'E', type: 'enum', group: 'g', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] }
|
||||||
|
const listDef: PropertyDef = { key: 'l', label: 'L', type: 'listOfValues', group: 'g', itemType: 'int' }
|
||||||
|
|
||||||
|
describe('property coercion', () => {
|
||||||
|
it('rounds and clamps integers', () => {
|
||||||
|
expect(coerce(intDef, '3.7')).toBe(4)
|
||||||
|
expect(coerce(intDef, 99)).toBe(10)
|
||||||
|
expect(coerce(intDef, -5)).toBe(0)
|
||||||
|
expect(coerce(intDef, 'nope')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses floats without rounding', () => {
|
||||||
|
expect(coerce(floatDef, '2.5')).toBe(2.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coerces list of values to the item type', () => {
|
||||||
|
expect(coerce(listDef, ['1', '2', 'x'])).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stringifies enum/string/color', () => {
|
||||||
|
expect(coerce(enumDef, 'a')).toBe('a')
|
||||||
|
expect(coerce({ key: 'c', label: 'C', type: 'color', group: 'g' }, '#fff')).toBe('#fff')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('property validation', () => {
|
||||||
|
const schema: PropertySchema = [
|
||||||
|
{ id: 'g', label: 'G', defs: [{ ...intDef, required: true }, enumDef] }
|
||||||
|
]
|
||||||
|
|
||||||
|
it('flags required empties and out-of-range', () => {
|
||||||
|
const r = validate(schema, { n: '' as unknown as number, e: 'a' })
|
||||||
|
expect(r.ok).toBe(false)
|
||||||
|
expect(r.errors.n).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts valid values', () => {
|
||||||
|
expect(validate(schema, { n: 5, e: 'a' }).ok).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown enum option', () => {
|
||||||
|
expect(validate(schema, { n: 5, e: 'zzz' }).ok).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('schema helpers', () => {
|
||||||
|
const schema: PropertySchema = [
|
||||||
|
{ id: 'g', label: 'G', defs: [{ ...intDef, default: 3 }, { ...enumDef, default: 'b' }] }
|
||||||
|
]
|
||||||
|
it('produces defaults', () => {
|
||||||
|
expect(defaultsFor(schema)).toEqual({ n: 3, e: 'b' })
|
||||||
|
})
|
||||||
|
it('indexes by key', () => {
|
||||||
|
expect(schemaIndex(schema).get('n')?.type).toBe('int')
|
||||||
|
})
|
||||||
|
})
|
||||||
30
tests/relations.test.ts
Normal file
30
tests/relations.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { canConnectMedia, mediumColor, getColorScheme, MEDIUM_IDS, MEDIA } from '@diagram/relations'
|
||||||
|
|
||||||
|
describe('port relations', () => {
|
||||||
|
it('allows connecting identical media', () => {
|
||||||
|
for (const id of MEDIUM_IDS) expect(canConnectMedia(id, id)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors declared cross-compatibility symmetrically', () => {
|
||||||
|
expect(canConnectMedia('water', 'hotWater')).toBe(true)
|
||||||
|
expect(canConnectMedia('hotWater', 'water')).toBe(true)
|
||||||
|
expect(canConnectMedia('steam', 'hotWater')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects incompatible media', () => {
|
||||||
|
expect(canConnectMedia('water', 'power')).toBe(false)
|
||||||
|
expect(canConnectMedia('gas', 'signal')).toBe(false)
|
||||||
|
expect(canConnectMedia('sewage', 'water')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every medium declares its compatibility list', () => {
|
||||||
|
for (const id of MEDIUM_IDS) expect(Array.isArray(MEDIA[id].compatibleWith)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves colors per scheme and falls back to default', () => {
|
||||||
|
expect(mediumColor('water', 'default')).toMatch(/^#/)
|
||||||
|
expect(mediumColor('water', 'colorblind')).not.toBe(mediumColor('water', 'default'))
|
||||||
|
expect(getColorScheme('does-not-exist').id).toBe('default')
|
||||||
|
})
|
||||||
|
})
|
||||||
26
tests/settings.test.ts
Normal file
26
tests/settings.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { DEFAULT_SETTINGS, snap, routerConfig, connectorConfig } from '@diagram/settings'
|
||||||
|
|
||||||
|
describe('settings', () => {
|
||||||
|
it('snaps to grid when enabled', () => {
|
||||||
|
const s = { ...DEFAULT_SETTINGS, gridSize: 20, snapToGrid: true }
|
||||||
|
expect(snap(23, s)).toBe(20)
|
||||||
|
expect(snap(31, s)).toBe(40)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not snap when disabled', () => {
|
||||||
|
const s = { ...DEFAULT_SETTINGS, snapToGrid: false }
|
||||||
|
expect(snap(23, s)).toBe(23)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a grid-aligned manhattan router', () => {
|
||||||
|
const r = routerConfig({ ...DEFAULT_SETTINGS, gridSize: 16 })
|
||||||
|
expect(r.name).toBe('manhattan')
|
||||||
|
expect(r.args.step).toBe(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches connector between jumpover arc and rounded', () => {
|
||||||
|
expect(connectorConfig({ ...DEFAULT_SETTINGS, jumpover: true }).name).toBe('jumpover')
|
||||||
|
expect(connectorConfig({ ...DEFAULT_SETTINGS, jumpover: false }).name).toBe('rounded')
|
||||||
|
})
|
||||||
|
})
|
||||||
23
tests/setup.ts
Normal file
23
tests/setup.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// jsdom lacks a few SVG/DOM APIs JointJS pokes at during construction.
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
if (!('performance' in globalThis)) {
|
||||||
|
// @ts-expect-error minimal shim
|
||||||
|
globalThis.performance = { now: () => 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.requestAnimationFrame) {
|
||||||
|
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) =>
|
||||||
|
setTimeout(() => cb(0), 16) as unknown as number) as typeof requestAnimationFrame
|
||||||
|
globalThis.cancelAnimationFrame = ((id: number) => clearTimeout(id)) as typeof cancelAnimationFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsdom's SVGElement.getBBox / getScreenCTM are stubs; provide safe values.
|
||||||
|
if (typeof SVGElement !== 'undefined') {
|
||||||
|
// @ts-expect-error patching prototype for tests
|
||||||
|
SVGElement.prototype.getBBox ||= () => ({ x: 0, y: 0, width: 60, height: 60 })
|
||||||
|
// @ts-expect-error patching prototype for tests
|
||||||
|
SVGElement.prototype.getScreenCTM ||= () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0, inverse: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false, addListener: vi.fn(), removeListener: vi.fn() }))
|
||||||
85
tests/shapes.test.ts
Normal file
85
tests/shapes.test.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { dia, shapes } from '@joint/core'
|
||||||
|
import {
|
||||||
|
PipelineNode,
|
||||||
|
createNode,
|
||||||
|
createLink,
|
||||||
|
applyNodeValues,
|
||||||
|
applyEdgeValues,
|
||||||
|
portMedium,
|
||||||
|
cellValues
|
||||||
|
} from '@diagram/shapes'
|
||||||
|
import { canConnectMedia, type MediumId } from '@diagram/relations'
|
||||||
|
|
||||||
|
const cellNamespace = { ...shapes, pipeline: { Node: PipelineNode } }
|
||||||
|
const makeGraph = (): dia.Graph => new dia.Graph({}, { cellNamespace })
|
||||||
|
|
||||||
|
describe('node creation', () => {
|
||||||
|
it('creates a pump node with its ports and medium metadata', () => {
|
||||||
|
const node = createNode('pump', 100, 100)
|
||||||
|
expect(node.get('kind')).toBe('node')
|
||||||
|
expect(node.get('symbolId')).toBe('pump')
|
||||||
|
expect(node.getPorts().map((p) => p.id).sort()).toEqual(['in', 'out'])
|
||||||
|
expect(portMedium(node, 'in')).toBe('water')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies title, size and rotation from values', () => {
|
||||||
|
const node = createNode('valve', 0, 0)
|
||||||
|
applyNodeValues(node, { ...cellValues(node), title: 'V-101', width: 90, height: 60, rotation: 90 })
|
||||||
|
expect(node.attr('label/text')).toBe('V-101')
|
||||||
|
expect(node.size()).toEqual({ width: 90, height: 60 })
|
||||||
|
expect(node.angle()).toBe(90)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the label when showLabel is no', () => {
|
||||||
|
const node = createNode('tank', 0, 0)
|
||||||
|
applyNodeValues(node, { ...cellValues(node), showLabel: 'no', title: 'T' })
|
||||||
|
expect(node.attr('label/text')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('connection rule (port media)', () => {
|
||||||
|
it('permits compatible ports and blocks incompatible ones', () => {
|
||||||
|
const pump = createNode('pump', 0, 0)
|
||||||
|
const sensor = createNode('sensor', 0, 0)
|
||||||
|
const water = portMedium(pump, 'out') as MediumId
|
||||||
|
const signal = portMedium(sensor, 'sig') as MediumId
|
||||||
|
expect(canConnectMedia(water, water)).toBe(true)
|
||||||
|
expect(canConnectMedia(water, signal)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge creation', () => {
|
||||||
|
it('creates a styled link and applies markers', () => {
|
||||||
|
const link = createLink({ id: 'a', port: 'out' }, { id: 'b', port: 'in' })
|
||||||
|
expect(link.get('kind')).toBe('edge')
|
||||||
|
applyEdgeValues(link, { ...cellValues(link), color: '#ff0000', headMarker: 'diamond', strokeWidth: 5 })
|
||||||
|
expect(link.attr('line/stroke')).toBe('#ff0000')
|
||||||
|
expect(link.attr('line/strokeWidth')).toBe(5)
|
||||||
|
expect(link.attr('line/targetMarker')).toMatchObject({ type: 'path' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serialization round-trip', () => {
|
||||||
|
it('preserves nodes, links, ports and values', () => {
|
||||||
|
const g1 = makeGraph()
|
||||||
|
const a = createNode('reservoir', 40, 40)
|
||||||
|
const b = createNode('outlet', 300, 40)
|
||||||
|
a.addTo(g1)
|
||||||
|
b.addTo(g1)
|
||||||
|
const link = createLink({ id: a.id as string, port: 'out' }, { id: b.id as string, port: 'in' })
|
||||||
|
link.addTo(g1)
|
||||||
|
applyNodeValues(a, { ...cellValues(a), title: 'Main reservoir' })
|
||||||
|
|
||||||
|
const json = JSON.stringify(g1.toJSON())
|
||||||
|
|
||||||
|
const g2 = makeGraph()
|
||||||
|
g2.fromJSON(JSON.parse(json))
|
||||||
|
expect(g2.getElements().length).toBe(2)
|
||||||
|
expect(g2.getLinks().length).toBe(1)
|
||||||
|
const restored = g2.getCell(a.id) as dia.Element
|
||||||
|
expect((restored.get('values') as Record<string, unknown>).title).toBe('Main reservoir')
|
||||||
|
expect(restored.getPort('out')).toBeTruthy()
|
||||||
|
expect(portMedium(restored, 'out')).toBe('water')
|
||||||
|
})
|
||||||
|
})
|
||||||
41
tests/symbols.test.ts
Normal file
41
tests/symbols.test.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { SYMBOLS, getSymbol, symbolDataUri, symbolsByCategory, SYMBOL_CATEGORIES } from '@diagram/symbols'
|
||||||
|
import { MEDIA } from '@diagram/relations'
|
||||||
|
import { schemaIndex } from '@diagram/properties'
|
||||||
|
|
||||||
|
describe('symbol registry', () => {
|
||||||
|
it('has unique ids', () => {
|
||||||
|
const ids = SYMBOLS.map((s) => s.id)
|
||||||
|
expect(new Set(ids).size).toBe(ids.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every port references a known medium and has fractional coords', () => {
|
||||||
|
for (const sym of SYMBOLS)
|
||||||
|
for (const p of sym.ports) {
|
||||||
|
expect(MEDIA[p.medium]).toBeDefined()
|
||||||
|
expect(p.x).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(p.x).toBeLessThanOrEqual(1)
|
||||||
|
expect(p.y).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(p.y).toBeLessThanOrEqual(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every symbol schema carries identity + presentation groups', () => {
|
||||||
|
for (const sym of SYMBOLS) {
|
||||||
|
const idx = schemaIndex(sym.schema)
|
||||||
|
expect(idx.has('title')).toBe(true)
|
||||||
|
expect(idx.has('color')).toBe(true)
|
||||||
|
expect(idx.has('rotation')).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recolors the icon in the data URI', () => {
|
||||||
|
const pump = getSymbol('pump')!
|
||||||
|
expect(symbolDataUri(pump)).toContain('data:image/svg')
|
||||||
|
expect(decodeURIComponent(symbolDataUri(pump, '#ff0000'))).toContain('#ff0000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups symbols by category', () => {
|
||||||
|
for (const cat of SYMBOL_CATEGORIES) expect(symbolsByCategory(cat).length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user