mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-23 22:48:57 -05:00
connector enhancements (#129)
* feat: enhance connector functionality with multiple labels and line types - Add support for three line types: single, double, and double with circle - Implement start, center, and end labels with independent height controls - Add custom RGB color picker for connectors and rectangles - Increase spacing between double lines for better visibility - Offset start/end labels by one tile to prevent node overlap - Fix label filtering to show connectors with any label type - Improve slider UI spacing to prevent interaction issues Closes #107 Closes #113 * Updated test suite and CI/CD workflow to include testing * fix: sync package-lock.json with package.json for CI --------- Co-authored-by: Stan <stanleylsmith@pm.me>
This commit is contained in:
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
@@ -1,12 +1,16 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ["Run Tests"]
|
||||
types:
|
||||
- completed
|
||||
branches: ["main", "master"]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
8
.github/workflows/pages.yml
vendored
8
.github/workflows/pages.yml
vendored
@@ -2,8 +2,11 @@
|
||||
name: Deploy static content to Pages
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
# Runs after tests complete successfully
|
||||
workflow_run:
|
||||
workflows: ["Run Tests"]
|
||||
types:
|
||||
- completed
|
||||
branches: ["main", "master"]
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
@@ -24,6 +27,7 @@ jobs:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
43
.github/workflows/test.yml
vendored
Normal file
43
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-node-${{ matrix.node-version }}
|
||||
path: |
|
||||
coverage/
|
||||
test-results/
|
||||
retention-days: 7
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -7867,9 +7867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.4.2.tgz",
|
||||
"integrity": "sha512-gD4T25a6ovNXsfXY1TwHXXXLnD/K2t99jyYMCSimSCBnBRJVQr5j+VAaU83RJCPzrTGhVQ6dqIga66xO2rtd5g==",
|
||||
"version": "25.5.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.2.tgz",
|
||||
"integrity": "sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -7884,7 +7884,6 @@
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -13691,7 +13690,7 @@
|
||||
"dependencies": {
|
||||
"@isoflow/isopacks": "^0.0.10",
|
||||
"fossflow": "*",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -3,9 +3,7 @@ module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
modulePaths: ['node_modules', '<rootDir>'],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
|
||||
testEnvironment: "node",
|
||||
modulePaths: ['node_modules', '<rootDir>'],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testPathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/dist/',
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { ColorSelector } from '../ColorSelector';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { theme } from 'src/styles/theme';
|
||||
|
||||
// Mock the useScene hook
|
||||
const mockColors = [
|
||||
{ id: 'color1', value: '#FF0000', name: 'Red' },
|
||||
{ id: 'color2', value: '#00FF00', name: 'Green' },
|
||||
{ id: 'color3', value: '#0000FF', name: 'Blue' },
|
||||
{ id: 'color4', value: '#FFFF00', name: 'Yellow' },
|
||||
{ id: 'color5', value: '#FF00FF', name: 'Magenta' }
|
||||
];
|
||||
|
||||
jest.mock('../../../hooks/useScene', () => ({
|
||||
useScene: jest.fn(() => ({
|
||||
colors: mockColors
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('ColorSelector', () => {
|
||||
const defaultProps = {
|
||||
onChange: jest.fn(),
|
||||
activeColor: undefined
|
||||
};
|
||||
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<ColorSelector {...defaultProps} {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render all colors from the scene', () => {
|
||||
renderComponent();
|
||||
|
||||
// Should render a button for each color
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(mockColors.length);
|
||||
});
|
||||
|
||||
it('should render all color swatches', () => {
|
||||
renderComponent();
|
||||
|
||||
// Should render a button for each color
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(mockColors.length);
|
||||
|
||||
// Each button should be clickable
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render empty state when no colors available', () => {
|
||||
const useScene = require('../../../hooks/useScene').useScene;
|
||||
useScene.mockImplementation(() => ({ colors: [] }));
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
// Should render container but no buttons
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0);
|
||||
|
||||
// Restore mock
|
||||
useScene.mockImplementation(() => ({ colors: mockColors }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('user interactions', () => {
|
||||
it('should call onChange with correct color ID when clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ onChange });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// Click the first color
|
||||
fireEvent.click(buttons[0]);
|
||||
expect(onChange).toHaveBeenCalledWith('color1');
|
||||
|
||||
// Click the third color
|
||||
fireEvent.click(buttons[2]);
|
||||
expect(onChange).toHaveBeenCalledWith('color3');
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle multiple rapid clicks', () => {
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ onChange });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// Rapidly click different colors
|
||||
fireEvent.click(buttons[0]);
|
||||
fireEvent.click(buttons[1]);
|
||||
fireEvent.click(buttons[2]);
|
||||
fireEvent.click(buttons[1]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(4);
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, 'color1');
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, 'color2');
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, 'color3');
|
||||
expect(onChange).toHaveBeenNthCalledWith(4, 'color2');
|
||||
});
|
||||
|
||||
it('should be keyboard accessible', () => {
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ onChange });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// All buttons should be focusable (have tabIndex)
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toHaveAttribute('tabindex', '0');
|
||||
});
|
||||
|
||||
// Buttons should respond to clicks (keyboard Enter/Space triggers click)
|
||||
fireEvent.click(buttons[0]);
|
||||
expect(onChange).toHaveBeenCalledWith('color1');
|
||||
|
||||
fireEvent.click(buttons[1]);
|
||||
expect(onChange).toHaveBeenCalledWith('color2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('active color indication', () => {
|
||||
it('should indicate the active color with scaled transform', () => {
|
||||
const { container } = renderComponent({ activeColor: 'color2' });
|
||||
|
||||
// Find all buttons (color swatches are inside buttons)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(mockColors.length);
|
||||
|
||||
// The second button should contain the active color
|
||||
// We can check if the active prop was passed correctly
|
||||
const activeButton = buttons[1]; // color2 is at index 1
|
||||
|
||||
// Check that ColorSwatch received isActive=true for color2
|
||||
// Since we can't easily check transform in JSDOM, we'll verify the component renders
|
||||
expect(activeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update active indication when activeColor prop changes', () => {
|
||||
const { rerender } = renderComponent({ activeColor: 'color1' });
|
||||
|
||||
let buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(mockColors.length);
|
||||
|
||||
// Change active color
|
||||
rerender(
|
||||
<ThemeProvider theme={theme}>
|
||||
<ColorSelector {...defaultProps} activeColor="color3" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(mockColors.length);
|
||||
// Verify buttons still render after prop change
|
||||
expect(buttons[2]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle no active color', () => {
|
||||
renderComponent({ activeColor: undefined });
|
||||
|
||||
// All buttons should still render
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(mockColors.length);
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid active color ID gracefully', () => {
|
||||
renderComponent({ activeColor: 'invalid-color-id' });
|
||||
|
||||
// All buttons should still render even with invalid active color
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(mockColors.length);
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle color with special characters in hex value', () => {
|
||||
const useScene = require('../../../hooks/useScene').useScene;
|
||||
useScene.mockImplementation(() => ({
|
||||
colors: [
|
||||
{ id: 'special', value: '#C0FFEE', name: 'Coffee' }
|
||||
]
|
||||
}));
|
||||
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ onChange });
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('special');
|
||||
|
||||
// Restore mock
|
||||
useScene.mockImplementation(() => ({ colors: mockColors }));
|
||||
});
|
||||
|
||||
it('should handle very long color lists efficiently', () => {
|
||||
const useScene = require('../../../hooks/useScene').useScene;
|
||||
const manyColors = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `color${i}`,
|
||||
value: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`,
|
||||
name: `Color ${i}`
|
||||
}));
|
||||
|
||||
useScene.mockImplementation(() => ({ colors: manyColors }));
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(100);
|
||||
|
||||
// Restore mock
|
||||
useScene.mockImplementation(() => ({ colors: mockColors }));
|
||||
});
|
||||
|
||||
it('should handle onChange being required properly', () => {
|
||||
// onChange is a required prop, so we test with a valid function
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ onChange });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// Should work normally with onChange provided
|
||||
fireEvent.click(buttons[0]);
|
||||
expect(onChange).toHaveBeenCalledWith('color1');
|
||||
});
|
||||
|
||||
it('should handle colors being updated dynamically', () => {
|
||||
const useScene = require('../../../hooks/useScene').useScene;
|
||||
const onChange = jest.fn();
|
||||
|
||||
// Start with 3 colors
|
||||
useScene.mockImplementation(() => ({
|
||||
colors: mockColors.slice(0, 3)
|
||||
}));
|
||||
|
||||
const { rerender } = renderComponent({ onChange });
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||
|
||||
// Update to 5 colors
|
||||
useScene.mockImplementation(() => ({
|
||||
colors: mockColors
|
||||
}));
|
||||
|
||||
rerender(
|
||||
<ThemeProvider theme={theme}>
|
||||
<ColorSelector {...defaultProps} onChange={onChange} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(5);
|
||||
|
||||
// Click the newly added color
|
||||
const buttons = screen.getAllByRole('button');
|
||||
fireEvent.click(buttons[4]);
|
||||
expect(onChange).toHaveBeenCalledWith('color5');
|
||||
});
|
||||
});
|
||||
});
|
||||
329
packages/fossflow-lib/src/hooks/__tests__/useHistory.test.tsx
Normal file
329
packages/fossflow-lib/src/hooks/__tests__/useHistory.test.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useHistory } from '../useHistory';
|
||||
|
||||
// Mock implementations
|
||||
const mockModelStore = {
|
||||
canUndo: jest.fn(),
|
||||
canRedo: jest.fn(),
|
||||
undo: jest.fn(),
|
||||
redo: jest.fn(),
|
||||
saveToHistory: jest.fn(),
|
||||
clearHistory: jest.fn()
|
||||
};
|
||||
|
||||
const mockSceneStore = {
|
||||
canUndo: jest.fn(),
|
||||
canRedo: jest.fn(),
|
||||
undo: jest.fn(),
|
||||
redo: jest.fn(),
|
||||
saveToHistory: jest.fn(),
|
||||
clearHistory: jest.fn()
|
||||
};
|
||||
|
||||
// Mock the store hooks
|
||||
jest.mock('../../stores/modelStore', () => ({
|
||||
useModelStore: jest.fn((selector) => {
|
||||
const state = {
|
||||
actions: mockModelStore
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
})
|
||||
}));
|
||||
|
||||
jest.mock('../../stores/sceneStore', () => ({
|
||||
useSceneStore: jest.fn((selector) => {
|
||||
const state = {
|
||||
actions: mockSceneStore
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
})
|
||||
}));
|
||||
|
||||
describe('useHistory', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockModelStore.canUndo.mockReturnValue(false);
|
||||
mockModelStore.canRedo.mockReturnValue(false);
|
||||
mockModelStore.undo.mockReturnValue(true);
|
||||
mockModelStore.redo.mockReturnValue(true);
|
||||
|
||||
mockSceneStore.canUndo.mockReturnValue(false);
|
||||
mockSceneStore.canRedo.mockReturnValue(false);
|
||||
mockSceneStore.undo.mockReturnValue(true);
|
||||
mockSceneStore.redo.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('undo/redo basic functionality', () => {
|
||||
it('should initialize with no undo/redo capability', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
expect(result.current.canRedo).toBe(false);
|
||||
});
|
||||
|
||||
it('should call saveToHistory on both stores', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.saveToHistory();
|
||||
});
|
||||
|
||||
expect(mockModelStore.saveToHistory).toHaveBeenCalled();
|
||||
expect(mockSceneStore.saveToHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should perform undo when model store has history', () => {
|
||||
mockModelStore.canUndo.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.canUndo).toBe(true);
|
||||
|
||||
act(() => {
|
||||
const success = result.current.undo();
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockModelStore.undo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should perform undo when scene store has history', () => {
|
||||
mockSceneStore.canUndo.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.canUndo).toBe(true);
|
||||
|
||||
act(() => {
|
||||
const success = result.current.undo();
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockSceneStore.undo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should perform redo when model store has future', () => {
|
||||
mockModelStore.canRedo.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.canRedo).toBe(true);
|
||||
|
||||
act(() => {
|
||||
const success = result.current.redo();
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockModelStore.redo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when undo is called with no history', () => {
|
||||
mockModelStore.undo.mockReturnValue(false);
|
||||
mockSceneStore.undo.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
const success = result.current.undo();
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when redo is called with no future', () => {
|
||||
mockModelStore.redo.mockReturnValue(false);
|
||||
mockSceneStore.redo.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
const success = result.current.redo();
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transaction functionality', () => {
|
||||
it('should save history before transaction and not during', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
// This should not trigger saveToHistory due to transaction
|
||||
result.current.saveToHistory();
|
||||
});
|
||||
});
|
||||
|
||||
// Should save once before transaction starts
|
||||
expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1);
|
||||
expect(mockSceneStore.saveToHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should track transaction state correctly', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.isInTransaction()).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
expect(result.current.isInTransaction()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isInTransaction()).toBe(false);
|
||||
});
|
||||
|
||||
it('should prevent nested transactions', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
// First transaction saves history
|
||||
expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Nested transaction should not save again
|
||||
result.current.transaction(() => {
|
||||
// Still in transaction
|
||||
expect(result.current.isInTransaction()).toBe(true);
|
||||
});
|
||||
|
||||
// Should still be 1 save
|
||||
expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle transaction errors gracefully', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
});
|
||||
}).toThrow('Test error');
|
||||
|
||||
// Transaction should be cleaned up
|
||||
expect(result.current.isInTransaction()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('history management', () => {
|
||||
it('should clear all history', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.clearHistory();
|
||||
});
|
||||
|
||||
expect(mockModelStore.clearHistory).toHaveBeenCalled();
|
||||
expect(mockSceneStore.clearHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should check both stores for undo capability', () => {
|
||||
// Only model has undo
|
||||
mockModelStore.canUndo.mockReturnValue(true);
|
||||
mockSceneStore.canUndo.mockReturnValue(false);
|
||||
|
||||
const { result: result1 } = renderHook(() => useHistory());
|
||||
expect(result1.current.canUndo).toBe(true);
|
||||
|
||||
// Only scene has undo
|
||||
mockModelStore.canUndo.mockReturnValue(false);
|
||||
mockSceneStore.canUndo.mockReturnValue(true);
|
||||
|
||||
const { result: result2 } = renderHook(() => useHistory());
|
||||
expect(result2.current.canUndo).toBe(true);
|
||||
|
||||
// Both have undo
|
||||
mockModelStore.canUndo.mockReturnValue(true);
|
||||
mockSceneStore.canUndo.mockReturnValue(true);
|
||||
|
||||
const { result: result3 } = renderHook(() => useHistory());
|
||||
expect(result3.current.canUndo).toBe(true);
|
||||
|
||||
// Neither has undo
|
||||
mockModelStore.canUndo.mockReturnValue(false);
|
||||
mockSceneStore.canUndo.mockReturnValue(false);
|
||||
|
||||
const { result: result4 } = renderHook(() => useHistory());
|
||||
expect(result4.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('should check both stores for redo capability', () => {
|
||||
// Only model has redo
|
||||
mockModelStore.canRedo.mockReturnValue(true);
|
||||
mockSceneStore.canRedo.mockReturnValue(false);
|
||||
|
||||
const { result: result1 } = renderHook(() => useHistory());
|
||||
expect(result1.current.canRedo).toBe(true);
|
||||
|
||||
// Only scene has redo
|
||||
mockModelStore.canRedo.mockReturnValue(false);
|
||||
mockSceneStore.canRedo.mockReturnValue(true);
|
||||
|
||||
const { result: result2 } = renderHook(() => useHistory());
|
||||
expect(result2.current.canRedo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing store actions gracefully', () => {
|
||||
// Mock stores returning undefined actions
|
||||
const useModelStore = require('../../stores/modelStore').useModelStore;
|
||||
const useSceneStore = require('../../stores/sceneStore').useSceneStore;
|
||||
|
||||
useModelStore.mockImplementation((selector) => {
|
||||
const state = { actions: undefined };
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
useSceneStore.mockImplementation((selector) => {
|
||||
const state = { actions: undefined };
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
// Should not throw and return safe defaults
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
expect(result.current.canRedo).toBe(false);
|
||||
|
||||
act(() => {
|
||||
expect(result.current.undo()).toBe(false);
|
||||
expect(result.current.redo()).toBe(false);
|
||||
// These should not throw
|
||||
result.current.saveToHistory();
|
||||
result.current.clearHistory();
|
||||
result.current.transaction(() => {});
|
||||
});
|
||||
|
||||
// Restore mocks for other tests
|
||||
useModelStore.mockImplementation((selector) => {
|
||||
const state = { actions: mockModelStore };
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
useSceneStore.mockImplementation((selector) => {
|
||||
const state = { actions: mockSceneStore };
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
});
|
||||
|
||||
it('should not save history during active transaction', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
// Clear previous calls from transaction setup
|
||||
mockModelStore.saveToHistory.mockClear();
|
||||
mockSceneStore.saveToHistory.mockClear();
|
||||
|
||||
// Try to save during transaction
|
||||
result.current.saveToHistory();
|
||||
|
||||
// Should not have saved
|
||||
expect(mockModelStore.saveToHistory).not.toHaveBeenCalled();
|
||||
expect(mockSceneStore.saveToHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user