From 7a4a37ec6d0171782778e18122da782f069e0556 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 3 Nov 2025 12:32:25 -0600 Subject: [PATCH] feat: add comprehensive Storybook coverage and migration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/abacus-react/MIGRATION_GUIDE.md | 286 +++++++++++ .../src/AbacusDisplayProvider.stories.tsx | 468 ++++++++++++++++++ .../src/StandaloneBead.stories.tsx | 378 ++++++++++++++ .../src/__tests__/AbacusReact.test.tsx | 233 +++++++++ .../__tests__/AbacusReact.zero-state.test.tsx | 282 +++++++++++ .../src/__tests__/debug-columns-test.tsx | 60 +++ .../src/__tests__/gesture-and-input.test.tsx | 239 +++++++++ .../place-value-positioning.test.tsx | 125 +++++ packages/abacus-react/src/__tests__/setup.ts | 45 ++ .../src/__tests__/step-advancement.test.tsx | 330 ++++++++++++ .../{vitest.config.ts => vitest.config.mts} | 4 +- 11 files changed, 2448 insertions(+), 2 deletions(-) create mode 100644 packages/abacus-react/MIGRATION_GUIDE.md create mode 100644 packages/abacus-react/src/AbacusDisplayProvider.stories.tsx create mode 100644 packages/abacus-react/src/StandaloneBead.stories.tsx create mode 100644 packages/abacus-react/src/__tests__/AbacusReact.test.tsx create mode 100644 packages/abacus-react/src/__tests__/AbacusReact.zero-state.test.tsx create mode 100644 packages/abacus-react/src/__tests__/debug-columns-test.tsx create mode 100644 packages/abacus-react/src/__tests__/gesture-and-input.test.tsx create mode 100644 packages/abacus-react/src/__tests__/place-value-positioning.test.tsx create mode 100644 packages/abacus-react/src/__tests__/setup.ts create mode 100644 packages/abacus-react/src/__tests__/step-advancement.test.tsx rename packages/abacus-react/{vitest.config.ts => vitest.config.mts} (71%) diff --git a/packages/abacus-react/MIGRATION_GUIDE.md b/packages/abacus-react/MIGRATION_GUIDE.md new file mode 100644 index 00000000..cceb175f --- /dev/null +++ b/packages/abacus-react/MIGRATION_GUIDE.md @@ -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` +- ✅ 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 ; +} +``` + +**After (new):** +```tsx +import { useAbacusPlaceStates } from '@soroban/abacus-react'; + +function MyComponent() { + const { + value, + setValue, + placeStates, // Map + 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 ; +} +``` + +### 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 ( +
+ + +
+ ); +} +``` + +### 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 ( +
+ + +
+ ); +} +``` + +## 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. diff --git a/packages/abacus-react/src/AbacusDisplayProvider.stories.tsx b/packages/abacus-react/src/AbacusDisplayProvider.stories.tsx new file mode 100644 index 00000000..b812e6a9 --- /dev/null +++ b/packages/abacus-react/src/AbacusDisplayProvider.stories.tsx @@ -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 = { + 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; + +// Basic Provider Usage +export const BasicUsage: Story = { + name: 'Basic Provider Usage', + render: () => ( + +
+ +

+ This abacus inherits all settings from the provider +

+
+
+ ), + 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: () => ( + +
+ +

+ Circle beads with heaven-earth coloring (colorblind palette) +

+
+
+ ), + parameters: { + docs: { + description: { + story: 'Provide initial configuration to override defaults' + } + } + } +}; + +// Interactive Config Demo +const ConfigDemo: React.FC = () => { + const { config, updateConfig, resetToDefaults } = useAbacusDisplay(); + + return ( +
+
+ +
+ +
+

Configuration Controls

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+ 💾 Changes are automatically saved to localStorage +
+
+
+ ); +}; + +export const InteractiveConfiguration: Story = { + name: 'Interactive Configuration', + render: () => ( + + + + ), + 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: () => ( + +
+
+

Multiple Abacuses

+
+ + + +
+
+ +
+

Standalone Beads

+
+ + + + + +
+
+ +

+ All components share the same bead shape (square) from the provider +

+
+
+ ), + 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 ( +
+

Current Configuration

+
+        {JSON.stringify(config, null, 2)}
+      
+
+ ); +}; + +export const UsingConfigHook: Story = { + name: 'Using useAbacusConfig Hook', + render: () => ( + +
+ + +
+
+ ), + 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 ( +
+ + +
+

Try changing settings:

+ +
+ + + +
+ + {hasChanges && ( +
+ ✓ Saved to localStorage! +
+ )} + +

+ Reload this page and your settings will be preserved. Open DevTools → Application → Local Storage to see the saved data. +

+
+
+ ); +}; + +export const LocalStoragePersistence: Story = { + name: 'localStorage Persistence', + render: () => ( + + + + ), + 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: () => ( +
+
+

Provider A

+ + +

Diamond beads

+
+
+ +
+

Provider B

+ + +

Circle beads

+
+
+
+ ), + 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: () => ( +
+ +

+ Components work without a provider by using default configuration +

+
+ ), + parameters: { + docs: { + description: { + story: 'Components gracefully fall back to defaults when used outside a provider' + } + } + } +}; diff --git a/packages/abacus-react/src/StandaloneBead.stories.tsx b/packages/abacus-react/src/StandaloneBead.stories.tsx new file mode 100644 index 00000000..44ac7f99 --- /dev/null +++ b/packages/abacus-react/src/StandaloneBead.stories.tsx @@ -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 = { + 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) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// 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: () => ( +
+
+ +

16px

+
+
+ +

28px (default)

+
+
+ +

40px

+
+
+ +

64px

+
+
+ ), + parameters: { + docs: { + description: { + story: 'Beads scale to any size while maintaining proportions' + } + } + } +}; + +// Color Variations +export const ColorPalette: Story = { + name: 'Color Palette', + render: () => ( +
+
+ +

Red

+
+
+ +

Orange

+
+
+ +

Yellow

+
+
+ +

Green

+
+
+ +

Blue

+
+
+ +

Purple

+
+
+ +

Pink

+
+
+ +

Gray

+
+
+ ), + parameters: { + docs: { + description: { + story: 'Beads support any hex color value' + } + } + } +}; + +// Shape Comparison +export const AllShapes: Story = { + name: 'All Shapes', + render: () => ( +
+
+ +

Diamond

+
+
+ +

Circle

+
+
+ +

Square

+
+
+ ), + parameters: { + docs: { + description: { + story: 'Compare all three available bead shapes' + } + } + } +}; + +// Active vs Inactive +export const ActiveState: Story = { + name: 'Active vs Inactive', + render: () => ( +
+
+ +

Active

+
+
+ +

Inactive (grayed out)

+
+
+ ), + parameters: { + docs: { + description: { + story: 'Inactive beads are automatically rendered in gray' + } + } + } +}; + +// With Context Provider +export const WithContextProvider: Story = { + name: 'Using Context Provider', + render: () => ( + +
+ + + + + +
+
+ ), + 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: () => ( + + ), + 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: () => ( +
+
+ +

Learning Progress

+
+

+ You've mastered basic addition! Keep practicing to improve your speed. +

+
+ + + + + +
+
+ ), + 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 ( +
+
+ {[1, 2, 3, 4, 5].map((step) => ( + + ))} +
+

Step {progress} of 5

+
+ + +
+
+ ); + }, + 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)' + } + } + } +}; diff --git a/packages/abacus-react/src/__tests__/AbacusReact.test.tsx b/packages/abacus-react/src/__tests__/AbacusReact.test.tsx new file mode 100644 index 00000000..bdbdfc0a --- /dev/null +++ b/packages/abacus-react/src/__tests__/AbacusReact.test.tsx @@ -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(); + expect(document.querySelector("svg")).toBeInTheDocument(); + }); + + it("renders with basic props", () => { + render(); + const svg = document.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); + + describe("showNumbers prop", () => { + it('does not show numbers when showNumbers="never"', () => { + render(); + // 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(); + // 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(); + + // 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(); + + 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(); + + // 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( + , + ); + + rerender(); + + // onValueChange should be called when value prop changes + expect(onValueChangeMock).toHaveBeenCalled(); + }); + }); + + describe("visual properties", () => { + it("applies different bead shapes", () => { + const { rerender } = render( + , + ); + expect(document.querySelector("svg")).toBeInTheDocument(); + + rerender(); + expect(document.querySelector("svg")).toBeInTheDocument(); + + rerender(); + expect(document.querySelector("svg")).toBeInTheDocument(); + }); + + it("applies different color schemes", () => { + const { rerender } = render( + , + ); + expect(document.querySelector("svg")).toBeInTheDocument(); + + rerender(); + expect(document.querySelector("svg")).toBeInTheDocument(); + + rerender(); + expect(document.querySelector("svg")).toBeInTheDocument(); + }); + + it("applies scale factor", () => { + render(); + expect(document.querySelector("svg")).toBeInTheDocument(); + }); + }); + + describe("accessibility", () => { + it("has proper ARIA attributes", () => { + render(); + const svg = document.querySelector("svg"); + expect(svg).toBeInTheDocument(); + // Test that SVG has some accessible attributes + expect(svg).toHaveAttribute("class"); + }); + + it("is keyboard accessible", () => { + render(); + 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
{JSON.stringify(dimensions)}
; + }; + + it("calculates correct dimensions for different column counts", () => { + const { rerender } = render( + , + ); + const dims1 = JSON.parse(screen.getByTestId("dimensions").textContent!); + + rerender( + , + ); + 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( + , + ); + const dimsNever = JSON.parse(screen.getByTestId("dimensions").textContent!); + + rerender( + , + ); + const dimsAlways = JSON.parse( + screen.getByTestId("dimensions").textContent!, + ); + + rerender( + , + ); + 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( + , + ); + const dims1x = JSON.parse(screen.getByTestId("dimensions").textContent!); + + rerender( + , + ); + 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); + }); +}); diff --git a/packages/abacus-react/src/__tests__/AbacusReact.zero-state.test.tsx b/packages/abacus-react/src/__tests__/AbacusReact.zero-state.test.tsx new file mode 100644 index 00000000..004ee2d5 --- /dev/null +++ b/packages/abacus-react/src/__tests__/AbacusReact.zero-state.test.tsx @@ -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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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 ( + { + currentValue = newValue; + mockOnValueChange(newValue); + }} + /> + ); + }; + + const { rerender } = render(); + + // Click rightmost column heaven bead (should set value to 5) + fireEvent.click(screen.getByTestId("bead-col-4-heaven")); + rerender(); + expect(mockOnValueChange).toHaveBeenLastCalledWith(5); + + // Click middle column heaven bead (should set value to 505) + fireEvent.click(screen.getByTestId("bead-col-2-heaven")); + rerender(); + 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(); + 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( + , + ); + + 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( + , + ); + + // 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"); + }); +}); diff --git a/packages/abacus-react/src/__tests__/debug-columns-test.tsx b/packages/abacus-react/src/__tests__/debug-columns-test.tsx new file mode 100644 index 00000000..a3b36290 --- /dev/null +++ b/packages/abacus-react/src/__tests__/debug-columns-test.tsx @@ -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( + , + ); + + // 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 + }); +}); diff --git a/packages/abacus-react/src/__tests__/gesture-and-input.test.tsx b/packages/abacus-react/src/__tests__/gesture-and-input.test.tsx new file mode 100644 index 00000000..cacaabce --- /dev/null +++ b/packages/abacus-react/src/__tests__/gesture-and-input.test.tsx @@ -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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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); + }); + }); +}); diff --git a/packages/abacus-react/src/__tests__/place-value-positioning.test.tsx b/packages/abacus-react/src/__tests__/place-value-positioning.test.tsx new file mode 100644 index 00000000..4b4b5981 --- /dev/null +++ b/packages/abacus-react/src/__tests__/place-value-positioning.test.tsx @@ -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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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 + }); +}); diff --git a/packages/abacus-react/src/__tests__/setup.ts b/packages/abacus-react/src/__tests__/setup.ts new file mode 100644 index 00000000..e7114840 --- /dev/null +++ b/packages/abacus-react/src/__tests__/setup.ts @@ -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()), +})); diff --git a/packages/abacus-react/src/__tests__/step-advancement.test.tsx b/packages/abacus-react/src/__tests__/step-advancement.test.tsx new file mode 100644 index 00000000..5dc52330 --- /dev/null +++ b/packages/abacus-react/src/__tests__/step-advancement.test.tsx @@ -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(currentValue); + const userHasInteracted = useRef(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 ( +
+
{currentValue}
+
{currentMultiStep}
+
{expectedSteps.length}
+
+ {expectedSteps[currentMultiStep]?.targetValue || "N/A"} +
+
{currentStepBeads ? "yes" : "no"}
+ + + + + +
{JSON.stringify(expectedSteps)}
+
+ ); +}; + +// Test cases +describe("Step Advancement Logic", () => { + beforeEach(() => { + vi.clearAllMocks(); + console.log = vi.fn(); + }); + + test("should generate expected steps for 3+14=17", () => { + render(); + + 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(); + + // 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(); + + // 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; diff --git a/packages/abacus-react/vitest.config.ts b/packages/abacus-react/vitest.config.mts similarity index 71% rename from packages/abacus-react/vitest.config.ts rename to packages/abacus-react/vitest.config.mts index ac366a4a..192fbb6d 100644 --- a/packages/abacus-react/vitest.config.ts +++ b/packages/abacus-react/vitest.config.mts @@ -1,5 +1,5 @@ /// -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, },