merge main into rh-test

This commit is contained in:
bymyself
2025-09-28 15:33:29 -07:00
parent 1c0f151d02
commit ff0c15b119
1317 changed files with 85439 additions and 18373 deletions

View File

@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
// Mock stores
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const getCanvas = vi.fn()
const setCursorStyle = vi.fn()
return {
useCanvasStore: vi.fn(() => ({
getCanvas,
setCursorStyle
}))
}
})
vi.mock('@/platform/settings/settingStore', () => {
const getFn = vi.fn()
return { useSettingStore: vi.fn(() => ({ get: getFn })) }
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
canvas: {
dispatchEvent: vi.fn()
}
}
}
}))
function createMockLGraphCanvas(read_only = true): LGraphCanvas {
const mockCanvas: Partial<LGraphCanvas> = { read_only }
return mockCanvas as LGraphCanvas
}
function createMockPointerEvent(
buttons: PointerEvent['buttons'] = 1
): PointerEvent {
const mockEvent: Partial<PointerEvent> = {
buttons,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
return mockEvent as PointerEvent
}
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
const mockEvent: Partial<WheelEvent> = {
ctrlKey,
metaKey,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
return mockEvent as WheelEvent
}
describe('useCanvasInteractions', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('handlePointer', () => {
it('should intercept left mouse events when canvas is read_only to enable space+drag navigation', () => {
const { getCanvas } = useCanvasStore()
const mockCanvas = createMockLGraphCanvas(true)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent(1) // Left Mouse Button
handlePointer(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should forward middle mouse button events to canvas', () => {
const { getCanvas } = useCanvasStore()
const mockCanvas = createMockLGraphCanvas(false)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent(4) // Middle mouse button
handlePointer(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should not prevent default when canvas is not in read_only mode and not middle button', () => {
const { getCanvas } = useCanvasStore()
const mockCanvas = createMockLGraphCanvas(false)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent(1)
handlePointer(mockEvent)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
it('should return early when canvas is null', () => {
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(null as unknown as LGraphCanvas) // TODO: Fix misaligned types
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent(1)
handlePointer(mockEvent)
expect(getCanvas).toHaveBeenCalled()
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
})
describe('handleWheel', () => {
it('should forward ctrl+wheel events to canvas in standard nav mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const { handleWheel } = useCanvasInteractions()
// Ctrl key pressed
const mockEvent = createMockWheelEvent(true)
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should forward all wheel events to canvas in legacy nav mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('legacy')
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent()
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should not prevent default for regular wheel events in standard nav mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent()
handleWheel(mockEvent)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,264 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import {
type LayoutChange,
LayoutSource,
type NodeLayout
} from '@/renderer/core/layout/types'
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
// Clear the store before each test
layoutStore.initializeFromLiteGraph([])
})
// Helper to create test node data
const createTestNode = (id: string): NodeLayout => ({
id,
position: { x: 100, y: 100 },
size: { width: 200, height: 100 },
zIndex: 0,
visible: true,
bounds: { x: 100, y: 100, width: 200, height: 100 }
})
it('should create and retrieve nodes', () => {
const nodeId = 'test-node-1'
const layout = createTestNode(nodeId)
// Create node
layoutStore.setSource(LayoutSource.External)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Retrieve node
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value).toEqual(layout)
})
it('should move nodes', () => {
const nodeId = 'test-node-2'
const layout = createTestNode(nodeId)
// Create node first
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Move node
const newPosition = { x: 200, y: 300 }
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: newPosition,
previousPosition: layout.position,
timestamp: Date.now(),
source: LayoutSource.Vue,
actor: 'test'
})
// Verify position updated
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.position).toEqual(newPosition)
})
it('should resize nodes', () => {
const nodeId = 'test-node-3'
const layout = createTestNode(nodeId)
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Resize node
const newSize = { width: 300, height: 150 }
layoutStore.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newSize,
previousSize: layout.size,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
// Verify size updated
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size).toEqual(newSize)
})
it('should delete nodes', () => {
const nodeId = 'test-node-4'
const layout = createTestNode(nodeId)
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Delete node
layoutStore.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId,
previousLayout: layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Verify node deleted
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value).toBeNull()
})
it('should handle source and actor tracking', async () => {
const nodeId = 'test-node-5'
const layout = createTestNode(nodeId)
// Set source and actor
layoutStore.setSource(LayoutSource.Vue)
layoutStore.setActor('user-123')
// Track change notifications AFTER setting source/actor
const changes: LayoutChange[] = []
const unsubscribe = layoutStore.onChange((change) => {
changes.push(change)
})
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
// Wait for async notification
await new Promise((resolve) => setTimeout(resolve, 50))
expect(changes.length).toBeGreaterThanOrEqual(1)
const lastChange = changes[changes.length - 1]
expect(lastChange.source).toBe('vue')
expect(lastChange.operation.actor).toBe('user-123')
unsubscribe()
})
it('should query nodes by spatial bounds', () => {
const nodes = [
{ id: 'node-a', position: { x: 0, y: 0 } },
{ id: 'node-b', position: { x: 100, y: 100 } },
{ id: 'node-c', position: { x: 250, y: 250 } }
]
// Create nodes with proper bounds
nodes.forEach(({ id, position }) => {
const layout: NodeLayout = {
...createTestNode(id),
position,
bounds: {
x: position.x,
y: position.y,
width: 200,
height: 100
}
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId: id,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
})
// Query nodes in bounds
const nodesInBounds = layoutStore.queryNodesInBounds({
x: 50,
y: 50,
width: 200,
height: 200
})
// node-a: (0,0) to (200,100) - overlaps with query bounds (50,50) to (250,250)
// node-b: (100,100) to (300,200) - overlaps with query bounds
// node-c: (250,250) to (450,350) - touches corner of query bounds
expect(nodesInBounds).toContain('node-a')
expect(nodesInBounds).toContain('node-b')
expect(nodesInBounds).toContain('node-c')
})
it('should maintain operation history', () => {
const nodeId = 'test-node-history'
const layout = createTestNode(nodeId)
const startTime = Date.now()
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: startTime,
source: LayoutSource.External,
actor: 'test-actor'
})
// Move node
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: { x: 150, y: 150 },
previousPosition: { x: 100, y: 100 },
timestamp: startTime + 100,
source: LayoutSource.Vue,
actor: 'test-actor'
})
// Get operations by actor
const operations = layoutStore.getOperationsByActor('test-actor')
expect(operations.length).toBeGreaterThanOrEqual(2)
expect(operations[0].type).toBe('createNode')
expect(operations[1].type).toBe('moveNode')
// Get operations since timestamp
const recentOps = layoutStore.getOperationsSince(startTime + 50)
expect(recentOps.length).toBeGreaterThanOrEqual(1)
expect(recentOps[0].type).toBe('moveNode')
})
})

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import type { NodeLayout } from '@/renderer/core/layout/types'
import {
REROUTE_RADIUS,
boundsIntersect,
calculateBounds,
pointInBounds
} from '@/renderer/core/layout/utils/layoutMath'
describe('layoutMath utils', () => {
describe('pointInBounds', () => {
it('detects inclusion correctly', () => {
const bounds = { x: 10, y: 10, width: 100, height: 50 }
expect(pointInBounds({ x: 10, y: 10 }, bounds)).toBe(true)
expect(pointInBounds({ x: 110, y: 60 }, bounds)).toBe(true)
expect(pointInBounds({ x: 9, y: 10 }, bounds)).toBe(false)
expect(pointInBounds({ x: 111, y: 10 }, bounds)).toBe(false)
expect(pointInBounds({ x: 10, y: 61 }, bounds)).toBe(false)
})
it('works with zero-size bounds', () => {
const zero = { x: 10, y: 20, width: 0, height: 0 }
expect(pointInBounds({ x: 10, y: 20 }, zero)).toBe(true)
expect(pointInBounds({ x: 10, y: 21 }, zero)).toBe(false)
expect(pointInBounds({ x: 9, y: 20 }, zero)).toBe(false)
})
})
describe('boundsIntersect', () => {
it('detects intersection correctly', () => {
const a = { x: 0, y: 0, width: 10, height: 10 }
const b = { x: 5, y: 5, width: 10, height: 10 }
const c = { x: 11, y: 0, width: 5, height: 5 }
expect(boundsIntersect(a, b)).toBe(true)
expect(boundsIntersect(a, c)).toBe(false)
})
it('treats touching edges as intersecting', () => {
const a = { x: 0, y: 0, width: 10, height: 10 }
const d = { x: 10, y: 0, width: 5, height: 5 } // touches at right edge
expect(boundsIntersect(a, d)).toBe(true)
})
})
describe('REROUTE_RADIUS', () => {
it('exports a sensible reroute radius', () => {
expect(REROUTE_RADIUS).toBeGreaterThan(0)
})
})
describe('calculateBounds', () => {
const createTestNode = (
id: string,
x: number,
y: number,
width: number,
height: number
): NodeLayout => ({
id,
position: { x, y },
size: { width, height },
zIndex: 0,
visible: true,
bounds: { x, y, width, height }
})
it('calculates bounds for single node', () => {
const nodes = [createTestNode('1', 10, 20, 100, 50)]
const bounds = calculateBounds(nodes)
expect(bounds).toEqual({
x: 10,
y: 20,
width: 100,
height: 50
})
})
it('calculates combined bounds for multiple nodes', () => {
const nodes = [
createTestNode('1', 0, 0, 50, 50), // Top-left: (0,0) to (50,50)
createTestNode('2', 100, 100, 30, 40), // Bottom-right: (100,100) to (130,140)
createTestNode('3', 25, 75, 20, 10) // Middle: (25,75) to (45,85)
]
const bounds = calculateBounds(nodes)
expect(bounds).toEqual({
x: 0, // leftmost
y: 0, // topmost
width: 130, // rightmost (130) - leftmost (0)
height: 140 // bottommost (140) - topmost (0)
})
})
it('handles nodes with negative positions', () => {
const nodes = [
createTestNode('1', -50, -30, 40, 20), // (-50,-30) to (-10,-10)
createTestNode('2', 10, 15, 25, 35) // (10,15) to (35,50)
]
const bounds = calculateBounds(nodes)
expect(bounds).toEqual({
x: -50,
y: -30,
width: 85, // 35 - (-50)
height: 80 // 50 - (-30)
})
})
it('handles empty array', () => {
const bounds = calculateBounds([])
expect(bounds).toEqual({
x: Infinity,
y: Infinity,
width: -Infinity,
height: -Infinity
})
})
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { makeLinkSegmentKey } from '@/renderer/core/layout/utils/layoutUtils'
describe('layoutUtils', () => {
describe('makeLinkSegmentKey', () => {
it('creates stable keys for null reroute', () => {
expect(makeLinkSegmentKey(10, null)).toBe('10:final')
expect(makeLinkSegmentKey(42, null)).toBe('42:final')
})
it('creates stable keys for numeric reroute ids', () => {
expect(makeLinkSegmentKey(10, 3)).toBe('10:3')
expect(makeLinkSegmentKey(42, 0)).toBe('42:0')
expect(makeLinkSegmentKey(42, 7)).toBe('42:7')
})
})
})

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import * as Y from 'yjs'
import {
NODE_LAYOUT_DEFAULTS,
type NodeLayoutMap,
yNodeToLayout
} from '@/renderer/core/layout/utils/mappers'
describe('mappers', () => {
it('yNodeToLayout reads from Yjs-attached map', () => {
const layout = {
id: 'node-1',
position: { x: 12, y: 34 },
size: { width: 111, height: 222 },
zIndex: 5,
visible: true,
bounds: { x: 12, y: 34, width: 111, height: 222 }
}
const doc = new Y.Doc()
const ynode = doc.getMap('node') as NodeLayoutMap
ynode.set('id', layout.id)
ynode.set('position', layout.position)
ynode.set('size', layout.size)
ynode.set('zIndex', layout.zIndex)
ynode.set('visible', layout.visible)
ynode.set('bounds', layout.bounds)
const back = yNodeToLayout(ynode)
expect(back).toEqual(layout)
})
it('yNodeToLayout applies defaults for missing fields', () => {
const doc = new Y.Doc()
const ynode = doc.getMap('node') as NodeLayoutMap
// Don't set any fields - they should all use defaults
const back = yNodeToLayout(ynode)
expect(back.id).toBe(NODE_LAYOUT_DEFAULTS.id)
expect(back.position).toEqual(NODE_LAYOUT_DEFAULTS.position)
expect(back.size).toEqual(NODE_LAYOUT_DEFAULTS.size)
expect(back.zIndex).toEqual(NODE_LAYOUT_DEFAULTS.zIndex)
expect(back.visible).toEqual(NODE_LAYOUT_DEFAULTS.visible)
expect(back.bounds).toEqual(NODE_LAYOUT_DEFAULTS.bounds)
})
})