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:
Stan
2025-09-06 16:52:20 +00:00
committed by GitHub
parent d5e02ea303
commit 607389a076
7 changed files with 664 additions and 11 deletions

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -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",

View File

@@ -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/',

View File

@@ -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');
});
});
});

View 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();
});
});
});
});
});