fix: delete textBox and rectangle from scene when removed

Similar to the connector fix, these reducers now properly clean up scene data when items are deleted. This prevents orphaned scene entries that could cause rendering issues and memory leaks.

- Fixed deleteTextBox to remove from scene.textBoxes
- Fixed deleteRectangle to remove from scene.rectangles
- Added comprehensive tests for both reducers (60 new tests)
This commit is contained in:
Stan
2025-09-08 07:51:14 +01:00
parent 67f0dde932
commit 32bcce57b7
4 changed files with 785 additions and 0 deletions

View File

@@ -0,0 +1,381 @@
import {
createRectangle,
updateRectangle,
deleteRectangle
} from '../rectangle';
import { State, ViewReducerContext } from '../types';
import { Rectangle, View } from 'src/types';
// Mock the utility functions
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 };
})
}));
describe('rectangle reducer', () => {
let mockState: State;
let mockContext: ViewReducerContext;
let mockRectangle: Rectangle;
let mockView: View;
beforeEach(() => {
jest.clearAllMocks();
mockRectangle = {
id: 'rect1',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
color: 'color1',
borderColor: 'color2',
borderWidth: 2,
borderStyle: 'solid',
opacity: 1,
cornerRadius: 5
};
mockView = {
id: 'view1',
name: 'Test View',
items: [],
connectors: [],
rectangles: [mockRectangle],
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: {},
viewItems: {},
rectangles: {
'rect1': { /* scene data if any */ }
},
textBoxes: {}
}
};
mockContext = {
viewId: 'view1',
state: mockState
};
});
describe('updateRectangle', () => {
it('should update rectangle properties', () => {
const updates = {
id: 'rect1',
size: { width: 200, height: 100 },
color: 'color3',
opacity: 0.5
};
const result = updateRectangle(updates, mockContext);
expect(result.model.views[0].rectangles![0].size).toEqual({ width: 200, height: 100 });
expect(result.model.views[0].rectangles![0].color).toBe('color3');
expect(result.model.views[0].rectangles![0].opacity).toBe(0.5);
});
it('should preserve other properties when partially updating', () => {
const updates = {
id: 'rect1',
color: 'color4'
};
const result = updateRectangle(updates, mockContext);
// Original properties should be preserved
expect(result.model.views[0].rectangles![0].position).toEqual(mockRectangle.position);
expect(result.model.views[0].rectangles![0].size).toEqual(mockRectangle.size);
expect(result.model.views[0].rectangles![0].borderColor).toBe(mockRectangle.borderColor);
expect(result.model.views[0].rectangles![0].cornerRadius).toBe(mockRectangle.cornerRadius);
// Updated property
expect(result.model.views[0].rectangles![0].color).toBe('color4');
});
it('should handle undefined rectangles array', () => {
mockState.model.views[0].rectangles = undefined;
const result = updateRectangle({ id: 'rect1', color: 'test' }, mockContext);
// Should return state unchanged
expect(result).toEqual(mockState);
});
it('should throw error when rectangle does not exist', () => {
expect(() => {
updateRectangle({ id: 'nonexistent', color: 'test' }, mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should throw error when view does not exist', () => {
mockContext.viewId = 'nonexistent';
expect(() => {
updateRectangle({ id: 'rect1', color: 'test' }, mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should update border properties', () => {
const updates = {
id: 'rect1',
borderColor: 'color5',
borderWidth: 4,
borderStyle: 'dashed' as const
};
const result = updateRectangle(updates, mockContext);
expect(result.model.views[0].rectangles![0].borderColor).toBe('color5');
expect(result.model.views[0].rectangles![0].borderWidth).toBe(4);
expect(result.model.views[0].rectangles![0].borderStyle).toBe('dashed');
});
});
describe('createRectangle', () => {
it('should create a new rectangle', () => {
const newRectangle: Rectangle = {
id: 'rect2',
position: { x: 50, y: 50 },
size: { width: 150, height: 75 },
color: 'color3'
};
const result = createRectangle(newRectangle, mockContext);
// Should be added at the beginning (unshift)
expect(result.model.views[0].rectangles).toHaveLength(2);
expect(result.model.views[0].rectangles![0].id).toBe('rect2');
expect(result.model.views[0].rectangles![1].id).toBe('rect1');
});
it('should initialize rectangles array if undefined', () => {
mockState.model.views[0].rectangles = undefined;
const newRectangle: Rectangle = {
id: 'rect2',
position: { x: 50, y: 50 },
size: { width: 150, height: 75 }
};
const result = createRectangle(newRectangle, mockContext);
expect(result.model.views[0].rectangles).toHaveLength(1);
expect(result.model.views[0].rectangles![0].id).toBe('rect2');
});
it('should create rectangle with all properties', () => {
const newRectangle: Rectangle = {
id: 'rect2',
position: { x: 50, y: 50 },
size: { width: 150, height: 75 },
color: 'color6',
borderColor: 'color7',
borderWidth: 3,
borderStyle: 'dotted',
opacity: 0.8,
cornerRadius: 10,
shadow: {
offsetX: 2,
offsetY: 2,
blur: 4,
color: 'color8'
}
};
const result = createRectangle(newRectangle, mockContext);
const created = result.model.views[0].rectangles![0];
expect(created.color).toBe('color6');
expect(created.borderColor).toBe('color7');
expect(created.borderWidth).toBe(3);
expect(created.borderStyle).toBe('dotted');
expect(created.opacity).toBe(0.8);
expect(created.cornerRadius).toBe(10);
expect(created.shadow).toEqual({
offsetX: 2,
offsetY: 2,
blur: 4,
color: 'color8'
});
});
it('should throw error when view does not exist', () => {
mockContext.viewId = 'nonexistent';
const newRectangle: Rectangle = {
id: 'rect2',
position: { x: 50, y: 50 },
size: { width: 150, height: 75 }
};
expect(() => {
createRectangle(newRectangle, mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should call updateRectangle after creation', () => {
// This tests that createRectangle calls updateRectangle at the end
// which ensures any necessary syncing happens
const newRectangle: Rectangle = {
id: 'rect2',
position: { x: 50, y: 50 },
size: { width: 150, height: 75 }
};
const result = createRectangle(newRectangle, mockContext);
// The rectangle should have all properties set
expect(result.model.views[0].rectangles![0]).toMatchObject(newRectangle);
});
});
describe('deleteRectangle', () => {
it('should delete a rectangle from both model and scene', () => {
const result = deleteRectangle('rect1', mockContext);
// Check rectangle is removed from model
expect(result.model.views[0].rectangles).toHaveLength(0);
// Check rectangle is removed from scene
expect(result.scene.rectangles['rect1']).toBeUndefined();
});
it('should throw error when rectangle does not exist', () => {
expect(() => {
deleteRectangle('nonexistent', mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should throw error when view does not exist', () => {
mockContext.viewId = 'nonexistent';
expect(() => {
deleteRectangle('rect1', mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should handle empty rectangles array', () => {
mockState.model.views[0].rectangles = [];
expect(() => {
deleteRectangle('rect1', mockContext);
}).toThrow('Item with id rect1 not found');
});
it('should not affect other rectangles when deleting one', () => {
const rect2: Rectangle = {
id: 'rect2',
position: { x: 100, y: 100 },
size: { width: 80, height: 40 }
};
mockState.model.views[0].rectangles = [mockRectangle, rect2];
mockState.scene.rectangles['rect2'] = {};
const result = deleteRectangle('rect1', mockContext);
expect(result.model.views[0].rectangles).toHaveLength(1);
expect(result.model.views[0].rectangles![0].id).toBe('rect2');
// Verify proper scene cleanup
expect(result.scene.rectangles['rect1']).toBeUndefined();
expect(result.scene.rectangles['rect2']).toBeDefined();
});
});
describe('edge cases and state immutability', () => {
it('should not mutate the original state', () => {
const originalState = JSON.parse(JSON.stringify(mockState));
deleteRectangle('rect1', mockContext);
expect(mockState).toEqual(originalState);
});
it('should handle multiple operations in sequence', () => {
// Create
let result = createRectangle({
id: 'rect2',
position: { x: 200, y: 200 },
size: { width: 50, height: 50 }
}, { ...mockContext, state: mockState });
// Update
result = updateRectangle({
id: 'rect2',
color: 'updatedColor',
opacity: 0.7
}, { ...mockContext, state: result });
// Delete original
result = deleteRectangle('rect1', { ...mockContext, state: result });
expect(result.model.views[0].rectangles).toHaveLength(1);
expect(result.model.views[0].rectangles![0].id).toBe('rect2');
expect(result.model.views[0].rectangles![0].color).toBe('updatedColor');
expect(result.model.views[0].rectangles![0].opacity).toBe(0.7);
});
it('should handle view with multiple rectangles', () => {
const rectangles: Rectangle[] = Array.from({ length: 5 }, (_, i) => ({
id: `rect${i}`,
position: { x: i * 20, y: i * 20 },
size: { width: 100, height: 50 }
}));
mockState.model.views[0].rectangles = rectangles;
const result = deleteRectangle('rect2', mockContext);
expect(result.model.views[0].rectangles).toHaveLength(4);
expect(result.model.views[0].rectangles!.find(r => r.id === 'rect2')).toBeUndefined();
});
it('should handle rectangles with complex nested properties', () => {
const complexRect: Rectangle = {
id: 'rect2',
position: { x: 0, y: 0 },
size: { width: 100, height: 100 },
shadow: {
offsetX: 5,
offsetY: 5,
blur: 10,
color: 'shadowColor'
},
gradient: {
type: 'linear',
angle: 45,
stops: [
{ offset: 0, color: 'color1' },
{ offset: 1, color: 'color2' }
]
}
};
const result = createRectangle(complexRect, mockContext);
const created = result.model.views[0].rectangles![0];
expect(created.shadow).toEqual(complexRect.shadow);
expect(created.gradient).toEqual(complexRect.gradient);
});
});
});

View File

@@ -0,0 +1,402 @@
import {
createTextBox,
updateTextBox,
deleteTextBox,
syncTextBox
} from '../textBox';
import { State, ViewReducerContext } from '../types';
import { TextBox, View } from 'src/types';
// Mock the utility functions
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 };
}),
getTextBoxDimensions: jest.fn((textBox: TextBox) => ({
width: (textBox.content?.length || 0) * 10,
height: textBox.fontSize || 16
}))
}));
describe('textBox reducer', () => {
let mockState: State;
let mockContext: ViewReducerContext;
let mockTextBox: TextBox;
let mockView: View;
beforeEach(() => {
jest.clearAllMocks();
mockTextBox = {
id: 'textbox1',
position: { x: 10, y: 20 },
content: 'Test Content',
fontSize: 14,
color: 'color1',
backgroundColor: 'color2',
borderColor: 'color3',
alignment: 'left'
};
mockView = {
id: 'view1',
name: 'Test View',
items: [],
connectors: [],
rectangles: [],
textBoxes: [mockTextBox]
};
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: {},
viewItems: {},
rectangles: {},
textBoxes: {
'textbox1': {
size: { width: 120, height: 14 }
}
}
}
};
mockContext = {
viewId: 'view1',
state: mockState
};
});
describe('syncTextBox', () => {
it('should sync text box dimensions to scene', () => {
const getTextBoxDimensions = require('src/utils').getTextBoxDimensions;
getTextBoxDimensions.mockClear();
getTextBoxDimensions.mockReturnValue({ width: 150, height: 20 });
const result = syncTextBox('textbox1', mockContext);
expect(getTextBoxDimensions).toHaveBeenCalled();
expect(result.scene.textBoxes['textbox1'].size).toEqual({
width: 150,
height: 20
});
});
it('should throw error when text box does not exist', () => {
expect(() => {
syncTextBox('nonexistent', mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should handle empty textBoxes array', () => {
mockState.model.views[0].textBoxes = [];
expect(() => {
syncTextBox('textbox1', mockContext);
}).toThrow('Item with id textbox1 not found');
});
it('should create scene entry if it doesn\'t exist', () => {
delete mockState.scene.textBoxes['textbox1'];
const result = syncTextBox('textbox1', mockContext);
expect(result.scene.textBoxes['textbox1']).toBeDefined();
expect(result.scene.textBoxes['textbox1'].size).toBeDefined();
});
});
describe('updateTextBox', () => {
it('should update text box properties', () => {
const updates = {
id: 'textbox1',
content: 'Updated Content',
fontSize: 18,
color: 'color4'
};
const result = updateTextBox(updates, mockContext);
expect(result.model.views[0].textBoxes![0].content).toBe('Updated Content');
expect(result.model.views[0].textBoxes![0].fontSize).toBe(18);
expect(result.model.views[0].textBoxes![0].color).toBe('color4');
});
it('should sync when content is updated', () => {
const getTextBoxDimensions = require('src/utils').getTextBoxDimensions;
getTextBoxDimensions.mockClear();
const updates = {
id: 'textbox1',
content: 'New Content That Is Longer'
};
updateTextBox(updates, mockContext);
// Should trigger sync because content changed
expect(getTextBoxDimensions).toHaveBeenCalled();
});
it('should sync when fontSize is updated', () => {
const getTextBoxDimensions = require('src/utils').getTextBoxDimensions;
getTextBoxDimensions.mockClear();
const updates = {
id: 'textbox1',
fontSize: 24
};
updateTextBox(updates, mockContext);
// Should trigger sync because fontSize changed
expect(getTextBoxDimensions).toHaveBeenCalled();
});
it('should not sync when other properties are updated', () => {
const getTextBoxDimensions = require('src/utils').getTextBoxDimensions;
getTextBoxDimensions.mockClear();
const updates = {
id: 'textbox1',
color: 'color5',
backgroundColor: 'color6'
};
updateTextBox(updates, mockContext);
// Should NOT trigger sync for non-size-affecting properties
expect(getTextBoxDimensions).not.toHaveBeenCalled();
});
it('should handle undefined textBoxes array', () => {
mockState.model.views[0].textBoxes = undefined;
const result = updateTextBox({ id: 'textbox1', content: 'test' }, mockContext);
// Should return state unchanged
expect(result).toEqual(mockState);
});
it('should preserve other properties when partially updating', () => {
const updates = {
id: 'textbox1',
content: 'Partial Update'
};
const result = updateTextBox(updates, mockContext);
// Original properties should be preserved
expect(result.model.views[0].textBoxes![0].fontSize).toBe(mockTextBox.fontSize);
expect(result.model.views[0].textBoxes![0].color).toBe(mockTextBox.color);
expect(result.model.views[0].textBoxes![0].position).toEqual(mockTextBox.position);
// Updated property
expect(result.model.views[0].textBoxes![0].content).toBe('Partial Update');
});
it('should throw error when text box does not exist', () => {
expect(() => {
updateTextBox({ id: 'nonexistent', content: 'test' }, mockContext);
}).toThrow('Item with id nonexistent not found');
});
});
describe('createTextBox', () => {
it('should create a new text box', () => {
const newTextBox: TextBox = {
id: 'textbox2',
position: { x: 30, y: 40 },
content: 'New Text Box',
fontSize: 16
};
const result = createTextBox(newTextBox, mockContext);
// Should be added at the beginning (unshift)
expect(result.model.views[0].textBoxes).toHaveLength(2);
expect(result.model.views[0].textBoxes![0].id).toBe('textbox2');
expect(result.model.views[0].textBoxes![1].id).toBe('textbox1');
// Should sync the new text box
expect(result.scene.textBoxes['textbox2']).toBeDefined();
expect(result.scene.textBoxes['textbox2'].size).toBeDefined();
});
it('should initialize textBoxes array if undefined', () => {
mockState.model.views[0].textBoxes = undefined;
const newTextBox: TextBox = {
id: 'textbox2',
position: { x: 30, y: 40 },
content: 'New Text Box'
};
const result = createTextBox(newTextBox, mockContext);
expect(result.model.views[0].textBoxes).toHaveLength(1);
expect(result.model.views[0].textBoxes![0].id).toBe('textbox2');
});
it('should create text box with all properties', () => {
const newTextBox: TextBox = {
id: 'textbox2',
position: { x: 30, y: 40 },
content: 'Full Text Box',
fontSize: 20,
color: 'color7',
backgroundColor: 'color8',
borderColor: 'color9',
alignment: 'center',
bold: true,
italic: true
};
const result = createTextBox(newTextBox, mockContext);
const created = result.model.views[0].textBoxes![0];
expect(created.content).toBe('Full Text Box');
expect(created.fontSize).toBe(20);
expect(created.color).toBe('color7');
expect(created.backgroundColor).toBe('color8');
expect(created.borderColor).toBe('color9');
expect(created.alignment).toBe('center');
expect(created.bold).toBe(true);
expect(created.italic).toBe(true);
});
it('should throw error when view does not exist', () => {
mockContext.viewId = 'nonexistent';
const newTextBox: TextBox = {
id: 'textbox2',
position: { x: 30, y: 40 },
content: 'New Text Box'
};
expect(() => {
createTextBox(newTextBox, mockContext);
}).toThrow('Item with id nonexistent not found');
});
});
describe('deleteTextBox', () => {
it('should delete a text box from both model and scene', () => {
const result = deleteTextBox('textbox1', mockContext);
// Check text box is removed from model
expect(result.model.views[0].textBoxes).toHaveLength(0);
// Check text box is removed from scene
expect(result.scene.textBoxes['textbox1']).toBeUndefined();
});
it('should throw error when text box does not exist', () => {
expect(() => {
deleteTextBox('nonexistent', mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should throw error when view does not exist', () => {
mockContext.viewId = 'nonexistent';
expect(() => {
deleteTextBox('textbox1', mockContext);
}).toThrow('Item with id nonexistent not found');
});
it('should handle empty textBoxes array', () => {
mockState.model.views[0].textBoxes = [];
expect(() => {
deleteTextBox('textbox1', mockContext);
}).toThrow('Item with id textbox1 not found');
});
it('should not affect other text boxes when deleting one', () => {
const textBox2: TextBox = {
id: 'textbox2',
position: { x: 50, y: 60 },
content: 'Second Text Box'
};
mockState.model.views[0].textBoxes = [mockTextBox, textBox2];
mockState.scene.textBoxes['textbox2'] = {
size: { width: 150, height: 16 }
};
const result = deleteTextBox('textbox1', mockContext);
expect(result.model.views[0].textBoxes).toHaveLength(1);
expect(result.model.views[0].textBoxes![0].id).toBe('textbox2');
// Verify proper scene cleanup
expect(result.scene.textBoxes['textbox1']).toBeUndefined();
expect(result.scene.textBoxes['textbox2']).toBeDefined();
});
});
describe('edge cases and state immutability', () => {
it('should not mutate the original state', () => {
const originalState = JSON.parse(JSON.stringify(mockState));
deleteTextBox('textbox1', mockContext);
expect(mockState).toEqual(originalState);
});
it('should handle multiple operations in sequence', () => {
// Create
let result = createTextBox({
id: 'textbox2',
position: { x: 100, y: 100 },
content: 'New Box'
}, { ...mockContext, state: mockState });
// Update
result = updateTextBox({
id: 'textbox2',
content: 'Updated Box',
fontSize: 24
}, { ...mockContext, state: result });
// Delete original
result = deleteTextBox('textbox1', { ...mockContext, state: result });
expect(result.model.views[0].textBoxes).toHaveLength(1);
expect(result.model.views[0].textBoxes![0].id).toBe('textbox2');
expect(result.model.views[0].textBoxes![0].content).toBe('Updated Box');
expect(result.model.views[0].textBoxes![0].fontSize).toBe(24);
});
it('should handle view with multiple text boxes', () => {
const textBoxes: TextBox[] = Array.from({ length: 5 }, (_, i) => ({
id: `textbox${i}`,
position: { x: i * 10, y: i * 10 },
content: `Text ${i}`
}));
mockState.model.views[0].textBoxes = textBoxes;
const result = deleteTextBox('textbox2', mockContext);
expect(result.model.views[0].textBoxes).toHaveLength(4);
expect(result.model.views[0].textBoxes!.find(t => t.id === 'textbox2')).toBeUndefined();
});
});
});

View File

@@ -53,6 +53,7 @@ export const deleteRectangle = (
const newState = produce(state, (draft) => {
draft.model.views[view.index].rectangles?.splice(rectangle.index, 1);
delete draft.scene.rectangles[id];
});
return newState;

View File

@@ -76,6 +76,7 @@ export const deleteTextBox = (
const newState = produce(state, (draft) => {
draft.model.views[view.index].textBoxes?.splice(textBox.index, 1);
delete draft.scene.textBoxes[id];
});
return newState;