feat: add comprehensive Storybook coverage and migration guide

- Add StandaloneBead.stories.tsx with 11 stories covering all use cases
  (icons, decorations, progress indicators, size/color variations)
- Add AbacusDisplayProvider.stories.tsx with 9 stories demonstrating
  context features, localStorage persistence, and configuration
- Add MIGRATION_GUIDE.md for useAbacusState → useAbacusPlaceStates
  with code examples, API comparison, and BigInt documentation
- Consolidate all test files to src/__tests__/ directory for consistency
- Fix vitest configuration ESM module issue (rename to .mts)

This improves discoverability, documentation, and developer experience
for the abacus-react component library.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-03 12:32:25 -06:00
parent 38455e1283
commit 7a4a37ec6d
11 changed files with 2448 additions and 2 deletions

View File

@@ -0,0 +1,286 @@
# Migration Guide: useAbacusState → useAbacusPlaceStates
## Overview
The `useAbacusState` hook has been **deprecated** in favor of the new `useAbacusPlaceStates` hook. This migration is part of a larger architectural improvement to eliminate array-based column indexing in favor of native place-value semantics.
## Why Migrate?
### Problems with `useAbacusState` (deprecated)
- ❌ Uses **array indices** for columns (0=leftmost, requires totalColumns)
- ❌ Requires threading `totalColumns` through component tree
- ❌ Index math creates confusion: `columnIndex = totalColumns - 1 - placeValue`
- ❌ Prone to off-by-one errors
- ❌ No support for BigInt (large numbers >15 digits)
### Benefits of `useAbacusPlaceStates` (new)
- ✅ Uses **place values** directly (0=ones, 1=tens, 2=hundreds)
- ✅ Native semantic meaning, no index conversion needed
- ✅ Cleaner architecture with `Map<PlaceValue, State>`
- ✅ Supports both `number` and `BigInt` for large values
- ✅ Type-safe with `ValidPlaceValues` (0-9)
- ✅ No totalColumns threading required
## Migration Steps
### 1. Update Hook Usage
**Before (deprecated):**
```tsx
import { useAbacusState } from '@soroban/abacus-react';
function MyComponent() {
const {
value,
setValue,
columnStates, // Array of column states
getColumnState,
setColumnState,
toggleBead
} = useAbacusState(123, 5); // totalColumns=5
// Need to calculate indices
const onesColumnIndex = 4; // rightmost
const tensColumnIndex = 3; // second from right
return <AbacusReact value={value} columns={5} />;
}
```
**After (new):**
```tsx
import { useAbacusPlaceStates } from '@soroban/abacus-react';
function MyComponent() {
const {
value,
setValue,
placeStates, // Map<PlaceValue, PlaceState>
getPlaceState,
setPlaceState,
toggleBeadAtPlace
} = useAbacusPlaceStates(123, 4); // maxPlaceValue=4 (0-4 = 5 columns)
// Direct place value access - no index math!
const onesState = getPlaceState(0);
const tensState = getPlaceState(1);
return <AbacusReact value={value} columns={5} />;
}
```
### 2. Update State Access Patterns
**Before (array indexing):**
```tsx
// Get state for tens column (need to know position in array)
const tensIndex = columnStates.length - 2; // second from right
const tensState = columnStates[tensIndex];
```
**After (place value):**
```tsx
// Get state for tens place - no calculation needed!
const tensState = getPlaceState(1); // 1 = tens place
```
### 3. Update State Manipulation
**Before:**
```tsx
// Toggle bead in ones column (need BeadConfig with column index)
toggleBead({
type: 'earth',
value: 1,
active: false,
position: 2,
placeValue: 0 // This was confusing - had place value BUT operated on column index
});
```
**After:**
```tsx
// Toggle bead at ones place - clean and semantic
toggleBeadAtPlace({
type: 'earth',
value: 1,
active: false,
position: 2,
placeValue: 0 // Now actually used as place value!
});
```
### 4. Update Iteration Logic
**Before (array iteration):**
```tsx
columnStates.forEach((state, columnIndex) => {
const placeValue = columnStates.length - 1 - columnIndex; // Manual conversion
console.log(`Column ${columnIndex} (place ${placeValue}):`, state);
});
```
**After (Map iteration):**
```tsx
placeStates.forEach((state, placeValue) => {
console.log(`Place ${placeValue}:`, state); // Direct access, no conversion!
});
```
## API Comparison
### useAbacusState (deprecated)
```typescript
function useAbacusState(
initialValue?: number,
targetColumns?: number
): {
value: number;
setValue: (newValue: number) => void;
columnStates: ColumnState[]; // Array
getColumnState: (columnIndex: number) => ColumnState;
setColumnState: (columnIndex: number, state: ColumnState) => void;
toggleBead: (bead: BeadConfig) => void;
}
```
### useAbacusPlaceStates (new)
```typescript
function useAbacusPlaceStates(
controlledValue?: number | bigint,
maxPlaceValue?: ValidPlaceValues
): {
value: number | bigint;
setValue: (newValue: number | bigint) => void;
placeStates: PlaceStatesMap; // Map
getPlaceState: (place: ValidPlaceValues) => PlaceState;
setPlaceState: (place: ValidPlaceValues, state: PlaceState) => void;
toggleBeadAtPlace: (bead: BeadConfig) => void;
}
```
## Complete Example
### Before: Array-based (deprecated)
```tsx
import { useState } from 'react';
import { useAbacusState, AbacusReact } from '@soroban/abacus-react';
function DeprecatedExample() {
const { value, setValue, columnStates } = useAbacusState(0, 3);
const handleAddTen = () => {
// Need to know array position of tens column
const totalColumns = columnStates.length;
const tensColumnIndex = totalColumns - 2; // Complex!
const current = columnStates[tensColumnIndex];
// Increment tens digit
const currentTensValue = (current.heavenActive ? 5 : 0) + current.earthActive;
const newTensValue = (currentTensValue + 1) % 10;
setValue(value + 10);
};
return (
<div>
<AbacusReact value={value} columns={3} interactive />
<button onClick={handleAddTen}>Add 10</button>
</div>
);
}
```
### After: Place-value based (new)
```tsx
import { useState } from 'react';
import { useAbacusPlaceStates, AbacusReact } from '@soroban/abacus-react';
function NewExample() {
const { value, setValue, getPlaceState } = useAbacusPlaceStates(0, 2);
const handleAddTen = () => {
// Direct access to tens place - simple!
const tensState = getPlaceState(1); // 1 = tens
// Increment tens digit
const currentTensValue = (tensState.heavenActive ? 5 : 0) + tensState.earthActive;
const newTensValue = (currentTensValue + 1) % 10;
if (typeof value === 'number') {
setValue(value + 10);
} else {
setValue(value + 10n);
}
};
return (
<div>
<AbacusReact value={value} columns={3} interactive />
<button onClick={handleAddTen}>Add 10</button>
</div>
);
}
```
## BigInt Support (New Feature)
The new hook supports BigInt for numbers exceeding JavaScript's safe integer limit:
```tsx
const { value, setValue } = useAbacusPlaceStates(
123456789012345678901234567890n, // BigInt!
29 // 30 digits (place values 0-29)
);
console.log(typeof value); // "bigint"
```
## Type Safety Improvements
The new hook uses branded types and strict typing:
```tsx
import type {
ValidPlaceValues, // 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
PlaceState,
PlaceStatesMap
} from '@soroban/abacus-react';
// Type-safe place value access
const onesState: PlaceState = getPlaceState(0);
const tensState: PlaceState = getPlaceState(1);
// Compile-time error for invalid place values
const invalidState = getPlaceState(15); // Error if maxPlaceValue < 15
```
## Timeline
- **Current**: Both hooks available, `useAbacusState` marked `@deprecated`
- **Next major version**: `useAbacusState` will be removed
- **Recommendation**: Migrate as soon as possible
## Getting Help
If you encounter issues during migration:
1. Check the [README.md](./README.md) for updated examples
2. Review [Storybook stories](./src) for usage patterns
3. Open an issue at https://github.com/anthropics/claude-code/issues
## Summary
| Feature | useAbacusState (old) | useAbacusPlaceStates (new) |
|---------|---------------------|---------------------------|
| Architecture | Array-based columns | Map-based place values |
| Index math | Required | Not needed |
| Semantic meaning | Indirect | Direct |
| BigInt support | ❌ No | ✅ Yes |
| Type safety | Basic | Enhanced |
| Column threading | Required | Not required |
| **Status** | ⚠️ Deprecated | ✅ Recommended |
**Bottom line:** The new hook eliminates complexity and makes your code more maintainable. Migration is straightforward - primarily renaming and removing index calculations.

View File

@@ -0,0 +1,468 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AbacusDisplayProvider, useAbacusDisplay, useAbacusConfig } from './AbacusContext';
import { AbacusReact } from './AbacusReact';
import { StandaloneBead } from './StandaloneBead';
import React from 'react';
const meta: Meta<typeof AbacusDisplayProvider> = {
title: 'Soroban/Components/AbacusDisplayProvider',
component: AbacusDisplayProvider,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Context provider for managing global abacus display configuration. Automatically persists settings to localStorage and provides SSR-safe hydration.'
}
}
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof meta>;
// Basic Provider Usage
export const BasicUsage: Story = {
name: 'Basic Provider Usage',
render: () => (
<AbacusDisplayProvider>
<div style={{ textAlign: 'center' }}>
<AbacusReact value={123} columns={3} showNumbers />
<p style={{ marginTop: '16px', fontSize: '14px', color: '#6b7280' }}>
This abacus inherits all settings from the provider
</p>
</div>
</AbacusDisplayProvider>
),
parameters: {
docs: {
description: {
story: 'Wrap your components with AbacusDisplayProvider to provide consistent configuration'
}
}
}
};
// With Initial Config
export const WithInitialConfig: Story = {
name: 'With Initial Config',
render: () => (
<AbacusDisplayProvider
initialConfig={{
beadShape: 'circle',
colorScheme: 'heaven-earth',
colorPalette: 'colorblind',
}}
>
<div style={{ textAlign: 'center' }}>
<AbacusReact value={456} columns={3} showNumbers />
<p style={{ marginTop: '16px', fontSize: '14px', color: '#6b7280' }}>
Circle beads with heaven-earth coloring (colorblind palette)
</p>
</div>
</AbacusDisplayProvider>
),
parameters: {
docs: {
description: {
story: 'Provide initial configuration to override defaults'
}
}
}
};
// Interactive Config Demo
const ConfigDemo: React.FC = () => {
const { config, updateConfig, resetToDefaults } = useAbacusDisplay();
return (
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: '20px' }}>
<AbacusReact value={789} columns={3} showNumbers scaleFactor={1.2} />
</div>
<div style={{
display: 'inline-block',
textAlign: 'left',
padding: '20px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
background: '#f9fafb'
}}>
<h3 style={{ marginTop: 0, fontSize: '16px' }}>Configuration Controls</h3>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', fontWeight: '500' }}>
Bead Shape:
</label>
<select
value={config.beadShape}
onChange={(e) => updateConfig({ beadShape: e.target.value as any })}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid #d1d5db'
}}
>
<option value="diamond">Diamond</option>
<option value="circle">Circle</option>
<option value="square">Square</option>
</select>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', fontWeight: '500' }}>
Color Scheme:
</label>
<select
value={config.colorScheme}
onChange={(e) => updateConfig({ colorScheme: e.target.value as any })}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid #d1d5db'
}}
>
<option value="monochrome">Monochrome</option>
<option value="place-value">Place Value</option>
<option value="heaven-earth">Heaven-Earth</option>
<option value="alternating">Alternating</option>
</select>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', fontWeight: '500' }}>
Color Palette:
</label>
<select
value={config.colorPalette}
onChange={(e) => updateConfig({ colorPalette: e.target.value as any })}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid #d1d5db'
}}
>
<option value="default">Default</option>
<option value="colorblind">Colorblind</option>
<option value="mnemonic">Mnemonic</option>
<option value="grayscale">Grayscale</option>
<option value="nature">Nature</option>
</select>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', fontSize: '13px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={config.animated}
onChange={(e) => updateConfig({ animated: e.target.checked })}
style={{ marginRight: '6px' }}
/>
Enable Animations
</label>
</div>
<button
onClick={resetToDefaults}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid #d1d5db',
background: 'white',
cursor: 'pointer',
fontSize: '13px'
}}
>
Reset to Defaults
</button>
<div style={{
marginTop: '12px',
padding: '8px',
background: '#fef3c7',
borderRadius: '4px',
fontSize: '11px',
color: '#92400e'
}}>
💾 Changes are automatically saved to localStorage
</div>
</div>
</div>
);
};
export const InteractiveConfiguration: Story = {
name: 'Interactive Configuration',
render: () => (
<AbacusDisplayProvider>
<ConfigDemo />
</AbacusDisplayProvider>
),
parameters: {
docs: {
description: {
story: 'Use the useAbacusDisplay hook to access and modify configuration. Changes persist across sessions via localStorage.'
}
}
}
};
// Consistent Styling Across Components
export const ConsistentStyling: Story = {
name: 'Consistent Styling',
render: () => (
<AbacusDisplayProvider
initialConfig={{
beadShape: 'square',
colorScheme: 'place-value',
colorPalette: 'nature',
}}
>
<div>
<div style={{ marginBottom: '20px', textAlign: 'center' }}>
<h3 style={{ fontSize: '16px', marginBottom: '12px' }}>Multiple Abacuses</h3>
<div style={{ display: 'flex', gap: '20px', justifyContent: 'center' }}>
<AbacusReact value={12} columns={2} showNumbers />
<AbacusReact value={345} columns={3} showNumbers />
<AbacusReact value={6789} columns={4} showNumbers />
</div>
</div>
<div style={{ textAlign: 'center' }}>
<h3 style={{ fontSize: '16px', marginBottom: '12px' }}>Standalone Beads</h3>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
<StandaloneBead size={32} color="#ef4444" />
<StandaloneBead size={32} color="#f97316" />
<StandaloneBead size={32} color="#eab308" />
<StandaloneBead size={32} color="#22c55e" />
<StandaloneBead size={32} color="#3b82f6" />
</div>
</div>
<p style={{ marginTop: '16px', fontSize: '14px', color: '#6b7280', textAlign: 'center' }}>
All components share the same bead shape (square) from the provider
</p>
</div>
</AbacusDisplayProvider>
),
parameters: {
docs: {
description: {
story: 'Provider ensures consistent styling across all abacus components and standalone beads'
}
}
}
};
// Using the Config Hook
const ConfigDisplay: React.FC = () => {
const config = useAbacusConfig();
return (
<div style={{
padding: '20px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
background: 'white',
fontFamily: 'monospace',
fontSize: '12px'
}}>
<h3 style={{ marginTop: 0, fontSize: '14px', fontFamily: 'sans-serif' }}>Current Configuration</h3>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(config, null, 2)}
</pre>
</div>
);
};
export const UsingConfigHook: Story = {
name: 'Using useAbacusConfig Hook',
render: () => (
<AbacusDisplayProvider
initialConfig={{
beadShape: 'diamond',
colorScheme: 'place-value',
animated: true,
}}
>
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-start' }}>
<AbacusReact value={234} columns={3} showNumbers />
<ConfigDisplay />
</div>
</AbacusDisplayProvider>
),
parameters: {
docs: {
description: {
story: 'Use useAbacusConfig() hook to read configuration values in your components'
}
}
}
};
// localStorage Persistence Demo
const PersistenceDemo: React.FC = () => {
const { config, updateConfig } = useAbacusDisplay();
const [hasChanges, setHasChanges] = React.useState(false);
const handleChange = (updates: any) => {
updateConfig(updates);
setHasChanges(true);
setTimeout(() => setHasChanges(false), 2000);
};
return (
<div style={{ textAlign: 'center' }}>
<AbacusReact value={555} columns={3} showNumbers scaleFactor={1.2} />
<div style={{
marginTop: '20px',
padding: '16px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
background: '#f9fafb',
maxWidth: '300px',
marginLeft: 'auto',
marginRight: 'auto'
}}>
<h4 style={{ marginTop: 0, fontSize: '14px' }}>Try changing settings:</h4>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', justifyContent: 'center' }}>
<button
onClick={() => handleChange({ beadShape: 'diamond' })}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: config.beadShape === 'diamond' ? '2px solid #8b5cf6' : '1px solid #d1d5db',
background: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
Diamond
</button>
<button
onClick={() => handleChange({ beadShape: 'circle' })}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: config.beadShape === 'circle' ? '2px solid #8b5cf6' : '1px solid #d1d5db',
background: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
Circle
</button>
<button
onClick={() => handleChange({ beadShape: 'square' })}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: config.beadShape === 'square' ? '2px solid #8b5cf6' : '1px solid #d1d5db',
background: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
Square
</button>
</div>
{hasChanges && (
<div style={{
padding: '8px',
background: '#dcfce7',
borderRadius: '4px',
fontSize: '12px',
color: '#166534',
marginBottom: '12px'
}}>
Saved to localStorage!
</div>
)}
<p style={{
margin: 0,
fontSize: '11px',
color: '#6b7280',
lineHeight: '1.5'
}}>
Reload this page and your settings will be preserved. Open DevTools Application Local Storage to see the saved data.
</p>
</div>
</div>
);
};
export const LocalStoragePersistence: Story = {
name: 'localStorage Persistence',
render: () => (
<AbacusDisplayProvider>
<PersistenceDemo />
</AbacusDisplayProvider>
),
parameters: {
docs: {
description: {
story: 'Configuration is automatically persisted to localStorage and restored on page reload. SSR-safe with proper hydration.'
}
}
}
};
// Multiple Providers (Not Recommended)
export const MultipleProviders: Story = {
name: 'Multiple Providers (Advanced)',
render: () => (
<div style={{ display: 'flex', gap: '40px' }}>
<div style={{ textAlign: 'center' }}>
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Provider A</h4>
<AbacusDisplayProvider initialConfig={{ beadShape: 'diamond', colorScheme: 'heaven-earth' }}>
<AbacusReact value={111} columns={3} showNumbers />
<p style={{ fontSize: '12px', marginTop: '8px', color: '#6b7280' }}>Diamond beads</p>
</AbacusDisplayProvider>
</div>
<div style={{ textAlign: 'center' }}>
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Provider B</h4>
<AbacusDisplayProvider initialConfig={{ beadShape: 'circle', colorScheme: 'place-value' }}>
<AbacusReact value={222} columns={3} showNumbers />
<p style={{ fontSize: '12px', marginTop: '8px', color: '#6b7280' }}>Circle beads</p>
</AbacusDisplayProvider>
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'You can use multiple providers with different configs, but typically one provider at the app root is sufficient. Note: Each provider maintains its own localStorage key.'
}
}
}
};
// Without Provider (Fallback)
export const WithoutProvider: Story = {
name: 'Without Provider (Fallback)',
render: () => (
<div style={{ textAlign: 'center' }}>
<StandaloneBead size={40} shape="diamond" color="#8b5cf6" />
<p style={{ marginTop: '12px', fontSize: '14px', color: '#6b7280' }}>
Components work without a provider by using default configuration
</p>
</div>
),
parameters: {
docs: {
description: {
story: 'Components gracefully fall back to defaults when used outside a provider'
}
}
}
};

View File

@@ -0,0 +1,378 @@
import type { Meta, StoryObj } from '@storybook/react';
import { StandaloneBead } from './StandaloneBead';
import { AbacusDisplayProvider } from './AbacusContext';
import React from 'react';
const meta: Meta<typeof StandaloneBead> = {
title: 'Soroban/Components/StandaloneBead',
component: StandaloneBead,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A standalone bead component that can be used outside of the full abacus for icons, decorations, or UI elements. Respects AbacusDisplayContext for consistent styling.'
}
}
},
tags: ['autodocs'],
decorators: [
(Story) => (
<div style={{ padding: '20px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
// Basic Examples
export const BasicDiamond: Story = {
name: 'Basic Diamond',
args: {
size: 28,
shape: 'diamond',
color: '#000000',
animated: false,
},
parameters: {
docs: {
description: {
story: 'Default diamond-shaped bead'
}
}
}
};
export const BasicCircle: Story = {
name: 'Basic Circle',
args: {
size: 28,
shape: 'circle',
color: '#000000',
animated: false,
},
parameters: {
docs: {
description: {
story: 'Circle-shaped bead'
}
}
}
};
export const BasicSquare: Story = {
name: 'Basic Square',
args: {
size: 28,
shape: 'square',
color: '#000000',
animated: false,
},
parameters: {
docs: {
description: {
story: 'Square-shaped bead with rounded corners'
}
}
}
};
// Size Variations
export const SizeVariations: Story = {
name: 'Size Variations',
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<div style={{ textAlign: 'center' }}>
<StandaloneBead size={16} color="#8b5cf6" />
<p style={{ fontSize: '12px', marginTop: '8px' }}>16px</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead size={28} color="#8b5cf6" />
<p style={{ fontSize: '12px', marginTop: '8px' }}>28px (default)</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead size={40} color="#8b5cf6" />
<p style={{ fontSize: '12px', marginTop: '8px' }}>40px</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead size={64} color="#8b5cf6" />
<p style={{ fontSize: '12px', marginTop: '8px' }}>64px</p>
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'Beads scale to any size while maintaining proportions'
}
}
}
};
// Color Variations
export const ColorPalette: Story = {
name: 'Color Palette',
render: () => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', maxWidth: '400px' }}>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#ef4444" size={32} />
<p style={{ fontSize: '10px', marginTop: '4px' }}>Red</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#f97316" size={32} />
<p style={{ fontSize: '10px', marginTop: '4px' }}>Orange</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#eab308" size={32} />
<p style={{ fontSize: '10px', marginTop: '4px' }}>Yellow</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#22c55e" size={32} />
<p style={{ fontSize: '10px', marginTop: '4px' }}>Green</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#3b82f6" size={32} />
<p style={{ fontSize: '10px', marginTop: '4px' }}>Blue</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#8b5cf6" size={32} />
<p style={{ fontSize: '10px', marginTop: '4px' }}>Purple</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#ec4899" size={32} />
<p style={{ fontSize: '10px', marginTop: '4px' }}>Pink</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#6b7280" size={32} />
<p style={{ fontSize: '10px', marginTop: '4px' }}>Gray</p>
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'Beads support any hex color value'
}
}
}
};
// Shape Comparison
export const AllShapes: Story = {
name: 'All Shapes',
render: () => (
<div style={{ display: 'flex', gap: '30px', alignItems: 'center' }}>
<div style={{ textAlign: 'center' }}>
<StandaloneBead shape="diamond" color="#8b5cf6" size={40} />
<p style={{ fontSize: '12px', marginTop: '8px' }}>Diamond</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead shape="circle" color="#8b5cf6" size={40} />
<p style={{ fontSize: '12px', marginTop: '8px' }}>Circle</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead shape="square" color="#8b5cf6" size={40} />
<p style={{ fontSize: '12px', marginTop: '8px' }}>Square</p>
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'Compare all three available bead shapes'
}
}
}
};
// Active vs Inactive
export const ActiveState: Story = {
name: 'Active vs Inactive',
render: () => (
<div style={{ display: 'flex', gap: '40px', alignItems: 'center' }}>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#8b5cf6" size={40} active={true} />
<p style={{ fontSize: '12px', marginTop: '8px' }}>Active</p>
</div>
<div style={{ textAlign: 'center' }}>
<StandaloneBead color="#8b5cf6" size={40} active={false} />
<p style={{ fontSize: '12px', marginTop: '8px' }}>Inactive (grayed out)</p>
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'Inactive beads are automatically rendered in gray'
}
}
}
};
// With Context Provider
export const WithContextProvider: Story = {
name: 'Using Context Provider',
render: () => (
<AbacusDisplayProvider initialConfig={{ beadShape: 'circle', colorScheme: 'place-value' }}>
<div style={{ display: 'flex', gap: '20px' }}>
<StandaloneBead size={40} color="#ef4444" />
<StandaloneBead size={40} color="#f97316" />
<StandaloneBead size={40} color="#eab308" />
<StandaloneBead size={40} color="#22c55e" />
<StandaloneBead size={40} color="#3b82f6" />
</div>
</AbacusDisplayProvider>
),
parameters: {
docs: {
description: {
story: 'Beads inherit shape from AbacusDisplayProvider context. Here they are all circles because the provider sets beadShape to "circle".'
}
}
}
};
// Use Case: Icon
export const AsIcon: Story = {
name: 'As Icon',
render: () => (
<button
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
border: '1px solid #d1d5db',
borderRadius: '6px',
background: 'white',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
<StandaloneBead size={20} color="#8b5cf6" shape="circle" />
Abacus Settings
</button>
),
parameters: {
docs: {
description: {
story: 'Using StandaloneBead as an icon in buttons or UI elements'
}
}
}
};
// Use Case: Decoration
export const AsDecoration: Story = {
name: 'As Decoration',
render: () => (
<div style={{
border: '2px solid #e5e7eb',
borderRadius: '8px',
padding: '20px',
background: 'linear-gradient(to bottom right, #f9fafb, #ffffff)',
maxWidth: '300px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<StandaloneBead size={24} color="#8b5cf6" shape="diamond" />
<h3 style={{ margin: 0, fontSize: '18px' }}>Learning Progress</h3>
</div>
<p style={{ margin: 0, fontSize: '14px', color: '#6b7280' }}>
You've mastered basic addition! Keep practicing to improve your speed.
</p>
<div style={{ display: 'flex', gap: '4px', marginTop: '16px' }}>
<StandaloneBead size={16} color="#22c55e" shape="circle" />
<StandaloneBead size={16} color="#22c55e" shape="circle" />
<StandaloneBead size={16} color="#22c55e" shape="circle" />
<StandaloneBead size={16} color="#e5e7eb" shape="circle" active={false} />
<StandaloneBead size={16} color="#e5e7eb" shape="circle" active={false} />
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'Using beads as decorative elements in cards or panels'
}
}
}
};
// Use Case: Progress Indicator
export const AsProgressIndicator: Story = {
name: 'As Progress Indicator',
render: () => {
const [progress, setProgress] = React.useState(3);
return (
<div style={{ textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center', marginBottom: '16px' }}>
{[1, 2, 3, 4, 5].map((step) => (
<StandaloneBead
key={step}
size={32}
color="#8b5cf6"
shape="circle"
active={step <= progress}
/>
))}
</div>
<p style={{ fontSize: '14px', marginBottom: '12px' }}>Step {progress} of 5</p>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<button
onClick={() => setProgress(Math.max(1, progress - 1))}
disabled={progress === 1}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: '1px solid #d1d5db',
background: progress === 1 ? '#f3f4f6' : 'white',
cursor: progress === 1 ? 'not-allowed' : 'pointer'
}}
>
Previous
</button>
<button
onClick={() => setProgress(Math.min(5, progress + 1))}
disabled={progress === 5}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: '1px solid #d1d5db',
background: progress === 5 ? '#f3f4f6' : 'white',
cursor: progress === 5 ? 'not-allowed' : 'pointer'
}}
>
Next
</button>
</div>
</div>
);
},
parameters: {
docs: {
description: {
story: 'Interactive progress indicator using beads'
}
}
}
};
// Animated
export const Animated: Story = {
name: 'With Animation',
args: {
size: 40,
color: '#8b5cf6',
animated: true,
},
parameters: {
docs: {
description: {
story: 'Beads support React Spring animations (subtle scale effect)'
}
}
}
};

View File

@@ -0,0 +1,233 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AbacusReact, useAbacusDimensions } from "../AbacusReact";
describe("AbacusReact", () => {
it("renders without crashing", () => {
render(<AbacusReact value={0} />);
expect(document.querySelector("svg")).toBeInTheDocument();
});
it("renders with basic props", () => {
render(<AbacusReact value={123} columns={3} />);
const svg = document.querySelector("svg");
expect(svg).toBeInTheDocument();
});
describe("showNumbers prop", () => {
it('does not show numbers when showNumbers="never"', () => {
render(<AbacusReact value={123} columns={3} showNumbers="never" />);
// NumberFlow components should not be rendered
expect(screen.queryByText("1")).not.toBeInTheDocument();
expect(screen.queryByText("2")).not.toBeInTheDocument();
expect(screen.queryByText("3")).not.toBeInTheDocument();
});
it('shows numbers when showNumbers="always"', () => {
render(<AbacusReact value={123} columns={3} showNumbers="always" />);
// NumberFlow components should render the place values
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
});
it('shows toggle button when showNumbers="toggleable"', () => {
render(<AbacusReact value={123} columns={3} showNumbers="toggleable" />);
// Should have a toggle button
const toggleButton = screen.getByRole("button");
expect(toggleButton).toBeInTheDocument();
expect(toggleButton).toHaveAttribute("title", "Show numbers");
});
it("toggles numbers visibility when button is clicked", async () => {
const user = userEvent.setup();
render(<AbacusReact value={123} columns={3} showNumbers="toggleable" />);
const toggleButton = screen.getByRole("button");
// Initially numbers should be hidden (default state for toggleable)
expect(screen.queryByText("1")).not.toBeInTheDocument();
expect(toggleButton).toHaveAttribute("title", "Show numbers");
// Click to show numbers
await user.click(toggleButton);
// Numbers should now be visible
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(toggleButton).toHaveAttribute("title", "Hide numbers");
// Click to hide numbers again
await user.click(toggleButton);
// Numbers should be hidden again
expect(screen.queryByText("1")).not.toBeInTheDocument();
expect(toggleButton).toHaveAttribute("title", "Show numbers");
});
});
describe("bead interactions", () => {
it("calls onClick when bead is clicked", async () => {
const user = userEvent.setup();
const onClickMock = vi.fn();
render(<AbacusReact value={0} columns={1} onClick={onClickMock} />);
// Find and click a bead (they have cursor:pointer style)
const bead = document.querySelector(".abacus-bead");
if (bead) {
await user.click(bead as Element);
expect(onClickMock).toHaveBeenCalled();
} else {
// If no bead found, test passes (component rendered without crashing)
expect(document.querySelector("svg")).toBeInTheDocument();
}
});
it("calls onValueChange when value changes", () => {
const onValueChangeMock = vi.fn();
const { rerender } = render(
<AbacusReact value={0} onValueChange={onValueChangeMock} />,
);
rerender(<AbacusReact value={5} onValueChange={onValueChangeMock} />);
// onValueChange should be called when value prop changes
expect(onValueChangeMock).toHaveBeenCalled();
});
});
describe("visual properties", () => {
it("applies different bead shapes", () => {
const { rerender } = render(
<AbacusReact value={1} beadShape="diamond" />,
);
expect(document.querySelector("svg")).toBeInTheDocument();
rerender(<AbacusReact value={1} beadShape="circle" />);
expect(document.querySelector("svg")).toBeInTheDocument();
rerender(<AbacusReact value={1} beadShape="square" />);
expect(document.querySelector("svg")).toBeInTheDocument();
});
it("applies different color schemes", () => {
const { rerender } = render(
<AbacusReact value={1} colorScheme="monochrome" />,
);
expect(document.querySelector("svg")).toBeInTheDocument();
rerender(<AbacusReact value={1} colorScheme="place-value" />);
expect(document.querySelector("svg")).toBeInTheDocument();
rerender(<AbacusReact value={1} colorScheme="alternating" />);
expect(document.querySelector("svg")).toBeInTheDocument();
});
it("applies scale factor", () => {
render(<AbacusReact value={1} scaleFactor={2} />);
expect(document.querySelector("svg")).toBeInTheDocument();
});
});
describe("accessibility", () => {
it("has proper ARIA attributes", () => {
render(<AbacusReact value={123} />);
const svg = document.querySelector("svg");
expect(svg).toBeInTheDocument();
// Test that SVG has some accessible attributes
expect(svg).toHaveAttribute("class");
});
it("is keyboard accessible", () => {
render(<AbacusReact value={123} showNumbers="toggleable" />);
const toggleButton = screen.getByRole("button");
expect(toggleButton).toBeInTheDocument();
// Button should be focusable
toggleButton.focus();
expect(document.activeElement).toBe(toggleButton);
});
});
});
describe("useAbacusDimensions", () => {
// Test hook using renderHook pattern with a wrapper component
const TestHookComponent = ({
columns,
scaleFactor,
showNumbers,
}: {
columns: number;
scaleFactor: number;
showNumbers: "always" | "never" | "toggleable";
}) => {
const dimensions = useAbacusDimensions(columns, scaleFactor, showNumbers);
return <div data-testid="dimensions">{JSON.stringify(dimensions)}</div>;
};
it("calculates correct dimensions for different column counts", () => {
const { rerender } = render(
<TestHookComponent columns={1} scaleFactor={1} showNumbers="never" />,
);
const dims1 = JSON.parse(screen.getByTestId("dimensions").textContent!);
rerender(
<TestHookComponent columns={3} scaleFactor={1} showNumbers="never" />,
);
const dims3 = JSON.parse(screen.getByTestId("dimensions").textContent!);
expect(dims3.width).toBeGreaterThan(dims1.width);
expect(dims1.height).toBeGreaterThan(0);
expect(dims3.height).toBe(dims1.height); // Same height for same showNumbers
});
it("adjusts height based on showNumbers setting", () => {
const { rerender } = render(
<TestHookComponent columns={3} scaleFactor={1} showNumbers="never" />,
);
const dimsNever = JSON.parse(screen.getByTestId("dimensions").textContent!);
rerender(
<TestHookComponent columns={3} scaleFactor={1} showNumbers="always" />,
);
const dimsAlways = JSON.parse(
screen.getByTestId("dimensions").textContent!,
);
rerender(
<TestHookComponent
columns={3}
scaleFactor={1}
showNumbers="toggleable"
/>,
);
const dimsToggleable = JSON.parse(
screen.getByTestId("dimensions").textContent!,
);
expect(dimsAlways.height).toBeGreaterThan(dimsNever.height);
expect(dimsToggleable.height).toBeGreaterThan(dimsNever.height);
expect(dimsToggleable.height).toBe(dimsAlways.height);
});
it("scales dimensions with scale factor", () => {
const { rerender } = render(
<TestHookComponent columns={3} scaleFactor={1} showNumbers="never" />,
);
const dims1x = JSON.parse(screen.getByTestId("dimensions").textContent!);
rerender(
<TestHookComponent columns={3} scaleFactor={2} showNumbers="never" />,
);
const dims2x = JSON.parse(screen.getByTestId("dimensions").textContent!);
expect(dims2x.width).toBeGreaterThan(dims1x.width);
expect(dims2x.height).toBeGreaterThan(dims1x.height);
expect(dims2x.beadSize).toBeGreaterThan(dims1x.beadSize);
});
});

View File

@@ -0,0 +1,282 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { AbacusReact } from "../AbacusReact";
describe("AbacusReact Zero State Interaction Bug", () => {
it("should handle bead clicks correctly when starting from value 0", () => {
const mockOnValueChange = vi.fn();
const mockOnBeadClick = vi.fn();
render(
<AbacusReact
value={0}
columns={5}
interactive={true}
gestures={false}
animated={false}
onValueChange={mockOnValueChange}
callbacks={{
onBeadClick: mockOnBeadClick,
}}
/>,
);
// Test clicking the leftmost column (index 0) heaven bead
console.log(
"Testing leftmost column (visual column 0) heaven bead click...",
);
const leftmostHeavenBead = screen.getByTestId("bead-col-0-heaven");
fireEvent.click(leftmostHeavenBead);
// Check if the callback was called with correct column index
expect(mockOnBeadClick).toHaveBeenCalledWith(
expect.objectContaining({
columnIndex: 0,
beadType: "heaven",
}),
);
// The value should change to 50000 (5 in leftmost column of 5-column abacus)
expect(mockOnValueChange).toHaveBeenCalledWith(50000);
mockOnValueChange.mockClear();
mockOnBeadClick.mockClear();
});
it("should handle middle column clicks correctly when starting from value 0", () => {
const mockOnValueChange = vi.fn();
const mockOnBeadClick = vi.fn();
render(
<AbacusReact
value={0}
columns={5}
interactive={true}
gestures={false}
animated={false}
onValueChange={mockOnValueChange}
callbacks={{
onBeadClick: mockOnBeadClick,
}}
/>,
);
// Test clicking middle column (index 2) heaven bead
console.log("Testing middle column (visual column 2) heaven bead click...");
const middleHeavenBead = screen.getByTestId("bead-col-2-heaven");
fireEvent.click(middleHeavenBead);
// Check if the callback was called with correct column index
expect(mockOnBeadClick).toHaveBeenCalledWith(
expect.objectContaining({
columnIndex: 2,
beadType: "heaven",
}),
);
// The value should change to 500 (5 in middle column)
expect(mockOnValueChange).toHaveBeenCalledWith(500);
});
it("should handle rightmost column clicks correctly when starting from value 0", () => {
const mockOnValueChange = vi.fn();
const mockOnBeadClick = vi.fn();
render(
<AbacusReact
value={0}
columns={5}
interactive={true}
gestures={false}
animated={false}
onValueChange={mockOnValueChange}
callbacks={{
onBeadClick: mockOnBeadClick,
}}
/>,
);
// Test clicking rightmost column (index 4) heaven bead
console.log(
"Testing rightmost column (visual column 4) heaven bead click...",
);
const rightmostHeavenBead = screen.getByTestId("bead-col-4-heaven");
fireEvent.click(rightmostHeavenBead);
// Check if the callback was called with correct column index
expect(mockOnBeadClick).toHaveBeenCalledWith(
expect.objectContaining({
columnIndex: 4,
beadType: "heaven",
}),
);
// The value should change to 5 (5 in rightmost column)
expect(mockOnValueChange).toHaveBeenCalledWith(5);
});
it("should handle earth bead clicks correctly when starting from value 0", () => {
const mockOnValueChange = vi.fn();
const mockOnBeadClick = vi.fn();
render(
<AbacusReact
value={0}
columns={5}
interactive={true}
gestures={false}
animated={false}
onValueChange={mockOnValueChange}
callbacks={{
onBeadClick: mockOnBeadClick,
}}
/>,
);
// Earth beads start after heaven beads
// Layout: 5 heaven beads, then 20 earth beads (4 per column)
console.log(
"Testing leftmost column (visual column 0) first earth bead click...",
);
const leftmostEarthBead = screen.getByTestId("bead-col-0-earth-pos-0");
fireEvent.click(leftmostEarthBead);
// Check if the callback was called with correct column index
expect(mockOnBeadClick).toHaveBeenCalledWith(
expect.objectContaining({
columnIndex: 0,
beadType: "earth",
position: 0,
}),
);
// The value should change to 10000 (1 in leftmost column)
expect(mockOnValueChange).toHaveBeenCalledWith(10000);
});
it("should handle sequential clicks across different columns", () => {
const mockOnValueChange = vi.fn();
let currentValue = 0;
const TestComponent = () => {
return (
<AbacusReact
value={currentValue}
columns={5}
interactive={true}
gestures={false}
animated={false}
onValueChange={(newValue) => {
currentValue = newValue;
mockOnValueChange(newValue);
}}
/>
);
};
const { rerender } = render(<TestComponent />);
// Click rightmost column heaven bead (should set value to 5)
fireEvent.click(screen.getByTestId("bead-col-4-heaven"));
rerender(<TestComponent />);
expect(mockOnValueChange).toHaveBeenLastCalledWith(5);
// Click middle column heaven bead (should set value to 505)
fireEvent.click(screen.getByTestId("bead-col-2-heaven"));
rerender(<TestComponent />);
expect(mockOnValueChange).toHaveBeenLastCalledWith(505);
// Click leftmost column earth bead (should set value to 10505)
fireEvent.click(screen.getByTestId("bead-col-0-earth-pos-0"));
rerender(<TestComponent />);
expect(mockOnValueChange).toHaveBeenLastCalledWith(10505);
console.log("Final value after sequential clicks:", currentValue);
expect(currentValue).toBe(10505);
});
it("should debug the bead layout and column mapping", () => {
const mockOnBeadClick = vi.fn();
render(
<AbacusReact
value={0}
columns={5}
interactive={true}
callbacks={{
onBeadClick: mockOnBeadClick,
}}
/>,
);
const beads = screen.getAllByRole("button");
console.log(`\n=== BEAD LAYOUT DEBUG ===`);
console.log(`Total interactive beads found: ${beads.length}`);
console.log(`Expected: 25 beads (5 heaven + 20 earth)`);
// Test specific beads using data-testid
const testBeads = [
"bead-col-0-heaven",
"bead-col-1-heaven",
"bead-col-2-heaven",
"bead-col-0-earth-pos-0",
"bead-col-0-earth-pos-1",
"bead-col-1-earth-pos-0",
"bead-col-2-earth-pos-0",
"bead-col-4-heaven",
"bead-col-4-earth-pos-3",
];
testBeads.forEach((testId) => {
try {
const bead = screen.getByTestId(testId);
mockOnBeadClick.mockClear();
fireEvent.click(bead);
if (mockOnBeadClick.mock.calls.length > 0) {
const call = mockOnBeadClick.mock.calls[0][0];
console.log(
`${testId}: column=${call.columnIndex}, type=${call.beadType}, position=${call.position || "N/A"}`,
);
} else {
console.log(`${testId}: No callback fired`);
}
} catch (error) {
console.log(`${testId}: Element not found`);
}
});
});
it("should handle numeral entry correctly when starting from value 0", () => {
const mockOnValueChange = vi.fn();
const mockOnColumnClick = vi.fn();
render(
<AbacusReact
value={0}
columns={5}
interactive={true}
gestures={false}
animated={false}
showNumbers={true}
onValueChange={mockOnValueChange}
callbacks={{
onColumnClick: mockOnColumnClick,
}}
/>,
);
// Find elements that should trigger column clicks (numeral areas)
// This is harder to test directly, but we can simulate the behavior
// Simulate clicking on column 2 numeral area and typing "7"
// This should be equivalent to setColumnValue(2, 7)
// For now, let's just verify that the component renders correctly
// with showNumbers enabled
expect(screen.getAllByRole("button").length).toBeGreaterThan(0); // Interactive beads exist
console.log("Numeral entry test - component renders with showNumbers=true");
});
});

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { AbacusReact } from "../AbacusReact";
describe("Debug Columns Test", () => {
it("should render value=3 with columns=3 correctly", () => {
const { container } = render(
<AbacusReact value={3} columns={3} interactive={true} />,
);
// Debug: log all testids to see what's happening
const allBeads = container.querySelectorAll("[data-testid]");
console.log("All bead testids:");
allBeads.forEach((bead) => {
const testId = bead.getAttribute("data-testid");
const isActive = bead.classList.contains("active");
console.log(` ${testId} - active: ${isActive}`);
});
// Check that we have beads in all 3 places
const place0Beads = container.querySelectorAll(
'[data-testid*="bead-place-0-"]',
);
const place1Beads = container.querySelectorAll(
'[data-testid*="bead-place-1-"]',
);
const place2Beads = container.querySelectorAll(
'[data-testid*="bead-place-2-"]',
);
console.log(`Place 0 beads: ${place0Beads.length}`);
console.log(`Place 1 beads: ${place1Beads.length}`);
console.log(`Place 2 beads: ${place2Beads.length}`);
// For value 3 with 3 columns, we should have:
// - Place 0 (ones): 3 active earth beads
// - Place 1 (tens): all inactive (no beads needed for tens place)
// - Place 2 (hundreds): all inactive (no beads needed for hundreds place)
// We should have beads in all 3 places
expect(place0Beads.length).toBeGreaterThan(0); // ones place
expect(place1Beads.length).toBeGreaterThan(0); // tens place
expect(place2Beads.length).toBeGreaterThan(0); // hundreds place
// Check active beads - only place 0 should have active beads
const activePlaceZero = container.querySelectorAll(
'[data-testid*="bead-place-0-"].active',
);
const activePlaceOne = container.querySelectorAll(
'[data-testid*="bead-place-1-"].active',
);
const activePlaceTwo = container.querySelectorAll(
'[data-testid*="bead-place-2-"].active',
);
expect(activePlaceZero).toHaveLength(3); // 3 active earth beads for ones
expect(activePlaceOne).toHaveLength(0); // no active beads for tens
expect(activePlaceTwo).toHaveLength(0); // no active beads for hundreds
});
});

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi } from "vitest";
import { render, fireEvent } from "@testing-library/react";
import { AbacusReact } from "../AbacusReact";
describe("Gesture and Input Functionality", () => {
describe("Gesture Support", () => {
it("should handle heaven bead gesture activation", () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={2}
interactive={true}
gestures={true}
onValueChange={onValueChange}
/>,
);
// Find a heaven bead in place 0 (ones place)
const heavenBead = container.querySelector(
'[data-testid="bead-place-0-heaven"]',
);
expect(heavenBead).toBeTruthy();
// Since gesture event simulation is complex, let's test by clicking the bead directly
// This tests the underlying state change logic that gestures would also trigger
fireEvent.click(heavenBead as HTMLElement);
// The value should change from 0 to 5 (heaven bead activated)
expect(onValueChange).toHaveBeenCalledWith(5);
});
it("should handle earth bead gesture activation", () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={2}
interactive={true}
gestures={true}
onValueChange={onValueChange}
/>,
);
// Find the first earth bead in place 0 (ones place)
const earthBead = container.querySelector(
'[data-testid="bead-place-0-earth-pos-0"]',
);
expect(earthBead).toBeTruthy();
// Test by clicking the bead directly (same logic as gestures would trigger)
fireEvent.click(earthBead as HTMLElement);
// The value should change from 0 to 1 (first earth bead activated)
expect(onValueChange).toHaveBeenCalledWith(1);
});
it("should handle gesture deactivation", () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={5}
columns={2}
interactive={true}
gestures={true}
onValueChange={onValueChange}
/>,
);
// Find the active heaven bead in place 0
const heavenBead = container.querySelector(
'[data-testid="bead-place-0-heaven"]',
);
expect(heavenBead).toBeTruthy();
// Test by clicking the active bead to deactivate it
fireEvent.click(heavenBead as HTMLElement);
// The value should change from 5 to 0 (heaven bead deactivated)
expect(onValueChange).toHaveBeenCalledWith(0);
});
});
describe("Numeral Input", () => {
it("should allow typing digits to change values", () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={3}
interactive={true}
showNumbers={true}
onValueChange={onValueChange}
/>,
);
// Find the abacus container (should be focusable for keyboard input)
const abacusContainer = container.querySelector(".abacus-container");
expect(abacusContainer).toBeTruthy();
// Focus the abacus and type a digit
fireEvent.focus(abacusContainer!);
fireEvent.keyDown(abacusContainer!, { key: "7" });
// The value should change to 7 in the ones place
expect(onValueChange).toHaveBeenCalledWith(7);
});
it("should allow navigating between columns with Tab", () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={3}
interactive={true}
showNumbers={true}
onValueChange={onValueChange}
/>,
);
const abacusContainer = container.querySelector(".abacus-container");
expect(abacusContainer).toBeTruthy();
// Focus and type in ones place
fireEvent.focus(abacusContainer!);
fireEvent.keyDown(abacusContainer!, { key: "3" });
expect(onValueChange).toHaveBeenLastCalledWith(3);
// Move to tens place with Tab
fireEvent.keyDown(abacusContainer!, { key: "Tab" });
fireEvent.keyDown(abacusContainer!, { key: "2" });
expect(onValueChange).toHaveBeenLastCalledWith(23);
// Move to hundreds place with Tab
fireEvent.keyDown(abacusContainer!, { key: "Tab" });
fireEvent.keyDown(abacusContainer!, { key: "1" });
expect(onValueChange).toHaveBeenLastCalledWith(123);
});
it("should allow navigating backwards with Shift+Tab", () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={123}
columns={3}
interactive={true}
showNumbers={true}
onValueChange={onValueChange}
/>,
);
const abacusContainer = container.querySelector(".abacus-container");
expect(abacusContainer).toBeTruthy();
// Focus the abacus (should start at rightmost/ones place)
fireEvent.focus(abacusContainer!);
// Move left to tens place
fireEvent.keyDown(abacusContainer!, { key: "Tab", shiftKey: true });
fireEvent.keyDown(abacusContainer!, { key: "5" });
expect(onValueChange).toHaveBeenLastCalledWith(153);
// Move left to hundreds place
fireEvent.keyDown(abacusContainer!, { key: "Tab", shiftKey: true });
fireEvent.keyDown(abacusContainer!, { key: "9" });
expect(onValueChange).toHaveBeenLastCalledWith(953);
});
it("should use Backspace to clear current column and move left", () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={123}
columns={3}
interactive={true}
showNumbers={true}
onValueChange={onValueChange}
/>,
);
const abacusContainer = container.querySelector(".abacus-container");
expect(abacusContainer).toBeTruthy();
// Focus the abacus (should start at rightmost/ones place with value 3)
fireEvent.focus(abacusContainer!);
// Backspace should clear ones place (3 -> 0) and move to tens
fireEvent.keyDown(abacusContainer!, { key: "Backspace" });
expect(onValueChange).toHaveBeenLastCalledWith(120);
// Next digit should go in tens place
fireEvent.keyDown(abacusContainer!, { key: "4" });
expect(onValueChange).toHaveBeenLastCalledWith(140);
});
});
describe("Integration Tests", () => {
it("should work with both gestures and numeral input on same abacus", () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={2}
interactive={true}
gestures={true}
showNumbers={true}
onValueChange={onValueChange}
/>,
);
// First use numeral input
const abacusContainer = container.querySelector(".abacus-container");
fireEvent.focus(abacusContainer!);
fireEvent.keyDown(abacusContainer!, { key: "3" });
expect(onValueChange).toHaveBeenLastCalledWith(3);
// Then use gesture to modify tens place
fireEvent.keyDown(abacusContainer!, { key: "Tab" }); // Move to tens
const heavenBead = container.querySelector(
'[data-testid="bead-place-1-heaven"]',
);
expect(heavenBead).toBeTruthy();
const beadElement = heavenBead as HTMLElement;
fireEvent.click(beadElement); // Test clicking the heaven bead to activate it
// Should now have 50 + 3 = 53
expect(onValueChange).toHaveBeenLastCalledWith(53);
});
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { AbacusReact } from "../AbacusReact";
describe("Place Value Positioning", () => {
it("should place single digit values in the rightmost column (ones place)", () => {
// Test case: single digit 3 with 3 columns should show in rightmost column
const { container } = render(
<AbacusReact value={3} columns={3} interactive={true} />,
);
// Get all bead elements that are active
const activeBeads = container.querySelectorAll(".abacus-bead.active");
// For value 3, we should have exactly 3 active earth beads (no heaven bead)
expect(activeBeads).toHaveLength(3);
// The active beads should all be in the rightmost column (ones place = place value 0)
activeBeads.forEach((bead) => {
const beadElement = bead as HTMLElement;
// Check that the data-testid indicates place value 0 (rightmost/ones place)
const testId = beadElement.getAttribute("data-testid");
expect(testId).toMatch(/bead-place-0/); // Should be bead-place-0-earth-pos-{position}
});
});
it("should place two digit values correctly across columns", () => {
// Test case: 23 with 3 columns
// Should show: [empty][2][3] = [empty][tens][ones]
const { container } = render(
<AbacusReact value={23} columns={3} interactive={true} />,
);
const activeBeads = container.querySelectorAll(".abacus-bead.active");
// For value 23: 2 earth beads (tens) + 3 earth beads (ones) = 5 total
expect(activeBeads).toHaveLength(5);
// Check that we have beads in place value 0 (ones) and place value 1 (tens)
const placeZeroBeads = container.querySelectorAll(
'[data-testid*="bead-place-0-"]',
);
const placeOneBeads = container.querySelectorAll(
'[data-testid*="bead-place-1-"]',
);
const placeTwoBeads = container.querySelectorAll(
'[data-testid*="bead-place-2-"]',
);
// Should have beads for all 3 places (ones, tens, hundreds)
expect(placeZeroBeads.length).toBeGreaterThan(0); // ones place should have beads
expect(placeOneBeads.length).toBeGreaterThan(0); // tens place should have beads
expect(placeTwoBeads.length).toBeGreaterThan(0); // hundreds place should have beads (but inactive)
// Count active beads in each place
const activePlaceZero = container.querySelectorAll(
'[data-testid*="bead-place-0-"].active',
);
const activePlaceOne = container.querySelectorAll(
'[data-testid*="bead-place-1-"].active',
);
expect(activePlaceZero).toHaveLength(3); // 3 active beads for ones
expect(activePlaceOne).toHaveLength(2); // 2 active beads for tens
});
it("should handle value 0 correctly in rightmost column", () => {
const { container } = render(
<AbacusReact value={0} columns={3} interactive={true} />,
);
// For value 0, no beads should be active
const activeBeads = container.querySelectorAll(".abacus-bead.active");
expect(activeBeads).toHaveLength(0);
// But there should still be beads in the ones place (place value 0)
const placeZeroBeads = container.querySelectorAll(
'[data-testid*="bead-place-0-"]',
);
expect(placeZeroBeads.length).toBeGreaterThan(0);
});
it("should maintain visual column ordering left-to-right as high-to-low place values", () => {
// For value 147 with 3 columns: [1][4][7] = [hundreds][tens][ones]
const { container } = render(
<AbacusReact value={147} columns={3} interactive={true} />,
);
// Find the container element and check that beads are positioned correctly
const svgElement = container.querySelector("svg");
expect(svgElement).toBeTruthy();
// Check that place values appear in the correct visual order
// This test verifies the column arrangement matches place value expectations
const placeZeroBeads = container.querySelectorAll(
'[data-testid*="bead-place-0-"]',
);
const placeOneBeads = container.querySelectorAll(
'[data-testid*="bead-place-1-"]',
);
const placeTwoBeads = container.querySelectorAll(
'[data-testid*="bead-place-2-"]',
);
// All three places should have beads
expect(placeZeroBeads.length).toBeGreaterThan(0); // ones
expect(placeOneBeads.length).toBeGreaterThan(0); // tens
expect(placeTwoBeads.length).toBeGreaterThan(0); // hundreds
// Check active bead counts match the digit values
const activePlaceZero = container.querySelectorAll(
'[data-testid*="bead-place-0-"].active',
);
const activePlaceOne = container.querySelectorAll(
'[data-testid*="bead-place-1-"].active',
);
const activePlaceTwo = container.querySelectorAll(
'[data-testid*="bead-place-2-"].active',
);
expect(activePlaceZero).toHaveLength(3); // 7 ones = 1 heaven (5) + 2 earth = 3 active beads
expect(activePlaceOne).toHaveLength(4); // 4 tens = 4 earth beads active
expect(activePlaceTwo).toHaveLength(1); // 1 hundred = 1 earth bead active
});
});

View File

@@ -0,0 +1,45 @@
import "@testing-library/jest-dom";
import React from "react";
// Mock for @react-spring/web
vi.mock("@react-spring/web", () => ({
useSpring: () => [
{
x: 0,
y: 0,
transform: "translate(0px, 0px)",
opacity: 1,
},
{
start: vi.fn(),
stop: vi.fn(),
set: vi.fn(),
},
],
animated: {
g: ({ children, ...props }: any) =>
React.createElement("g", props, children),
div: ({ children, ...props }: any) =>
React.createElement("div", props, children),
},
config: {
gentle: {},
},
to: (values: any[], fn: Function) => {
if (Array.isArray(values) && typeof fn === "function") {
return fn(...values);
}
return "translate(0px, 0px)";
},
}));
// Mock for @use-gesture/react
vi.mock("@use-gesture/react", () => ({
useDrag: () => () => ({}), // Return a function that returns an empty object
}));
// Mock for @number-flow/react
vi.mock("@number-flow/react", () => ({
default: ({ value }: { value: number }) =>
React.createElement("span", {}, value.toString()),
}));

View File

@@ -0,0 +1,330 @@
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
import { render, fireEvent, screen } from "@testing-library/react";
import { vi } from "vitest";
// Mock the instruction generator
const generateAbacusInstructions = (
startValue: number,
targetValue: number,
) => {
// Mock implementation for 3+14=17 case
if (startValue === 3 && targetValue === 8) {
return {
stepBeadHighlights: [
{
placeValue: 0,
beadType: "heaven" as const,
stepIndex: 0,
direction: "activate" as const,
order: 0,
},
],
};
}
if (startValue === 8 && targetValue === 18) {
return {
stepBeadHighlights: [
{
placeValue: 1,
beadType: "earth" as const,
position: 0,
stepIndex: 0,
direction: "activate" as const,
order: 0,
},
],
};
}
if (startValue === 18 && targetValue === 17) {
return {
stepBeadHighlights: [
{
placeValue: 0,
beadType: "earth" as const,
position: 0,
stepIndex: 0,
direction: "deactivate" as const,
order: 0,
},
],
};
}
return { stepBeadHighlights: [] };
};
// Test component that implements the step advancement logic
const StepAdvancementTest: React.FC = () => {
const [currentValue, setCurrentValue] = useState(3);
const [currentMultiStep, setCurrentMultiStep] = useState(0);
const lastValueForStepAdvancement = useRef<number>(currentValue);
const userHasInteracted = useRef<boolean>(false);
// Mock current step data (3 + 14 = 17)
const currentStep = {
startValue: 3,
targetValue: 17,
stepBeadHighlights: [
{
placeValue: 0,
beadType: "heaven" as const,
stepIndex: 0,
direction: "activate" as const,
order: 0,
},
{
placeValue: 1,
beadType: "earth" as const,
position: 0,
stepIndex: 1,
direction: "activate" as const,
order: 0,
},
{
placeValue: 0,
beadType: "earth" as const,
position: 0,
stepIndex: 2,
direction: "deactivate" as const,
order: 0,
},
],
totalSteps: 3,
};
// Define the static expected steps
const expectedSteps = useMemo(() => {
if (
!currentStep.stepBeadHighlights ||
!currentStep.totalSteps ||
currentStep.totalSteps <= 1
) {
return [];
}
const stepIndices = [
...new Set(currentStep.stepBeadHighlights.map((bead) => bead.stepIndex)),
].sort();
const steps = [];
let value = currentStep.startValue;
if (currentStep.startValue === 3 && currentStep.targetValue === 17) {
const milestones = [8, 18, 17];
for (let i = 0; i < stepIndices.length && i < milestones.length; i++) {
steps.push({
index: i,
stepIndex: stepIndices[i],
targetValue: milestones[i],
startValue: value,
description: `Step ${i + 1}`,
});
value = milestones[i];
}
}
console.log("📋 Generated expected steps:", steps);
return steps;
}, []);
// Get arrows for immediate next action
const getCurrentStepBeads = useCallback(() => {
if (currentValue === currentStep.targetValue) return undefined;
if (expectedSteps.length === 0) return currentStep.stepBeadHighlights;
const currentExpectedStep = expectedSteps[currentMultiStep];
if (!currentExpectedStep) return undefined;
try {
const instruction = generateAbacusInstructions(
currentValue,
currentExpectedStep.targetValue,
);
const immediateAction = instruction.stepBeadHighlights?.filter(
(bead) => bead.stepIndex === 0,
);
console.log("🎯 Expected step progression:", {
currentValue,
expectedStepIndex: currentMultiStep,
expectedStepTarget: currentExpectedStep.targetValue,
expectedStepDescription: currentExpectedStep.description,
immediateActionBeads: immediateAction?.length || 0,
totalExpectedSteps: expectedSteps.length,
});
return immediateAction && immediateAction.length > 0
? immediateAction
: undefined;
} catch (error) {
console.warn("⚠️ Failed to generate step guidance:", error);
return undefined;
}
}, [currentValue, expectedSteps, currentMultiStep]);
// Step advancement logic
useEffect(() => {
const valueChanged = currentValue !== lastValueForStepAdvancement.current;
const currentExpectedStep = expectedSteps[currentMultiStep];
console.log("🔍 Expected step advancement check:", {
currentValue,
lastValue: lastValueForStepAdvancement.current,
valueChanged,
userHasInteracted: userHasInteracted.current,
expectedStepIndex: currentMultiStep,
expectedStepTarget: currentExpectedStep?.targetValue,
expectedStepReached: currentExpectedStep
? currentValue === currentExpectedStep.targetValue
: false,
totalExpectedSteps: expectedSteps.length,
finalTargetReached: currentValue === currentStep?.targetValue,
});
if (
valueChanged &&
userHasInteracted.current &&
expectedSteps.length > 0 &&
currentExpectedStep
) {
if (currentValue === currentExpectedStep.targetValue) {
const hasMoreExpectedSteps =
currentMultiStep < expectedSteps.length - 1;
console.log("🎯 Expected step completed:", {
completedStep: currentMultiStep,
targetReached: currentExpectedStep.targetValue,
hasMoreSteps: hasMoreExpectedSteps,
willAdvance: hasMoreExpectedSteps,
});
if (hasMoreExpectedSteps) {
const timeoutId = setTimeout(() => {
console.log(
"⚡ Advancing to next expected step:",
currentMultiStep,
"→",
currentMultiStep + 1,
);
setCurrentMultiStep((prev) => prev + 1);
lastValueForStepAdvancement.current = currentValue;
}, 100); // Shorter delay for testing
return () => clearTimeout(timeoutId);
}
}
}
}, [currentValue, currentMultiStep, expectedSteps]);
// Update reference when step changes
useEffect(() => {
lastValueForStepAdvancement.current = currentValue;
userHasInteracted.current = false;
}, [currentMultiStep]);
const handleValueChange = (newValue: number) => {
userHasInteracted.current = true;
setCurrentValue(newValue);
};
const currentStepBeads = getCurrentStepBeads();
return (
<div data-testid="step-test">
<div data-testid="current-value">{currentValue}</div>
<div data-testid="expected-step-index">{currentMultiStep}</div>
<div data-testid="expected-steps-length">{expectedSteps.length}</div>
<div data-testid="current-expected-target">
{expectedSteps[currentMultiStep]?.targetValue || "N/A"}
</div>
<div data-testid="has-step-beads">{currentStepBeads ? "yes" : "no"}</div>
<button data-testid="set-value-8" onClick={() => handleValueChange(8)}>
Set Value to 8
</button>
<button data-testid="set-value-18" onClick={() => handleValueChange(18)}>
Set Value to 18
</button>
<button data-testid="set-value-17" onClick={() => handleValueChange(17)}>
Set Value to 17
</button>
<div data-testid="expected-steps">{JSON.stringify(expectedSteps)}</div>
</div>
);
};
// Test cases
describe("Step Advancement Logic", () => {
beforeEach(() => {
vi.clearAllMocks();
console.log = vi.fn();
});
test("should generate expected steps for 3+14=17", () => {
render(<StepAdvancementTest />);
expect(screen.getByTestId("expected-steps-length")).toHaveTextContent("3");
expect(screen.getByTestId("current-expected-target")).toHaveTextContent(
"8",
);
expect(screen.getByTestId("expected-step-index")).toHaveTextContent("0");
});
test("should advance from step 0 to step 1 when reaching value 8", async () => {
render(<StepAdvancementTest />);
// Initial state
expect(screen.getByTestId("current-value")).toHaveTextContent("3");
expect(screen.getByTestId("expected-step-index")).toHaveTextContent("0");
expect(screen.getByTestId("current-expected-target")).toHaveTextContent(
"8",
);
// Click to set value to 8
fireEvent.click(screen.getByTestId("set-value-8"));
// Should still be step 0 immediately
expect(screen.getByTestId("current-value")).toHaveTextContent("8");
expect(screen.getByTestId("expected-step-index")).toHaveTextContent("0");
// Wait for timeout to advance step
await new Promise((resolve) => setTimeout(resolve, 150));
// Should now be step 1
expect(screen.getByTestId("expected-step-index")).toHaveTextContent("1");
expect(screen.getByTestId("current-expected-target")).toHaveTextContent(
"18",
);
});
test("should advance through all steps", async () => {
render(<StepAdvancementTest />);
// Step 0 → 1 (3 → 8)
fireEvent.click(screen.getByTestId("set-value-8"));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(screen.getByTestId("expected-step-index")).toHaveTextContent("1");
// Step 1 → 2 (8 → 18)
fireEvent.click(screen.getByTestId("set-value-18"));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(screen.getByTestId("expected-step-index")).toHaveTextContent("2");
// Step 2 → complete (18 → 17)
fireEvent.click(screen.getByTestId("set-value-17"));
await new Promise((resolve) => setTimeout(resolve, 150));
// Should stay at step 2 since it's the last step
expect(screen.getByTestId("expected-step-index")).toHaveTextContent("2");
});
});
export default StepAdvancementTest;

View File

@@ -1,5 +1,5 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
@@ -7,7 +7,7 @@ export default defineConfig({
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
setupFiles: ["./src/__tests__/setup.ts"],
css: true,
testTimeout: 10000,
},