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:
Ilya Ashikhmin 2026-07-02 23:31:12 +02:00
parent e4783c31f2
commit e70ec822c9
13 changed files with 553 additions and 52 deletions

View File

@ -69,7 +69,7 @@ export function App(): JSX.Element {
const onAddCenter = useCallback(
(symbolId: string) => {
if (!ctrl) return
if (!ctrl || !ctrl.paper) return
const box = ctrl.paper.el.getBoundingClientRect()
ctrl.addNodeAtClient(symbolId, box.left + box.width / 2, box.top + box.height / 2)
},
@ -88,7 +88,7 @@ export function App(): JSX.Element {
}, [ctrl])
const onExport = useCallback(() => {
if (!ctrl) return
if (!ctrl || !ctrl.paper) return
const svg = ctrl.paper.svg.cloneNode(true) as SVGSVGElement
const blob = new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)

View File

@ -30,7 +30,8 @@ type Emit = 'selection' | 'change' | 'settings' | 'flow'
export class DiagramController {
readonly graph: dia.Graph
readonly paper: dia.Paper
/** Undefined in headless mode (no DOM host, e.g. tests). */
readonly paper?: dia.Paper
settings: DiagramSettings
private history = new History()
private selection = new Set<string>()
@ -45,9 +46,10 @@ export class DiagramController {
flow: new Set()
}
constructor(el: HTMLElement, settings: DiagramSettings = DEFAULT_SETTINGS) {
constructor(el: HTMLElement | null, settings: DiagramSettings = DEFAULT_SETTINGS) {
this.settings = { ...settings }
this.graph = new dia.Graph({}, { cellNamespace })
if (el) {
this.paper = new dia.Paper({
el,
model: this.graph,
@ -63,15 +65,13 @@ export class DiagramController {
snapLinks: { radius: 20 },
linkPinning: false,
markAvailable: true,
defaultLink: () => {
const link = createLink({ id: '' }, { id: '' })
return link
},
defaultLink: () => createLink({ id: '' }, { id: '' }),
validateConnection: this.validateConnection,
validateMagnet: (_view, magnet) => magnet.getAttribute('magnet') !== 'passive'
})
this.animator = new FlowAnimator(this.graph)
this.bindEvents()
}
this.animator = new FlowAnimator(this.graph)
this.snapshot()
}
@ -86,26 +86,28 @@ export class DiagramController {
}
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.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.paper.on('blank:pointerclick', () => this.clearSelection())
paper.on('blank:pointerclick', () => this.clearSelection())
// 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 p = el.position()
el.position(snap(p.x, this.settings), snap(p.y, this.settings))
this.snapshot()
})
this.paper.on('link:connect', () => this.snapshot())
paper.on('link:connect', () => this.snapshot())
// Reveal ports of hovered node.
this.paper.on('element:mouseenter', (view) => this.setPortsVisible(view.model as dia.Element, true))
this.paper.on('element:mouseleave', (view) => {
paper.on('element:mouseenter', (view) => this.setPortsVisible(view.model as dia.Element, true))
paper.on('element:mouseleave', (view) => {
if (!this.selection.has(view.model.id as string)) {
this.setPortsVisible(view.model as dia.Element, false)
}
@ -136,6 +138,7 @@ export class DiagramController {
// ---- Node / edge creation ----------------------------------------------
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))
return this.addNode(symbolId, local.x, local.y)
}
@ -185,23 +188,25 @@ export class DiagramController {
private refreshHighlights(): void {
for (const id of this.selection) {
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
highlighters.mask.add(view, cell.isLink() ? 'line' : 'body', 'sel', {
deep: false,
padding: 4,
attrs: { stroke: '#f0c000', 'stroke-width': 2 }
})
if (!cell.isLink()) this.setPortsVisible(cell as dia.Element, true)
}
}
private clearHighlights(): void {
for (const id of this.selection) {
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 (cell && !cell.isLink()) this.setPortsVisible(cell as dia.Element, false)
}
}
@ -339,6 +344,7 @@ export class DiagramController {
setSettings(patch: Partial<DiagramSettings>): void {
this.settings = { ...this.settings, ...patch }
const s = this.settings
if (this.paper) {
// Some grid runtime methods are undeclared in the shipped d.ts.
const paper = this.paper as unknown as {
drawGrid: (opt: unknown) => void
@ -351,6 +357,7 @@ export class DiagramController {
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()) {
link.router(routerConfig(s).name, routerConfig(s).args)
link.connector(connectorConfig(s).name, connectorConfig(s).args)
@ -362,14 +369,17 @@ export class DiagramController {
// ---- Zoom / fit ---------------------------------------------------------
zoom(factor: number): void {
if (!this.paper) return
const next = Math.min(3, Math.max(0.2, this.paper.scale().sx * factor))
this.paper.scale(next, next)
}
resetZoom(): void {
if (!this.paper) return
this.paper.scale(1, 1)
this.paper.translate(0, 0)
}
fitContent(): void {
if (!this.paper) return
this.paper.transformToFitContent({ padding: 40, minScale: 0.2, maxScale: 2 })
}
@ -494,6 +504,6 @@ export class DiagramController {
dispose(): void {
this.animator.stop()
this.paper.remove()
this.paper?.remove()
}
}

View File

@ -95,8 +95,9 @@ export function solveFlow(nodes: FlowNodeInput[], edges: FlowEdgeInput[]): FlowS
const s = subtreeNet.get(node)!
subtreeNet.set(parent, subtreeNet.get(parent)! + s)
const edge = parentEdge.get(node)!
// Positive subtree surplus flows node → parent.
const forwardIsSourceToTarget = edge.source === node ? s < 0 : s > 0
// Positive subtree surplus (s > 0) flows out of the subtree: node → parent.
// "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 })
}

25
tests/catalogue.test.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
})
})