fix: handle orphaned connector references when deleting items (#139)

- Filter out invalid connector references during model loading to prevent crashes
- Automatically remove connectors when their referenced items are deleted
- Add comprehensive tests for orphaned connector handling
- Models with corrupted connectors can now be loaded successfully

Fixes #139
This commit is contained in:
Stan
2025-09-18 08:34:26 +01:00
parent 72bf34f5e8
commit d698a1a120
4 changed files with 855 additions and 13 deletions

View File

@@ -0,0 +1,433 @@
import { renderHook, act } from '@testing-library/react';
import { useInitialDataManager } from '../useInitialDataManager';
import { InitialData } from 'src/types';
import * as modelStoreModule from 'src/stores/modelStore';
import * as uiStateStoreModule from 'src/stores/uiStateStore';
import * as useViewModule from 'src/hooks/useView';
// Mock console methods
const originalConsoleWarn = console.warn;
const originalConsoleLog = console.log;
const originalAlert = window.alert;
beforeAll(() => {
console.warn = jest.fn();
console.log = jest.fn();
window.alert = jest.fn();
});
afterAll(() => {
console.warn = originalConsoleWarn;
console.log = originalConsoleLog;
window.alert = originalAlert;
});
// Mock dependencies
jest.mock('src/stores/modelStore');
jest.mock('src/stores/uiStateStore');
jest.mock('src/hooks/useView');
jest.mock('src/schemas/model', () => ({
modelSchema: {
safeParse: jest.fn()
}
}));
describe('useInitialDataManager - Orphaned Connector Handling', () => {
let mockModelStore: any;
let mockUiStateStore: any;
let mockChangeView: jest.Mock;
let mockModelSchema: any;
beforeEach(() => {
jest.clearAllMocks();
// Setup mock model store
mockModelStore = {
actions: {
set: jest.fn()
},
icons: [],
colors: []
};
(modelStoreModule.useModelStore as jest.Mock).mockImplementation((selector) => {
if (typeof selector === 'function') {
return selector(mockModelStore);
}
return mockModelStore;
});
// Setup mock UI state store
mockUiStateStore = {
actions: {
setScroll: jest.fn(),
setZoom: jest.fn(),
setIconCategoriesState: jest.fn(),
resetUiState: jest.fn()
},
rendererEl: null,
editorMode: 'INTERACTIVE'
};
(uiStateStoreModule.useUiStateStore as jest.Mock).mockImplementation((selector) => {
if (typeof selector === 'function') {
return selector(mockUiStateStore);
}
return mockUiStateStore;
});
// Setup mock changeView
mockChangeView = jest.fn();
(useViewModule.useView as jest.Mock).mockReturnValue({
changeView: mockChangeView
});
// Setup mock model schema
mockModelSchema = require('src/schemas/model').modelSchema;
mockModelSchema.safeParse.mockReturnValue({ success: true });
});
it('should filter out connectors with invalid item references during load', () => {
const { result } = renderHook(() => useInitialDataManager());
const initialData: InitialData = {
version: '1.0',
title: 'Test',
description: '',
colors: [],
icons: [],
items: [],
views: [
{
id: 'view1',
name: 'Test View',
items: [
{ id: 'item1', tile: { x: 0, y: 0 } },
{ id: 'item2', tile: { x: 1, y: 0 } }
],
connectors: [
{
id: 'connector1',
anchors: [
{ id: 'anchor1', ref: { item: 'item1' }, face: 'right' },
{ id: 'anchor2', ref: { item: 'item2' }, face: 'left' }
]
},
{
id: 'connector2',
anchors: [
{ id: 'anchor3', ref: { item: 'item1' }, face: 'top' },
{ id: 'anchor4', ref: { item: 'nonexistent' }, face: 'bottom' } // Invalid reference
]
},
{
id: 'connector3',
anchors: [
{ id: 'anchor5', ref: { item: 'nonexistent1' }, face: 'right' }, // Invalid reference
{ id: 'anchor6', ref: { item: 'nonexistent2' }, face: 'left' } // Invalid reference
]
}
],
rectangles: [],
textBoxes: []
}
]
};
act(() => {
result.current.load(initialData);
});
// Check that the model store was called with filtered connectors
const setCall = mockModelStore.actions.set.mock.calls[0][0];
expect(setCall.views[0].connectors).toHaveLength(1);
expect(setCall.views[0].connectors[0].id).toBe('connector1');
// Check that warnings were logged for removed connectors
expect(console.warn).toHaveBeenCalledWith('Removing connector connector2 due to invalid item references');
expect(console.warn).toHaveBeenCalledWith('Removing connector connector3 due to invalid item references');
});
it('should allow connectors that reference other anchors', () => {
const { result } = renderHook(() => useInitialDataManager());
const initialData: InitialData = {
version: '1.0',
title: 'Test',
description: '',
colors: [],
icons: [],
items: [],
views: [
{
id: 'view1',
name: 'Test View',
items: [
{ id: 'item1', tile: { x: 0, y: 0 } }
],
connectors: [
{
id: 'connector1',
anchors: [
{ id: 'anchor1', ref: { item: 'item1' }, face: 'right' },
{ id: 'anchor2', ref: { anchor: 'anchor3' }, face: 'left' } // References another anchor
]
}
],
rectangles: [],
textBoxes: []
}
]
};
act(() => {
result.current.load(initialData);
});
// Connector with anchor reference should be preserved
const setCall = mockModelStore.actions.set.mock.calls[0][0];
expect(setCall.views[0].connectors).toHaveLength(1);
expect(setCall.views[0].connectors[0].id).toBe('connector1');
});
it('should handle views with no connectors', () => {
const { result } = renderHook(() => useInitialDataManager());
const initialData: InitialData = {
version: '1.0',
title: 'Test',
description: '',
colors: [],
icons: [],
items: [],
views: [
{
id: 'view1',
name: 'Test View',
items: [{ id: 'item1', tile: { x: 0, y: 0 } }],
rectangles: [],
textBoxes: []
}
]
};
act(() => {
result.current.load(initialData);
});
// Should not throw and should load successfully
expect(mockModelStore.actions.set).toHaveBeenCalled();
expect(result.current.isReady).toBe(true);
});
it('should handle all connectors being invalid', () => {
const { result } = renderHook(() => useInitialDataManager());
const initialData: InitialData = {
version: '1.0',
title: 'Test',
description: '',
colors: [],
icons: [],
items: [],
views: [
{
id: 'view1',
name: 'Test View',
items: [{ id: 'item1', tile: { x: 0, y: 0 } }],
connectors: [
{
id: 'connector1',
anchors: [
{ id: 'anchor1', ref: { item: 'nonexistent1' }, face: 'right' },
{ id: 'anchor2', ref: { item: 'nonexistent2' }, face: 'left' }
]
},
{
id: 'connector2',
anchors: [
{ id: 'anchor3', ref: { item: 'deleted1' }, face: 'top' },
{ id: 'anchor4', ref: { item: 'deleted2' }, face: 'bottom' }
]
}
],
rectangles: [],
textBoxes: []
}
]
};
act(() => {
result.current.load(initialData);
});
// All connectors should be removed
const setCall = mockModelStore.actions.set.mock.calls[0][0];
expect(setCall.views[0].connectors).toHaveLength(0);
expect(console.warn).toHaveBeenCalledTimes(2);
});
it('should handle mixed valid and invalid anchor references', () => {
const { result } = renderHook(() => useInitialDataManager());
const initialData: InitialData = {
version: '1.0',
title: 'Test',
description: '',
colors: [],
icons: [],
items: [],
views: [
{
id: 'view1',
name: 'Test View',
items: [
{ id: 'item1', tile: { x: 0, y: 0 } },
{ id: 'item2', tile: { x: 1, y: 0 } }
],
connectors: [
{
id: 'connector1',
anchors: [
{ id: 'anchor1', ref: { item: 'item1' }, face: 'right' }, // Valid
{ id: 'anchor2', ref: { item: 'item2' }, face: 'left' }, // Valid
{ id: 'anchor3', ref: { item: 'nonexistent' }, face: 'top' } // Invalid
]
}
],
rectangles: [],
textBoxes: []
}
]
};
act(() => {
result.current.load(initialData);
});
// Connector with any invalid anchor should be removed
const setCall = mockModelStore.actions.set.mock.calls[0][0];
expect(setCall.views[0].connectors).toHaveLength(0);
expect(console.warn).toHaveBeenCalledWith('Removing connector connector1 due to invalid item references');
});
it('should not modify original initialData object', () => {
const { result } = renderHook(() => useInitialDataManager());
const initialData: InitialData = {
version: '1.0',
title: 'Test',
description: '',
colors: [],
icons: [],
items: [],
views: [
{
id: 'view1',
name: 'Test View',
items: [{ id: 'item1', tile: { x: 0, y: 0 } }],
connectors: [
{
id: 'connector1',
anchors: [
{ id: 'anchor1', ref: { item: 'nonexistent' }, face: 'right' },
{ id: 'anchor2', ref: { item: 'item1' }, face: 'left' }
]
}
],
rectangles: [],
textBoxes: []
}
]
};
const originalData = JSON.parse(JSON.stringify(initialData));
act(() => {
result.current.load(initialData);
});
// Original data should not be modified
expect(initialData).toEqual(originalData);
});
it('should handle validation errors gracefully', () => {
const { result } = renderHook(() => useInitialDataManager());
mockModelSchema.safeParse.mockReturnValueOnce({
success: false,
error: {
errors: [{ message: 'Validation failed' }]
}
});
const initialData: InitialData = {
version: '1.0',
title: 'Test',
description: '',
colors: [],
icons: [],
items: [],
views: []
};
act(() => {
result.current.load(initialData);
});
expect(window.alert).toHaveBeenCalledWith('There is an error in your model.');
expect(mockModelStore.actions.set).not.toHaveBeenCalled();
expect(result.current.isReady).toBe(false);
});
it('should preserve connectors with all valid references', () => {
const { result } = renderHook(() => useInitialDataManager());
const initialData: InitialData = {
version: '1.0',
title: 'Test',
description: '',
colors: [],
icons: [],
items: [],
views: [
{
id: 'view1',
name: 'Test View',
items: [
{ id: 'item1', tile: { x: 0, y: 0 } },
{ id: 'item2', tile: { x: 1, y: 0 } },
{ id: 'item3', tile: { x: 2, y: 0 } }
],
connectors: [
{
id: 'connector1',
anchors: [
{ id: 'anchor1', ref: { item: 'item1' }, face: 'right' },
{ id: 'anchor2', ref: { item: 'item2' }, face: 'left' }
]
},
{
id: 'connector2',
anchors: [
{ id: 'anchor3', ref: { item: 'item2' }, face: 'right' },
{ id: 'anchor4', ref: { item: 'item3' }, face: 'left' }
]
}
],
rectangles: [],
textBoxes: []
}
]
};
act(() => {
result.current.load(initialData);
});
// All valid connectors should be preserved
const setCall = mockModelStore.actions.set.mock.calls[0][0];
expect(setCall.views[0].connectors).toHaveLength(2);
expect(setCall.views[0].connectors[0].id).toBe('connector1');
expect(setCall.views[0].connectors[1].id).toBe('connector2');
expect(console.warn).not.toHaveBeenCalled();
});
});

View File

@@ -61,7 +61,30 @@ export const useInitialDataManager = () => {
return;
}
const initialData = _initialData;
// Clean up invalid connector references before loading
const initialData = { ..._initialData };
initialData.views = initialData.views.map(view => {
if (!view.connectors) return view;
const validConnectors = view.connectors.filter(connector => {
// Check if all anchors reference existing items
const hasValidAnchors = connector.anchors.every(anchor => {
if (anchor.ref.item) {
// Check if the referenced item exists in the view
return view.items.some(item => item.id === anchor.ref.item);
}
return true; // Allow anchors that reference other anchors
});
if (!hasValidAnchors) {
console.warn(`Removing connector ${connector.id} due to invalid item references`);
}
return hasValidAnchors;
});
return { ...view, connectors: validConnectors };
});
if (initialData.views.length === 0) {
const updates = reducers.view({

View File

@@ -0,0 +1,385 @@
import {
deleteViewItem,
updateViewItem,
createViewItem
} from '../viewItem';
import { State, ViewReducerContext } from '../types';
import { ViewItem, View, Connector } from 'src/types';
// Mock the utility functions and reducers
jest.mock('src/utils', () => ({
getItemByIdOrThrow: jest.fn((items: any[], id: string) => {
const index = items.findIndex((item: any) =>
(typeof item === 'object' && item.id === id) || item === id
);
if (index === -1) {
throw new Error(`Item with id ${id} not found`);
}
return { value: items[index], index };
}),
getConnectorsByViewItem: jest.fn((viewItemId: string, connectors: Connector[]) => {
return connectors.filter(connector =>
connector.anchors.some((anchor: any) =>
anchor.ref?.item === viewItemId
)
);
})
}));
jest.mock('src/schemas/validation', () => ({
validateView: jest.fn(() => [])
}));
jest.mock('../view', () => ({
view: jest.fn((params: any) => params.ctx.state)
}));
describe('viewItem reducer', () => {
let mockState: State;
let mockContext: ViewReducerContext;
let mockViewItem: ViewItem;
let mockView: View;
let mockConnector: Connector;
beforeEach(() => {
jest.clearAllMocks();
mockViewItem = {
id: 'item1',
tile: { x: 0, y: 0 },
size: { width: 100, height: 100 }
};
mockConnector = {
id: 'connector1',
anchors: [
{
id: 'anchor1',
ref: { item: 'item1' },
face: 'right',
offset: 0
},
{
id: 'anchor2',
ref: { item: 'item2' },
face: 'left',
offset: 0
}
]
};
mockView = {
id: 'view1',
name: 'Test View',
items: [mockViewItem, { id: 'item2', tile: { x: 1, y: 0 } }],
connectors: [mockConnector],
rectangles: [],
textBoxes: []
};
mockState = {
model: {
version: '1.0',
title: 'Test Model',
description: '',
colors: [],
icons: [],
items: [],
views: [mockView]
},
scene: {
viewId: 'view1',
viewport: { x: 0, y: 0, zoom: 1 },
grid: { enabled: true, size: 10, style: 'dots' },
connectors: {
'connector1': {
path: {
tiles: [],
rectangle: { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }
}
}
},
viewItems: {},
rectangles: {},
textBoxes: {}
}
};
mockContext = {
viewId: 'view1',
state: mockState
};
});
describe('deleteViewItem', () => {
it('should delete a view item and its associated connectors', () => {
const result = deleteViewItem('item1', mockContext);
// Check item is removed from model
expect(result.model.views[0].items).toHaveLength(1);
expect(result.model.views[0].items.find(item => item.id === 'item1')).toBeUndefined();
// Check connectors referencing the item are removed
expect(result.model.views[0].connectors).toHaveLength(0);
expect(result.scene.connectors['connector1']).toBeUndefined();
});
it('should only remove connectors that reference the deleted item', () => {
const connector2: Connector = {
id: 'connector2',
anchors: [
{
id: 'anchor3',
ref: { item: 'item2' },
face: 'top'
},
{
id: 'anchor4',
ref: { item: 'item3' },
face: 'bottom'
}
]
};
mockState.model.views[0].connectors = [mockConnector, connector2];
mockState.scene.connectors['connector2'] = {
path: { tiles: [], rectangle: { from: { x: 1, y: 1 }, to: { x: 2, y: 2 } } }
};
const result = deleteViewItem('item1', mockContext);
// Check only connector1 is removed
expect(result.model.views[0].connectors).toHaveLength(1);
expect(result.model.views[0].connectors![0].id).toBe('connector2');
expect(result.scene.connectors['connector1']).toBeUndefined();
expect(result.scene.connectors['connector2']).toBeDefined();
});
it('should handle deletion when no connectors reference the item', () => {
// Create a connector that doesn't reference item1
mockState.model.views[0].connectors = [{
id: 'connector2',
anchors: [
{
id: 'anchor3',
ref: { item: 'item2' },
face: 'top'
},
{
id: 'anchor4',
ref: { item: 'item3' },
face: 'bottom'
}
]
}];
const result = deleteViewItem('item1', mockContext);
// Item should be removed but connector should remain
expect(result.model.views[0].items).toHaveLength(1);
expect(result.model.views[0].connectors).toHaveLength(1);
});
it('should handle deletion when view has no connectors', () => {
mockState.model.views[0].connectors = undefined;
const result = deleteViewItem('item1', mockContext);
expect(result.model.views[0].items).toHaveLength(1);
expect(result.model.views[0].connectors).toBeUndefined();
});
it('should throw error when item does not exist', () => {
expect(() => {
deleteViewItem('nonexistent', mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should throw error when view does not exist', () => {
mockContext.viewId = 'nonexistent';
expect(() => {
deleteViewItem('item1', mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should handle connectors with multiple anchors referencing the same item', () => {
const complexConnector: Connector = {
id: 'connector3',
anchors: [
{
id: 'anchor5',
ref: { item: 'item1' },
face: 'top'
},
{
id: 'anchor6',
ref: { item: 'item1' },
face: 'bottom'
},
{
id: 'anchor7',
ref: { item: 'item2' },
face: 'left'
}
]
};
mockState.model.views[0].connectors = [complexConnector];
const result = deleteViewItem('item1', mockContext);
// Connector should be removed since it references the deleted item
expect(result.model.views[0].connectors).toHaveLength(0);
});
});
describe('updateViewItem', () => {
it('should update view item properties', () => {
const updates = {
id: 'item1',
tile: { x: 2, y: 2 },
size: { width: 200, height: 200 }
};
const result = updateViewItem(updates, mockContext);
const updatedItem = result.model.views[0].items.find(item => item.id === 'item1');
expect(updatedItem?.tile).toEqual({ x: 2, y: 2 });
expect(updatedItem?.size).toEqual({ width: 200, height: 200 });
});
it('should update connectors when item tile position changes', () => {
const updates = {
id: 'item1',
tile: { x: 5, y: 5 }
};
const result = updateViewItem(updates, mockContext);
// The item should be updated with new position
const updatedItem = result.model.views[0].items.find(item => item.id === 'item1');
expect(updatedItem?.tile).toEqual({ x: 5, y: 5 });
// When tile changes, connectors that reference this item are updated
// The mock implementation tracks that connectors referencing the item were found
const getConnectorsByViewItem = require('src/utils').getConnectorsByViewItem;
expect(getConnectorsByViewItem).toHaveBeenCalled();
});
it('should validate view after update', () => {
const validateView = require('src/schemas/validation').validateView;
validateView.mockReturnValueOnce([{ message: 'Validation error' }]);
expect(() => {
updateViewItem({ id: 'item1', tile: { x: 1, y: 1 } }, mockContext);
}).toThrow('Validation error');
});
it('should not update connectors when tile position is not changed', () => {
const view = require('../view').view;
view.mockClear();
const updates = {
id: 'item1',
size: { width: 150, height: 150 }
};
updateViewItem(updates, mockContext);
// View reducer should not be called for connector updates
expect(view).not.toHaveBeenCalled();
});
it('should throw error when item does not exist', () => {
expect(() => {
updateViewItem({ id: 'nonexistent', tile: { x: 1, y: 1 } }, mockContext);
}).toThrow('Item with id nonexistent not found');
});
});
describe('createViewItem', () => {
it('should create a new view item', () => {
const newItem: ViewItem = {
id: 'item3',
tile: { x: 3, y: 3 },
size: { width: 100, height: 100 }
};
const result = createViewItem(newItem, mockContext);
// Should be added at the beginning (unshift)
expect(result.model.views[0].items).toHaveLength(3);
expect(result.model.views[0].items[0].id).toBe('item3');
});
it('should validate view after creation', () => {
const validateView = require('src/schemas/validation').validateView;
validateView.mockReturnValueOnce([{ message: 'Invalid item' }]);
const newItem: ViewItem = {
id: 'item3',
tile: { x: 3, y: 3 }
};
expect(() => {
createViewItem(newItem, mockContext);
}).toThrow('Invalid item');
});
it('should throw error when view does not exist', () => {
mockContext.viewId = 'nonexistent';
const newItem: ViewItem = {
id: 'item3',
tile: { x: 3, y: 3 }
};
expect(() => {
createViewItem(newItem, mockContext);
}).toThrow('Item with id nonexistent not found');
});
});
describe('edge cases and state immutability', () => {
it('should not mutate the original state', () => {
const originalState = JSON.parse(JSON.stringify(mockState));
deleteViewItem('item1', mockContext);
expect(mockState).toEqual(originalState);
});
it('should handle multiple operations in sequence', () => {
// Create
let result = createViewItem({
id: 'item3',
tile: { x: 2, y: 2 }
}, { ...mockContext, state: mockState });
// Update
result = updateViewItem({
id: 'item3',
size: { width: 150, height: 150 }
}, { ...mockContext, state: result });
// Delete original
result = deleteViewItem('item1', { ...mockContext, state: result });
expect(result.model.views[0].items.find(item => item.id === 'item3')).toBeDefined();
expect(result.model.views[0].items.find(item => item.id === 'item3')?.size).toEqual({ width: 150, height: 150 });
expect(result.model.views[0].items.find(item => item.id === 'item1')).toBeUndefined();
// Connector referencing item1 should be removed
expect(result.model.views[0].connectors).toHaveLength(0);
});
it('should handle deletion of all items with connectors', () => {
// Delete all items one by one
let result = deleteViewItem('item1', mockContext);
result = deleteViewItem('item2', { ...mockContext, state: result });
expect(result.model.views[0].items).toHaveLength(0);
expect(result.model.views[0].connectors).toHaveLength(0);
});
});
});

View File

@@ -75,23 +75,24 @@ export const deleteViewItem = (
draft.model.views[view.index].items.splice(viewItem.index, 1);
const connectorsToUpdate = getConnectorsByViewItem(
// Find connectors that reference this deleted item
const connectorsToDelete = getConnectorsByViewItem(
viewItem.value.id,
view.value.connectors ?? []
);
const updatedConnectors = connectorsToUpdate.reduce((acc, connector) => {
return reducers.view({
action: 'SYNC_CONNECTOR',
payload: connector.id,
ctx: { viewId, state: acc }
// Remove connectors that reference the deleted item
if (connectorsToDelete.length > 0 && draft.model.views[view.index].connectors) {
draft.model.views[view.index].connectors =
draft.model.views[view.index].connectors?.filter(
connector => !connectorsToDelete.some(c => c.id === connector.id)
);
// Also remove from scene
connectorsToDelete.forEach(connector => {
delete draft.scene.connectors[connector.id];
});
}, draft);
draft.model.views[view.index].connectors =
updatedConnectors.model.views[view.index].connectors;
draft.scene.connectors = updatedConnectors.scene.connectors;
}
});
return newState;