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:
286
packages/abacus-react/MIGRATION_GUIDE.md
Normal file
286
packages/abacus-react/MIGRATION_GUIDE.md
Normal 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.
|
||||
468
packages/abacus-react/src/AbacusDisplayProvider.stories.tsx
Normal file
468
packages/abacus-react/src/AbacusDisplayProvider.stories.tsx
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
378
packages/abacus-react/src/StandaloneBead.stories.tsx
Normal file
378
packages/abacus-react/src/StandaloneBead.stories.tsx
Normal 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)'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
233
packages/abacus-react/src/__tests__/AbacusReact.test.tsx
Normal file
233
packages/abacus-react/src/__tests__/AbacusReact.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
60
packages/abacus-react/src/__tests__/debug-columns-test.tsx
Normal file
60
packages/abacus-react/src/__tests__/debug-columns-test.tsx
Normal 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
|
||||
});
|
||||
});
|
||||
239
packages/abacus-react/src/__tests__/gesture-and-input.test.tsx
Normal file
239
packages/abacus-react/src/__tests__/gesture-and-input.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
45
packages/abacus-react/src/__tests__/setup.ts
Normal file
45
packages/abacus-react/src/__tests__/setup.ts
Normal 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()),
|
||||
}));
|
||||
330
packages/abacus-react/src/__tests__/step-advancement.test.tsx
Normal file
330
packages/abacus-react/src/__tests__/step-advancement.test.tsx
Normal 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;
|
||||
@@ -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,
|
||||
},
|
||||
Reference in New Issue
Block a user