mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-25 23:49:10 -05:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user