diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6482970..82b6311 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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) diff --git a/src/renderer/diagram/controller.ts b/src/renderer/diagram/controller.ts index f2e5e75..9ae3df0 100644 --- a/src/renderer/diagram/controller.ts +++ b/src/renderer/diagram/controller.ts @@ -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() @@ -45,33 +46,32 @@ 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 }) - this.paper = new dia.Paper({ - el, - model: this.graph, - width: '100%', - height: '100%', - gridSize: this.settings.gridSize, - drawGrid: this.settings.showGrid ? { name: 'mesh', args: { color: '#2a2c36' } } : false, - background: { color: this.settings.theme === 'dark' ? '#181920' : '#f4f5f7' }, - async: true, - cellViewNamespace: cellNamespace, - defaultRouter: routerConfig(this.settings), - defaultConnector: connectorConfig(this.settings), - snapLinks: { radius: 20 }, - linkPinning: false, - markAvailable: true, - defaultLink: () => { - const link = createLink({ id: '' }, { id: '' }) - return link - }, - validateConnection: this.validateConnection, - validateMagnet: (_view, magnet) => magnet.getAttribute('magnet') !== 'passive' - }) + if (el) { + this.paper = new dia.Paper({ + el, + model: this.graph, + width: '100%', + height: '100%', + gridSize: this.settings.gridSize, + drawGrid: this.settings.showGrid ? { name: 'mesh', args: { color: '#2a2c36' } } : false, + background: { color: this.settings.theme === 'dark' ? '#181920' : '#f4f5f7' }, + async: true, + cellViewNamespace: cellNamespace, + defaultRouter: routerConfig(this.settings), + defaultConnector: connectorConfig(this.settings), + snapLinks: { radius: 20 }, + linkPinning: false, + markAvailable: true, + defaultLink: () => createLink({ id: '' }, { id: '' }), + validateConnection: this.validateConnection, + validateMagnet: (_view, magnet) => magnet.getAttribute('magnet') !== 'passive' + }) + this.bindEvents() + } this.animator = new FlowAnimator(this.graph) - this.bindEvents() 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,18 +344,20 @@ export class DiagramController { setSettings(patch: Partial): void { this.settings = { ...this.settings, ...patch } const s = this.settings - // Some grid runtime methods are undeclared in the shipped d.ts. - const paper = this.paper as unknown as { - drawGrid: (opt: unknown) => void - clearGrid: () => void + 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 + 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()) { 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() } } diff --git a/src/renderer/diagram/flow.ts b/src/renderer/diagram/flow.ts index 531abc2..dd6c3e8 100644 --- a/src/renderer/diagram/flow.ts +++ b/src/renderer/diagram/flow.ts @@ -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 }) } diff --git a/tests/catalogue.test.ts b/tests/catalogue.test.ts new file mode 100644 index 0000000..4c2a929 --- /dev/null +++ b/tests/catalogue.test.ts @@ -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') + }) +}) diff --git a/tests/controller.test.ts b/tests/controller.test.ts new file mode 100644 index 0000000..5bf0d5f --- /dev/null +++ b/tests/controller.test.ts @@ -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).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() + }) +}) diff --git a/tests/edges.test.ts b/tests/edges.test.ts new file mode 100644 index 0000000..a10c6b8 --- /dev/null +++ b/tests/edges.test.ts @@ -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: '' }) + }) +}) diff --git a/tests/flow.test.ts b/tests/flow.test.ts new file mode 100644 index 0000000..fb74130 --- /dev/null +++ b/tests/flow.test.ts @@ -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) + }) +}) diff --git a/tests/properties.test.ts b/tests/properties.test.ts new file mode 100644 index 0000000..6ac780c --- /dev/null +++ b/tests/properties.test.ts @@ -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') + }) +}) diff --git a/tests/relations.test.ts b/tests/relations.test.ts new file mode 100644 index 0000000..c629a14 --- /dev/null +++ b/tests/relations.test.ts @@ -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') + }) +}) diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..0e4f226 --- /dev/null +++ b/tests/settings.test.ts @@ -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') + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..aa1b403 --- /dev/null +++ b/tests/setup.ts @@ -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() })) diff --git a/tests/shapes.test.ts b/tests/shapes.test.ts new file mode 100644 index 0000000..baec35f --- /dev/null +++ b/tests/shapes.test.ts @@ -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).title).toBe('Main reservoir') + expect(restored.getPort('out')).toBeTruthy() + expect(portMedium(restored, 'out')).toBe('water') + }) +}) diff --git a/tests/symbols.test.ts b/tests/symbols.test.ts new file mode 100644 index 0000000..7c69e7d --- /dev/null +++ b/tests/symbols.test.ts @@ -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) + }) +})