From 85c6369373423448f77a71a7439b6055cf04db56 Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Sun, 14 Dec 2025 19:11:29 +0400 Subject: [PATCH 1/6] fix: correct parameter order in moveCard function and update related tests --- resources/dist/flowforge.js | 2 +- resources/js/flowforge.js | 2 +- src/Commands/DiagnosePositionsCommand.php | 409 +++++++++++++++++ src/Concerns/InteractsWithBoard.php | 6 +- src/FlowforgeServiceProvider.php | 2 + .../Feature/CharacterSpaceExhaustionTest.php | 352 +++++++++++++++ .../Feature/ConcurrentOperationStressTest.php | 296 +++++++++++++ tests/Feature/DragDropFunctionalityTest.php | 196 ++++++++- .../JavaScriptPhpParameterFlowTest.php | 386 ++++++++++++++++ tests/Feature/ParameterCombinationTest.php | 248 +++++++++++ tests/Feature/ParameterOrderMutationTest.php | 290 ++++++++++++ tests/Feature/PerformanceRegressionTest.php | 231 ++++++++++ .../PositionInversionReproductionTest.php | 412 ++++++++++++++++++ 13 files changed, 2822 insertions(+), 10 deletions(-) create mode 100644 src/Commands/DiagnosePositionsCommand.php create mode 100644 tests/Feature/CharacterSpaceExhaustionTest.php create mode 100644 tests/Feature/ConcurrentOperationStressTest.php create mode 100644 tests/Feature/JavaScriptPhpParameterFlowTest.php create mode 100644 tests/Feature/ParameterCombinationTest.php create mode 100644 tests/Feature/ParameterOrderMutationTest.php create mode 100644 tests/Feature/PerformanceRegressionTest.php create mode 100644 tests/Feature/PositionInversionReproductionTest.php diff --git a/resources/dist/flowforge.js b/resources/dist/flowforge.js index 90efd11..065cf2c 100644 --- a/resources/dist/flowforge.js +++ b/resources/dist/flowforge.js @@ -1 +1 @@ -function d({state:l}){return{state:l,isLoading:{},fullyLoaded:{},init(){this.$wire.$on("kanban-items-loaded",t=>{let{columnId:e,isFullyLoaded:i}=t;i&&(this.fullyLoaded[e]=!0)})},handleSortableEnd(t){let e=t.to.sortable.toArray(),i=t.item.getAttribute("x-sortable-item"),a=t.to.getAttribute("data-column-id"),o=t.item;this.setCardState(o,!0);let s=e.indexOf(i),r=s>0?e[s-1]:null,n=sthis.setCardState(o,!1)).catch(()=>this.setCardState(o,!1))},setCardState(t,e){t.style.opacity=e?"0.7":"",t.style.pointerEvents=e?"none":""},isLoadingColumn(t){return this.isLoading[t]||!1},isColumnFullyLoaded(t){return this.fullyLoaded[t]||!1},handleSmoothScroll(t){this.isLoadingColumn(t)||this.isColumnFullyLoaded(t)||(this.isLoading[t]=!0,this.$wire.loadMoreItems(t).then(()=>setTimeout(()=>this.isLoading[t]=!1,100)).catch(()=>this.isLoading[t]=!1))},handleColumnScroll(t,e){if(this.isColumnFullyLoaded(e))return;let{scrollTop:i,scrollHeight:a,clientHeight:o}=t.target;(i+o)/a>=.8&&!this.isLoadingColumn(e)&&this.handleSmoothScroll(e)}}}export{d as default}; +function d({state:l}){return{state:l,isLoading:{},fullyLoaded:{},init(){this.$wire.$on("kanban-items-loaded",t=>{let{columnId:e,isFullyLoaded:i}=t;i&&(this.fullyLoaded[e]=!0)})},handleSortableEnd(t){let e=t.to.sortable.toArray(),i=t.item.getAttribute("x-sortable-item"),a=t.to.getAttribute("data-column-id"),o=t.item;this.setCardState(o,!0);let s=e.indexOf(i),r=s>0?e[s-1]:null,n=sthis.setCardState(o,!1)).catch(()=>this.setCardState(o,!1))},setCardState(t,e){t.style.opacity=e?"0.7":"",t.style.pointerEvents=e?"none":""},isLoadingColumn(t){return this.isLoading[t]||!1},isColumnFullyLoaded(t){return this.fullyLoaded[t]||!1},handleSmoothScroll(t){this.isLoadingColumn(t)||this.isColumnFullyLoaded(t)||(this.isLoading[t]=!0,this.$wire.loadMoreItems(t).then(()=>setTimeout(()=>this.isLoading[t]=!1,100)).catch(()=>this.isLoading[t]=!1))},handleColumnScroll(t,e){if(this.isColumnFullyLoaded(e))return;let{scrollTop:i,scrollHeight:a,clientHeight:o}=t.target;(i+o)/a>=.8&&!this.isLoadingColumn(e)&&this.handleSmoothScroll(e)}}}export{d as default}; diff --git a/resources/js/flowforge.js b/resources/js/flowforge.js index a51357c..9fef938 100644 --- a/resources/js/flowforge.js +++ b/resources/js/flowforge.js @@ -25,7 +25,7 @@ export default function flowforge({state}) { const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; - this.$wire.moveCard(cardId, targetColumn, beforeCardId, afterCardId) + this.$wire.moveCard(cardId, targetColumn, afterCardId, beforeCardId) .then(() => this.setCardState(cardElement, false)) .catch(() => this.setCardState(cardElement, false)); }, diff --git a/src/Commands/DiagnosePositionsCommand.php b/src/Commands/DiagnosePositionsCommand.php new file mode 100644 index 0000000..4efa593 --- /dev/null +++ b/src/Commands/DiagnosePositionsCommand.php @@ -0,0 +1,409 @@ + 'utf8mb4_bin', + 'pgsql' => 'C', + 'sqlsrv' => 'Latin1_General_BIN2', + 'sqlite' => null, // SQLite doesn't need collation + ]; + + public function handle(): int + { + $this->displayHeader(); + + // Get parameters (interactive or from options) + $model = $this->option('model') ?? text( + label: 'Model class (e.g., App\\Models\\Task)', + required: true, + validate: fn (string $value) => $this->validateModelClass($value) + ); + + $columnField = $this->option('column') ?? text( + label: 'Column identifier field (for grouping)', + placeholder: 'status', + required: true + ); + + $positionField = $this->option('position') ?? text( + label: 'Position field', + default: 'position', + required: true + ); + + // Validate model + if (! class_exists($model)) { + error("Model class '{$model}' does not exist"); + + return self::FAILURE; + } + + $modelInstance = new $model; + if (! $modelInstance instanceof Model) { + error("Class '{$model}' is not an Eloquent model"); + + return self::FAILURE; + } + + // Display configuration + info("✓ Model: {$model}"); + info("✓ Column Identifier: {$columnField}"); + info("✓ Position Identifier: {$positionField}"); + $this->newLine(); + + // Run diagnostics + $issues = []; + + // 1. Check collation + $this->line('🔍 Checking database collation...'); + $collationIssue = $this->checkCollation($modelInstance, $positionField); + if ($collationIssue) { + $issues[] = $collationIssue; + } + + // 2. Check for position inversions + $this->line('🔍 Scanning for position inversions...'); + $inversionIssues = $this->checkInversions($modelInstance, $columnField, $positionField); + if (count($inversionIssues) > 0) { + $issues = array_merge($issues, $inversionIssues); + } + + // 3. Check for duplicates + $this->line('🔍 Checking for duplicate positions...'); + $duplicateIssues = $this->checkDuplicates($modelInstance, $columnField, $positionField); + if (count($duplicateIssues) > 0) { + $issues = array_merge($issues, $duplicateIssues); + } + + // 4. Check for null positions + $this->line('🔍 Checking for null positions...'); + $nullIssue = $this->checkNullPositions($modelInstance, $positionField); + if ($nullIssue) { + $issues[] = $nullIssue; + } + + $this->newLine(); + + // Display results + if (empty($issues)) { + info('✅ All checks passed! No issues detected.'); + + return self::SUCCESS; + } + + warning(sprintf('âš ī¸ Found %d issue(s):', count($issues))); + $this->newLine(); + + foreach ($issues as $index => $issue) { + $this->displayIssue($index + 1, $issue); + } + + // Offer fixes if --fix option is provided + if ($this->option('fix') && isset($collationIssue)) { + $this->applyCollationFix($modelInstance, $positionField); + } + + return self::FAILURE; + } + + private function displayHeader(): void + { + $this->newLine(); + $this->line('╔══════════════════════════════════════════════════════════════╗'); + $this->line('║ Flowforge Position Diagnostics ║'); + $this->line('╚══════════════════════════════════════════════════════════════╝'); + $this->newLine(); + } + + private function validateModelClass(string $value): ?string + { + if (! class_exists($value)) { + return "Model class '{$value}' does not exist"; + } + + if (! is_subclass_of($value, Model::class)) { + return "Class '{$value}' is not an Eloquent model"; + } + + return null; + } + + private function checkCollation(Model $model, string $positionField): ?array + { + $connection = $model->getConnection(); + $driver = $connection->getDriverName(); + $table = $model->getTable(); + + // Skip for SQLite (no collation needed) + if ($driver === 'sqlite') { + info(' ✓ SQLite database - no collation check needed'); + + return null; + } + + $expectedCollation = $this->expectedCollations[$driver] ?? null; + + if (! $expectedCollation) { + warning(" âš ī¸ Unknown database driver: {$driver}"); + + return null; + } + + // Get actual collation + $actualCollation = $this->getColumnCollation($connection, $table, $positionField); + + if ($actualCollation === $expectedCollation) { + info(" ✓ Collation correct: {$actualCollation}"); + + return null; + } + + return [ + 'type' => 'collation', + 'severity' => 'critical', + 'table' => $table, + 'column' => $positionField, + 'expected' => $expectedCollation, + 'actual' => $actualCollation ?? 'unknown', + 'driver' => $driver, + ]; + } + + private function getColumnCollation($connection, string $table, string $column): ?string + { + $driver = $connection->getDriverName(); + + try { + if ($driver === 'mysql') { + $result = DB::select("SHOW FULL COLUMNS FROM `{$table}` WHERE Field = ?", [$column]); + + return $result[0]->Collation ?? null; + } + + if ($driver === 'pgsql') { + $result = DB::select(' + SELECT collation_name + FROM information_schema.columns + WHERE table_name = ? AND column_name = ? + ', [$table, $column]); + + return $result[0]->collation_name ?? null; + } + + if ($driver === 'sqlsrv') { + $result = DB::select(' + SELECT COLLATION_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = ? AND COLUMN_NAME = ? + ', [$table, $column]); + + return $result[0]->COLLATION_NAME ?? null; + } + } catch (\Exception $e) { + warning(" Could not determine collation: {$e->getMessage()}"); + } + + return null; + } + + private function checkInversions(Model $model, string $columnField, string $positionField): array + { + $issues = []; + $columns = $model->query()->distinct()->pluck($columnField)->map(fn ($value) => $value instanceof \BackedEnum ? $value->value : $value); + + foreach ($columns as $column) { + $records = $model->query() + ->where($columnField, $column) + ->whereNotNull($positionField) + ->orderBy('id') + ->get(); + + if ($records->count() < 2) { + continue; + } + + $inversions = []; + for ($i = 0; $i < $records->count() - 1; $i++) { + $current = $records[$i]; + $next = $records[$i + 1]; + + $currentPos = $current->getAttribute($positionField); + $nextPos = $next->getAttribute($positionField); + + // Check if positions are inverted (current >= next when they should be current < next) + if (strcmp($currentPos, $nextPos) >= 0) { + $inversions[] = [ + 'current_id' => $current->getKey(), + 'current_pos' => $currentPos, + 'next_id' => $next->getKey(), + 'next_pos' => $nextPos, + ]; + } + } + + if (count($inversions) > 0) { + $issues[] = [ + 'type' => 'inversion', + 'severity' => 'high', + 'column' => $column, + 'count' => count($inversions), + 'examples' => array_slice($inversions, 0, 3), // Show first 3 examples + ]; + } + } + + if (empty($issues)) { + info(' ✓ No position inversions detected'); + } + + return $issues; + } + + private function checkDuplicates(Model $model, string $columnField, string $positionField): array + { + $issues = []; + $columns = $model->query()->distinct()->pluck($columnField)->map(fn ($value) => $value instanceof \BackedEnum ? $value->value : $value); + + foreach ($columns as $column) { + $duplicates = DB::table($model->getTable()) + ->select($positionField, DB::raw('COUNT(*) as duplicate_count')) + ->where($columnField, $column) + ->whereNotNull($positionField) + ->groupBy($positionField) + ->havingRaw('COUNT(*) > 1') + ->get(); + + if ($duplicates->count() > 0) { + $issues[] = [ + 'type' => 'duplicate', + 'severity' => 'medium', + 'column' => $column, + 'count' => $duplicates->sum('duplicate_count'), + 'unique_positions' => $duplicates->count(), + ]; + } + } + + if (empty($issues)) { + info(' ✓ No duplicate positions detected'); + } + + return $issues; + } + + private function checkNullPositions(Model $model, string $positionField): ?array + { + $nullCount = $model->query()->whereNull($positionField)->count(); + + if ($nullCount === 0) { + info(' ✓ No null positions detected'); + + return null; + } + + return [ + 'type' => 'null', + 'severity' => 'low', + 'count' => $nullCount, + ]; + } + + private function displayIssue(int $number, array $issue): void + { + $severityColors = [ + 'critical' => 'error', + 'high' => 'error', + 'medium' => 'warning', + 'low' => 'info', + ]; + + $color = $severityColors[$issue['severity']] ?? 'info'; + + $this->line("Issue #{$number}: " . strtoupper($issue['type'])); + + if ($issue['type'] === 'collation') { + error(' ❌ COLLATION MISMATCH'); + $this->line(" Expected: {$issue['expected']} (binary comparison)"); + $this->line(" Found: {$issue['actual']} (case-insensitive comparison)"); + $this->newLine(); + $this->line(' This causes incorrect position ordering!'); + $this->newLine(); + warning(' 🔧 Fix: Run this migration to correct collation:'); + $this->newLine(); + + if ($issue['driver'] === 'mysql') { + $this->line(" ALTER TABLE {$issue['table']} MODIFY {$issue['column']} VARCHAR(255)"); + $this->line(' CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;'); + } elseif ($issue['driver'] === 'pgsql') { + $this->line(" ALTER TABLE {$issue['table']} ALTER COLUMN {$issue['column']}"); + $this->line(' TYPE VARCHAR(255) COLLATE "C";'); + } + $this->newLine(); + } + + if ($issue['type'] === 'inversion') { + error(" ❌ Found {$issue['count']} inverted position pair(s) in column '{$issue['column']}':"); + foreach ($issue['examples'] as $example) { + $this->line(" - Card #{$example['current_id']} (pos: \"{$example['current_pos']}\") comes before Card #{$example['next_id']} (pos: \"{$example['next_pos']}\")"); + } + $this->newLine(); + } + + if ($issue['type'] === 'duplicate') { + warning(" âš ī¸ Found {$issue['count']} duplicate positions in column '{$issue['column']}'"); + $this->line(" ({$issue['unique_positions']} unique position values with duplicates)"); + $this->newLine(); + } + + if ($issue['type'] === 'null') { + info(" â„šī¸ Found {$issue['count']} records with null positions"); + $this->newLine(); + } + + info(' 💡 After fixing issues, run: php artisan flowforge:repair-positions'); + $this->newLine(); + } + + private function applyCollationFix(Model $model, string $positionField): void + { + $connection = $model->getConnection(); + $driver = $connection->getDriverName(); + $table = $model->getTable(); + + $this->line('🔧 Applying collation fix...'); + + try { + if ($driver === 'mysql') { + DB::statement("ALTER TABLE `{$table}` MODIFY `{$positionField}` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"); + info(' ✓ Collation updated successfully'); + } elseif ($driver === 'pgsql') { + DB::statement("ALTER TABLE \"{$table}\" ALTER COLUMN \"{$positionField}\" TYPE VARCHAR(255) COLLATE \"C\""); + info(' ✓ Collation updated successfully'); + } else { + warning(" âš ī¸ Auto-fix not supported for {$driver}. Please run the migration manually."); + } + } catch (\Exception $e) { + error(" ❌ Failed to apply fix: {$e->getMessage()}"); + } + } +} diff --git a/src/Concerns/InteractsWithBoard.php b/src/Concerns/InteractsWithBoard.php index cb93bd9..205a4fb 100644 --- a/src/Concerns/InteractsWithBoard.php +++ b/src/Concerns/InteractsWithBoard.php @@ -215,15 +215,15 @@ protected function calculatePositionBetweenCards( $afterPos = $afterCard?->getAttribute($positionField); if ($beforePos && $afterPos && is_string($beforePos) && is_string($afterPos)) { - return Rank::betweenRanks(Rank::fromString($beforePos), Rank::fromString($afterPos))->get(); + return Rank::betweenRanks(Rank::fromString($afterPos), Rank::fromString($beforePos))->get(); } if ($beforePos && is_string($beforePos)) { - return Rank::after(Rank::fromString($beforePos))->get(); + return Rank::before(Rank::fromString($beforePos))->get(); } if ($afterPos && is_string($afterPos)) { - return Rank::before(Rank::fromString($afterPos))->get(); + return Rank::after(Rank::fromString($afterPos))->get(); } return Rank::forEmptySequence()->get(); diff --git a/src/FlowforgeServiceProvider.php b/src/FlowforgeServiceProvider.php index 9e9138d..4f0a6d6 100644 --- a/src/FlowforgeServiceProvider.php +++ b/src/FlowforgeServiceProvider.php @@ -9,6 +9,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\DB; +use Relaticle\Flowforge\Commands\DiagnosePositionsCommand; use Relaticle\Flowforge\Commands\MakeKanbanBoardCommand; use Relaticle\Flowforge\Commands\RepairPositionsCommand; use Spatie\LaravelPackageTools\Commands\InstallCommand; @@ -114,6 +115,7 @@ protected function getAssets(): array protected function getCommands(): array { return [ + DiagnosePositionsCommand::class, MakeKanbanBoardCommand::class, RepairPositionsCommand::class, ]; diff --git a/tests/Feature/CharacterSpaceExhaustionTest.php b/tests/Feature/CharacterSpaceExhaustionTest.php new file mode 100644 index 0000000..f3c5b6e --- /dev/null +++ b/tests/Feature/CharacterSpaceExhaustionTest.php @@ -0,0 +1,352 @@ +board = Livewire::test(TestBoard::class); +}); + +describe('Character Space Exhaustion Tests - Finding Breaking Points', function () { + it('stresses 100 sequential insertions at bottom of column', function () { + // STRESS TEST: Test Rank algorithm with 100 sequential insertions + // Expected: Position strings grow linearly, all positions unique + // This tests Rank::after() under heavy sequential usage + + $cards = collect(); + $lengthMetrics = []; + $maxLengthObserved = 0; + + // Create 100 cards sequentially at bottom + for ($i = 1; $i <= 100; $i++) { + $rank = $i === 1 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $card = Task::factory()->create([ + 'title' => "Sequential #{$i}", + 'status' => 'todo', + 'order_position' => $rank->get(), + ]); + + $cards->push($card); + + $posLength = strlen($card->order_position); + $maxLengthObserved = max($maxLengthObserved, $posLength); + + // Track metrics at checkpoints + if (in_array($i, [10, 20, 50, 75, 100])) { + $allPositions = $cards->pluck('order_position')->map(fn ($pos) => strlen($pos)); + + $lengthMetrics[$i] = [ + 'avg_length' => round($allPositions->avg(), 2), + 'max_length' => $allPositions->max(), + 'min_length' => $allPositions->min(), + 'latest_pos' => $card->order_position, + ]; + + // Check for inversions at each checkpoint + $inversions = detectInversions(Task::class, 'todo'); + expect($inversions)->toBeEmpty( + "No inversions after {$i} sequential insertions" + ); + } + } + + // Verify all positions are unique + $uniquePositions = $cards->pluck('order_position')->unique()->count(); + expect($uniquePositions)->toBe(100, 'All 100 positions should be unique'); + + // Verify max length is reasonable + expect($maxLengthObserved)->toBeLessThan( + 10, + 'Position strings should stay short for sequential insertions' + ); + + dump('=== SEQUENTIAL INSERTION METRICS (100 cards) ===', $lengthMetrics); + dump("Max length: {$maxLengthObserved} chars"); + }); + + it('monitors position string length growth patterns with 500 sequential cards', function () { + // STRESS TEST: Create 500 cards sequentially (not insertions) + // Expected: Linear growth in position strings + // This tests the Rank::after() method under heavy sequential usage + + $cards = collect(); + $lengthMetrics = []; + + for ($i = 1; $i <= 500; $i++) { + $rank = $i === 1 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $card = Task::factory()->create([ + 'title' => "Sequential Card {$i}", + 'status' => 'in_progress', + 'order_position' => $rank->get(), + ]); + + $cards->push($card); + + // Track metrics every 50 cards + if ($i % 50 === 0) { + $positions = $cards->pluck('order_position')->map(fn ($pos) => strlen($pos)); + + $lengthMetrics[$i] = [ + 'avg_length' => round($positions->avg(), 2), + 'max_length' => $positions->max(), + 'min_length' => $positions->min(), + 'latest_pos' => $card->order_position, + 'latest_length' => strlen($card->order_position), + ]; + } + } + + // Verify all cards created + expect($cards->count())->toBe(500, 'All 500 cards should be created'); + + // Verify average length stays reasonable (< 10 chars for sequential) + $finalAvg = collect($cards->pluck('order_position'))->map(fn ($pos) => strlen($pos))->avg(); + expect($finalAvg)->toBeLessThan( + 10, + 'Average position length should stay reasonable for sequential cards' + ); + + // Verify no inversions in sequential creation + $inversions = detectInversions(Task::class, 'in_progress'); + expect($inversions)->toBeEmpty( + 'Sequential creation should never produce inversions' + ); + + // Verify positions are unique + $uniquePositions = $cards->pluck('order_position')->unique()->count(); + expect($uniquePositions)->toBe(500, 'All positions should be unique'); + + // Dump metrics for analysis + dump('=== SEQUENTIAL GROWTH METRICS (500 cards) ===', $lengthMetrics); + }); + + it('tests character space at MIN_CHAR boundary', function () { + // BOUNDARY TEST: Test positions near MIN_CHAR ('0') + // Create a card with position just above MIN_CHAR and insert before it + + $boundaryCard = Task::factory()->create([ + 'title' => 'Near Min Boundary', + 'status' => 'review', + 'order_position' => '1', // Just above MIN_CHAR ('0') + ]); + + $newCard = Task::factory()->create([ + 'title' => 'Insert Before Min Boundary', + 'status' => 'review', + 'order_position' => Rank::forEmptySequence()->get(), + ]); + + // Move card to be BEFORE the boundary card (should create position < '1') + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'review', + null, // afterCardId=null (move to top) + (string) $boundaryCard->id // beforeCardId + ); + + $newCard->refresh(); + + // Verify new position is less than '1' + expect(strcmp($newCard->order_position, '1'))->toBeLessThan( + 0, + 'Position should be < "1" when moved before it' + ); + + // Verify position doesn't end with MIN_CHAR ('0') + $lastChar = substr($newCard->order_position, -1); + expect($lastChar)->not->toBe( + Rank::MIN_CHAR, + 'Position should not end with MIN_CHAR' + ); + + dump('Position near MIN_CHAR boundary:', $newCard->order_position); + }); + + it('tests character space at MAX_CHAR boundary', function () { + // BOUNDARY TEST: Test positions near MAX_CHAR ('z') + // Create a card with position just below MAX_CHAR and insert after it + + $boundaryCard = Task::factory()->create([ + 'title' => 'Near Max Boundary', + 'status' => 'review', + 'order_position' => 'y', // Just below MAX_CHAR ('z') + ]); + + $newCard = Task::factory()->create([ + 'title' => 'Insert After Max Boundary', + 'status' => 'review', + 'order_position' => Rank::forEmptySequence()->get(), + ]); + + // Move card to be AFTER the boundary card (should create position > 'y') + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'review', + (string) $boundaryCard->id, // afterCardId + null // beforeCardId=null (move to bottom) + ); + + $newCard->refresh(); + + // Verify new position is greater than 'y' + expect(strcmp($newCard->order_position, 'y'))->toBeGreaterThan( + 0, + 'Position should be > "y" when moved after it' + ); + + // Verify position is valid (< MAX_CHAR or extended properly) + expect(strlen($newCard->order_position))->toBeLessThan( + Rank::MAX_RANK_LEN, + 'Position should be under MAX_RANK_LEN' + ); + + dump('Position near MAX_CHAR boundary:', $newCard->order_position); + }); + + it('verifies character space exhaustion with progressive insertions', function () { + // EXHAUSTION TEST: Insert cards progressively, subdividing space + // Expected: Position strings grow longer as we subdivide the space + + // Create boundary cards + $cards = collect([ + Task::factory()->create([ + 'title' => 'Start', + 'status' => 'done', + 'order_position' => 'a', + ]), + Task::factory()->create([ + 'title' => 'End', + 'status' => 'done', + 'order_position' => 'b', + ]), + ]); + + $insertions = []; + $maxLength = 0; + + // Insert 20 cards, each splitting existing space + for ($i = 1; $i <= 20; $i++) { + // Pick two adjacent cards to insert between + $sortedCards = $cards->sortBy('order_position')->values(); + $pairIndex = ($i - 1) % ($sortedCards->count() - 1); + $afterCard = $sortedCards->get($pairIndex); + $beforeCard = $sortedCards->get($pairIndex + 1); + + $newCard = Task::factory()->create([ + 'title' => "Subdivision #{$i}", + 'status' => 'done', + 'order_position' => Rank::forEmptySequence()->get(), + ]); + + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'done', + (string) $afterCard->id, + (string) $beforeCard->id + ); + + $newCard->refresh(); + $cards->push($newCard); + + $posLength = strlen($newCard->order_position); + $maxLength = max($maxLength, $posLength); + + $insertions[] = [ + 'insertion' => $i, + 'between' => [$afterCard->title, $beforeCard->title], + 'position' => $newCard->order_position, + 'length' => $posLength, + ]; + + // Verify correct placement + expect(strcmp($afterCard->order_position, $newCard->order_position))->toBeLessThan(0); + expect(strcmp($newCard->order_position, $beforeCard->order_position))->toBeLessThan(0); + } + + // Verify all positions unique + $allPositions = Task::where('status', 'done')->pluck('order_position'); + $uniqueCount = $allPositions->unique()->count(); + expect($uniqueCount)->toBe( + $allPositions->count(), + 'All positions should be unique' + ); + + // Verify positions are properly ordered when sorted + $sortedPositions = Task::where('status', 'done') + ->orderBy('order_position') + ->pluck('order_position') + ->toArray(); + + for ($i = 0; $i < count($sortedPositions) - 1; $i++) { + expect(strcmp($sortedPositions[$i], $sortedPositions[$i + 1]))->toBeLessThan( + 0, + "Sorted position {$i} should be < position " . ($i + 1) + ); + } + + dump('=== PROGRESSIVE SUBDIVISION INSERTIONS ==='); + dump('Sample insertions:', array_slice($insertions, 0, 10)); + dump("Max length: {$maxLength} chars"); + }); + + it('validates position uniqueness with systematic insertions', function () { + // UNIQUENESS TEST: Ensure all positions remain unique with systematic insertions + // Create base cards + $cards = collect(); + + // Create 50 cards sequentially + for ($i = 1; $i <= 50; $i++) { + $rank = $i === 1 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $card = Task::factory()->create([ + 'title' => "Card #{$i}", + 'status' => 'backlog', + 'order_position' => $rank->get(), + ]); + + $cards->push($card); + } + + // Verify ALL positions are unique + $allPositions = Task::where('status', 'backlog')->pluck('order_position'); + $uniquePositions = $allPositions->unique(); + + expect($uniquePositions->count())->toBe( + 50, + 'All 50 positions must be unique - no duplicates allowed' + ); + + expect($uniquePositions->count())->toBe( + $allPositions->count(), + 'Unique count should match total count' + ); + + // Verify no inversions + $inversions = detectInversions(Task::class, 'backlog'); + expect($inversions)->toBeEmpty( + 'No inversions should exist in sequential creation' + ); + + // Verify positions are properly ordered + $positions = $cards->pluck('order_position')->toArray(); + for ($i = 0; $i < count($positions) - 1; $i++) { + expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( + 0, + "Position {$i} should be < position " . ($i + 1) + ); + } + }); +}); diff --git a/tests/Feature/ConcurrentOperationStressTest.php b/tests/Feature/ConcurrentOperationStressTest.php new file mode 100644 index 0000000..3a11b24 --- /dev/null +++ b/tests/Feature/ConcurrentOperationStressTest.php @@ -0,0 +1,296 @@ +board = Livewire::test(TestBoard::class); +}); + +describe('Concurrent Operation Stress Tests - Simulating Real-World Concurrency', function () { + it('handles rapid successive moves of same card (20+ times)', function () { + // STRESS TEST: Move the same card 20+ times rapidly + // Simulates a user repeatedly changing their mind or network lag causing multiple requests + + // Create cards in each column + $todoCards = Task::factory()->count(5)->create(['status' => 'todo']); + $inProgressCards = Task::factory()->count(5)->create(['status' => 'in_progress']); + $completedCards = Task::factory()->count(5)->create(['status' => 'completed']); + + $targetCard = $todoCards->first(); + $statuses = ['todo', 'in_progress', 'completed']; + + // Perform 20 rapid successive moves + for ($i = 0; $i < 20; $i++) { + $randomStatus = $statuses[array_rand($statuses)]; + + $this->board->call('moveCard', (string) $targetCard->id, $randomStatus); + $targetCard->refresh(); + + // Verify card has valid position after each move + expect($targetCard->order_position)->not()->toBeNull() + ->and($targetCard->order_position)->toBeString() + ->and(strlen($targetCard->order_position))->toBeGreaterThan(0) + ->and($targetCard->status)->toBe($randomStatus); + } + + // Verify positions are properly sorted in each column + foreach ($statuses as $status) { + $positions = Task::where('status', $status) + ->orderBy('order_position') + ->pluck('order_position') + ->toArray(); + + // Check positions are in ascending order + for ($i = 0; $i < count($positions) - 1; $i++) { + expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( + 0, + "Positions should be sorted in {$status} column after 20 rapid moves" + ); + } + } + + // Verify final card state is valid + $targetCard->refresh(); + expect($targetCard->order_position)->not()->toBeNull(); + }); + + it('simulates concurrent-like operations with interleaved moves', function () { + // CONCURRENCY SIMULATION: Multiple cards moving simultaneously (interleaved) + // Create 20 cards spread across columns + $cards = collect(); + foreach (['todo', 'in_progress', 'completed'] as $status) { + $statusCards = Task::factory()->count(7)->create(['status' => $status]); + $cards = $cards->merge($statusCards); + } + + // Simulate 5 "concurrent" operations by interleaving them + // In real concurrency, these would execute simultaneously + $operations = []; + for ($i = 0; $i < 5; $i++) { + $card = $cards->random(); + $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); + $operations[] = ['card' => $card, 'status' => $newStatus]; + } + + // Execute all operations + foreach ($operations as $op) { + $this->board->call('moveCard', (string) $op['card']->id, $op['status']); + } + + // Verify positions are properly sorted in each column + foreach (['todo', 'in_progress', 'completed'] as $status) { + $positions = Task::where('status', $status) + ->orderBy('order_position') + ->pluck('order_position') + ->toArray(); + + // Check positions are in ascending order + for ($i = 0; $i < count($positions) - 1; $i++) { + expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( + 0, + "Positions should be sorted in {$status} column after concurrent operations" + ); + } + } + + // Verify all positions are unique in each column + foreach (['todo', 'in_progress', 'completed'] as $status) { + $positions = Task::where('status', $status) + ->pluck('order_position') + ->toArray(); + + $uniqueCount = count(array_unique($positions)); + expect($uniqueCount)->toBe( + count($positions), + "All positions in {$status} should be unique" + ); + } + }); + + it('mass reorders entire column (reverse all cards)', function () { + // STRESS TEST: Reverse order of all cards in a column + // Simulates bulk reordering operations + + // Create 20 cards in sequential order + $cards = collect(); + for ($i = 1; $i <= 20; $i++) { + $rank = $i === 1 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $card = Task::factory()->create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => $rank->get(), + ]); + + $cards->push($card); + } + + // Reverse the order by moving each card to the top + $reversedCards = $cards->reverse(); + foreach ($reversedCards as $card) { + // Move to top (afterCardId=null, beforeCardId=first card) + $firstCard = Task::where('status', 'todo') + ->orderBy('order_position') + ->first(); + + $this->board->call( + 'moveCard', + (string) $card->id, + 'todo', + null, // afterCardId=null (move to top) + (string) $firstCard->id // beforeCardId=first card + ); + } + + // Verify all positions are valid and unique + $positions = Task::where('status', 'todo') + ->pluck('order_position') + ->toArray(); + + expect(count(array_unique($positions)))->toBe( + 20, + 'All 20 positions should be unique after mass reorder' + ); + + // Verify no inversions + $sortedPositions = Task::where('status', 'todo') + ->orderBy('order_position') + ->pluck('order_position') + ->toArray(); + + for ($i = 0; $i < count($sortedPositions) - 1; $i++) { + expect(strcmp($sortedPositions[$i], $sortedPositions[$i + 1]))->toBeLessThan( + 0, + "Position {$i} should be < position " . ($i + 1) . ' after mass reorder' + ); + } + }); + + it('handles simultaneous moves to same column from different columns', function () { + // CONCURRENCY SCENARIO: Multiple cards moving into same destination column + // Create cards in different columns + $todoCards = Task::factory()->count(5)->create(['status' => 'todo']); + $inProgressCards = Task::factory()->count(5)->create(['status' => 'in_progress']); + $completedCards = Task::factory()->count(5)->create(['status' => 'completed']); + + // Move 3 cards from different columns all to 'in_progress' + $this->board->call('moveCard', (string) $todoCards->get(0)->id, 'in_progress'); + $this->board->call('moveCard', (string) $todoCards->get(1)->id, 'in_progress'); + $this->board->call('moveCard', (string) $completedCards->get(0)->id, 'in_progress'); + + // Verify positions are properly sorted in target column + $positions = Task::where('status', 'in_progress') + ->orderBy('order_position') + ->pluck('order_position') + ->toArray(); + + for ($i = 0; $i < count($positions) - 1; $i++) { + expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( + 0, + 'Positions should be sorted in in_progress column' + ); + } + + // Verify all positions unique in target column + $positions = Task::where('status', 'in_progress') + ->pluck('order_position') + ->toArray(); + + expect(count(array_unique($positions)))->toBe( + count($positions), + 'All positions in in_progress should be unique' + ); + }); + + it('stress tests alternating column movements (ping-pong pattern)', function () { + // STRESS TEST: Move cards back and forth between columns rapidly + // Simulates indecisive users or workflow state changes + + $card1 = Task::factory()->create(['status' => 'todo']); + $card2 = Task::factory()->create(['status' => 'in_progress']); + $card3 = Task::factory()->create(['status' => 'completed']); + + // Perform 30 ping-pong movements + for ($i = 0; $i < 30; $i++) { + // Card 1: todo <-> in_progress + $this->board->call( + 'moveCard', + (string) $card1->id, + $i % 2 === 0 ? 'in_progress' : 'todo' + ); + + // Card 2: in_progress <-> completed + $this->board->call( + 'moveCard', + (string) $card2->id, + $i % 2 === 0 ? 'completed' : 'in_progress' + ); + + // Card 3: completed <-> todo + $this->board->call( + 'moveCard', + (string) $card3->id, + $i % 2 === 0 ? 'todo' : 'completed' + ); + } + + // Verify all cards have valid positions + foreach ([$card1, $card2, $card3] as $card) { + $card->refresh(); + expect($card->order_position)->not()->toBeNull() + ->and(strlen($card->order_position))->toBeGreaterThan(0); + } + + // Verify positions are properly sorted in each column + foreach (['todo', 'in_progress', 'completed'] as $status) { + $positions = Task::where('status', $status) + ->orderBy('order_position') + ->pluck('order_position') + ->toArray(); + + for ($i = 0; $i < count($positions) - 1; $i++) { + expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( + 0, + "Positions should be sorted in {$status} after ping-pong movements" + ); + } + } + }); + + it('validates data consistency under high-frequency operations', function () { + // CONSISTENCY TEST: Verify database state remains consistent under stress + // Create baseline + $cards = Task::factory()->count(30)->create(); + + $initialCount = Task::count(); + $initialProjectIds = Task::pluck('project_id')->filter()->unique()->count(); + + // Perform 50 high-frequency operations + for ($i = 0; $i < 50; $i++) { + $card = $cards->random(); + $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); + + $this->board->call('moveCard', (string) $card->id, $newStatus); + } + + // Verify data integrity + $finalCount = Task::count(); + expect($finalCount)->toBe($initialCount, 'Card count should remain stable'); + + // Verify relationships intact + $finalProjectIds = Task::pluck('project_id')->filter()->unique()->count(); + expect($finalProjectIds)->toBe( + $initialProjectIds, + 'Project relationships should remain intact' + ); + + // Verify no null positions + $nullPositions = Task::whereNull('order_position')->count(); + expect($nullPositions)->toBe(0, 'No cards should have null positions'); + }); +}); diff --git a/tests/Feature/DragDropFunctionalityTest.php b/tests/Feature/DragDropFunctionalityTest.php index b20c056..96bfbea 100644 --- a/tests/Feature/DragDropFunctionalityTest.php +++ b/tests/Feature/DragDropFunctionalityTest.php @@ -124,8 +124,8 @@ $sourceCard->refresh(); - // beforeCardId actually places the card AFTER the specified card (based on implementation) - expect(strcmp($sourceCard->order_position, $targetCard->order_position))->toBeGreaterThan(0); + // beforeCardId places the card BEFORE the specified card (fixed implementation) + expect(strcmp($sourceCard->order_position, $targetCard->order_position))->toBeLessThan(0); // Project relationship should remain intact expect($sourceCard->project_id)->not()->toBeNull(); @@ -151,7 +151,7 @@ $highPriorityTask->refresh(); $mediumPriorityTask->refresh(); - expect(strcmp($highPriorityTask->order_position, $mediumPriorityTask->order_position))->toBeGreaterThan(0); + expect(strcmp($highPriorityTask->order_position, $mediumPriorityTask->order_position))->toBeLessThan(0); }); }); @@ -376,8 +376,8 @@ $taskToMove = $newTasks->first(); $targetTask = $newTasks->last(); - // Move first task after last task - $this->board->call('moveCard', (string) $taskToMove->id, 'todo', null, (string) $targetTask->id); + // Move first task after last task (afterCardId=last, beforeCardId=null) + $this->board->call('moveCard', (string) $taskToMove->id, 'todo', (string) $targetTask->id, null); // Verify no duplicate positions in todo column $todoPositions = Task::where('status', 'todo') @@ -508,4 +508,190 @@ ->and($constrainedTask->due_date)->not()->toBeNull(); }); }); + + describe('Large Scale Stress Testing (500+ Cards)', function () { + it('handles 500+ cards in single environment', function () { + // Create large production environment with 500+ tasks + $project = $this->projects->first(); + $users = $this->users; + + // Create 500 additional tasks across different statuses + $largeBatch = Task::factory()->count(500)->create([ + 'project_id' => $project->id, + 'assigned_to' => $users->random()->id, + 'created_by' => $users->random()->id, + ]); + + $totalTasks = Task::count(); + expect($totalTasks)->toBeGreaterThanOrEqual(500, 'Should have at least 500 tasks'); + + // Test move performance with large dataset + $testCard = $largeBatch->random(); + $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); + + $startTime = microtime(true); + $this->board->call('moveCard', (string) $testCard->id, $newStatus); + $duration = microtime(true) - $startTime; + + // Should complete within 500ms even with 500+ cards + expect($duration)->toBeLessThan(0.5, 'Move should complete within 500ms at scale'); + + $testCard->refresh(); + expect($testCard->status)->toBe($newStatus); + + // Verify data integrity with large dataset + $orphanedTasks = Task::whereNull('project_id')->count(); + expect($orphanedTasks)->toBe(0, 'No orphaned tasks should exist'); + }); + + it('performs 100 operations on 250-card board', function () { + // Create medium-large board (250 cards) + $project = $this->projects->first(); + $users = $this->users; + + Task::factory()->count(250)->create([ + 'project_id' => $project->id, + 'assigned_to' => $users->random()->id, + 'created_by' => $users->random()->id, + ]); + + $totalTasks = Task::count(); + expect($totalTasks)->toBeGreaterThanOrEqual(250); + + // Perform 100 random move operations + for ($i = 0; $i < 100; $i++) { + $task = Task::inRandomOrder()->first(); + $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); + + $this->board->call('moveCard', (string) $task->id, $newStatus); + } + + // Verify no data corruption after 100 operations + $finalCount = Task::count(); + expect($finalCount)->toBe($totalTasks, 'Task count should remain stable'); + + // Verify no orphaned data + $orphanedTasks = Task::whereNull('project_id')->count(); + expect($orphanedTasks)->toBe(0); + }); + + it('validates position uniqueness across 300 cards in single column', function () { + // Create 300 cards all in 'todo' column + $project = $this->projects->first(); + + Task::factory()->count(300)->create([ + 'project_id' => $project->id, + 'status' => 'todo', + ]); + + // Get all positions in the todo column + $positions = Task::where('status', 'todo') + ->whereNotNull('order_position') + ->pluck('order_position') + ->toArray(); + + // Verify ALL positions are unique + $uniqueCount = count(array_unique($positions)); + expect($uniqueCount)->toBe( + count($positions), + 'All 300+ positions should be unique in single column' + ); + + // Verify positions can be sorted (no invalid characters) + $sortedPositions = $positions; + sort($sortedPositions); + expect(count($sortedPositions))->toBe(count($positions)); + }); + }); + + describe('Invariant Validation Under Stress', function () { + it('validates sorted positions invariant across 50 operations', function () { + // Create base dataset + $tasks = Task::factory()->count(50)->create([ + 'project_id' => $this->projects->first()->id, + 'status' => 'todo', + ]); + + // Perform 50 random moves, checking invariant after EACH move + for ($i = 0; $i < 50; $i++) { + $task = $tasks->random(); + $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); + + $this->board->call('moveCard', (string) $task->id, $newStatus); + + // INVARIANT: Positions in each column must be sortable and ordered + foreach (['todo', 'in_progress', 'completed'] as $status) { + $columnPositions = Task::where('status', $status) + ->whereNotNull('order_position') + ->orderBy('order_position') + ->pluck('order_position') + ->toArray(); + + // Verify positions are in ascending order + for ($j = 0; $j < count($columnPositions) - 1; $j++) { + expect(strcmp($columnPositions[$j], $columnPositions[$j + 1]))->toBeLessThan( + 0, + "Position {$j} should be < position " . ($j + 1) . " in {$status} column at operation {$i}" + ); + } + } + } + }); + + it('validates no duplicate positions invariant during bulk operations', function () { + // Create base dataset with mixed statuses + Task::factory()->count(75)->create([ + 'project_id' => $this->projects->first()->id, + ]); + + $allTasks = Task::all(); + + // Perform 30 operations, checking for duplicates after EACH operation + for ($i = 0; $i < 30; $i++) { + $task = $allTasks->random(); + $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); + + $this->board->call('moveCard', (string) $task->id, $newStatus); + + // INVARIANT: No duplicate positions within each column + foreach (['todo', 'in_progress', 'completed'] as $status) { + $positions = Task::where('status', $status) + ->whereNotNull('order_position') + ->pluck('order_position') + ->toArray(); + + $uniquePositions = array_unique($positions); + + expect(count($uniquePositions))->toBe( + count($positions), + "No duplicate positions allowed in {$status} column at operation {$i}" + ); + } + } + }); + + it('validates position validity invariant under stress', function () { + // Create dataset + Task::factory()->count(40)->create([ + 'project_id' => $this->projects->first()->id, + ]); + + $allTasks = Task::all(); + + // Perform 40 rapid sequential moves + for ($i = 0; $i < 40; $i++) { + $task = $allTasks->random(); + $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); + + $this->board->call('moveCard', (string) $task->id, $newStatus); + $task->refresh(); + + // INVARIANT: Every moved card must have valid position + expect($task->order_position)->not()->toBeNull() + ->and($task->order_position)->toBeString() + ->and(strlen($task->order_position))->toBeGreaterThan(0) + ->and(strlen($task->order_position))->toBeLessThan(1024); // Under MAX_RANK_LEN + } + }); + }); }); diff --git a/tests/Feature/JavaScriptPhpParameterFlowTest.php b/tests/Feature/JavaScriptPhpParameterFlowTest.php new file mode 100644 index 0000000..9fbcba2 --- /dev/null +++ b/tests/Feature/JavaScriptPhpParameterFlowTest.php @@ -0,0 +1,386 @@ +board = Livewire::test(TestBoard::class); +}); + +describe('JavaScript → PHP Parameter Flow Validation', function () { + it('validates exact parameter flow matches flowforge.js:24-28', function () { + // This test mirrors EXACT JavaScript logic from flowforge.js + // Lines 24-28: + // const cardIndex = newOrder.indexOf(cardId); + // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; + // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; + // this.$wire.moveCard(cardId, targetColumn, afterCardId, beforeCardId) + + $cards = collect(['a', 'b', 'c', 'd', 'e'])->map( + fn ($pos) => Task::factory()->create([ + 'title' => "Card {$pos}", + 'status' => 'todo', + 'order_position' => $pos, + ]) + ); + + $cardToMove = $cards->get(0); // Moving card 'a' + $newOrderIndex = 2; // Wants to be at index 2 (between 'b' and 'c') + + // SIMULATE EXACT JAVASCRIPT CALCULATION: + // const cardIndex = newOrder.indexOf(cardId); // = 2 + // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; + $afterCardId = $newOrderIndex > 0 + ? $cards->get($newOrderIndex - 1)->id // Card 'b' (index 1) + : null; + + // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; + $beforeCardId = $newOrderIndex < $cards->count() + ? $cards->get($newOrderIndex)->id // Card 'c' (index 2) + : null; + + // JavaScript sends (line 28): + // this.$wire.moveCard(cardId, targetColumn, afterCardId, beforeCardId) + $this->board->call( + 'moveCard', + (string) $cardToMove->id, + 'todo', + (string) $afterCardId, // 3rd param: Card 'b' + (string) $beforeCardId // 4th param: Card 'c' + ); + + $cardToMove->refresh(); + $afterCard = $cards->get(1); // Card 'b' + $beforeCard = $cards->get(2); // Card 'c' + + // Verify: 'b' < movedCard < 'c' + expect(strcmp($afterCard->fresh()->order_position, $cardToMove->order_position))->toBeLessThan( + 0, + "Moved card should be after '{$afterCard->title}'" + ); + expect(strcmp($cardToMove->order_position, $beforeCard->fresh()->order_position))->toBeLessThan( + 0, + "Moved card should be before '{$beforeCard->title}'" + ); + }); + + it('tests JavaScript edge case: moving to TOP (index 0)', function () { + $cards = collect(['a', 'b', 'c'])->map( + fn ($pos) => Task::factory()->create([ + 'title' => "Card {$pos}", + 'status' => 'todo', + 'order_position' => $pos, + ]) + ); + + $newCard = Task::factory()->create([ + 'title' => 'NewTop', + 'status' => 'todo', + 'order_position' => 'm', + ]); + + // SIMULATE JAVASCRIPT: Moving to index 0 (top) + $newOrderIndex = 0; + + // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; + $afterCardId = $newOrderIndex > 0 + ? $cards->get($newOrderIndex - 1)->id + : null; // null (no card before) + + // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; + $beforeCardId = $newOrderIndex < $cards->count() + ? $cards->get($newOrderIndex)->id + : null; // Card 'a' + + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + $afterCardId, // null + (string) $beforeCardId // Card 'a' + ); + + $newCard->refresh(); + + // Verify: newCard < 'a' + expect(strcmp($newCard->order_position, $cards->get(0)->fresh()->order_position))->toBeLessThan( + 0, + 'Card moved to top should be before first card' + ); + }); + + it('tests JavaScript edge case: moving to BOTTOM (last index)', function () { + $cards = collect(['a', 'b', 'c'])->map( + fn ($pos) => Task::factory()->create([ + 'title' => "Card {$pos}", + 'status' => 'todo', + 'order_position' => $pos, + ]) + ); + + $newCard = Task::factory()->create([ + 'title' => 'NewBottom', + 'status' => 'todo', + 'order_position' => 'm', + ]); + + // SIMULATE JAVASCRIPT: Moving to last index (bottom) + $newOrderIndex = $cards->count(); // = 3 (after last card) + + // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; + $afterCardId = $newOrderIndex > 0 + ? $cards->get($newOrderIndex - 1)->id // Card 'c' + : null; + + // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; + $beforeCardId = $newOrderIndex < $cards->count() + ? $cards->get($newOrderIndex)->id + : null; // null (no card after) + + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + (string) $afterCardId, // Card 'c' + $beforeCardId // null + ); + + $newCard->refresh(); + + // Verify: 'c' < newCard + expect(strcmp($cards->last()->fresh()->order_position, $newCard->order_position))->toBeLessThan( + 0, + 'Card moved to bottom should be after last card' + ); + }); + + it('tests JavaScript edge case: moving BETWEEN cards (middle index)', function () { + $cards = collect(['a', 'b', 'c', 'd'])->map( + fn ($pos) => Task::factory()->create([ + 'title' => "Card {$pos}", + 'status' => 'todo', + 'order_position' => $pos, + ]) + ); + + $newCard = Task::factory()->create([ + 'title' => 'NewMiddle', + 'status' => 'todo', + 'order_position' => 'm', + ]); + + // SIMULATE JAVASCRIPT: Moving to index 2 (between 'b' and 'c') + $newOrderIndex = 2; + + // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; + $afterCardId = $newOrderIndex > 0 + ? $cards->get($newOrderIndex - 1)->id // Card 'b' (index 1) + : null; + + // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; + $beforeCardId = $newOrderIndex < $cards->count() + ? $cards->get($newOrderIndex)->id // Card 'c' (index 2) + : null; + + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + (string) $afterCardId, // Card 'b' + (string) $beforeCardId // Card 'c' + ); + + $newCard->refresh(); + $afterCard = $cards->get(1); // Card 'b' + $beforeCard = $cards->get(2); // Card 'c' + + // Verify: 'b' < newCard < 'c' + expect(strcmp($afterCard->fresh()->order_position, $newCard->order_position))->toBeLessThan( + 0, + 'Card should be after Card b' + ); + expect(strcmp($newCard->order_position, $beforeCard->fresh()->order_position))->toBeLessThan( + 0, + 'Card should be before Card c' + ); + }); + + it('simulates browser drag-drop with exact index calculations', function () { + // Create ordered list like browser shows + $cards = collect(); + for ($i = 1; $i <= 5; $i++) { + $lastRank = $i === 1 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $cards->push(Task::factory()->create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => $lastRank->get(), + ])); + } + + // Simulate dragging Card 1 to position between Card 3 and Card 4 + // Visual: C1, C2, C3, [NEW POSITION], C4, C5 + // In array: index 0, 1, 2, [3], 4, 5 + $cardToMove = $cards->get(0); + $targetIndex = 3; + + // EXACT JavaScript logic from flowforge.js:24-26 + $afterCardId = $targetIndex > 0 + ? $cards->get($targetIndex - 1)->id + : null; // Card 3 (index 2) + + $beforeCardId = $targetIndex < $cards->count() + ? $cards->get($targetIndex)->id + : null; // Card 4 (index 3) + + // JavaScript NOW sends (line 28): + // moveCard(cardId, column, afterCardId, beforeCardId) + $this->board->call( + 'moveCard', + (string) $cardToMove->id, + 'todo', + (string) $afterCardId, // Card 3 + (string) $beforeCardId // Card 4 + ); + + $cardToMove->refresh(); + $card3 = $cards->get(2)->fresh(); + $card4 = $cards->get(3)->fresh(); + + // Verify: Card3 < MovedCard < Card4 + expect(strcmp($card3->order_position, $cardToMove->order_position))->toBeLessThan( + 0, + 'Moved card should be after Card 3' + ); + expect(strcmp($cardToMove->order_position, $card4->order_position))->toBeLessThan( + 0, + 'Moved card should be before Card 4' + ); + }); + + it('tests all possible index positions in a 10-card column', function () { + // Create 10 cards + $cards = collect(); + for ($i = 0; $i < 10; $i++) { + $rank = $i === 0 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $cards->push(Task::factory()->create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => $rank->get(), + ])); + } + + // Test moving a new card to EVERY possible index (0 through 10) + for ($targetIndex = 0; $targetIndex <= 10; $targetIndex++) { + $newCard = Task::factory()->create([ + 'title' => "New at index {$targetIndex}", + 'status' => 'todo', + 'order_position' => 'm', + ]); + + // SIMULATE JAVASCRIPT INDEX CALCULATION + $afterCardId = $targetIndex > 0 + ? $cards->get($targetIndex - 1)->id + : null; + + $beforeCardId = $targetIndex < $cards->count() + ? $cards->get($targetIndex)->id + : null; + + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + $afterCardId, + $beforeCardId + ); + + $newCard->refresh(); + + // Verify correct placement based on index + if ($targetIndex > 0) { + $afterCard = $cards->get($targetIndex - 1)->fresh(); + expect(strcmp($afterCard->order_position, $newCard->order_position))->toBeLessThan( + 0, + "At index {$targetIndex}, card should be after card at index " . ($targetIndex - 1) + ); + } + + if ($targetIndex < $cards->count()) { + $beforeCard = $cards->get($targetIndex)->fresh(); + expect(strcmp($newCard->order_position, $beforeCard->order_position))->toBeLessThan( + 0, + "At index {$targetIndex}, card should be before card at index {$targetIndex}" + ); + } + + // Clean up for next iteration + $newCard->delete(); + } + }); + + it('validates cross-column moves with JavaScript parameter logic', function () { + // Create cards in different columns + $todoCards = collect(['a', 'b', 'c'])->map( + fn ($pos) => Task::factory()->create([ + 'title' => "Todo {$pos}", + 'status' => 'todo', + 'order_position' => $pos, + ]) + ); + + $inProgressCards = collect(['d', 'e', 'f'])->map( + fn ($pos) => Task::factory()->create([ + 'title' => "InProgress {$pos}", + 'status' => 'in_progress', + 'order_position' => $pos, + ]) + ); + + // Move a todo card to in_progress column at index 1 + $cardToMove = $todoCards->first(); + $targetIndex = 1; + + // SIMULATE JAVASCRIPT for in_progress column + $afterCardId = $targetIndex > 0 + ? $inProgressCards->get($targetIndex - 1)->id // Card 'd' + : null; + + $beforeCardId = $targetIndex < $inProgressCards->count() + ? $inProgressCards->get($targetIndex)->id // Card 'e' + : null; + + $this->board->call( + 'moveCard', + (string) $cardToMove->id, + 'in_progress', // Moving to different column + (string) $afterCardId, // Card 'd' + (string) $beforeCardId // Card 'e' + ); + + $cardToMove->refresh(); + + // Verify moved to correct column + $status = $cardToMove->status instanceof \BackedEnum ? $cardToMove->status->value : $cardToMove->status; + expect($status)->toBe('in_progress'); + + // Verify positioned correctly: 'd' < movedCard < 'e' + $afterCard = $inProgressCards->get(0)->fresh(); + $beforeCard = $inProgressCards->get(1)->fresh(); + + expect(strcmp($afterCard->order_position, $cardToMove->order_position))->toBeLessThan( + 0, + 'Moved card should be after Card d in new column' + ); + expect(strcmp($cardToMove->order_position, $beforeCard->order_position))->toBeLessThan(0, + 'Moved card should be before Card e in new column' + ); + }); +}); diff --git a/tests/Feature/ParameterCombinationTest.php b/tests/Feature/ParameterCombinationTest.php new file mode 100644 index 0000000..512544d --- /dev/null +++ b/tests/Feature/ParameterCombinationTest.php @@ -0,0 +1,248 @@ +board = Livewire::test(TestBoard::class); +}); + +describe('Parameter Combination Testing - Finding the RIGHT way', function () { + it('tests ALL 4 possible parameter combinations for inserting between cards', function () { + // Create 3 cards in sequence + $cardA = Task::factory()->create([ + 'title' => 'Card A', + 'status' => 'todo', + 'order_position' => 'a', + ]); + + $cardB = Task::factory()->create([ + 'title' => 'Card B', + 'status' => 'todo', + 'order_position' => 'b', + ]); + + $cardC = Task::factory()->create([ + 'title' => 'Card C', + 'status' => 'todo', + 'order_position' => 'c', + ]); + + // We want to insert NEW card between A and B + // Expected result: A < NEW < B (positions: "a" < newPos < "b") + + $results = []; + + // Combination 1: (newCard, column, afterCardId=A, beforeCardId=B) + try { + $new1 = Task::factory()->create(['title' => 'New1', 'status' => 'todo', 'order_position' => 'm']); + $this->board->call('moveCard', (string) $new1->id, 'todo', (string) $cardA->id, (string) $cardB->id); + $new1->refresh(); + $results['Combo1_after=A_before=B'] = [ + 'success' => true, + 'position' => $new1->order_position, + 'correct_order' => strcmp($cardA->order_position, $new1->order_position) < 0 && strcmp($new1->order_position, $cardB->order_position) < 0, + ]; + } catch (\Exception $e) { + $results['Combo1_after=A_before=B'] = ['success' => false, 'error' => $e->getMessage()]; + } + + // Combination 2: (newCard, column, afterCardId=B, beforeCardId=A) - REVERSED + try { + $new2 = Task::factory()->create(['title' => 'New2', 'status' => 'todo', 'order_position' => 'm']); + $this->board->call('moveCard', (string) $new2->id, 'todo', (string) $cardB->id, (string) $cardA->id); + $new2->refresh(); + $results['Combo2_after=B_before=A'] = [ + 'success' => true, + 'position' => $new2->order_position, + 'correct_order' => strcmp($cardA->order_position, $new2->order_position) < 0 && strcmp($new2->order_position, $cardB->order_position) < 0, + ]; + } catch (\Exception $e) { + $results['Combo2_after=B_before=A'] = ['success' => false, 'error' => $e->getMessage()]; + } + + // Combination 3: (newCard, column, beforeCardId=B, afterCardId=A) - Swapped param order + try { + $new3 = Task::factory()->create(['title' => 'New3', 'status' => 'todo', 'order_position' => 'm']); + // This is how JavaScript calls it! + $this->board->call('moveCard', (string) $new3->id, 'todo', (string) $cardB->id, (string) $cardA->id); + $new3->refresh(); + $results['Combo3_JS_order_before=B_after=A'] = [ + 'success' => true, + 'position' => $new3->order_position, + 'correct_order' => strcmp($cardA->order_position, $new3->order_position) < 0 && strcmp($new3->order_position, $cardB->order_position) < 0, + ]; + } catch (\Exception $e) { + $results['Combo3_JS_order_before=B_after=A'] = ['success' => false, 'error' => $e->getMessage()]; + } + + // Combination 4: (newCard, column, beforeCardId=A, afterCardId=B) - Different swap + try { + $new4 = Task::factory()->create(['title' => 'New4', 'status' => 'todo', 'order_position' => 'm']); + $this->board->call('moveCard', (string) $new4->id, 'todo', (string) $cardA->id, (string) $cardB->id); + $new4->refresh(); + $results['Combo4_before=A_after=B'] = [ + 'success' => true, + 'position' => $new4->order_position, + 'correct_order' => strcmp($cardA->order_position, $new4->order_position) < 0 && strcmp($new4->order_position, $cardB->order_position) < 0, + ]; + } catch (\Exception $e) { + $results['Combo4_before=A_after=B'] = ['success' => false, 'error' => $e->getMessage()]; + } + + dump('=== PARAMETER COMBINATION RESULTS ==='); + dump($results); + + // Find which combination works + $workingCombos = array_filter($results, fn ($r) => $r['success'] === true && ($r['correct_order'] ?? false)); + dump('Working combinations:', array_keys($workingCombos)); + + expect(count($workingCombos))->toBeGreaterThan(0, 'At least one combination should work correctly'); + }); + + it('tests moving to TOP of column - both parameter orders', function () { + $cardA = Task::factory()->create(['title' => 'Card A', 'status' => 'todo', 'order_position' => 'a']); + $cardB = Task::factory()->create(['title' => 'Card B', 'status' => 'todo', 'order_position' => 'b']); + + // Want: NEW < A < B + $results = []; + + // Test 1: afterCardId=null, beforeCardId=A + try { + $new1 = Task::factory()->create(['title' => 'NewTop1', 'status' => 'todo', 'order_position' => 'm']); + $this->board->call('moveCard', (string) $new1->id, 'todo', null, (string) $cardA->id); + $new1->refresh(); + $results['after=null_before=A'] = [ + 'success' => true, + 'position' => $new1->order_position, + 'correct' => strcmp($new1->order_position, $cardA->order_position) < 0, + ]; + } catch (\Exception $e) { + $results['after=null_before=A'] = ['success' => false, 'error' => $e->getMessage()]; + } + + // Test 2: beforeCardId=A, afterCardId=null (JS order) + try { + $new2 = Task::factory()->create(['title' => 'NewTop2', 'status' => 'todo', 'order_position' => 'm']); + $this->board->call('moveCard', (string) $new2->id, 'todo', (string) $cardA->id, null); + $new2->refresh(); + $results['before=A_after=null'] = [ + 'success' => true, + 'position' => $new2->order_position, + 'correct' => strcmp($new2->order_position, $cardA->order_position) < 0, + ]; + } catch (\Exception $e) { + $results['before=A_after=null'] = ['success' => false, 'error' => $e->getMessage()]; + } + + dump('=== TOP POSITION RESULTS ==='); + dump($results); + + $workingCombos = array_filter($results, fn ($r) => $r['success'] === true && ($r['correct'] ?? false)); + dump('Working combinations:', array_keys($workingCombos)); + + expect(count($workingCombos))->toBeGreaterThan(0); + }); + + it('tests moving to BOTTOM of column - both parameter orders', function () { + $cardA = Task::factory()->create(['title' => 'Card A', 'status' => 'todo', 'order_position' => 'a']); + $cardB = Task::factory()->create(['title' => 'Card B', 'status' => 'todo', 'order_position' => 'b']); + + // Want: A < B < NEW + $results = []; + + // Test 1: afterCardId=B, beforeCardId=null + try { + $new1 = Task::factory()->create(['title' => 'NewBottom1', 'status' => 'todo', 'order_position' => 'm']); + $this->board->call('moveCard', (string) $new1->id, 'todo', (string) $cardB->id, null); + $new1->refresh(); + $results['after=B_before=null'] = [ + 'success' => true, + 'position' => $new1->order_position, + 'correct' => strcmp($new1->order_position, $cardB->order_position) > 0, + ]; + } catch (\Exception $e) { + $results['after=B_before=null'] = ['success' => false, 'error' => $e->getMessage()]; + } + + // Test 2: beforeCardId=null, afterCardId=B (JS order) + try { + $new2 = Task::factory()->create(['title' => 'NewBottom2', 'status' => 'todo', 'order_position' => 'm']); + $this->board->call('moveCard', (string) $new2->id, 'todo', null, (string) $cardB->id); + $new2->refresh(); + $results['before=null_after=B'] = [ + 'success' => true, + 'position' => $new2->order_position, + 'correct' => strcmp($new2->order_position, $cardB->order_position) > 0, + ]; + } catch (\Exception $e) { + $results['before=null_after=B'] = ['success' => false, 'error' => $e->getMessage()]; + } + + dump('=== BOTTOM POSITION RESULTS ==='); + dump($results); + + $workingCombos = array_filter($results, fn ($r) => $r['success'] === true && ($r['correct'] ?? false)); + dump('Working combinations:', array_keys($workingCombos)); + + expect(count($workingCombos))->toBeGreaterThan(0); + }); + + it('simulates exact browser drag-and-drop behavior', function () { + // Create ordered list like browser shows + $cards = collect(); + for ($i = 1; $i <= 5; $i++) { + $lastRank = $i === 1 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $cards->push(Task::factory()->create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => $lastRank->get(), + ])); + } + + // Simulate dragging Card 1 to position between Card 3 and Card 4 + // Visual: C1, C2, C3, [NEW POSITION], C4, C5 + // In array: index 0, 1, 2, [3], 4 + $cardToMove = $cards->get(0); + $targetIndex = 3; + + // JavaScript logic from flowforge.js: + $afterCardId = $targetIndex > 0 ? $cards->get($targetIndex - 1)->id : null; // Card 3 + $beforeCardId = $targetIndex < $cards->count() ? $cards->get($targetIndex)->id : null; // Card 4 + + dump('Browser would send:', [ + 'cardToMove' => $cardToMove->title, + 'afterCard' => $cards->get($targetIndex - 1)->title, + 'beforeCard' => $cards->get($targetIndex)->title, + ]); + + // JavaScript NOW sends: moveCard(cardId, column, afterCardId, beforeCardId) - FIXED! + $this->board->call( + 'moveCard', + (string) $cardToMove->id, + 'todo', + (string) $afterCardId, // 3rd param (after fix) + (string) $beforeCardId // 4th param (after fix) + ); + + $cardToMove->refresh(); + $card3 = $cards->get(2)->fresh(); + $card4 = $cards->get(3)->fresh(); + + dump('Result:', [ + 'card3_pos' => $card3->order_position, + 'moved_card_pos' => $cardToMove->order_position, + 'card4_pos' => $card4->order_position, + 'is_between' => strcmp($card3->order_position, $cardToMove->order_position) < 0 && + strcmp($cardToMove->order_position, $card4->order_position) < 0, + ]); + + expect(strcmp($card3->order_position, $cardToMove->order_position))->toBeLessThan(0); + expect(strcmp($cardToMove->order_position, $card4->order_position))->toBeLessThan(0); + }); +}); diff --git a/tests/Feature/ParameterOrderMutationTest.php b/tests/Feature/ParameterOrderMutationTest.php new file mode 100644 index 0000000..e9a55c3 --- /dev/null +++ b/tests/Feature/ParameterOrderMutationTest.php @@ -0,0 +1,290 @@ +board = Livewire::test(TestBoard::class); +}); + +/** + * Helper to detect position inversions in a column + */ +function detectInversions(string $modelClass, string $columnValue, string $positionField = 'order_position'): array +{ + $records = $modelClass::query() + ->where('status', $columnValue) + ->whereNotNull($positionField) + ->orderBy('id') + ->get(); + + $inversions = []; + for ($i = 0; $i < $records->count() - 1; $i++) { + $current = $records[$i]; + $next = $records[$i + 1]; + + $currentPos = $current->getAttribute($positionField); + $nextPos = $next->getAttribute($positionField); + + if (strcmp($currentPos, $nextPos) >= 0) { + $inversions[] = [ + 'current_id' => $current->id, + 'current_pos' => $currentPos, + 'next_id' => $next->id, + 'next_pos' => $nextPos, + ]; + } + } + + return $inversions; +} + +describe('Parameter Order Mutation Tests - Prove the Fix Matters', function () { + it('documents correct parameter order after fix', function () { + // DOCUMENTATION TEST: Shows how parameters work AFTER the fix + // This test verifies the fix is working correctly + + $cardA = Task::factory()->create([ + 'title' => 'Card A', + 'status' => 'todo', + 'order_position' => 'a', + ]); + + $cardB = Task::factory()->create([ + 'title' => 'Card B', + 'status' => 'todo', + 'order_position' => 'b', + ]); + + $cardC = Task::factory()->create([ + 'title' => 'Card C', + 'status' => 'todo', + 'order_position' => 'c', + ]); + + $newCard = Task::factory()->create([ + 'title' => 'New Card', + 'status' => 'todo', + 'order_position' => 'm', + ]); + + // CORRECT PARAMETER ORDER (after fix): + // moveCard(cardId, column, afterCardId, beforeCardId) + // afterCardId = card BEFORE the new position (visually above) + // beforeCardId = card AFTER the new position (visually below) + + // Want: A < NewCard < B + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + (string) $cardA->id, // afterCardId: card A (comes before new position) + (string) $cardB->id // beforeCardId: card B (comes after new position) + ); + + $newCard->refresh(); + + // Verify correct placement + $isCorrect = strcmp($cardA->order_position, $newCard->order_position) < 0 + && strcmp($newCard->order_position, $cardB->order_position) < 0; + + expect($isCorrect)->toBeTrue( + 'With current fix, card should be between A and B' + ); + }); + + it('proves PHP logic with swapped parameters creates exception', function () { + // MUTATION TEST: Simulates OLD BROKEN PHP logic + // This shows what happens when betweenRanks parameters are swapped + + $cardA = Task::factory()->create([ + 'status' => 'todo', + 'order_position' => 'a', + ]); + + $cardB = Task::factory()->create([ + 'status' => 'todo', + 'order_position' => 'b', + ]); + + // SIMULATE OLD BROKEN PHP LOGIC: + // Used to be: betweenRanks($beforePos, $afterPos) - WRONG ORDER + // This throws exception because 'b' > 'a' + + expect(function () use ($cardA, $cardB) { + Rank::betweenRanks( + Rank::fromString($cardB->order_position), // 'b' as prev (WRONG) + Rank::fromString($cardA->order_position) // 'a' as next (WRONG) + ); + })->toThrow(PrevGreaterThanOrEquals::class); + }); + + it('validates parameter semantic meanings under stress', function () { + // Create 10 cards in sequence + $cards = collect(); + for ($i = 0; $i < 10; $i++) { + $rank = $i === 0 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $cards->push(Task::factory()->create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => $rank->get(), + ])); + } + + // Test EVERY adjacent pair with correct parameter semantics + for ($i = 0; $i < $cards->count() - 1; $i++) { + $newCard = Task::factory()->create([ + 'title' => "New Card {$i}", + 'status' => 'todo', + 'order_position' => 'm', + ]); + + $afterCard = $cards->get($i); + $beforeCard = $cards->get($i + 1); + + // CORRECT ORDER: afterCardId, beforeCardId + // afterCard = visually ABOVE (smaller position) + // beforeCard = visually BELOW (larger position) + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + (string) $afterCard->id, // 3rd param: card BEFORE new position + (string) $beforeCard->id // 4th param: card AFTER new position + ); + + $newCard->refresh(); + $afterCard = $afterCard->fresh(); + $beforeCard = $beforeCard->fresh(); + + // Invariant: afterCard < newCard < beforeCard + expect(strcmp($afterCard->order_position, $newCard->order_position))->toBeLessThan( + 0, + "After inserting between {$afterCard->title} and {$beforeCard->title}, " . + 'new card position should be > afterCard' + ); + + expect(strcmp($newCard->order_position, $beforeCard->order_position))->toBeLessThan( + 0, + "After inserting between {$afterCard->title} and {$beforeCard->title}, " . + 'new card position should be < beforeCard' + ); + } + }); + + it('verifies correct behavior for all edge cases', function () { + $cards = collect(['a', 'b', 'c'])->map( + fn ($pos) => Task::factory()->create([ + 'status' => 'todo', + 'order_position' => $pos, + ]) + ); + + // Edge Case 1: Move to TOP (afterCardId=null, beforeCardId=firstCard) + $newCard1 = Task::factory()->create(['status' => 'todo', 'order_position' => 'm']); + $this->board->call( + 'moveCard', + (string) $newCard1->id, + 'todo', + null, // afterCardId=null (no card before) + (string) $cards->get(0)->id // beforeCardId=first card + ); + $newCard1->refresh(); + expect(strcmp($newCard1->order_position, $cards->get(0)->order_position))->toBeLessThan( + 0, + 'Card moved to top should have position < first card' + ); + + // Edge Case 2: Move to BOTTOM (afterCardId=lastCard, beforeCardId=null) + $newCard2 = Task::factory()->create(['status' => 'todo', 'order_position' => 'm']); + $this->board->call( + 'moveCard', + (string) $newCard2->id, + 'todo', + (string) $cards->last()->id, // afterCardId=last card + null // beforeCardId=null (no card after) + ); + $newCard2->refresh(); + expect(strcmp($cards->last()->order_position, $newCard2->order_position))->toBeLessThan( + 0, + 'Card moved to bottom should have position > last card' + ); + + // Edge Case 3: Move BETWEEN (both non-null) + $newCard3 = Task::factory()->create(['status' => 'todo', 'order_position' => 'm']); + $this->board->call( + 'moveCard', + (string) $newCard3->id, + 'todo', + (string) $cards->get(0)->id, // afterCardId=first card + (string) $cards->get(1)->id // beforeCardId=second card + ); + $newCard3->refresh(); + expect(strcmp($cards->get(0)->order_position, $newCard3->order_position))->toBeLessThan( + 0, + 'Card moved between should have position > first card' + ); + expect(strcmp($newCard3->order_position, $cards->get(1)->order_position))->toBeLessThan( + 0, + 'Card moved between should have position < second card' + ); + }); + + it('stresses parameter order with rapid alternating insertions', function () { + $cards = collect(); + for ($i = 0; $i < 5; $i++) { + $rank = $i === 0 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $cards->push(Task::factory()->create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => $rank->get(), + ])); + } + + // Rapidly insert 20 cards, alternating between positions + for ($round = 0; $round < 20; $round++) { + $newCard = Task::factory()->create([ + 'title' => "Rapid Card {$round}", + 'status' => 'todo', + 'order_position' => 'm', + ]); + + // Alternate between inserting at different positions + $targetIndex = $round % ($cards->count() - 1); + $afterCard = $cards->get($targetIndex); + $beforeCard = $cards->get($targetIndex + 1); + + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + (string) $afterCard->id, + (string) $beforeCard->id + ); + + $newCard->refresh(); + + // Verify correct placement EVERY time + expect(strcmp($afterCard->fresh()->order_position, $newCard->order_position))->toBeLessThan( + 0, + "Round {$round}: Card should be after {$afterCard->title}" + ); + expect(strcmp($newCard->order_position, $beforeCard->fresh()->order_position))->toBeLessThan( + 0, + "Round {$round}: Card should be before {$beforeCard->title}" + ); + } + }); + + // NOTE: More aggressive stress testing of random moves is covered in + // ConcurrentOperationStressTest.php - this test was too unreliable here +}); diff --git a/tests/Feature/PerformanceRegressionTest.php b/tests/Feature/PerformanceRegressionTest.php new file mode 100644 index 0000000..d3a1ddd --- /dev/null +++ b/tests/Feature/PerformanceRegressionTest.php @@ -0,0 +1,231 @@ +board = Livewire::test(TestBoard::class); +}); + +describe('Performance Regression Baselines - Prevent Future Slowdowns', function () { + it('benchmarks move operations at different scales', function ($cardCount, $maxDuration) { + // Create cards + Task::factory()->count($cardCount)->create(['status' => 'todo']); + + $testCard = Task::inRandomOrder()->first(); + $newStatus = 'in_progress'; + + // Measure move duration + $startTime = microtime(true); + $this->board->call('moveCard', (string) $testCard->id, $newStatus); + $duration = microtime(true) - $startTime; + + // Verify within performance threshold + expect($duration)->toBeLessThan( + $maxDuration, + "Move with {$cardCount} cards should complete within {$maxDuration}s (took {$duration}s)" + ); + + $testCard->refresh(); + expect($testCard->status)->toBe($newStatus); + })->with([ + '50 cards' => [50, 0.1], // 100ms + '100 cards' => [100, 0.2], // 200ms + '250 cards' => [250, 0.3], // 300ms + '500 cards' => [500, 0.5], // 500ms + ]); + + it('tracks position string length growth over time', function ($cardCount) { + // Create cards sequentially + $cards = collect(); + $lengthMetrics = []; + + for ($i = 1; $i <= $cardCount; $i++) { + $rank = $i === 1 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $card = Task::factory()->create([ + 'status' => 'todo', + 'order_position' => $rank->get(), + ]); + + $cards->push($card); + + // Track metrics at checkpoints + if ($i % 25 === 0) { + $lengths = $cards->pluck('order_position')->map(fn ($p) => strlen($p)); + $lengthMetrics[$i] = [ + 'avg' => round($lengths->avg(), 2), + 'max' => $lengths->max(), + ]; + } + } + + // Verify growth is linear, not exponential + $checkpoints = array_keys($lengthMetrics); + for ($i = 1; $i < count($checkpoints); $i++) { + $prev = $lengthMetrics[$checkpoints[$i - 1]]; + $curr = $lengthMetrics[$checkpoints[$i]]; + + // Growth should be gradual (max increase of 2 chars per 25 cards) + $avgGrowth = $curr['avg'] - $prev['avg']; + expect($avgGrowth)->toBeLessThan( + 2, + 'Average position length growth should be gradual' + ); + } + + dump("Length metrics for {$cardCount} cards:", $lengthMetrics); + })->with([ + '100 cards' => 100, + '200 cards' => 200, + ]); + + it('benchmarks bulk operations performance', function () { + // Create baseline + Task::factory()->count(100)->create(); + + $tasks = Task::all(); + + // Benchmark 50 rapid moves + $durations = []; + for ($i = 0; $i < 50; $i++) { + $task = $tasks->random(); + $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); + + $start = microtime(true); + $this->board->call('moveCard', (string) $task->id, $newStatus); + $durations[] = microtime(true) - $start; + } + + $avgDuration = array_sum($durations) / count($durations); + $maxDuration = max($durations); + + // Performance baselines + expect($avgDuration)->toBeLessThan(0.1, 'Average operation should be < 100ms'); + expect($maxDuration)->toBeLessThan(0.3, 'Max operation should be < 300ms'); + + dump('Bulk operations performance:', [ + 'avg_duration_ms' => round($avgDuration * 1000, 2), + 'max_duration_ms' => round($maxDuration * 1000, 2), + 'total_operations' => 50, + ]); + }); + + it('validates database query performance under load', function () { + // Create large dataset + Task::factory()->count(300)->create(); + + // Measure query performance for common operations + $metrics = []; + + // 1. Query all tasks in a column + $start = microtime(true); + $todoTasks = Task::where('status', 'todo')->get(); + $metrics['query_column'] = microtime(true) - $start; + + // 2. Query with ordering + $start = microtime(true); + $orderedTasks = Task::where('status', 'todo') + ->orderBy('order_position') + ->get(); + $metrics['query_ordered'] = microtime(true) - $start; + + // 3. Count operations + $start = microtime(true); + $count = Task::where('status', 'todo')->count(); + $metrics['count_query'] = microtime(true) - $start; + + // All queries should be fast (< 50ms) + foreach ($metrics as $operation => $duration) { + expect($duration)->toBeLessThan( + 0.05, + "{$operation} should complete within 50ms (took " . round($duration * 1000, 2) . 'ms)' + ); + } + + dump('Database query performance:', array_map( + fn ($d) => round($d * 1000, 2) . 'ms', + $metrics + )); + }); + + it('establishes memory usage baselines', function () { + $beforeMemory = memory_get_usage(true); + + // Create 200 cards and perform operations + Task::factory()->count(200)->create(); + $tasks = Task::all(); + + // Perform 30 operations + for ($i = 0; $i < 30; $i++) { + $task = $tasks->random(); + $this->board->call( + 'moveCard', + (string) $task->id, + collect(['todo', 'in_progress', 'completed'])->random() + ); + } + + $afterMemory = memory_get_usage(true); + $memoryUsed = $afterMemory - $beforeMemory; + $memoryUsedMB = round($memoryUsed / 1024 / 1024, 2); + + // Memory usage should be reasonable (< 10MB for 200 cards + 30 ops) + expect($memoryUsedMB)->toBeLessThan( + 10, + "Memory usage should be under 10MB (used {$memoryUsedMB}MB)" + ); + + dump('Memory usage:', [ + 'before_mb' => round($beforeMemory / 1024 / 1024, 2), + 'after_mb' => round($afterMemory / 1024 / 1024, 2), + 'used_mb' => $memoryUsedMB, + ]); + }); + + it('validates position generation performance', function () { + // Benchmark position generation algorithms + $metrics = []; + + // 1. Empty sequence position + $start = microtime(true); + for ($i = 0; $i < 100; $i++) { + $pos = Rank::forEmptySequence()->get(); + } + $metrics['empty_sequence'] = (microtime(true) - $start) / 100; + + // 2. After position + $lastPos = Rank::forEmptySequence(); + $start = microtime(true); + for ($i = 0; $i < 100; $i++) { + $lastPos = Rank::after($lastPos); + } + $metrics['after_position'] = (microtime(true) - $start) / 100; + + // 3. Between positions + $pos1 = Rank::fromString('a'); + $pos2 = Rank::fromString('b'); + $start = microtime(true); + for ($i = 0; $i < 100; $i++) { + $pos = Rank::betweenRanks($pos1, $pos2)->get(); + } + $metrics['between_positions'] = (microtime(true) - $start) / 100; + + // All operations should be < 1ms on average + foreach ($metrics as $operation => $avgDuration) { + expect($avgDuration)->toBeLessThan( + 0.001, + "{$operation} should be < 1ms per operation" + ); + } + + dump('Position generation performance (avg per operation):', array_map( + fn ($d) => round($d * 1000000, 2) . 'Îŧs', + $metrics + )); + }); +}); diff --git a/tests/Feature/PositionInversionReproductionTest.php b/tests/Feature/PositionInversionReproductionTest.php new file mode 100644 index 0000000..d7c72bb --- /dev/null +++ b/tests/Feature/PositionInversionReproductionTest.php @@ -0,0 +1,412 @@ +board = Livewire::test(TestBoard::class); +}); + +/** + * Helper to detect position inversions in a column + */ +function detectInversions(string $modelClass, string $columnValue, string $positionField = 'order_position'): array +{ + $records = $modelClass::query() + ->where('status', $columnValue) + ->whereNotNull($positionField) + ->orderBy('id') + ->get(); + + $inversions = []; + for ($i = 0; $i < $records->count() - 1; $i++) { + $current = $records[$i]; + $next = $records[$i + 1]; + + $currentPos = $current->getAttribute($positionField); + $nextPos = $next->getAttribute($positionField); + + if (strcmp($currentPos, $nextPos) >= 0) { + $inversions[] = [ + 'current_id' => $current->id, + 'current_pos' => $currentPos, + 'next_id' => $next->id, + 'next_pos' => $nextPos, + ]; + } + } + + return $inversions; +} + +describe('Real-World Position Inversion Scenarios', function () { + it('reproduces inversions from rapid sequential moves between same positions', function () { + // Create 5 cards in todo column + $cards = collect(); + for ($i = 1; $i <= 5; $i++) { + $cards->push(Task::factory()->create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => Rank::forEmptySequence()->get(), + ])); + } + + // Simulate rapid back-and-forth movements (simulates user indecision) + $targetCard = $cards->get(2); + $bugReproduced = false; + + for ($j = 0; $j < 10; $j++) { + try { + // Move card 3 between card 1 and card 2 repeatedly + $this->board->call( + 'moveCard', + (string) $targetCard->id, + 'todo', + (string) $cards->get(0)->id, // afterCardId + (string) $cards->get(1)->id // beforeCardId + ); + + // Refresh positions + $cards = $cards->map(fn ($card) => $card->fresh()); + } catch (\Relaticle\Flowforge\Exceptions\PrevGreaterThanOrEquals $e) { + dump("BUG REPRODUCED at move #{$j}: " . $e->getMessage()); + $bugReproduced = true; + + break; + } + } + + // Check for inversions if no exception was thrown + if (! $bugReproduced) { + $inversions = detectInversions(Task::class, 'todo'); + + if (count($inversions) > 0) { + dump('INVERSION REPRODUCED!', $inversions); + $bugReproduced = true; + } + } + + // This test succeeds when it reproduces the bug + expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from rapid moves'); + }); + + it('reproduces inversions from inserting many cards between two existing cards', function () { + // Create initial boundary cards + $firstCard = Task::factory()->create([ + 'title' => 'First Card', + 'status' => 'todo', + 'order_position' => Rank::forEmptySequence()->get(), // Gets 'm' + ]); + + $lastCard = Task::factory()->create([ + 'title' => 'Last Card', + 'status' => 'todo', + 'order_position' => Rank::after(Rank::fromString($firstCard->order_position))->get(), + ]); + + // Insert 50 cards between these two + $insertedCards = collect(); + $bugReproduced = false; + + for ($i = 1; $i <= 50; $i++) { + $newCard = Task::factory()->create([ + 'title' => "Inserted Card {$i}", + 'status' => 'todo', + 'order_position' => Rank::forEmptySequence()->get(), // Temporary position + ]); + + try { + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + (string) $firstCard->id, + (string) $lastCard->id + ); + + $newCard->refresh(); + $insertedCards->push($newCard); + + // Check after every 10 insertions + if ($i % 10 === 0) { + $inversions = detectInversions(Task::class, 'todo'); + if (count($inversions) > 0) { + dump("INVERSION DETECTED after {$i} insertions!", $inversions); + $bugReproduced = true; + + break; + } + } + } catch (\Relaticle\Flowforge\Exceptions\PrevGreaterThanOrEquals $e) { + dump("BUG REPRODUCED at insertion #{$i}: " . $e->getMessage()); + $bugReproduced = true; + + break; + } + } + + // Final check if no bug found yet + if (! $bugReproduced) { + $inversions = detectInversions(Task::class, 'todo'); + if (count($inversions) > 0) { + dump('INVERSIONS DETECTED in final check!', $inversions); + $bugReproduced = true; + } + } + + // This test succeeds when it reproduces the bug + expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from many insertions'); + }); + + it('reproduces inversions from concurrent-like operations (simulated)', function () { + // Create 10 cards + $cards = collect(); + for ($i = 1; $i <= 10; $i++) { + $lastRank = $i === 1 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $cards->push(Task::factory()->create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => $lastRank->get(), + ])); + } + + // Simulate concurrent operations: multiple cards moving at "same time" + // We'll move 3 different cards to 3 different positions simultaneously (in succession) + $operations = [ + ['card' => $cards->get(2), 'after' => $cards->get(5), 'before' => $cards->get(6)], + ['card' => $cards->get(7), 'after' => $cards->get(1), 'before' => $cards->get(2)], + ['card' => $cards->get(4), 'after' => $cards->get(8), 'before' => $cards->get(9)], + ]; + + $bugReproduced = false; + + foreach ($operations as $index => $op) { + try { + $this->board->call( + 'moveCard', + (string) $op['card']->id, + 'todo', + (string) $op['after']->id, + (string) $op['before']->id + ); + } catch (\Relaticle\Flowforge\Exceptions\PrevGreaterThanOrEquals $e) { + dump("BUG REPRODUCED at operation #{$index}: " . $e->getMessage()); + $bugReproduced = true; + + break; + } + } + + // Check for inversions if no exception was thrown + if (! $bugReproduced) { + $inversions = detectInversions(Task::class, 'todo'); + + if (count($inversions) > 0) { + dump('CONCURRENT OPERATIONS CAUSED INVERSIONS!', $inversions); + $bugReproduced = true; + } + } + + // This test succeeds when it reproduces the bug + expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from concurrent operations'); + }); + + it('stress tests position system with 100 random moves', function () { + // Create 20 cards + $cards = collect(); + for ($i = 1; $i <= 20; $i++) { + $lastRank = $i === 1 + ? Rank::forEmptySequence() + : Rank::after(Rank::fromString($cards->last()->order_position)); + + $cards->push(Task::factory()->create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => $lastRank->get(), + ])); + } + + // Perform 100 random moves + $bugReproduced = false; + for ($move = 1; $move <= 100; $move++) { + // Pick random card and random position + $cardToMove = $cards->random(); + $otherCards = $cards->where('id', '!=', $cardToMove->id); + + if ($otherCards->count() < 2) { + continue; + } + + $afterCard = $otherCards->random(); + $beforeCard = $otherCards->where('id', '!=', $afterCard->id)->random(); + + try { + $this->board->call( + 'moveCard', + (string) $cardToMove->id, + 'todo', + (string) $afterCard->id, + (string) $beforeCard->id + ); + + // Refresh all cards + $cards = $cards->map(fn ($card) => $card->fresh()); + + // Check for inversions every 20 moves + if ($move % 20 === 0) { + $inversions = detectInversions(Task::class, 'todo'); + if (count($inversions) > 0) { + dump("INVERSION FOUND after move #{$move}!", $inversions); + $bugReproduced = true; + + break; + } + } + } catch (\Exception $e) { + // If we get PrevGreaterThanOrEquals exception, we've reproduced the bug! + if (str_contains($e->getMessage(), 'Previous Rank')) { + dump("BUG REPRODUCED at move #{$move}: " . $e->getMessage()); + $bugReproduced = true; + + break; + } + + throw $e; + } + } + + // Final check if no bug found yet + if (! $bugReproduced) { + $inversions = detectInversions(Task::class, 'todo'); + + if (count($inversions) > 0) { + dump('FINAL CHECK: Inversions detected!', $inversions); + $bugReproduced = true; + } + } + + // This test succeeds when it reproduces the bug + expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from random moves'); + }); + + it('tests the exact scenario from production data - now fixed', function () { + // Based on diagnostic output, we know these inversions existed: + // Card #019b18e5-a8b6-7350-9a8c-6534f48280df (pos: "VU") comes before + // Card #019b18e5-a8b7-73ec-9be1-89c01264fab3 (pos: "T") + + // Create positions that would previously cause issues + $card1 = Task::factory()->create([ + 'title' => 'Card with position T', + 'status' => 'review', + 'order_position' => 'T', + ]); + + $card2 = Task::factory()->create([ + 'title' => 'Card with position VU', + 'status' => 'review', + 'order_position' => 'VU', + ]); + + // According to strcmp, "T" < "VU" - these are in correct lexicographic order + $comparison = strcmp('T', 'VU'); + expect($comparison)->toBeLessThan(0, 'T should be lexicographically less than VU'); + + // Now try to move a card between them - with the fix, this should succeed + $newCard = Task::factory()->create([ + 'title' => 'New card to insert', + 'status' => 'review', + 'order_position' => Rank::forEmptySequence()->get(), + ]); + + // With the fix, inserting between properly ordered positions should work + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'review', + (string) $card1->id, // afterCardId (position "T") + (string) $card2->id // beforeCardId (position "VU") + ); + + $newCard->refresh(); + + // Verify the new card is positioned correctly between T and VU + expect(strcmp($card1->order_position, $newCard->order_position))->toBeLessThan(0, 'New card should be after T'); + expect(strcmp($newCard->order_position, $card2->order_position))->toBeLessThan(0, 'New card should be before VU'); + }); + + it('verifies rank string growth leads to inversions', function () { + // Theory: After many insertions, rank strings grow longer + // and eventually two adjacent ranks can become inverted + + $prevCard = Task::factory()->create([ + 'title' => 'Boundary Card 1', + 'status' => 'todo', + 'order_position' => 'a', + ]); + + $nextCard = Task::factory()->create([ + 'title' => 'Boundary Card 2', + 'status' => 'todo', + 'order_position' => 'b', + ]); + + $positions = [$prevCard->order_position, $nextCard->order_position]; + $bugReproduced = false; + + // Insert 100 cards between 'a' and 'b' + for ($i = 1; $i <= 100; $i++) { + $newCard = Task::factory()->create([ + 'title' => "Insert {$i}", + 'status' => 'todo', + 'order_position' => Rank::forEmptySequence()->get(), + ]); + + // Get current boundary cards + $prevCard = $prevCard->fresh(); + $nextCard = $nextCard->fresh(); + + try { + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + (string) $prevCard->id, + (string) $nextCard->id + ); + + $newCard->refresh(); + $positions[] = $newCard->order_position; + + // Track position string lengths + if ($i % 25 === 0) { + $avgLength = collect($positions)->avg(fn ($p) => strlen($p)); + dump("After {$i} insertions, average position length: {$avgLength}"); + } + } catch (\Relaticle\Flowforge\Exceptions\PrevGreaterThanOrEquals $e) { + dump("BUG REPRODUCED at insertion #{$i}: " . $e->getMessage()); + dump('Positions created so far:', $positions); + $bugReproduced = true; + + break; + } + } + + // Check if any inversions exist (if no exception was thrown) + if (! $bugReproduced) { + $inversions = detectInversions(Task::class, 'todo'); + + if (count($inversions) > 0) { + dump('Position lengths that caused inversions:', collect($positions)->map(fn ($p) => strlen($p))->all()); + $bugReproduced = true; + } + } + + // This test succeeds when it reproduces the bug + expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from rank string growth'); + }); +}); From 81f547e5553c5215383c9531665530f1d102caba Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Sun, 14 Dec 2025 19:41:17 +0400 Subject: [PATCH 2/6] feat: implement automatic retry logic for position updates to handle conflicts --- .../add_unique_position_constraint.php.stub | 35 ++++ src/Concerns/InteractsWithBoard.php | 168 ++++++++++++++++-- .../MaxRetriesExceededException.php | 12 ++ .../PositionInversionReproductionTest.php | 32 +--- 4 files changed, 204 insertions(+), 43 deletions(-) create mode 100644 database/migrations/add_unique_position_constraint.php.stub create mode 100644 src/Exceptions/MaxRetriesExceededException.php diff --git a/database/migrations/add_unique_position_constraint.php.stub b/database/migrations/add_unique_position_constraint.php.stub new file mode 100644 index 0000000..59031db --- /dev/null +++ b/database/migrations/add_unique_position_constraint.php.stub @@ -0,0 +1,35 @@ +unique(['status_column', 'position_column'], 'unique_position_per_column'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('your_table_name', function (Blueprint $table) { + $table->dropUnique('unique_position_per_column'); + }); + } +}; diff --git a/src/Concerns/InteractsWithBoard.php b/src/Concerns/InteractsWithBoard.php index 205a4fb..527f2d8 100644 --- a/src/Concerns/InteractsWithBoard.php +++ b/src/Concerns/InteractsWithBoard.php @@ -8,9 +8,12 @@ use Filament\Actions\ActionGroup; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use InvalidArgumentException; use Relaticle\Flowforge\Board; +use Relaticle\Flowforge\Exceptions\MaxRetriesExceededException; use Relaticle\Flowforge\Services\Rank; use Throwable; @@ -95,27 +98,168 @@ public function moveCard( throw new InvalidArgumentException("Card not found: {$cardId}"); } - // Calculate new position using Rank service - $newPosition = $this->calculatePositionBetweenCards($afterCardId, $beforeCardId, $targetColumnId); + // Calculate and update position with automatic retry on conflicts + $newPosition = $this->calculateAndUpdatePositionWithRetry($card, $targetColumnId, $afterCardId, $beforeCardId); - // Use transaction for data consistency - DB::transaction(function () use ($card, $board, $targetColumnId, $newPosition) { + // Emit success event after successful transaction + $this->dispatch('kanban-card-moved', [ + 'cardId' => $cardId, + 'columnId' => $targetColumnId, + 'position' => $newPosition, + ]); + } + + /** + * Calculate position and update card within transaction with pessimistic locking. + * This prevents race conditions when multiple users drag cards simultaneously. + */ + protected function calculateAndUpdatePosition( + Model $card, + string $targetColumnId, + ?string $afterCardId, + ?string $beforeCardId + ): string { + $newPosition = null; + + DB::transaction(function () use ($card, $targetColumnId, $afterCardId, $beforeCardId, &$newPosition) { + $board = $this->getBoard(); + $query = $board->getQuery(); + $positionField = $board->getPositionIdentifierAttribute(); + + // LOCK reference cards for reading to prevent stale data + $afterCard = $afterCardId + ? (clone $query)->where('id', $afterCardId)->lockForUpdate()->first() + : null; + + $beforeCard = $beforeCardId + ? (clone $query)->where('id', $beforeCardId)->lockForUpdate()->first() + : null; + + // Calculate position INSIDE transaction with locked data + $newPosition = $this->calculatePositionBetweenLockedCards( + $afterCard, + $beforeCard, + $targetColumnId + ); + + // Update card position $columnIdentifier = $board->getColumnIdentifierAttribute(); $columnValue = $this->resolveStatusValue($card, $columnIdentifier, $targetColumnId); - $positionIdentifier = $board->getPositionIdentifierAttribute(); $card->update([ $columnIdentifier => $columnValue, - $positionIdentifier => $newPosition, + $positionField => $newPosition, ]); }); - // Emit success event after successful transaction - $this->dispatch('kanban-card-moved', [ - 'cardId' => $cardId, - 'columnId' => $targetColumnId, - 'position' => $newPosition, - ]); + return $newPosition; + } + + /** + * Calculate position between locked cards (used within transaction). + */ + protected function calculatePositionBetweenLockedCards( + ?Model $afterCard, + ?Model $beforeCard, + string $columnId + ): string { + if (! $afterCard && ! $beforeCard) { + return $this->getBoardPositionInColumn($columnId, 'bottom'); + } + + $positionField = $this->getBoard()->getPositionIdentifierAttribute(); + + $beforePos = $beforeCard?->getAttribute($positionField); + $afterPos = $afterCard?->getAttribute($positionField); + + if ($beforePos && $afterPos && is_string($beforePos) && is_string($afterPos)) { + return Rank::betweenRanks(Rank::fromString($afterPos), Rank::fromString($beforePos))->get(); + } + + if ($beforePos && is_string($beforePos)) { + return Rank::before(Rank::fromString($beforePos))->get(); + } + + if ($afterPos && is_string($afterPos)) { + return Rank::after(Rank::fromString($afterPos))->get(); + } + + return Rank::forEmptySequence()->get(); + } + + /** + * Calculate and update position with automatic retry on conflicts. + * Wraps calculateAndUpdatePosition() with retry logic to handle rare duplicate position conflicts. + */ + protected function calculateAndUpdatePositionWithRetry( + Model $card, + string $targetColumnId, + ?string $afterCardId, + ?string $beforeCardId, + int $maxAttempts = 3 + ): string { + $baseDelay = 50; // milliseconds + $lastException = null; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + try { + return $this->calculateAndUpdatePosition( + $card, + $targetColumnId, + $afterCardId, + $beforeCardId + ); + } catch (QueryException $e) { + // Check if this is a unique constraint violation + if (! $this->isDuplicatePositionError($e)) { + throw $e; // Not a duplicate, rethrow + } + + $lastException = $e; + + // Log the conflict for monitoring + Log::info('Position conflict detected, retrying', [ + 'card_id' => $card->id, + 'target_column' => $targetColumnId, + 'attempt' => $attempt, + 'max_attempts' => $maxAttempts, + ]); + + // Max retries reached? + if ($attempt >= $maxAttempts) { + throw new MaxRetriesExceededException( + "Failed to move card after {$maxAttempts} attempts due to position conflicts", + previous: $e + ); + } + + // Exponential backoff: 50ms, 100ms, 200ms + $delay = $baseDelay * pow(2, $attempt - 1); + usleep($delay * 1000); + + // Refresh reference cards before retry (they may have moved) + continue; + } + } + + // Should never reach here + throw $lastException ?? new \RuntimeException('Unexpected retry loop exit'); + } + + /** + * Check if a QueryException is due to unique constraint violation on positions. + */ + protected function isDuplicatePositionError(QueryException $e): bool + { + $errorCode = $e->errorInfo[1] ?? null; + + // SQLite: SQLITE_CONSTRAINT (19) + // MySQL: ER_DUP_ENTRY (1062) + // PostgreSQL: unique_violation (23505) + + return in_array($errorCode, [19, 1062, 23505]) || + str_contains($e->getMessage(), 'unique_position_per_column') || + str_contains($e->getMessage(), 'UNIQUE constraint failed'); } public function loadMoreItems(string $columnId, ?int $count = null): void diff --git a/src/Exceptions/MaxRetriesExceededException.php b/src/Exceptions/MaxRetriesExceededException.php new file mode 100644 index 0000000..f304aaa --- /dev/null +++ b/src/Exceptions/MaxRetriesExceededException.php @@ -0,0 +1,12 @@ +board = Livewire::test(TestBoard::class); }); -/** - * Helper to detect position inversions in a column - */ -function detectInversions(string $modelClass, string $columnValue, string $positionField = 'order_position'): array -{ - $records = $modelClass::query() - ->where('status', $columnValue) - ->whereNotNull($positionField) - ->orderBy('id') - ->get(); - - $inversions = []; - for ($i = 0; $i < $records->count() - 1; $i++) { - $current = $records[$i]; - $next = $records[$i + 1]; - - $currentPos = $current->getAttribute($positionField); - $nextPos = $next->getAttribute($positionField); - - if (strcmp($currentPos, $nextPos) >= 0) { - $inversions[] = [ - 'current_id' => $current->id, - 'current_pos' => $currentPos, - 'next_id' => $next->id, - 'next_pos' => $nextPos, - ]; - } - } - - return $inversions; -} +// Note: detectInversions() helper function is defined in ParameterOrderMutationTest.php describe('Real-World Position Inversion Scenarios', function () { it('reproduces inversions from rapid sequential moves between same positions', function () { From 788df3c42aa70ceff9b722abc244d93829d17147 Mon Sep 17 00:00:00 2001 From: ManukMinasyan <2556185+ManukMinasyan@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:41:41 +0000 Subject: [PATCH 3/6] Fix styling --- tests/Feature/JavaScriptPhpParameterFlowTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Feature/JavaScriptPhpParameterFlowTest.php b/tests/Feature/JavaScriptPhpParameterFlowTest.php index 9fbcba2..785a0e7 100644 --- a/tests/Feature/JavaScriptPhpParameterFlowTest.php +++ b/tests/Feature/JavaScriptPhpParameterFlowTest.php @@ -379,7 +379,8 @@ 0, 'Moved card should be after Card d in new column' ); - expect(strcmp($cardToMove->order_position, $beforeCard->order_position))->toBeLessThan(0, + expect(strcmp($cardToMove->order_position, $beforeCard->order_position))->toBeLessThan( + 0, 'Moved card should be before Card e in new column' ); }); From 0ca428a8c26f9656b245c76a98f7dff0e831b7bf Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Fri, 26 Dec 2025 16:57:36 +0400 Subject: [PATCH 4/6] feat: implement decimal-based position management and rebalancing service --- database/factories/TaskFactory.php | 14 +- phpstan.neon.dist | 2 +- src/Commands/DiagnosePositionsCommand.php | 224 ++++------- src/Commands/RebalancePositionsCommand.php | 191 +++++++++ src/Commands/RepairPositionsCommand.php | 12 +- src/Concerns/HasBoardRecords.php | 6 +- src/Concerns/InteractsWithBoard.php | 184 ++++++--- src/Exceptions/InvalidChars.php | 17 - .../LastCharCantBeEqualToMinChar.php | 22 - src/Exceptions/MaxRankLength.php | 24 -- src/Exceptions/PrevGreaterThanOrEquals.php | 20 - src/FlowforgeServiceProvider.php | 15 +- src/Services/DecimalPosition.php | 279 +++++++++++++ src/Services/PositionRebalancer.php | 237 +++++++++++ src/Services/Rank.php | 194 --------- tests/Datasets/TaskMovements.php | 16 +- tests/Feature/BlueprintMacroTest.php | 81 +--- .../Feature/CharacterSpaceExhaustionTest.php | 289 ++++++------- .../Feature/ConcurrentOperationStressTest.php | 48 ++- .../JavaScriptPhpParameterFlowTest.php | 223 ++++++----- tests/Feature/ParameterCombinationTest.php | 148 ++++--- tests/Feature/ParameterOrderMutationTest.php | 157 ++++---- tests/Feature/PerformanceRegressionTest.php | 89 ++--- .../PositionInversionReproductionTest.php | 378 +++++++----------- tests/Unit/DecimalPositionServiceTest.php | 376 +++++++++++++++++ tests/Unit/PositionRebalancerServiceTest.php | 155 +++++++ tests/Unit/RankServiceTest.php | 327 --------------- .../2024_01_01_000000_create_tasks_table.php | 22 +- 28 files changed, 2113 insertions(+), 1637 deletions(-) create mode 100644 src/Commands/RebalancePositionsCommand.php delete mode 100644 src/Exceptions/InvalidChars.php delete mode 100644 src/Exceptions/LastCharCantBeEqualToMinChar.php delete mode 100644 src/Exceptions/MaxRankLength.php delete mode 100644 src/Exceptions/PrevGreaterThanOrEquals.php create mode 100644 src/Services/DecimalPosition.php create mode 100644 src/Services/PositionRebalancer.php delete mode 100644 src/Services/Rank.php create mode 100644 tests/Unit/DecimalPositionServiceTest.php create mode 100644 tests/Unit/PositionRebalancerServiceTest.php delete mode 100644 tests/Unit/RankServiceTest.php diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index c504dca..002ed93 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -3,7 +3,7 @@ namespace Relaticle\Flowforge\Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; -use Relaticle\Flowforge\Services\Rank; +use Relaticle\Flowforge\Services\DecimalPosition; use Relaticle\Flowforge\Tests\Fixtures\Task; class TaskFactory extends Factory @@ -176,15 +176,15 @@ private function generateResearchTitle(): string private function generatePosition(): string { - // Generate positions in a realistic range - static $baseRank = null; + // Generate positions in a realistic range using decimal positions + static $position = null; - if ($baseRank === null) { - $baseRank = Rank::forEmptySequence(); + if ($position === null) { + $position = DecimalPosition::forEmptyColumn(); } else { - $baseRank = Rank::after($baseRank); + $position = DecimalPosition::after($position); } - return $baseRank->get(); + return $position; } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 260b5e1..de458d6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,7 +2,7 @@ includes: - phpstan-baseline.neon parameters: - level: 4 + level: 5 paths: - src - config diff --git a/src/Commands/DiagnosePositionsCommand.php b/src/Commands/DiagnosePositionsCommand.php index 4efa593..144e2ee 100644 --- a/src/Commands/DiagnosePositionsCommand.php +++ b/src/Commands/DiagnosePositionsCommand.php @@ -5,6 +5,7 @@ use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; +use Relaticle\Flowforge\Services\DecimalPosition; use function Laravel\Prompts\error; use function Laravel\Prompts\info; @@ -16,17 +17,9 @@ class DiagnosePositionsCommand extends Command protected $signature = 'flowforge:diagnose-positions {--model= : Model class to diagnose (e.g., App\\Models\\Task)} {--column= : Column identifier field} - {--position= : Position field name} - {--fix : Automatically apply collation fixes}'; + {--position= : Position field name}'; - protected $description = 'Diagnose position column collation and ordering issues'; - - private array $expectedCollations = [ - 'mysql' => 'utf8mb4_bin', - 'pgsql' => 'C', - 'sqlsrv' => 'Latin1_General_BIN2', - 'sqlite' => null, // SQLite doesn't need collation - ]; + protected $description = 'Diagnose position column issues including gaps, inversions, and duplicates'; public function handle(): int { @@ -66,37 +59,37 @@ public function handle(): int } // Display configuration - info("✓ Model: {$model}"); - info("✓ Column Identifier: {$columnField}"); - info("✓ Position Identifier: {$positionField}"); + info("Model: {$model}"); + info("Column Identifier: {$columnField}"); + info("Position Identifier: {$positionField}"); $this->newLine(); // Run diagnostics $issues = []; - // 1. Check collation - $this->line('🔍 Checking database collation...'); - $collationIssue = $this->checkCollation($modelInstance, $positionField); - if ($collationIssue) { - $issues[] = $collationIssue; + // 1. Check for small gaps (needs rebalancing) + $this->line('Checking position gaps...'); + $gapIssues = $this->checkGaps($modelInstance, $columnField, $positionField); + if (count($gapIssues) > 0) { + $issues = array_merge($issues, $gapIssues); } // 2. Check for position inversions - $this->line('🔍 Scanning for position inversions...'); + $this->line('Scanning for position inversions...'); $inversionIssues = $this->checkInversions($modelInstance, $columnField, $positionField); if (count($inversionIssues) > 0) { $issues = array_merge($issues, $inversionIssues); } // 3. Check for duplicates - $this->line('🔍 Checking for duplicate positions...'); + $this->line('Checking for duplicate positions...'); $duplicateIssues = $this->checkDuplicates($modelInstance, $columnField, $positionField); if (count($duplicateIssues) > 0) { $issues = array_merge($issues, $duplicateIssues); } // 4. Check for null positions - $this->line('🔍 Checking for null positions...'); + $this->line('Checking for null positions...'); $nullIssue = $this->checkNullPositions($modelInstance, $positionField); if ($nullIssue) { $issues[] = $nullIssue; @@ -106,32 +99,26 @@ public function handle(): int // Display results if (empty($issues)) { - info('✅ All checks passed! No issues detected.'); + info('All checks passed! No issues detected.'); return self::SUCCESS; } - warning(sprintf('âš ī¸ Found %d issue(s):', count($issues))); + warning(sprintf('Found %d issue(s):', count($issues))); $this->newLine(); foreach ($issues as $index => $issue) { $this->displayIssue($index + 1, $issue); } - // Offer fixes if --fix option is provided - if ($this->option('fix') && isset($collationIssue)) { - $this->applyCollationFix($modelInstance, $positionField); - } - return self::FAILURE; } private function displayHeader(): void { $this->newLine(); - $this->line('╔══════════════════════════════════════════════════════════════╗'); - $this->line('║ Flowforge Position Diagnostics ║'); - $this->line('╚══════════════════════════════════════════════════════════════╝'); + $this->line('Flowforge Position Diagnostics'); + $this->line('==============================='); $this->newLine(); } @@ -148,82 +135,56 @@ private function validateModelClass(string $value): ?string return null; } - private function checkCollation(Model $model, string $positionField): ?array + private function checkGaps(Model $model, string $columnField, string $positionField): array { - $connection = $model->getConnection(); - $driver = $connection->getDriverName(); - $table = $model->getTable(); - - // Skip for SQLite (no collation needed) - if ($driver === 'sqlite') { - info(' ✓ SQLite database - no collation check needed'); - - return null; - } - - $expectedCollation = $this->expectedCollations[$driver] ?? null; - - if (! $expectedCollation) { - warning(" âš ī¸ Unknown database driver: {$driver}"); - - return null; - } - - // Get actual collation - $actualCollation = $this->getColumnCollation($connection, $table, $positionField); + $issues = []; + $columns = $model->query()->distinct()->pluck($columnField)->map(fn ($value) => $value instanceof \BackedEnum ? $value->value : $value); - if ($actualCollation === $expectedCollation) { - info(" ✓ Collation correct: {$actualCollation}"); + foreach ($columns as $column) { + $positions = $model->query() + ->where($columnField, $column) + ->whereNotNull($positionField) + ->orderBy($positionField) + ->orderBy('id') + ->pluck($positionField); - return null; - } + if ($positions->count() < 2) { + continue; + } - return [ - 'type' => 'collation', - 'severity' => 'critical', - 'table' => $table, - 'column' => $positionField, - 'expected' => $expectedCollation, - 'actual' => $actualCollation ?? 'unknown', - 'driver' => $driver, - ]; - } + $smallGaps = 0; + $minGap = null; - private function getColumnCollation($connection, string $table, string $column): ?string - { - $driver = $connection->getDriverName(); + for ($i = 0; $i < $positions->count() - 1; $i++) { + $current = DecimalPosition::normalize($positions[$i]); + $next = DecimalPosition::normalize($positions[$i + 1]); + $gap = DecimalPosition::gap($current, $next); - try { - if ($driver === 'mysql') { - $result = DB::select("SHOW FULL COLUMNS FROM `{$table}` WHERE Field = ?", [$column]); + if ($minGap === null || DecimalPosition::lessThan($gap, $minGap)) { + $minGap = $gap; + } - return $result[0]->Collation ?? null; + if (DecimalPosition::needsRebalancing($current, $next)) { + $smallGaps++; + } } - if ($driver === 'pgsql') { - $result = DB::select(' - SELECT collation_name - FROM information_schema.columns - WHERE table_name = ? AND column_name = ? - ', [$table, $column]); - - return $result[0]->collation_name ?? null; + if ($smallGaps > 0) { + $issues[] = [ + 'type' => 'small_gaps', + 'severity' => 'medium', + 'column' => $column, + 'count' => $smallGaps, + 'min_gap' => $minGap, + ]; } + } - if ($driver === 'sqlsrv') { - $result = DB::select(' - SELECT COLLATION_NAME - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = ? AND COLUMN_NAME = ? - ', [$table, $column]); - - return $result[0]->COLLATION_NAME ?? null; - } - } catch (\Exception $e) { - warning(" Could not determine collation: {$e->getMessage()}"); + if (empty($issues)) { + info(' No small gaps detected (no rebalancing needed)'); } - return null; + return $issues; } private function checkInversions(Model $model, string $columnField, string $positionField): array @@ -247,11 +208,11 @@ private function checkInversions(Model $model, string $columnField, string $posi $current = $records[$i]; $next = $records[$i + 1]; - $currentPos = $current->getAttribute($positionField); - $nextPos = $next->getAttribute($positionField); + $currentPos = DecimalPosition::normalize($current->getAttribute($positionField)); + $nextPos = DecimalPosition::normalize($next->getAttribute($positionField)); // Check if positions are inverted (current >= next when they should be current < next) - if (strcmp($currentPos, $nextPos) >= 0) { + if (DecimalPosition::compare($currentPos, $nextPos) >= 0) { $inversions[] = [ 'current_id' => $current->getKey(), 'current_pos' => $currentPos, @@ -273,7 +234,7 @@ private function checkInversions(Model $model, string $columnField, string $posi } if (empty($issues)) { - info(' ✓ No position inversions detected'); + info(' No position inversions detected'); } return $issues; @@ -305,7 +266,7 @@ private function checkDuplicates(Model $model, string $columnField, string $posi } if (empty($issues)) { - info(' ✓ No duplicate positions detected'); + info(' No duplicate positions detected'); } return $issues; @@ -316,7 +277,7 @@ private function checkNullPositions(Model $model, string $positionField): ?array $nullCount = $model->query()->whereNull($positionField)->count(); if ($nullCount === 0) { - info(' ✓ No null positions detected'); + info(' No null positions detected'); return null; } @@ -330,80 +291,35 @@ private function checkNullPositions(Model $model, string $positionField): ?array private function displayIssue(int $number, array $issue): void { - $severityColors = [ - 'critical' => 'error', - 'high' => 'error', - 'medium' => 'warning', - 'low' => 'info', - ]; - - $color = $severityColors[$issue['severity']] ?? 'info'; - $this->line("Issue #{$number}: " . strtoupper($issue['type'])); - if ($issue['type'] === 'collation') { - error(' ❌ COLLATION MISMATCH'); - $this->line(" Expected: {$issue['expected']} (binary comparison)"); - $this->line(" Found: {$issue['actual']} (case-insensitive comparison)"); - $this->newLine(); - $this->line(' This causes incorrect position ordering!'); - $this->newLine(); - warning(' 🔧 Fix: Run this migration to correct collation:'); - $this->newLine(); - - if ($issue['driver'] === 'mysql') { - $this->line(" ALTER TABLE {$issue['table']} MODIFY {$issue['column']} VARCHAR(255)"); - $this->line(' CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;'); - } elseif ($issue['driver'] === 'pgsql') { - $this->line(" ALTER TABLE {$issue['table']} ALTER COLUMN {$issue['column']}"); - $this->line(' TYPE VARCHAR(255) COLLATE "C";'); - } + if ($issue['type'] === 'small_gaps') { + warning(" Found {$issue['count']} position pair(s) with gap below " . DecimalPosition::MIN_GAP . " in column '{$issue['column']}'"); + $this->line(" Minimum gap found: {$issue['min_gap']}"); + $this->line(' This may cause precision issues. Consider running: php artisan flowforge:rebalance-positions'); $this->newLine(); } if ($issue['type'] === 'inversion') { - error(" ❌ Found {$issue['count']} inverted position pair(s) in column '{$issue['column']}':"); + error(" Found {$issue['count']} inverted position pair(s) in column '{$issue['column']}':"); foreach ($issue['examples'] as $example) { - $this->line(" - Card #{$example['current_id']} (pos: \"{$example['current_pos']}\") comes before Card #{$example['next_id']} (pos: \"{$example['next_pos']}\")"); + $this->line(" - Record #{$example['current_id']} (pos: {$example['current_pos']}) >= Record #{$example['next_id']} (pos: {$example['next_pos']})"); } $this->newLine(); } if ($issue['type'] === 'duplicate') { - warning(" âš ī¸ Found {$issue['count']} duplicate positions in column '{$issue['column']}'"); + warning(" Found {$issue['count']} duplicate positions in column '{$issue['column']}'"); $this->line(" ({$issue['unique_positions']} unique position values with duplicates)"); $this->newLine(); } if ($issue['type'] === 'null') { - info(" â„šī¸ Found {$issue['count']} records with null positions"); + info(" Found {$issue['count']} records with null positions"); $this->newLine(); } - info(' 💡 After fixing issues, run: php artisan flowforge:repair-positions'); + info(' After fixing issues, run: php artisan flowforge:repair-positions'); $this->newLine(); } - - private function applyCollationFix(Model $model, string $positionField): void - { - $connection = $model->getConnection(); - $driver = $connection->getDriverName(); - $table = $model->getTable(); - - $this->line('🔧 Applying collation fix...'); - - try { - if ($driver === 'mysql') { - DB::statement("ALTER TABLE `{$table}` MODIFY `{$positionField}` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"); - info(' ✓ Collation updated successfully'); - } elseif ($driver === 'pgsql') { - DB::statement("ALTER TABLE \"{$table}\" ALTER COLUMN \"{$positionField}\" TYPE VARCHAR(255) COLLATE \"C\""); - info(' ✓ Collation updated successfully'); - } else { - warning(" âš ī¸ Auto-fix not supported for {$driver}. Please run the migration manually."); - } - } catch (\Exception $e) { - error(" ❌ Failed to apply fix: {$e->getMessage()}"); - } - } } diff --git a/src/Commands/RebalancePositionsCommand.php b/src/Commands/RebalancePositionsCommand.php new file mode 100644 index 0000000..c5ec682 --- /dev/null +++ b/src/Commands/RebalancePositionsCommand.php @@ -0,0 +1,191 @@ +displayHeader(); + + // Get parameters + $model = $this->option('model') ?? text( + label: 'Model class (e.g., App\\Models\\Task)', + required: true, + validate: fn (string $value) => $this->validateModelClass($value) + ); + + $columnField = $this->option('column') ?? text( + label: 'Column identifier field (for grouping)', + placeholder: 'status', + required: true + ); + + $positionField = $this->option('position') ?? text( + label: 'Position field', + default: 'position', + required: true + ); + + // Validate model + if (! class_exists($model)) { + error("Model class '{$model}' does not exist"); + + return self::FAILURE; + } + + $modelInstance = new $model; + if (! $modelInstance instanceof Model) { + error("Class '{$model}' is not an Eloquent model"); + + return self::FAILURE; + } + + // Display configuration + info("Model: {$model}"); + info("Column Identifier: {$columnField}"); + info("Position Identifier: {$positionField}"); + $this->newLine(); + + $rebalancer = new PositionRebalancer; + $query = $model::query(); + + // Check which columns need rebalancing + $specificGroup = $this->option('group'); + + if ($specificGroup) { + // Rebalance specific group + $this->rebalanceGroup($rebalancer, $query, $columnField, $specificGroup, $positionField); + } else { + // Find and rebalance all groups needing it + $this->rebalanceAllNeeded($rebalancer, $query, $columnField, $positionField); + } + + return self::SUCCESS; + } + + private function displayHeader(): void + { + $this->newLine(); + $this->line('Flowforge Position Rebalancer'); + $this->line('============================='); + $this->newLine(); + } + + private function validateModelClass(string $value): ?string + { + if (! class_exists($value)) { + return "Model class '{$value}' does not exist"; + } + + if (! is_subclass_of($value, Model::class)) { + return "Class '{$value}' is not an Eloquent model"; + } + + return null; + } + + private function rebalanceGroup( + PositionRebalancer $rebalancer, + $query, + string $columnField, + string $groupId, + string $positionField + ): void { + // Get current stats + $stats = $rebalancer->getGapStatistics($query, $columnField, $groupId, $positionField); + + $this->line("Column '{$groupId}':"); + $this->line(" Records: {$stats['count']}"); + + if ($stats['min_gap'] !== null) { + $this->line(" Min gap: {$stats['min_gap']}"); + $this->line(" Max gap: {$stats['max_gap']}"); + $this->line(" Small gaps: {$stats['small_gaps']}"); + } + + $this->newLine(); + + if ($this->option('dry-run')) { + info("Dry run - would rebalance {$stats['count']} records in column '{$groupId}'"); + + return; + } + + if (! confirm("Rebalance {$stats['count']} records in column '{$groupId}'?", true)) { + info('Operation cancelled.'); + + return; + } + + $count = $rebalancer->rebalanceColumn($query, $columnField, $groupId, $positionField); + info("Rebalanced {$count} records in column '{$groupId}'"); + } + + private function rebalanceAllNeeded( + PositionRebalancer $rebalancer, + $query, + string $columnField, + string $positionField + ): void { + $columnsNeedingRebalancing = $rebalancer->findColumnsNeedingRebalancing( + $query, + $columnField, + $positionField + ); + + if ($columnsNeedingRebalancing->isEmpty()) { + info('No columns need rebalancing. All gap sizes are healthy.'); + + return; + } + + $this->line(sprintf('Found %d column(s) needing rebalancing:', $columnsNeedingRebalancing->count())); + $this->newLine(); + + foreach ($columnsNeedingRebalancing as $columnId) { + $stats = $rebalancer->getGapStatistics($query, $columnField, (string) $columnId, $positionField); + $this->line(" - {$columnId}: {$stats['count']} records, {$stats['small_gaps']} small gaps"); + } + + $this->newLine(); + + if ($this->option('dry-run')) { + info('Dry run - no changes applied'); + + return; + } + + if (! confirm('Rebalance all columns listed above?', true)) { + info('Operation cancelled.'); + + return; + } + + $results = $rebalancer->rebalanceAll($query, $columnField, $positionField); + + $this->newLine(); + info('Rebalancing complete:'); + foreach ($results as $columnId => $count) { + $this->line(" - {$columnId}: {$count} records rebalanced"); + } + } +} diff --git a/src/Commands/RepairPositionsCommand.php b/src/Commands/RepairPositionsCommand.php index a9f08a6..1dbf6d5 100644 --- a/src/Commands/RepairPositionsCommand.php +++ b/src/Commands/RepairPositionsCommand.php @@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; -use Relaticle\Flowforge\Services\Rank; +use Relaticle\Flowforge\Services\DecimalPosition; use function Laravel\Prompts\confirm; use function Laravel\Prompts\info; @@ -325,15 +325,17 @@ private function getDuplicatePositions(Builder $query, string $positionField): a private function generatePositions(iterable $records, string $strategy): array { $positions = []; - $lastRank = null; + $lastPosition = null; foreach ($records as $record) { $positionValue = $record instanceof Model ? $record->getAttribute('position') : $record->position ?? null; if ($strategy === 'regenerate' || is_null($positionValue)) { - $rank = $lastRank ? Rank::after($lastRank) : Rank::forEmptySequence(); + $newPosition = $lastPosition !== null + ? DecimalPosition::after($lastPosition) + : DecimalPosition::forEmptyColumn(); $recordId = $record instanceof Model ? $record->getKey() : $record->id; - $positions[$recordId] = $rank->get(); - $lastRank = $rank; + $positions[$recordId] = $newPosition; + $lastPosition = $newPosition; } } diff --git a/src/Concerns/HasBoardRecords.php b/src/Concerns/HasBoardRecords.php index 9188f2b..e5dba02 100644 --- a/src/Concerns/HasBoardRecords.php +++ b/src/Concerns/HasBoardRecords.php @@ -84,7 +84,8 @@ public function getBoardRecords(string $columnId): Collection $positionField = $this->getPositionIdentifierAttribute(); if ($positionField && $this->modelHasColumn($queryClone->getModel(), $positionField)) { - $queryClone->orderBy($positionField, 'asc'); + $queryClone->orderBy($positionField, 'asc') + ->orderBy('id', 'asc'); // Tie-breaker for deterministic order } return $queryClone->limit($limit)->get(); @@ -175,6 +176,7 @@ public function getRecordsBeforePosition(string $columnId, string $position, int ->where($statusField, $columnId) ->where($positionField, '<', $position) ->orderBy($positionField, 'desc') + ->orderBy('id', 'desc') // Tie-breaker for deterministic order ->limit($limit) ->get(); } @@ -197,6 +199,7 @@ public function getRecordsAfterPosition(string $columnId, string $position, int ->where($statusField, $columnId) ->where($positionField, '>', $position) ->orderBy($positionField, 'asc') + ->orderBy('id', 'asc') // Tie-breaker for deterministic order ->limit($limit) ->get(); } @@ -218,6 +221,7 @@ public function getLastPositionInColumn(string $columnId): ?string $record = (clone $query) ->where($statusField, $columnId) ->orderBy($positionField, 'desc') + ->orderBy('id', 'desc') // Tie-breaker for deterministic order ->first(); return $record?->getAttribute($positionField); diff --git a/src/Concerns/InteractsWithBoard.php b/src/Concerns/InteractsWithBoard.php index 527f2d8..d353333 100644 --- a/src/Concerns/InteractsWithBoard.php +++ b/src/Concerns/InteractsWithBoard.php @@ -14,7 +14,8 @@ use InvalidArgumentException; use Relaticle\Flowforge\Board; use Relaticle\Flowforge\Exceptions\MaxRetriesExceededException; -use Relaticle\Flowforge\Services\Rank; +use Relaticle\Flowforge\Services\DecimalPosition; +use Relaticle\Flowforge\Services\PositionRebalancer; use Throwable; trait InteractsWithBoard @@ -76,7 +77,7 @@ protected function makeBoard(): Board } /** - * Move card to new position using Rank-based positioning. + * Move card to new position using decimal-based positioning. * * @throws Throwable */ @@ -119,12 +120,13 @@ protected function calculateAndUpdatePosition( ?string $afterCardId, ?string $beforeCardId ): string { - $newPosition = null; + $newPosition = ''; DB::transaction(function () use ($card, $targetColumnId, $afterCardId, $beforeCardId, &$newPosition) { $board = $this->getBoard(); $query = $board->getQuery(); $positionField = $board->getPositionIdentifierAttribute(); + $columnField = $board->getColumnIdentifierAttribute(); // LOCK reference cards for reading to prevent stale data $afterCard = $afterCardId @@ -135,19 +137,41 @@ protected function calculateAndUpdatePosition( ? (clone $query)->where('id', $beforeCardId)->lockForUpdate()->first() : null; + // Get positions from locked cards + $afterPos = $afterCard?->getAttribute($positionField); + $beforePos = $beforeCard?->getAttribute($positionField); + // Calculate position INSIDE transaction with locked data - $newPosition = $this->calculatePositionBetweenLockedCards( - $afterCard, - $beforeCard, - $targetColumnId - ); + $newPosition = $this->calculateDecimalPosition($afterPos, $beforePos, $targetColumnId); + + // Check if rebalancing is needed after this insert + if ($afterPos !== null && $beforePos !== null) { + $afterPosStr = DecimalPosition::normalize($afterPos); + $beforePosStr = DecimalPosition::normalize($beforePos); + + if (DecimalPosition::needsRebalancing($afterPosStr, $beforePosStr)) { + // Rebalance the column - this redistributes positions evenly + $this->rebalanceColumn($targetColumnId); + + // Recalculate position after rebalancing + $afterCard = $afterCardId + ? (clone $query)->where('id', $afterCardId)->lockForUpdate()->first() + : null; + $beforeCard = $beforeCardId + ? (clone $query)->where('id', $beforeCardId)->lockForUpdate()->first() + : null; + + $afterPos = $afterCard?->getAttribute($positionField); + $beforePos = $beforeCard?->getAttribute($positionField); + $newPosition = $this->calculateDecimalPosition($afterPos, $beforePos, $targetColumnId); + } + } // Update card position - $columnIdentifier = $board->getColumnIdentifierAttribute(); - $columnValue = $this->resolveStatusValue($card, $columnIdentifier, $targetColumnId); + $columnValue = $this->resolveStatusValue($card, $columnField, $targetColumnId); $card->update([ - $columnIdentifier => $columnValue, + $columnField => $columnValue, $positionField => $newPosition, ]); }); @@ -156,35 +180,51 @@ protected function calculateAndUpdatePosition( } /** - * Calculate position between locked cards (used within transaction). + * Calculate position using DecimalPosition service. + * + * @param mixed $afterPos Position of card above (null for top) + * @param mixed $beforePos Position of card below (null for bottom) + * @param string $columnId Target column ID */ - protected function calculatePositionBetweenLockedCards( - ?Model $afterCard, - ?Model $beforeCard, - string $columnId - ): string { - if (! $afterCard && ! $beforeCard) { + protected function calculateDecimalPosition(mixed $afterPos, mixed $beforePos, string $columnId): string + { + // Handle empty column case + if ($afterPos === null && $beforePos === null) { return $this->getBoardPositionInColumn($columnId, 'bottom'); } - $positionField = $this->getBoard()->getPositionIdentifierAttribute(); - - $beforePos = $beforeCard?->getAttribute($positionField); - $afterPos = $afterCard?->getAttribute($positionField); + // Normalize positions to strings for BCMath + $afterPosStr = $afterPos !== null ? DecimalPosition::normalize($afterPos) : null; + $beforePosStr = $beforePos !== null ? DecimalPosition::normalize($beforePos) : null; - if ($beforePos && $afterPos && is_string($beforePos) && is_string($afterPos)) { - return Rank::betweenRanks(Rank::fromString($afterPos), Rank::fromString($beforePos))->get(); - } + return DecimalPosition::calculate($afterPosStr, $beforePosStr); + } - if ($beforePos && is_string($beforePos)) { - return Rank::before(Rank::fromString($beforePos))->get(); - } + /** + * Rebalance all positions in a column, redistributing them evenly. + * Called automatically when gap between positions falls below MIN_GAP. + */ + protected function rebalanceColumn(string $columnId): void + { + $board = $this->getBoard(); + $query = $board->getQuery(); - if ($afterPos && is_string($afterPos)) { - return Rank::after(Rank::fromString($afterPos))->get(); + if (! $query) { + return; } - return Rank::forEmptySequence()->get(); + $rebalancer = new PositionRebalancer; + $count = $rebalancer->rebalanceColumn( + $query, + $board->getColumnIdentifierAttribute(), + $columnId, + $board->getPositionIdentifierAttribute() + ); + + Log::info('Flowforge: Auto-rebalanced column due to small gap', [ + 'column' => $columnId, + 'records' => $count, + ]); } /** @@ -262,6 +302,54 @@ protected function isDuplicatePositionError(QueryException $e): bool str_contains($e->getMessage(), 'UNIQUE constraint failed'); } + /** + * Execute position update with retry mechanism for race conditions. + * Handles cases where rapid card movements cause stale data issues. + * + * @template T + * + * @param callable(): T $callback + * @return T + */ + protected function withPositionRetry(callable $callback, string $cardId, string $targetColumnId, int $maxAttempts = 3): mixed + { + $baseDelay = 50; // milliseconds + $lastException = null; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + try { + return $callback(); + } catch (QueryException $e) { + if (! $this->isDuplicatePositionError($e)) { + throw $e; + } + + $lastException = $e; + + Log::info('Position conflict detected, retrying', [ + 'card_id' => $cardId, + 'target_column' => $targetColumnId, + 'attempt' => $attempt, + 'max_attempts' => $maxAttempts, + ]); + + if ($attempt >= $maxAttempts) { + throw new MaxRetriesExceededException( + "Failed to move card after {$maxAttempts} attempts due to position conflicts", + previous: $e + ); + } + + $delay = $baseDelay * pow(2, $attempt - 1); + usleep($delay * 1000); + + continue; + } + } + + throw $lastException ?? new \RuntimeException('Unexpected retry loop exit'); + } + public function loadMoreItems(string $columnId, ?int $count = null): void { $count = $count ?? $this->getBoard()->getCardsPerColumn(); @@ -347,7 +435,7 @@ protected function calculatePositionBetweenCards( $query = $this->getBoard()->getQuery(); if (! $query) { - return Rank::forEmptySequence()->get(); + return DecimalPosition::forEmptyColumn(); } $positionField = $this->getBoard()->getPositionIdentifierAttribute(); @@ -358,19 +446,11 @@ protected function calculatePositionBetweenCards( $afterCard = $afterCardId ? (clone $query)->find($afterCardId) : null; $afterPos = $afterCard?->getAttribute($positionField); - if ($beforePos && $afterPos && is_string($beforePos) && is_string($afterPos)) { - return Rank::betweenRanks(Rank::fromString($afterPos), Rank::fromString($beforePos))->get(); - } - - if ($beforePos && is_string($beforePos)) { - return Rank::before(Rank::fromString($beforePos))->get(); - } - - if ($afterPos && is_string($afterPos)) { - return Rank::after(Rank::fromString($afterPos))->get(); - } + // Normalize positions + $afterPosStr = $afterPos !== null ? DecimalPosition::normalize($afterPos) : null; + $beforePosStr = $beforePos !== null ? DecimalPosition::normalize($beforePos) : null; - return Rank::forEmptySequence()->get(); + return DecimalPosition::calculate($afterPosStr, $beforePosStr); } /** @@ -474,7 +554,7 @@ public function getBoardPositionInColumn(string $columnId, string $position = 't { $query = $this->getBoard()->getQuery(); if (! $query) { - return Rank::forEmptySequence()->get(); + return DecimalPosition::forEmptyColumn(); } $board = $this->getBoard(); @@ -487,31 +567,33 @@ public function getBoardPositionInColumn(string $columnId, string $position = 't $firstRecord = $queryClone ->whereNotNull($positionField) ->orderBy($positionField, 'asc') + ->orderBy('id', 'asc') // Tie-breaker for deterministic order ->first(); if ($firstRecord) { $firstPosition = $firstRecord->getAttribute($positionField); - if (is_string($firstPosition)) { - return Rank::before(Rank::fromString($firstPosition))->get(); + if ($firstPosition !== null) { + return DecimalPosition::before(DecimalPosition::normalize($firstPosition)); } } - return Rank::forEmptySequence()->get(); + return DecimalPosition::forEmptyColumn(); } // Get last valid position (ignore null positions) $lastRecord = $queryClone ->whereNotNull($positionField) ->orderBy($positionField, 'desc') + ->orderBy('id', 'desc') // Tie-breaker for deterministic order ->first(); if ($lastRecord) { $lastPosition = $lastRecord->getAttribute($positionField); - if (is_string($lastPosition)) { - return Rank::after(Rank::fromString($lastPosition))->get(); + if ($lastPosition !== null) { + return DecimalPosition::after(DecimalPosition::normalize($lastPosition)); } } - return Rank::forEmptySequence()->get(); + return DecimalPosition::forEmptyColumn(); } } diff --git a/src/Exceptions/InvalidChars.php b/src/Exceptions/InvalidChars.php deleted file mode 100644 index 7893b40..0000000 --- a/src/Exceptions/InvalidChars.php +++ /dev/null @@ -1,17 +0,0 @@ - $chars - */ - public static function forInputRankWithInvalidChars(string $rank, array $chars): self - { - return new self('Rank provided contains an invalid Char. Rank Provided: ' . $rank . ' - Invalid char: ' . implode(', ', $chars)); - } -} diff --git a/src/Exceptions/LastCharCantBeEqualToMinChar.php b/src/Exceptions/LastCharCantBeEqualToMinChar.php deleted file mode 100644 index 70c04b8..0000000 --- a/src/Exceptions/LastCharCantBeEqualToMinChar.php +++ /dev/null @@ -1,22 +0,0 @@ -get() . ') is greater than or equals to Next (' . $next->get() . ')'); - } -} diff --git a/src/FlowforgeServiceProvider.php b/src/FlowforgeServiceProvider.php index 4f0a6d6..8f51960 100644 --- a/src/FlowforgeServiceProvider.php +++ b/src/FlowforgeServiceProvider.php @@ -8,9 +8,9 @@ use Filament\Support\Facades\FilamentIcon; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Blade; -use Illuminate\Support\Facades\DB; use Relaticle\Flowforge\Commands\DiagnosePositionsCommand; use Relaticle\Flowforge\Commands\MakeKanbanBoardCommand; +use Relaticle\Flowforge\Commands\RebalancePositionsCommand; use Relaticle\Flowforge\Commands\RepairPositionsCommand; use Spatie\LaravelPackageTools\Commands\InstallCommand; use Spatie\LaravelPackageTools\Package; @@ -117,6 +117,7 @@ protected function getCommands(): array return [ DiagnosePositionsCommand::class, MakeKanbanBoardCommand::class, + RebalancePositionsCommand::class, RepairPositionsCommand::class, ]; } @@ -154,16 +155,10 @@ protected function getScriptData(): array */ private function registerBlueprintMacros(): void { + // DECIMAL(20,10) for position - 10 integer digits + 10 decimal places + // Supports ~33 bisections before precision loss, with 65535 gap Blueprint::macro('flowforgePositionColumn', function (string $name = 'position') { - $driver = DB::connection()->getDriverName(); - $column = $this->string($name)->nullable(); - - return match ($driver) { - 'pgsql' => $column->collation('C'), - 'mysql' => $column->collation('utf8mb4_bin'), - 'sqlsrv' => $column->collation('Latin1_General_BIN2'), - default => $column, // No collation needed - BINARY by default, e.g. SQLite - }; + return $this->decimal($name, 20, 10)->nullable(); }); } } diff --git a/src/Services/DecimalPosition.php b/src/Services/DecimalPosition.php new file mode 100644 index 0000000..01e8ea2 --- /dev/null +++ b/src/Services/DecimalPosition.php @@ -0,0 +1,279 @@ + Array of evenly-spaced positions + */ + public static function generateSequence(int $count): array + { + $positions = []; + for ($i = 1; $i <= $count; $i++) { + $positions[] = bcmul((string) $i, self::DEFAULT_GAP, self::SCALE); + } + + return $positions; + } + + /** + * Normalize a position string to ensure consistent format. + * Converts numeric values to properly scaled decimal strings. + */ + public static function normalize(string|int|float $position): string + { + return bcadd((string) $position, '0', self::SCALE); + } + + /** + * Compare two positions. + * + * @return int -1 if $a < $b, 0 if equal, 1 if $a > $b + */ + public static function compare(string $a, string $b): int + { + return bccomp($a, $b, self::SCALE); + } + + /** + * Check if position A is less than position B. + */ + public static function lessThan(string $a, string $b): bool + { + return self::compare($a, $b) < 0; + } + + /** + * Check if position A is greater than position B. + */ + public static function greaterThan(string $a, string $b): bool + { + return self::compare($a, $b) > 0; + } + + /** + * Get the gap between two positions. + */ + public static function gap(string $lower, string $upper): string + { + return bcsub($upper, $lower, self::SCALE); + } + + /** + * Generate N positions between two bounds with independent jitter. + * + * Useful for bulk insertions that need multiple unique positions + * within a given range. Each position gets independent jitter. + * + * @param string $after Lower bound position + * @param string $before Upper bound position + * @param int $count Number of positions to generate + * @return array Array of unique positions + */ + public static function generateBetween(string $after, string $before, int $count): array + { + if ($count < 1) { + return []; + } + + $positions = []; + $gap = bcsub($before, $after, self::SCALE); + $step = bcdiv($gap, (string) ($count + 1), self::SCALE); + + for ($i = 1; $i <= $count; $i++) { + $basePosition = bcadd($after, bcmul($step, (string) $i, self::SCALE), self::SCALE); + + // Add jitter to each position (5% of step size) + $jitterRange = bcmul($step, '0.05', self::SCALE); + $jitter = self::generateJitter($jitterRange); + + $positions[] = bcadd($basePosition, $jitter, self::SCALE); + } + + return $positions; + } + + /** + * Generate cryptographically secure random jitter in range [-$maxOffset, +$maxOffset]. + * + * Uses random_bytes() for cryptographic randomness, then scales to the + * desired range using BCMath for precision. + * + * @param string $maxOffset Maximum absolute offset (positive number) + * @return string Random value in [-maxOffset, +maxOffset] + */ + private static function generateJitter(string $maxOffset): string + { + // If maxOffset is zero or very small, return zero + if (bccomp($maxOffset, '0', self::SCALE) <= 0) { + return '0.0000000000'; + } + + // Get 8 random bytes and convert to unsigned 64-bit integer + $bytes = random_bytes(8); + $randomInt = unpack('P', $bytes)[1]; // Unsigned 64-bit little-endian + + // Normalize to [0, 1] range + // PHP_INT_MAX is the max for signed, but we have unsigned so use 2^64 + $maxUint64 = '18446744073709551615'; // 2^64 - 1 + $normalized = bcdiv((string) $randomInt, $maxUint64, self::SCALE); + + // Scale to [-1, 1] range + $scaled = bcsub(bcmul($normalized, '2', self::SCALE), '1', self::SCALE); + + // Apply to max offset: result in [-maxOffset, +maxOffset] + return bcmul($scaled, $maxOffset, self::SCALE); + } +} diff --git a/src/Services/PositionRebalancer.php b/src/Services/PositionRebalancer.php new file mode 100644 index 0000000..c9767f5 --- /dev/null +++ b/src/Services/PositionRebalancer.php @@ -0,0 +1,237 @@ +where($columnField, $columnId) + ->whereNotNull($positionField) + ->orderBy($positionField) + ->orderBy('id') // Tie-breaker for deterministic order + ->get(); + + if ($records->isEmpty()) { + return 0; + } + + $positions = DecimalPosition::generateSequence($records->count()); + + DB::transaction(function () use ($records, $positions, $positionField) { + foreach ($records as $index => $record) { + /** @var Model $record */ + $record->update([$positionField => $positions[$index]]); + } + }); + + Log::info('Flowforge: Rebalanced column positions', [ + 'column' => $columnId, + 'count' => $records->count(), + ]); + + return $records->count(); + } + + /** + * Check if a column needs rebalancing by scanning for small gaps. + * + * @param Builder $query The base query for the model + * @param string $columnField The field identifying the column + * @param string $columnId The column value to check + * @param string $positionField The field storing positions + */ + public function needsRebalancing( + Builder $query, + string $columnField, + string $columnId, + string $positionField + ): bool { + $positions = (clone $query) + ->where($columnField, $columnId) + ->whereNotNull($positionField) + ->orderBy($positionField) + ->pluck($positionField); + + return $this->hasSmallGaps($positions); + } + + /** + * Find columns that need rebalancing. + * + * @param Builder $query The base query for the model + * @param string $columnField The field identifying the column + * @param string $positionField The field storing positions + * @return Collection Column IDs that need rebalancing + */ + public function findColumnsNeedingRebalancing( + Builder $query, + string $columnField, + string $positionField + ): Collection { + $columns = (clone $query) + ->select($columnField) + ->distinct() + ->pluck($columnField); + + return $columns->filter(function ($columnId) use ($query, $columnField, $positionField) { + return $this->needsRebalancing($query, $columnField, (string) $columnId, $positionField); + })->values(); + } + + /** + * Rebalance all columns that need it. + * + * @param Builder $query The base query for the model + * @param string $columnField The field identifying the column + * @param string $positionField The field storing positions + * @return array Map of column ID to records rebalanced + */ + public function rebalanceAll( + Builder $query, + string $columnField, + string $positionField + ): array { + $results = []; + + $columnsNeedingRebalancing = $this->findColumnsNeedingRebalancing( + $query, + $columnField, + $positionField + ); + + foreach ($columnsNeedingRebalancing as $columnId) { + $results[(string) $columnId] = $this->rebalanceColumn( + $query, + $columnField, + (string) $columnId, + $positionField + ); + } + + return $results; + } + + /** + * Get gap statistics for a column. + * + * @param Builder $query The base query for the model + * @param string $columnField The field identifying the column + * @param string $columnId The column value to analyze + * @param string $positionField The field storing positions + * @return array{count: int, min_gap: string|null, max_gap: string|null, avg_gap: string|null, small_gaps: int} + */ + public function getGapStatistics( + Builder $query, + string $columnField, + string $columnId, + string $positionField + ): array { + $positions = (clone $query) + ->where($columnField, $columnId) + ->whereNotNull($positionField) + ->orderBy($positionField) + ->pluck($positionField) + ->map(fn ($p) => DecimalPosition::normalize($p)) + ->values(); + + if ($positions->count() < 2) { + return [ + 'count' => $positions->count(), + 'min_gap' => null, + 'max_gap' => null, + 'avg_gap' => null, + 'small_gaps' => 0, + ]; + } + + $gaps = []; + $smallGapCount = 0; + + for ($i = 1; $i < $positions->count(); $i++) { + $gap = DecimalPosition::gap($positions[$i - 1], $positions[$i]); + $gaps[] = $gap; + + if (bccomp($gap, DecimalPosition::MIN_GAP, DecimalPosition::SCALE) < 0) { + $smallGapCount++; + } + } + + // Calculate min/max/avg using bcmath + $minGap = $gaps[0]; + $maxGap = $gaps[0]; + $totalGap = '0'; + + foreach ($gaps as $gap) { + if (bccomp($gap, $minGap, DecimalPosition::SCALE) < 0) { + $minGap = $gap; + } + if (bccomp($gap, $maxGap, DecimalPosition::SCALE) > 0) { + $maxGap = $gap; + } + $totalGap = bcadd($totalGap, $gap, DecimalPosition::SCALE); + } + + $avgGap = bcdiv($totalGap, (string) count($gaps), DecimalPosition::SCALE); + + return [ + 'count' => $positions->count(), + 'min_gap' => $minGap, + 'max_gap' => $maxGap, + 'avg_gap' => $avgGap, + 'small_gaps' => $smallGapCount, + ]; + } + + /** + * Check if a collection of positions has any gaps below MIN_GAP. + * + * @param Collection $positions + */ + private function hasSmallGaps(Collection $positions): bool + { + if ($positions->count() < 2) { + return false; + } + + $normalized = $positions + ->map(fn ($p) => DecimalPosition::normalize($p)) + ->values(); + + for ($i = 1; $i < $normalized->count(); $i++) { + if (DecimalPosition::needsRebalancing($normalized[$i - 1], $normalized[$i])) { + return true; + } + } + + return false; + } +} diff --git a/src/Services/Rank.php b/src/Services/Rank.php deleted file mode 100644 index 0faec0a..0000000 --- a/src/Services/Rank.php +++ /dev/null @@ -1,194 +0,0 @@ -rank = $rank; - } - - /** - * @param non-empty-string $rank - */ - private static function rankValidator(string $rank): void - { - if (strlen($rank) > self::MAX_RANK_LEN) { - throw MaxRankLength::forInputRank($rank, self::MAX_RANK_LEN); - } - - $invalidChars = array_filter( - str_split($rank), - static function ($char) { - return ord($char) < ord(self::MIN_CHAR) || ord($char) > ord(self::MAX_CHAR); - } - ); - - if ($invalidChars !== []) { - throw InvalidChars::forInputRankWithInvalidChars($rank, array_values($invalidChars)); - } - - $lastChar = substr($rank, -1); - if ($lastChar === self::MIN_CHAR) { - throw LastCharCantBeEqualToMinChar::forRank($rank, self::MIN_CHAR); - } - } - - /** - * @return non-empty-string - */ - public function get(): string - { - return $this->rank; - } - - /** - * @param non-empty-string $rank - */ - public static function fromString(string $rank): self - { - return new self($rank); - } - - public static function forEmptySequence(): self - { - return self::fromString(self::mid(self::MIN_CHAR, self::MAX_CHAR)); - } - - public static function after(self $prevRank): self - { - $char = substr($prevRank->get(), -1); - - if (ord($char) + 1 >= ord(self::MAX_CHAR)) { - return self::fromString( - $prevRank->get() . chr(ord(self::MIN_CHAR) + 1) - ); - } - - $return = substr($prevRank->get(), 0, -1) . chr(ord($char) + 1); - - Assert::stringNotEmpty($return); - - return self::fromString($return); - } - - public static function before(self $nextRank): self - { - $char = substr($nextRank->get(), -1); - - if (ord($char) - 1 <= ord(self::MIN_CHAR)) { - $return = substr($nextRank->get(), 0, -1) . chr(ord($char) - 1) . chr(ord(self::MAX_CHAR) - 1); - - Assert::stringNotEmpty($return); - - return self::fromString($return); - } - - $return = substr($nextRank->get(), 0, -1) . chr(ord($char) - 1); - - Assert::stringNotEmpty($return); - - return self::fromString($return); - } - - public static function betweenRanks(self $prevRank, self $nextRank): self - { - if (strcmp($prevRank->get(), $nextRank->get()) >= 0) { - throw PrevGreaterThanOrEquals::betweenRanks($prevRank, $nextRank); - } - - $rank = ''; - $i = 0; - while ($i <= self::MAX_RANK_LEN) { - $prevChar = $prevRank->getChar($i, self::MIN_CHAR); - $nextChar = $nextRank->getChar($i, self::MAX_CHAR); - $i++; - - $midChar = self::mid($prevChar, $nextChar); - if (in_array($midChar, [$prevChar, $nextChar])) { - $rank .= $prevChar; - - continue; - } - - $rank .= $midChar; - - break; - } - - Assert::stringNotEmpty($rank); - - return self::fromString($rank); - } - - /** - * @param 0|positive-int $i - * @param non-empty-string $defaultChar - * @return non-empty-string - */ - private function getChar(int $i, string $defaultChar): string - { - $return = $this->rank[$i] ?? $defaultChar; - - Assert::stringNotEmpty($return); - - return $return; - } - - /** - * @param non-empty-string $prev - * @param non-empty-string $next - * @return non-empty-string - * - * @psalm-pure - */ - private static function mid(string $prev, string $next): string - { - if (ord($prev) >= ord($next)) { - return $prev; - } - - $return = chr((int) ((ord($prev) + ord($next)) / 2)); - - Assert::stringNotEmpty($return); - - return $return; - } -} diff --git a/tests/Datasets/TaskMovements.php b/tests/Datasets/TaskMovements.php index 823b6ca..aa37981 100644 --- a/tests/Datasets/TaskMovements.php +++ b/tests/Datasets/TaskMovements.php @@ -1,6 +1,6 @@ ['todo', 'in_progress'], @@ -88,10 +88,10 @@ // Production board state factory function createProductionBoardState(): array { - $rank = Rank::forEmptySequence(); + $position = DecimalPosition::forEmptyColumn(); $tasks = []; - // Generate tasks with proper Rank positions + // Generate tasks with proper decimal positions $taskData = [ ['Fix critical security vulnerability', 'todo', 'high'], ['Implement user authentication', 'todo', 'high'], @@ -109,17 +109,15 @@ function createProductionBoardState(): array ['Create deployment scripts', 'completed', 'medium'], ]; - foreach ($taskData as $index => [$title, $status, $priority]) { - if ($index > 0) { - $rank = Rank::after($rank); - } - + foreach ($taskData as [$title, $status, $priority]) { $tasks[] = [ 'title' => $title, 'status' => $status, - 'order_position' => $rank->get(), + 'order_position' => $position, 'priority' => $priority, ]; + + $position = DecimalPosition::after($position); } return $tasks; diff --git a/tests/Feature/BlueprintMacroTest.php b/tests/Feature/BlueprintMacroTest.php index b1960e6..87d0916 100644 --- a/tests/Feature/BlueprintMacroTest.php +++ b/tests/Feature/BlueprintMacroTest.php @@ -2,93 +2,34 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\ColumnDefinition; -use Illuminate\Support\Facades\DB; - -test('flowforgePositionColumn macro creates position column with correct collation for MySQL', function () { - // Mock MySQL connection - DB::shouldReceive('connection->getDriverName') - ->once() - ->andReturn('mysql'); +test('flowforgePositionColumn macro creates decimal position column', function () { $blueprint = new Blueprint('test_table'); $column = $blueprint->flowforgePositionColumn(); expect($column)->toBeInstanceOf(ColumnDefinition::class) - ->and($column->get('type'))->toBe('string') + ->and($column->get('type'))->toBe('decimal') ->and($column->get('nullable'))->toBeTrue() - ->and($column->get('collation'))->toBe('utf8mb4_bin'); -}); - -test('flowforgePositionColumn macro creates position column with correct collation for PostgreSQL', function () { - // Mock PostgreSQL connection - DB::shouldReceive('connection->getDriverName') - ->once() - ->andReturn('pgsql'); - - $blueprint = new Blueprint('test_table'); - $column = $blueprint->flowforgePositionColumn(); - - expect($column)->toBeInstanceOf(ColumnDefinition::class) - ->and($column->get('type'))->toBe('string') - ->and($column->get('nullable'))->toBeTrue() - ->and($column->get('collation'))->toBe('C'); + ->and($column->get('total'))->toBe(20) + ->and($column->get('places'))->toBe(10); }); test('flowforgePositionColumn macro accepts custom column name', function () { - // Mock MySQL connection - DB::shouldReceive('connection->getDriverName') - ->once() - ->andReturn('mysql'); - $blueprint = new Blueprint('test_table'); $column = $blueprint->flowforgePositionColumn('sort_order'); expect($column)->toBeInstanceOf(ColumnDefinition::class) ->and($column->get('name'))->toBe('sort_order') - ->and($column->get('collation'))->toBe('utf8mb4_bin'); + ->and($column->get('type'))->toBe('decimal') + ->and($column->get('nullable'))->toBeTrue(); }); -test('flowforgePositionColumn macro creates position column with correct collation for SQL Server', function () { - // Mock SQL Server connection - DB::shouldReceive('connection->getDriverName') - ->once() - ->andReturn('sqlsrv'); - +test('flowforgePositionColumn macro creates column with correct precision for 33+ bisections', function () { $blueprint = new Blueprint('test_table'); $column = $blueprint->flowforgePositionColumn(); - expect($column)->toBeInstanceOf(ColumnDefinition::class) - ->and($column->get('type'))->toBe('string') - ->and($column->get('nullable'))->toBeTrue() - ->and($column->get('collation'))->toBe('Latin1_General_BIN2'); -}); - -test('flowforgePositionColumn macro works with SQLite (no collation needed)', function () { - // Mock SQLite connection - DB::shouldReceive('connection->getDriverName') - ->once() - ->andReturn('sqlite'); - - $blueprint = new Blueprint('test_table'); - $column = $blueprint->flowforgePositionColumn(); - - expect($column)->toBeInstanceOf(ColumnDefinition::class) - ->and($column->get('type'))->toBe('string') - ->and($column->get('nullable'))->toBeTrue() - ->and($column->get('collation'))->toBeNull(); // SQLite uses BINARY by default -}); - -test('flowforgePositionColumn macro works with unsupported database driver', function () { - // Mock unsupported driver - DB::shouldReceive('connection->getDriverName') - ->once() - ->andReturn('unknown_driver'); - - $blueprint = new Blueprint('test_table'); - $column = $blueprint->flowforgePositionColumn(); - - expect($column)->toBeInstanceOf(ColumnDefinition::class) - ->and($column->get('type'))->toBe('string') - ->and($column->get('nullable'))->toBeTrue() - ->and($column->get('collation'))->toBeNull(); // Graceful fallback + // DECIMAL(20,10) = 10 integer digits + 10 decimal places + // This supports approximately 33 bisections before hitting MIN_GAP + expect($column->get('total'))->toBe(20) + ->and($column->get('places'))->toBe(10); }); diff --git a/tests/Feature/CharacterSpaceExhaustionTest.php b/tests/Feature/CharacterSpaceExhaustionTest.php index f3c5b6e..1b9aa0f 100644 --- a/tests/Feature/CharacterSpaceExhaustionTest.php +++ b/tests/Feature/CharacterSpaceExhaustionTest.php @@ -1,7 +1,7 @@ board = Livewire::test(TestBoard::class); }); -describe('Character Space Exhaustion Tests - Finding Breaking Points', function () { +describe('Decimal Position Gap Tests - Ensuring Precision Never Fails', function () { it('stresses 100 sequential insertions at bottom of column', function () { - // STRESS TEST: Test Rank algorithm with 100 sequential insertions - // Expected: Position strings grow linearly, all positions unique - // This tests Rank::after() under heavy sequential usage + // STRESS TEST: Test DecimalPosition with 100 sequential insertions + // Expected: Positions grow linearly, all positions unique + // This tests DecimalPosition::after() under heavy sequential usage $cards = collect(); - $lengthMetrics = []; - $maxLengthObserved = 0; + $position = DecimalPosition::forEmptyColumn(); // Create 100 cards sequentially at bottom for ($i = 1; $i <= 100; $i++) { - $rank = $i === 1 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $card = Task::factory()->create([ 'title' => "Sequential #{$i}", 'status' => 'todo', - 'order_position' => $rank->get(), + 'order_position' => $position, ]); $cards->push($card); + $position = DecimalPosition::after($position); - $posLength = strlen($card->order_position); - $maxLengthObserved = max($maxLengthObserved, $posLength); - - // Track metrics at checkpoints + // Check for inversions at checkpoints if (in_array($i, [10, 20, 50, 75, 100])) { - $allPositions = $cards->pluck('order_position')->map(fn ($pos) => strlen($pos)); - - $lengthMetrics[$i] = [ - 'avg_length' => round($allPositions->avg(), 2), - 'max_length' => $allPositions->max(), - 'min_length' => $allPositions->min(), - 'latest_pos' => $card->order_position, - ]; - - // Check for inversions at each checkpoint $inversions = detectInversions(Task::class, 'todo'); expect($inversions)->toBeEmpty( "No inversions after {$i} sequential insertions" @@ -59,61 +42,43 @@ $uniquePositions = $cards->pluck('order_position')->unique()->count(); expect($uniquePositions)->toBe(100, 'All 100 positions should be unique'); - // Verify max length is reasonable - expect($maxLengthObserved)->toBeLessThan( - 10, - 'Position strings should stay short for sequential insertions' - ); + // Verify positions are in ascending order + $positions = Task::where('status', 'todo') + ->orderBy('order_position') + ->orderBy('id') + ->pluck('order_position') + ->toArray(); - dump('=== SEQUENTIAL INSERTION METRICS (100 cards) ===', $lengthMetrics); - dump("Max length: {$maxLengthObserved} chars"); + for ($i = 0; $i < count($positions) - 1; $i++) { + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($positions[$i]), + DecimalPosition::normalize($positions[$i + 1]) + ))->toBeTrue("Position {$i} should be less than position " . ($i + 1)); + } }); - it('monitors position string length growth patterns with 500 sequential cards', function () { - // STRESS TEST: Create 500 cards sequentially (not insertions) - // Expected: Linear growth in position strings - // This tests the Rank::after() method under heavy sequential usage + it('monitors position growth patterns with 500 sequential cards', function () { + // STRESS TEST: Create 500 cards sequentially + // Expected: Linear growth in position values, not exponential + // This tests DecimalPosition::after() under heavy sequential usage $cards = collect(); - $lengthMetrics = []; + $position = DecimalPosition::forEmptyColumn(); for ($i = 1; $i <= 500; $i++) { - $rank = $i === 1 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $card = Task::factory()->create([ 'title' => "Sequential Card {$i}", 'status' => 'in_progress', - 'order_position' => $rank->get(), + 'order_position' => $position, ]); $cards->push($card); - - // Track metrics every 50 cards - if ($i % 50 === 0) { - $positions = $cards->pluck('order_position')->map(fn ($pos) => strlen($pos)); - - $lengthMetrics[$i] = [ - 'avg_length' => round($positions->avg(), 2), - 'max_length' => $positions->max(), - 'min_length' => $positions->min(), - 'latest_pos' => $card->order_position, - 'latest_length' => strlen($card->order_position), - ]; - } + $position = DecimalPosition::after($position); } // Verify all cards created expect($cards->count())->toBe(500, 'All 500 cards should be created'); - // Verify average length stays reasonable (< 10 chars for sequential) - $finalAvg = collect($cards->pluck('order_position'))->map(fn ($pos) => strlen($pos))->avg(); - expect($finalAvg)->toBeLessThan( - 10, - 'Average position length should stay reasonable for sequential cards' - ); - // Verify no inversions in sequential creation $inversions = detectInversions(Task::class, 'in_progress'); expect($inversions)->toBeEmpty( @@ -123,28 +88,25 @@ // Verify positions are unique $uniquePositions = $cards->pluck('order_position')->unique()->count(); expect($uniquePositions)->toBe(500, 'All positions should be unique'); - - // Dump metrics for analysis - dump('=== SEQUENTIAL GROWTH METRICS (500 cards) ===', $lengthMetrics); }); - it('tests character space at MIN_CHAR boundary', function () { - // BOUNDARY TEST: Test positions near MIN_CHAR ('0') - // Create a card with position just above MIN_CHAR and insert before it + it('tests position calculation near minimum boundary', function () { + // BOUNDARY TEST: Test positions near zero + // Create a card with small position and insert before it $boundaryCard = Task::factory()->create([ - 'title' => 'Near Min Boundary', + 'title' => 'Near Zero Boundary', 'status' => 'review', - 'order_position' => '1', // Just above MIN_CHAR ('0') + 'order_position' => '1000.0000000000', ]); $newCard = Task::factory()->create([ - 'title' => 'Insert Before Min Boundary', + 'title' => 'Insert Before Boundary', 'status' => 'review', - 'order_position' => Rank::forEmptySequence()->get(), + 'order_position' => DecimalPosition::forEmptyColumn(), ]); - // Move card to be BEFORE the boundary card (should create position < '1') + // Move card to be BEFORE the boundary card $this->board->call( 'moveCard', (string) $newCard->id, @@ -155,39 +117,30 @@ $newCard->refresh(); - // Verify new position is less than '1' - expect(strcmp($newCard->order_position, '1'))->toBeLessThan( - 0, - 'Position should be < "1" when moved before it' - ); - - // Verify position doesn't end with MIN_CHAR ('0') - $lastChar = substr($newCard->order_position, -1); - expect($lastChar)->not->toBe( - Rank::MIN_CHAR, - 'Position should not end with MIN_CHAR' - ); - - dump('Position near MIN_CHAR boundary:', $newCard->order_position); + // Verify new position is less than boundary + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($newCard->order_position), + DecimalPosition::normalize($boundaryCard->order_position) + ))->toBeTrue('Position should be < boundary when moved before it'); }); - it('tests character space at MAX_CHAR boundary', function () { - // BOUNDARY TEST: Test positions near MAX_CHAR ('z') - // Create a card with position just below MAX_CHAR and insert after it + it('tests position calculation near large values', function () { + // BOUNDARY TEST: Test positions with large values + // Create a card with large position and insert after it $boundaryCard = Task::factory()->create([ - 'title' => 'Near Max Boundary', + 'title' => 'Large Position', 'status' => 'review', - 'order_position' => 'y', // Just below MAX_CHAR ('z') + 'order_position' => '9999999999.0000000000', // Large position ]); $newCard = Task::factory()->create([ - 'title' => 'Insert After Max Boundary', + 'title' => 'Insert After Large', 'status' => 'review', - 'order_position' => Rank::forEmptySequence()->get(), + 'order_position' => DecimalPosition::forEmptyColumn(), ]); - // Move card to be AFTER the boundary card (should create position > 'y') + // Move card to be AFTER the boundary card $this->board->call( 'moveCard', (string) $newCard->id, @@ -198,54 +151,44 @@ $newCard->refresh(); - // Verify new position is greater than 'y' - expect(strcmp($newCard->order_position, 'y'))->toBeGreaterThan( - 0, - 'Position should be > "y" when moved after it' - ); - - // Verify position is valid (< MAX_CHAR or extended properly) - expect(strlen($newCard->order_position))->toBeLessThan( - Rank::MAX_RANK_LEN, - 'Position should be under MAX_RANK_LEN' - ); - - dump('Position near MAX_CHAR boundary:', $newCard->order_position); + // Verify new position is greater than boundary + expect(DecimalPosition::greaterThan( + DecimalPosition::normalize($newCard->order_position), + DecimalPosition::normalize($boundaryCard->order_position) + ))->toBeTrue('Position should be > boundary when moved after it'); }); - it('verifies character space exhaustion with progressive insertions', function () { - // EXHAUSTION TEST: Insert cards progressively, subdividing space - // Expected: Position strings grow longer as we subdivide the space + it('verifies progressive bisection insertions never fail', function () { + // BISECTION TEST: Insert cards progressively, subdividing space + // With decimal positions, midpoint calculation never fails // Create boundary cards $cards = collect([ Task::factory()->create([ 'title' => 'Start', 'status' => 'done', - 'order_position' => 'a', - ]), - Task::factory()->create([ - 'title' => 'End', - 'status' => 'done', - 'order_position' => 'b', + 'order_position' => DecimalPosition::forEmptyColumn(), ]), ]); - $insertions = []; - $maxLength = 0; + $secondPosition = DecimalPosition::after(DecimalPosition::forEmptyColumn()); + $cards->push(Task::factory()->create([ + 'title' => 'End', + 'status' => 'done', + 'order_position' => $secondPosition, + ])); - // Insert 20 cards, each splitting existing space - for ($i = 1; $i <= 20; $i++) { - // Pick two adjacent cards to insert between + // Insert 30 cards between first and second (forces bisection) + for ($i = 1; $i <= 30; $i++) { + // Get current sorted cards $sortedCards = $cards->sortBy('order_position')->values(); - $pairIndex = ($i - 1) % ($sortedCards->count() - 1); - $afterCard = $sortedCards->get($pairIndex); - $beforeCard = $sortedCards->get($pairIndex + 1); + $afterCard = $sortedCards->first(); + $beforeCard = $sortedCards->get(1); $newCard = Task::factory()->create([ - 'title' => "Subdivision #{$i}", + 'title' => "Bisection #{$i}", 'status' => 'done', - 'order_position' => Rank::forEmptySequence()->get(), + 'order_position' => DecimalPosition::forEmptyColumn(), ]); $this->board->call( @@ -259,19 +202,17 @@ $newCard->refresh(); $cards->push($newCard); - $posLength = strlen($newCard->order_position); - $maxLength = max($maxLength, $posLength); - - $insertions[] = [ - 'insertion' => $i, - 'between' => [$afterCard->title, $beforeCard->title], - 'position' => $newCard->order_position, - 'length' => $posLength, - ]; - // Verify correct placement - expect(strcmp($afterCard->order_position, $newCard->order_position))->toBeLessThan(0); - expect(strcmp($newCard->order_position, $beforeCard->order_position))->toBeLessThan(0); + $afterPos = DecimalPosition::normalize($afterCard->order_position); + $newPos = DecimalPosition::normalize($newCard->order_position); + $beforePos = DecimalPosition::normalize($beforeCard->fresh()->order_position); + + expect(DecimalPosition::lessThan($afterPos, $newPos))->toBeTrue( + "Bisection {$i}: new position should be > afterCard" + ); + expect(DecimalPosition::lessThan($newPos, $beforePos))->toBeTrue( + "Bisection {$i}: new position should be < beforeCard" + ); } // Verify all positions unique @@ -279,45 +220,39 @@ $uniqueCount = $allPositions->unique()->count(); expect($uniqueCount)->toBe( $allPositions->count(), - 'All positions should be unique' + 'All positions should be unique after 30 bisections' ); // Verify positions are properly ordered when sorted $sortedPositions = Task::where('status', 'done') ->orderBy('order_position') + ->orderBy('id') ->pluck('order_position') ->toArray(); for ($i = 0; $i < count($sortedPositions) - 1; $i++) { - expect(strcmp($sortedPositions[$i], $sortedPositions[$i + 1]))->toBeLessThan( - 0, - "Sorted position {$i} should be < position " . ($i + 1) - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($sortedPositions[$i]), + DecimalPosition::normalize($sortedPositions[$i + 1]) + ))->toBeTrue("Sorted position {$i} should be < position " . ($i + 1)); } - - dump('=== PROGRESSIVE SUBDIVISION INSERTIONS ==='); - dump('Sample insertions:', array_slice($insertions, 0, 10)); - dump("Max length: {$maxLength} chars"); }); it('validates position uniqueness with systematic insertions', function () { // UNIQUENESS TEST: Ensure all positions remain unique with systematic insertions - // Create base cards $cards = collect(); + $position = DecimalPosition::forEmptyColumn(); // Create 50 cards sequentially for ($i = 1; $i <= 50; $i++) { - $rank = $i === 1 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $card = Task::factory()->create([ 'title' => "Card #{$i}", 'status' => 'backlog', - 'order_position' => $rank->get(), + 'order_position' => $position, ]); $cards->push($card); + $position = DecimalPosition::after($position); } // Verify ALL positions are unique @@ -343,10 +278,46 @@ // Verify positions are properly ordered $positions = $cards->pluck('order_position')->toArray(); for ($i = 0; $i < count($positions) - 1; $i++) { - expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( - 0, - "Position {$i} should be < position " . ($i + 1) - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($positions[$i]), + DecimalPosition::normalize($positions[$i + 1]) + ))->toBeTrue("Position {$i} should be < position " . ($i + 1)); + } + }); + + it('tests deep bisection without precision loss', function () { + // PRECISION TEST: Deeply bisect to test decimal precision + // DECIMAL(20,10) should support ~33 bisections before MIN_GAP + + $lower = DecimalPosition::forEmptyColumn(); // 65535 + $upper = DecimalPosition::after($lower); // 131070 + + $bisectionCount = 0; + $lastMid = null; + + // Keep bisecting until we can't anymore or hit 50 iterations + while ($bisectionCount < 50) { + $mid = DecimalPosition::between($lower, $upper); + + // Verify the midpoint is actually between lower and upper + if (! DecimalPosition::lessThan($lower, $mid) || ! DecimalPosition::lessThan($mid, $upper)) { + break; // Precision exhausted + } + + // Check if we've hit MIN_GAP (rebalancing would be needed) + if (DecimalPosition::needsRebalancing($lower, $mid)) { + break; + } + + $lastMid = $mid; + $upper = $mid; // Narrow the gap + $bisectionCount++; } + + // DECIMAL(20,10) should support at least 25-30 bisections + expect($bisectionCount)->toBeGreaterThanOrEqual( + 25, + "Should support at least 25 bisections before precision concerns (got {$bisectionCount})" + ); }); }); diff --git a/tests/Feature/ConcurrentOperationStressTest.php b/tests/Feature/ConcurrentOperationStressTest.php index 3a11b24..84f1c9f 100644 --- a/tests/Feature/ConcurrentOperationStressTest.php +++ b/tests/Feature/ConcurrentOperationStressTest.php @@ -1,7 +1,7 @@ order_position)->not()->toBeNull() - ->and($targetCard->order_position)->toBeString() - ->and(strlen($targetCard->order_position))->toBeGreaterThan(0) ->and($targetCard->status)->toBe($randomStatus); } - // Verify positions are properly sorted in each column + // Verify positions are properly sorted in each column (using decimal comparison) foreach ($statuses as $status) { $positions = Task::where('status', $status) ->orderBy('order_position') + ->orderBy('id') ->pluck('order_position') ->toArray(); // Check positions are in ascending order for ($i = 0; $i < count($positions) - 1; $i++) { - expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( - 0, + $current = DecimalPosition::normalize($positions[$i]); + $next = DecimalPosition::normalize($positions[$i + 1]); + expect(DecimalPosition::lessThan($current, $next))->toBeTrue( "Positions should be sorted in {$status} column after 20 rapid moves" ); } @@ -84,13 +84,15 @@ foreach (['todo', 'in_progress', 'completed'] as $status) { $positions = Task::where('status', $status) ->orderBy('order_position') + ->orderBy('id') ->pluck('order_position') ->toArray(); // Check positions are in ascending order for ($i = 0; $i < count($positions) - 1; $i++) { - expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( - 0, + $current = DecimalPosition::normalize($positions[$i]); + $next = DecimalPosition::normalize($positions[$i + 1]); + expect(DecimalPosition::lessThan($current, $next))->toBeTrue( "Positions should be sorted in {$status} column after concurrent operations" ); } @@ -116,18 +118,16 @@ // Create 20 cards in sequential order $cards = collect(); + $position = DecimalPosition::forEmptyColumn(); for ($i = 1; $i <= 20; $i++) { - $rank = $i === 1 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $card = Task::factory()->create([ 'title' => "Card {$i}", 'status' => 'todo', - 'order_position' => $rank->get(), + 'order_position' => $position, ]); $cards->push($card); + $position = DecimalPosition::after($position); } // Reverse the order by moving each card to the top @@ -136,6 +136,7 @@ // Move to top (afterCardId=null, beforeCardId=first card) $firstCard = Task::where('status', 'todo') ->orderBy('order_position') + ->orderBy('id') ->first(); $this->board->call( @@ -160,12 +161,14 @@ // Verify no inversions $sortedPositions = Task::where('status', 'todo') ->orderBy('order_position') + ->orderBy('id') ->pluck('order_position') ->toArray(); for ($i = 0; $i < count($sortedPositions) - 1; $i++) { - expect(strcmp($sortedPositions[$i], $sortedPositions[$i + 1]))->toBeLessThan( - 0, + $current = DecimalPosition::normalize($sortedPositions[$i]); + $next = DecimalPosition::normalize($sortedPositions[$i + 1]); + expect(DecimalPosition::lessThan($current, $next))->toBeTrue( "Position {$i} should be < position " . ($i + 1) . ' after mass reorder' ); } @@ -186,12 +189,14 @@ // Verify positions are properly sorted in target column $positions = Task::where('status', 'in_progress') ->orderBy('order_position') + ->orderBy('id') ->pluck('order_position') ->toArray(); for ($i = 0; $i < count($positions) - 1; $i++) { - expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( - 0, + $current = DecimalPosition::normalize($positions[$i]); + $next = DecimalPosition::normalize($positions[$i + 1]); + expect(DecimalPosition::lessThan($current, $next))->toBeTrue( 'Positions should be sorted in in_progress column' ); } @@ -242,20 +247,21 @@ // Verify all cards have valid positions foreach ([$card1, $card2, $card3] as $card) { $card->refresh(); - expect($card->order_position)->not()->toBeNull() - ->and(strlen($card->order_position))->toBeGreaterThan(0); + expect($card->order_position)->not()->toBeNull(); } // Verify positions are properly sorted in each column foreach (['todo', 'in_progress', 'completed'] as $status) { $positions = Task::where('status', $status) ->orderBy('order_position') + ->orderBy('id') ->pluck('order_position') ->toArray(); for ($i = 0; $i < count($positions) - 1; $i++) { - expect(strcmp($positions[$i], $positions[$i + 1]))->toBeLessThan( - 0, + $current = DecimalPosition::normalize($positions[$i]); + $next = DecimalPosition::normalize($positions[$i + 1]); + expect(DecimalPosition::lessThan($current, $next))->toBeTrue( "Positions should be sorted in {$status} after ping-pong movements" ); } diff --git a/tests/Feature/JavaScriptPhpParameterFlowTest.php b/tests/Feature/JavaScriptPhpParameterFlowTest.php index 785a0e7..c520a5c 100644 --- a/tests/Feature/JavaScriptPhpParameterFlowTest.php +++ b/tests/Feature/JavaScriptPhpParameterFlowTest.php @@ -1,7 +1,7 @@ map( - fn ($pos) => Task::factory()->create([ - 'title' => "Card {$pos}", - 'status' => 'todo', - 'order_position' => $pos, - ]) + function ($label) use (&$position) { + $card = Task::factory()->create([ + 'title' => "Card {$label}", + 'status' => 'todo', + 'order_position' => $position, + ]); + $position = DecimalPosition::after($position); + + return $card; + } ); $cardToMove = $cards->get(0); // Moving card 'a' @@ -56,29 +62,36 @@ $beforeCard = $cards->get(2); // Card 'c' // Verify: 'b' < movedCard < 'c' - expect(strcmp($afterCard->fresh()->order_position, $cardToMove->order_position))->toBeLessThan( - 0, - "Moved card should be after '{$afterCard->title}'" - ); - expect(strcmp($cardToMove->order_position, $beforeCard->fresh()->order_position))->toBeLessThan( - 0, - "Moved card should be before '{$beforeCard->title}'" - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($afterCard->fresh()->order_position), + DecimalPosition::normalize($cardToMove->order_position) + ))->toBeTrue("Moved card should be after '{$afterCard->title}'"); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($cardToMove->order_position), + DecimalPosition::normalize($beforeCard->fresh()->order_position) + ))->toBeTrue("Moved card should be before '{$beforeCard->title}'"); }); it('tests JavaScript edge case: moving to TOP (index 0)', function () { + $position = DecimalPosition::forEmptyColumn(); $cards = collect(['a', 'b', 'c'])->map( - fn ($pos) => Task::factory()->create([ - 'title' => "Card {$pos}", - 'status' => 'todo', - 'order_position' => $pos, - ]) + function ($label) use (&$position) { + $card = Task::factory()->create([ + 'title' => "Card {$label}", + 'status' => 'todo', + 'order_position' => $position, + ]); + $position = DecimalPosition::after($position); + + return $card; + } ); + // Use a position after the existing cards to avoid unique constraint collision $newCard = Task::factory()->create([ 'title' => 'NewTop', 'status' => 'todo', - 'order_position' => 'm', + 'order_position' => $position, // $position is already past card 'c' ]); // SIMULATE JAVASCRIPT: Moving to index 0 (top) @@ -105,25 +118,32 @@ $newCard->refresh(); // Verify: newCard < 'a' - expect(strcmp($newCard->order_position, $cards->get(0)->fresh()->order_position))->toBeLessThan( - 0, - 'Card moved to top should be before first card' - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($newCard->order_position), + DecimalPosition::normalize($cards->get(0)->fresh()->order_position) + ))->toBeTrue('Card moved to top should be before first card'); }); it('tests JavaScript edge case: moving to BOTTOM (last index)', function () { + $position = DecimalPosition::forEmptyColumn(); $cards = collect(['a', 'b', 'c'])->map( - fn ($pos) => Task::factory()->create([ - 'title' => "Card {$pos}", - 'status' => 'todo', - 'order_position' => $pos, - ]) + function ($label) use (&$position) { + $card = Task::factory()->create([ + 'title' => "Card {$label}", + 'status' => 'todo', + 'order_position' => $position, + ]); + $position = DecimalPosition::after($position); + + return $card; + } ); + // Use a position after the existing cards to avoid unique constraint collision $newCard = Task::factory()->create([ 'title' => 'NewBottom', 'status' => 'todo', - 'order_position' => 'm', + 'order_position' => $position, // $position is already past card 'c' ]); // SIMULATE JAVASCRIPT: Moving to last index (bottom) @@ -150,25 +170,32 @@ $newCard->refresh(); // Verify: 'c' < newCard - expect(strcmp($cards->last()->fresh()->order_position, $newCard->order_position))->toBeLessThan( - 0, - 'Card moved to bottom should be after last card' - ); + expect(DecimalPosition::greaterThan( + DecimalPosition::normalize($newCard->order_position), + DecimalPosition::normalize($cards->last()->fresh()->order_position) + ))->toBeTrue('Card moved to bottom should be after last card'); }); it('tests JavaScript edge case: moving BETWEEN cards (middle index)', function () { + $position = DecimalPosition::forEmptyColumn(); $cards = collect(['a', 'b', 'c', 'd'])->map( - fn ($pos) => Task::factory()->create([ - 'title' => "Card {$pos}", - 'status' => 'todo', - 'order_position' => $pos, - ]) + function ($label) use (&$position) { + $card = Task::factory()->create([ + 'title' => "Card {$label}", + 'status' => 'todo', + 'order_position' => $position, + ]); + $position = DecimalPosition::after($position); + + return $card; + } ); + // Use a position after the existing cards to avoid unique constraint collision $newCard = Task::factory()->create([ 'title' => 'NewMiddle', 'status' => 'todo', - 'order_position' => 'm', + 'order_position' => $position, // $position is already past card 'd' ]); // SIMULATE JAVASCRIPT: Moving to index 2 (between 'b' and 'c') @@ -197,29 +224,27 @@ $beforeCard = $cards->get(2); // Card 'c' // Verify: 'b' < newCard < 'c' - expect(strcmp($afterCard->fresh()->order_position, $newCard->order_position))->toBeLessThan( - 0, - 'Card should be after Card b' - ); - expect(strcmp($newCard->order_position, $beforeCard->fresh()->order_position))->toBeLessThan( - 0, - 'Card should be before Card c' - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($afterCard->fresh()->order_position), + DecimalPosition::normalize($newCard->order_position) + ))->toBeTrue('Card should be after Card b'); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($newCard->order_position), + DecimalPosition::normalize($beforeCard->fresh()->order_position) + ))->toBeTrue('Card should be before Card c'); }); it('simulates browser drag-drop with exact index calculations', function () { // Create ordered list like browser shows $cards = collect(); + $position = DecimalPosition::forEmptyColumn(); for ($i = 1; $i <= 5; $i++) { - $lastRank = $i === 1 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $cards->push(Task::factory()->create([ 'title' => "Card {$i}", 'status' => 'todo', - 'order_position' => $lastRank->get(), + 'order_position' => $position, ])); + $position = DecimalPosition::after($position); } // Simulate dragging Card 1 to position between Card 3 and Card 4 @@ -252,38 +277,38 @@ $card4 = $cards->get(3)->fresh(); // Verify: Card3 < MovedCard < Card4 - expect(strcmp($card3->order_position, $cardToMove->order_position))->toBeLessThan( - 0, - 'Moved card should be after Card 3' - ); - expect(strcmp($cardToMove->order_position, $card4->order_position))->toBeLessThan( - 0, - 'Moved card should be before Card 4' - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($card3->order_position), + DecimalPosition::normalize($cardToMove->order_position) + ))->toBeTrue('Moved card should be after Card 3'); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($cardToMove->order_position), + DecimalPosition::normalize($card4->order_position) + ))->toBeTrue('Moved card should be before Card 4'); }); it('tests all possible index positions in a 10-card column', function () { // Create 10 cards $cards = collect(); + $position = DecimalPosition::forEmptyColumn(); for ($i = 0; $i < 10; $i++) { - $rank = $i === 0 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $cards->push(Task::factory()->create([ 'title' => "Card {$i}", 'status' => 'todo', - 'order_position' => $rank->get(), + 'order_position' => $position, ])); + $position = DecimalPosition::after($position); } // Test moving a new card to EVERY possible index (0 through 10) for ($targetIndex = 0; $targetIndex <= 10; $targetIndex++) { + // Use unique position for each new card to avoid unique constraint collision $newCard = Task::factory()->create([ 'title' => "New at index {$targetIndex}", 'status' => 'todo', - 'order_position' => 'm', + 'order_position' => $position, // $position starts past card 9 ]); + $position = DecimalPosition::after($position); // Increment for next iteration // SIMULATE JAVASCRIPT INDEX CALCULATION $afterCardId = $targetIndex > 0 @@ -307,18 +332,18 @@ // Verify correct placement based on index if ($targetIndex > 0) { $afterCard = $cards->get($targetIndex - 1)->fresh(); - expect(strcmp($afterCard->order_position, $newCard->order_position))->toBeLessThan( - 0, - "At index {$targetIndex}, card should be after card at index " . ($targetIndex - 1) - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($afterCard->order_position), + DecimalPosition::normalize($newCard->order_position) + ))->toBeTrue("At index {$targetIndex}, card should be after card at index " . ($targetIndex - 1)); } if ($targetIndex < $cards->count()) { $beforeCard = $cards->get($targetIndex)->fresh(); - expect(strcmp($newCard->order_position, $beforeCard->order_position))->toBeLessThan( - 0, - "At index {$targetIndex}, card should be before card at index {$targetIndex}" - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($newCard->order_position), + DecimalPosition::normalize($beforeCard->order_position) + ))->toBeTrue("At index {$targetIndex}, card should be before card at index {$targetIndex}"); } // Clean up for next iteration @@ -328,20 +353,32 @@ it('validates cross-column moves with JavaScript parameter logic', function () { // Create cards in different columns + $todoPosition = DecimalPosition::forEmptyColumn(); $todoCards = collect(['a', 'b', 'c'])->map( - fn ($pos) => Task::factory()->create([ - 'title' => "Todo {$pos}", - 'status' => 'todo', - 'order_position' => $pos, - ]) + function ($label) use (&$todoPosition) { + $card = Task::factory()->create([ + 'title' => "Todo {$label}", + 'status' => 'todo', + 'order_position' => $todoPosition, + ]); + $todoPosition = DecimalPosition::after($todoPosition); + + return $card; + } ); + $inProgressPosition = DecimalPosition::forEmptyColumn(); $inProgressCards = collect(['d', 'e', 'f'])->map( - fn ($pos) => Task::factory()->create([ - 'title' => "InProgress {$pos}", - 'status' => 'in_progress', - 'order_position' => $pos, - ]) + function ($label) use (&$inProgressPosition) { + $card = Task::factory()->create([ + 'title' => "InProgress {$label}", + 'status' => 'in_progress', + 'order_position' => $inProgressPosition, + ]); + $inProgressPosition = DecimalPosition::after($inProgressPosition); + + return $card; + } ); // Move a todo card to in_progress column at index 1 @@ -375,13 +412,13 @@ $afterCard = $inProgressCards->get(0)->fresh(); $beforeCard = $inProgressCards->get(1)->fresh(); - expect(strcmp($afterCard->order_position, $cardToMove->order_position))->toBeLessThan( - 0, - 'Moved card should be after Card d in new column' - ); - expect(strcmp($cardToMove->order_position, $beforeCard->order_position))->toBeLessThan( - 0, - 'Moved card should be before Card e in new column' - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($afterCard->order_position), + DecimalPosition::normalize($cardToMove->order_position) + ))->toBeTrue('Moved card should be after Card d in new column'); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($cardToMove->order_position), + DecimalPosition::normalize($beforeCard->order_position) + ))->toBeTrue('Moved card should be before Card e in new column'); }); }); diff --git a/tests/Feature/ParameterCombinationTest.php b/tests/Feature/ParameterCombinationTest.php index 512544d..423b540 100644 --- a/tests/Feature/ParameterCombinationTest.php +++ b/tests/Feature/ParameterCombinationTest.php @@ -1,7 +1,7 @@ create([ 'title' => 'Card A', 'status' => 'todo', - 'order_position' => 'a', + 'order_position' => $position, ]); + $position = DecimalPosition::after($position); $cardB = Task::factory()->create([ 'title' => 'Card B', 'status' => 'todo', - 'order_position' => 'b', + 'order_position' => $position, ]); + $position = DecimalPosition::after($position); $cardC = Task::factory()->create([ 'title' => 'Card C', 'status' => 'todo', - 'order_position' => 'c', + 'order_position' => $position, ]); + $position = DecimalPosition::after($position); // Continue after cardC for new cards // We want to insert NEW card between A and B - // Expected result: A < NEW < B (positions: "a" < newPos < "b") + // Expected result: A < NEW < B $results = []; // Combination 1: (newCard, column, afterCardId=A, beforeCardId=B) try { - $new1 = Task::factory()->create(['title' => 'New1', 'status' => 'todo', 'order_position' => 'm']); + $new1 = Task::factory()->create(['title' => 'New1', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); $this->board->call('moveCard', (string) $new1->id, 'todo', (string) $cardA->id, (string) $cardB->id); $new1->refresh(); $results['Combo1_after=A_before=B'] = [ 'success' => true, 'position' => $new1->order_position, - 'correct_order' => strcmp($cardA->order_position, $new1->order_position) < 0 && strcmp($new1->order_position, $cardB->order_position) < 0, + 'correct_order' => DecimalPosition::lessThan( + DecimalPosition::normalize($cardA->order_position), + DecimalPosition::normalize($new1->order_position) + ) && DecimalPosition::lessThan( + DecimalPosition::normalize($new1->order_position), + DecimalPosition::normalize($cardB->order_position) + ), ]; } catch (\Exception $e) { $results['Combo1_after=A_before=B'] = ['success' => false, 'error' => $e->getMessage()]; @@ -51,13 +62,20 @@ // Combination 2: (newCard, column, afterCardId=B, beforeCardId=A) - REVERSED try { - $new2 = Task::factory()->create(['title' => 'New2', 'status' => 'todo', 'order_position' => 'm']); + $new2 = Task::factory()->create(['title' => 'New2', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); $this->board->call('moveCard', (string) $new2->id, 'todo', (string) $cardB->id, (string) $cardA->id); $new2->refresh(); $results['Combo2_after=B_before=A'] = [ 'success' => true, 'position' => $new2->order_position, - 'correct_order' => strcmp($cardA->order_position, $new2->order_position) < 0 && strcmp($new2->order_position, $cardB->order_position) < 0, + 'correct_order' => DecimalPosition::lessThan( + DecimalPosition::normalize($cardA->order_position), + DecimalPosition::normalize($new2->order_position) + ) && DecimalPosition::lessThan( + DecimalPosition::normalize($new2->order_position), + DecimalPosition::normalize($cardB->order_position) + ), ]; } catch (\Exception $e) { $results['Combo2_after=B_before=A'] = ['success' => false, 'error' => $e->getMessage()]; @@ -65,14 +83,21 @@ // Combination 3: (newCard, column, beforeCardId=B, afterCardId=A) - Swapped param order try { - $new3 = Task::factory()->create(['title' => 'New3', 'status' => 'todo', 'order_position' => 'm']); + $new3 = Task::factory()->create(['title' => 'New3', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); // This is how JavaScript calls it! $this->board->call('moveCard', (string) $new3->id, 'todo', (string) $cardB->id, (string) $cardA->id); $new3->refresh(); $results['Combo3_JS_order_before=B_after=A'] = [ 'success' => true, 'position' => $new3->order_position, - 'correct_order' => strcmp($cardA->order_position, $new3->order_position) < 0 && strcmp($new3->order_position, $cardB->order_position) < 0, + 'correct_order' => DecimalPosition::lessThan( + DecimalPosition::normalize($cardA->order_position), + DecimalPosition::normalize($new3->order_position) + ) && DecimalPosition::lessThan( + DecimalPosition::normalize($new3->order_position), + DecimalPosition::normalize($cardB->order_position) + ), ]; } catch (\Exception $e) { $results['Combo3_JS_order_before=B_after=A'] = ['success' => false, 'error' => $e->getMessage()]; @@ -80,44 +105,54 @@ // Combination 4: (newCard, column, beforeCardId=A, afterCardId=B) - Different swap try { - $new4 = Task::factory()->create(['title' => 'New4', 'status' => 'todo', 'order_position' => 'm']); + $new4 = Task::factory()->create(['title' => 'New4', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); $this->board->call('moveCard', (string) $new4->id, 'todo', (string) $cardA->id, (string) $cardB->id); $new4->refresh(); $results['Combo4_before=A_after=B'] = [ 'success' => true, 'position' => $new4->order_position, - 'correct_order' => strcmp($cardA->order_position, $new4->order_position) < 0 && strcmp($new4->order_position, $cardB->order_position) < 0, + 'correct_order' => DecimalPosition::lessThan( + DecimalPosition::normalize($cardA->order_position), + DecimalPosition::normalize($new4->order_position) + ) && DecimalPosition::lessThan( + DecimalPosition::normalize($new4->order_position), + DecimalPosition::normalize($cardB->order_position) + ), ]; } catch (\Exception $e) { $results['Combo4_before=A_after=B'] = ['success' => false, 'error' => $e->getMessage()]; } - dump('=== PARAMETER COMBINATION RESULTS ==='); - dump($results); - // Find which combination works $workingCombos = array_filter($results, fn ($r) => $r['success'] === true && ($r['correct_order'] ?? false)); - dump('Working combinations:', array_keys($workingCombos)); expect(count($workingCombos))->toBeGreaterThan(0, 'At least one combination should work correctly'); }); it('tests moving to TOP of column - both parameter orders', function () { - $cardA = Task::factory()->create(['title' => 'Card A', 'status' => 'todo', 'order_position' => 'a']); - $cardB = Task::factory()->create(['title' => 'Card B', 'status' => 'todo', 'order_position' => 'b']); + $position = DecimalPosition::forEmptyColumn(); + $cardA = Task::factory()->create(['title' => 'Card A', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); + $cardB = Task::factory()->create(['title' => 'Card B', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); // Continue for new cards // Want: NEW < A < B $results = []; // Test 1: afterCardId=null, beforeCardId=A try { - $new1 = Task::factory()->create(['title' => 'NewTop1', 'status' => 'todo', 'order_position' => 'm']); + $new1 = Task::factory()->create(['title' => 'NewTop1', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); $this->board->call('moveCard', (string) $new1->id, 'todo', null, (string) $cardA->id); $new1->refresh(); $results['after=null_before=A'] = [ 'success' => true, 'position' => $new1->order_position, - 'correct' => strcmp($new1->order_position, $cardA->order_position) < 0, + 'correct' => DecimalPosition::lessThan( + DecimalPosition::normalize($new1->order_position), + DecimalPosition::normalize($cardA->order_position) + ), ]; } catch (\Exception $e) { $results['after=null_before=A'] = ['success' => false, 'error' => $e->getMessage()]; @@ -125,43 +160,50 @@ // Test 2: beforeCardId=A, afterCardId=null (JS order) try { - $new2 = Task::factory()->create(['title' => 'NewTop2', 'status' => 'todo', 'order_position' => 'm']); + $new2 = Task::factory()->create(['title' => 'NewTop2', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); $this->board->call('moveCard', (string) $new2->id, 'todo', (string) $cardA->id, null); $new2->refresh(); $results['before=A_after=null'] = [ 'success' => true, 'position' => $new2->order_position, - 'correct' => strcmp($new2->order_position, $cardA->order_position) < 0, + 'correct' => DecimalPosition::lessThan( + DecimalPosition::normalize($new2->order_position), + DecimalPosition::normalize($cardA->order_position) + ), ]; } catch (\Exception $e) { $results['before=A_after=null'] = ['success' => false, 'error' => $e->getMessage()]; } - dump('=== TOP POSITION RESULTS ==='); - dump($results); - $workingCombos = array_filter($results, fn ($r) => $r['success'] === true && ($r['correct'] ?? false)); - dump('Working combinations:', array_keys($workingCombos)); expect(count($workingCombos))->toBeGreaterThan(0); }); it('tests moving to BOTTOM of column - both parameter orders', function () { - $cardA = Task::factory()->create(['title' => 'Card A', 'status' => 'todo', 'order_position' => 'a']); - $cardB = Task::factory()->create(['title' => 'Card B', 'status' => 'todo', 'order_position' => 'b']); + $position = DecimalPosition::forEmptyColumn(); + $cardA = Task::factory()->create(['title' => 'Card A', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); + $cardB = Task::factory()->create(['title' => 'Card B', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); // Continue for new cards // Want: A < B < NEW $results = []; // Test 1: afterCardId=B, beforeCardId=null try { - $new1 = Task::factory()->create(['title' => 'NewBottom1', 'status' => 'todo', 'order_position' => 'm']); + $new1 = Task::factory()->create(['title' => 'NewBottom1', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); $this->board->call('moveCard', (string) $new1->id, 'todo', (string) $cardB->id, null); $new1->refresh(); $results['after=B_before=null'] = [ 'success' => true, 'position' => $new1->order_position, - 'correct' => strcmp($new1->order_position, $cardB->order_position) > 0, + 'correct' => DecimalPosition::greaterThan( + DecimalPosition::normalize($new1->order_position), + DecimalPosition::normalize($cardB->order_position) + ), ]; } catch (\Exception $e) { $results['after=B_before=null'] = ['success' => false, 'error' => $e->getMessage()]; @@ -169,23 +211,23 @@ // Test 2: beforeCardId=null, afterCardId=B (JS order) try { - $new2 = Task::factory()->create(['title' => 'NewBottom2', 'status' => 'todo', 'order_position' => 'm']); + $new2 = Task::factory()->create(['title' => 'NewBottom2', 'status' => 'todo', 'order_position' => $position]); + $position = DecimalPosition::after($position); $this->board->call('moveCard', (string) $new2->id, 'todo', null, (string) $cardB->id); $new2->refresh(); $results['before=null_after=B'] = [ 'success' => true, 'position' => $new2->order_position, - 'correct' => strcmp($new2->order_position, $cardB->order_position) > 0, + 'correct' => DecimalPosition::greaterThan( + DecimalPosition::normalize($new2->order_position), + DecimalPosition::normalize($cardB->order_position) + ), ]; } catch (\Exception $e) { $results['before=null_after=B'] = ['success' => false, 'error' => $e->getMessage()]; } - dump('=== BOTTOM POSITION RESULTS ==='); - dump($results); - $workingCombos = array_filter($results, fn ($r) => $r['success'] === true && ($r['correct'] ?? false)); - dump('Working combinations:', array_keys($workingCombos)); expect(count($workingCombos))->toBeGreaterThan(0); }); @@ -193,16 +235,14 @@ it('simulates exact browser drag-and-drop behavior', function () { // Create ordered list like browser shows $cards = collect(); + $position = DecimalPosition::forEmptyColumn(); for ($i = 1; $i <= 5; $i++) { - $lastRank = $i === 1 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $cards->push(Task::factory()->create([ 'title' => "Card {$i}", 'status' => 'todo', - 'order_position' => $lastRank->get(), + 'order_position' => $position, ])); + $position = DecimalPosition::after($position); } // Simulate dragging Card 1 to position between Card 3 and Card 4 @@ -215,12 +255,6 @@ $afterCardId = $targetIndex > 0 ? $cards->get($targetIndex - 1)->id : null; // Card 3 $beforeCardId = $targetIndex < $cards->count() ? $cards->get($targetIndex)->id : null; // Card 4 - dump('Browser would send:', [ - 'cardToMove' => $cardToMove->title, - 'afterCard' => $cards->get($targetIndex - 1)->title, - 'beforeCard' => $cards->get($targetIndex)->title, - ]); - // JavaScript NOW sends: moveCard(cardId, column, afterCardId, beforeCardId) - FIXED! $this->board->call( 'moveCard', @@ -234,15 +268,13 @@ $card3 = $cards->get(2)->fresh(); $card4 = $cards->get(3)->fresh(); - dump('Result:', [ - 'card3_pos' => $card3->order_position, - 'moved_card_pos' => $cardToMove->order_position, - 'card4_pos' => $card4->order_position, - 'is_between' => strcmp($card3->order_position, $cardToMove->order_position) < 0 && - strcmp($cardToMove->order_position, $card4->order_position) < 0, - ]); - - expect(strcmp($card3->order_position, $cardToMove->order_position))->toBeLessThan(0); - expect(strcmp($cardToMove->order_position, $card4->order_position))->toBeLessThan(0); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($card3->order_position), + DecimalPosition::normalize($cardToMove->order_position) + ))->toBeTrue('Moved card should be after Card 3'); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($cardToMove->order_position), + DecimalPosition::normalize($card4->order_position) + ))->toBeTrue('Moved card should be before Card 4'); }); }); diff --git a/tests/Feature/ParameterOrderMutationTest.php b/tests/Feature/ParameterOrderMutationTest.php index e9a55c3..39f13c9 100644 --- a/tests/Feature/ParameterOrderMutationTest.php +++ b/tests/Feature/ParameterOrderMutationTest.php @@ -1,8 +1,7 @@ getAttribute($positionField); - $nextPos = $next->getAttribute($positionField); + $currentPos = DecimalPosition::normalize($current->getAttribute($positionField)); + $nextPos = DecimalPosition::normalize($next->getAttribute($positionField)); - if (strcmp($currentPos, $nextPos) >= 0) { + // Check if positions are inverted (should be current < next) + if (DecimalPosition::compare($currentPos, $nextPos) >= 0) { $inversions[] = [ 'current_id' => $current->id, 'current_pos' => $currentPos, @@ -42,36 +42,36 @@ function detectInversions(string $modelClass, string $columnValue, string $posit return $inversions; } -describe('Parameter Order Mutation Tests - Prove the Fix Matters', function () { - it('documents correct parameter order after fix', function () { - // DOCUMENTATION TEST: Shows how parameters work AFTER the fix +describe('Parameter Order Mutation Tests - Decimal Positioning', function () { + it('documents correct parameter order with decimal positions', function () { + // DOCUMENTATION TEST: Shows how parameters work with decimal positions // This test verifies the fix is working correctly $cardA = Task::factory()->create([ 'title' => 'Card A', 'status' => 'todo', - 'order_position' => 'a', + 'order_position' => '65535.0000000000', ]); $cardB = Task::factory()->create([ 'title' => 'Card B', 'status' => 'todo', - 'order_position' => 'b', + 'order_position' => '131070.0000000000', ]); $cardC = Task::factory()->create([ 'title' => 'Card C', 'status' => 'todo', - 'order_position' => 'c', + 'order_position' => '196605.0000000000', ]); $newCard = Task::factory()->create([ 'title' => 'New Card', 'status' => 'todo', - 'order_position' => 'm', + 'order_position' => DecimalPosition::forEmptyColumn(), ]); - // CORRECT PARAMETER ORDER (after fix): + // CORRECT PARAMETER ORDER: // moveCard(cardId, column, afterCardId, beforeCardId) // afterCardId = card BEFORE the new position (visually above) // beforeCardId = card AFTER the new position (visually below) @@ -87,54 +87,55 @@ function detectInversions(string $modelClass, string $columnValue, string $posit $newCard->refresh(); - // Verify correct placement - $isCorrect = strcmp($cardA->order_position, $newCard->order_position) < 0 - && strcmp($newCard->order_position, $cardB->order_position) < 0; + // Verify correct placement using decimal comparison + $cardAPos = DecimalPosition::normalize($cardA->order_position); + $newCardPos = DecimalPosition::normalize($newCard->order_position); + $cardBPos = DecimalPosition::normalize($cardB->order_position); + + $isCorrect = DecimalPosition::lessThan($cardAPos, $newCardPos) + && DecimalPosition::lessThan($newCardPos, $cardBPos); expect($isCorrect)->toBeTrue( - 'With current fix, card should be between A and B' + 'Card should be between A and B' ); }); - it('proves PHP logic with swapped parameters creates exception', function () { - // MUTATION TEST: Simulates OLD BROKEN PHP logic - // This shows what happens when betweenRanks parameters are swapped + it('proves midpoint calculation never fails (unlike old Rank service)', function () { + // IMPROVEMENT TEST: With decimal positions, midpoint always works + // No more PrevGreaterThanOrEquals exception! $cardA = Task::factory()->create([ 'status' => 'todo', - 'order_position' => 'a', + 'order_position' => '65535.0000000000', ]); $cardB = Task::factory()->create([ 'status' => 'todo', - 'order_position' => 'b', + 'order_position' => '131070.0000000000', ]); - // SIMULATE OLD BROKEN PHP LOGIC: - // Used to be: betweenRanks($beforePos, $afterPos) - WRONG ORDER - // This throws exception because 'b' > 'a' + // Decimal midpoint calculation always succeeds + $midpoint = DecimalPosition::between( + DecimalPosition::normalize($cardA->order_position), + DecimalPosition::normalize($cardB->order_position) + ); - expect(function () use ($cardA, $cardB) { - Rank::betweenRanks( - Rank::fromString($cardB->order_position), // 'b' as prev (WRONG) - Rank::fromString($cardA->order_position) // 'a' as next (WRONG) - ); - })->toThrow(PrevGreaterThanOrEquals::class); + // The midpoint should be between the two positions + expect(DecimalPosition::lessThan(DecimalPosition::normalize($cardA->order_position), $midpoint))->toBeTrue(); + expect(DecimalPosition::lessThan($midpoint, DecimalPosition::normalize($cardB->order_position)))->toBeTrue(); }); it('validates parameter semantic meanings under stress', function () { // Create 10 cards in sequence $cards = collect(); + $position = DecimalPosition::forEmptyColumn(); for ($i = 0; $i < 10; $i++) { - $rank = $i === 0 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $cards->push(Task::factory()->create([ 'title' => "Card {$i}", 'status' => 'todo', - 'order_position' => $rank->get(), + 'order_position' => $position, ])); + $position = DecimalPosition::after($position); } // Test EVERY adjacent pair with correct parameter semantics @@ -142,7 +143,7 @@ function detectInversions(string $modelClass, string $columnValue, string $posit $newCard = Task::factory()->create([ 'title' => "New Card {$i}", 'status' => 'todo', - 'order_position' => 'm', + 'order_position' => DecimalPosition::forEmptyColumn(), ]); $afterCard = $cards->get($i); @@ -164,14 +165,16 @@ function detectInversions(string $modelClass, string $columnValue, string $posit $beforeCard = $beforeCard->fresh(); // Invariant: afterCard < newCard < beforeCard - expect(strcmp($afterCard->order_position, $newCard->order_position))->toBeLessThan( - 0, + $afterCardPos = DecimalPosition::normalize($afterCard->order_position); + $newCardPos = DecimalPosition::normalize($newCard->order_position); + $beforeCardPos = DecimalPosition::normalize($beforeCard->order_position); + + expect(DecimalPosition::lessThan($afterCardPos, $newCardPos))->toBeTrue( "After inserting between {$afterCard->title} and {$beforeCard->title}, " . 'new card position should be > afterCard' ); - expect(strcmp($newCard->order_position, $beforeCard->order_position))->toBeLessThan( - 0, + expect(DecimalPosition::lessThan($newCardPos, $beforeCardPos))->toBeTrue( "After inserting between {$afterCard->title} and {$beforeCard->title}, " . 'new card position should be < beforeCard' ); @@ -179,15 +182,21 @@ function detectInversions(string $modelClass, string $columnValue, string $posit }); it('verifies correct behavior for all edge cases', function () { + $position = DecimalPosition::forEmptyColumn(); $cards = collect(['a', 'b', 'c'])->map( - fn ($pos) => Task::factory()->create([ - 'status' => 'todo', - 'order_position' => $pos, - ]) + function ($label) use (&$position) { + $card = Task::factory()->create([ + 'status' => 'todo', + 'order_position' => $position, + ]); + $position = DecimalPosition::after($position); + + return $card; + } ); // Edge Case 1: Move to TOP (afterCardId=null, beforeCardId=firstCard) - $newCard1 = Task::factory()->create(['status' => 'todo', 'order_position' => 'm']); + $newCard1 = Task::factory()->create(['status' => 'todo', 'order_position' => DecimalPosition::forEmptyColumn()]); $this->board->call( 'moveCard', (string) $newCard1->id, @@ -196,13 +205,13 @@ function detectInversions(string $modelClass, string $columnValue, string $posit (string) $cards->get(0)->id // beforeCardId=first card ); $newCard1->refresh(); - expect(strcmp($newCard1->order_position, $cards->get(0)->order_position))->toBeLessThan( - 0, - 'Card moved to top should have position < first card' - ); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($newCard1->order_position), + DecimalPosition::normalize($cards->get(0)->order_position) + ))->toBeTrue('Card moved to top should have position < first card'); // Edge Case 2: Move to BOTTOM (afterCardId=lastCard, beforeCardId=null) - $newCard2 = Task::factory()->create(['status' => 'todo', 'order_position' => 'm']); + $newCard2 = Task::factory()->create(['status' => 'todo', 'order_position' => DecimalPosition::forEmptyColumn()]); $this->board->call( 'moveCard', (string) $newCard2->id, @@ -211,13 +220,13 @@ function detectInversions(string $modelClass, string $columnValue, string $posit null // beforeCardId=null (no card after) ); $newCard2->refresh(); - expect(strcmp($cards->last()->order_position, $newCard2->order_position))->toBeLessThan( - 0, - 'Card moved to bottom should have position > last card' - ); + expect(DecimalPosition::greaterThan( + DecimalPosition::normalize($newCard2->order_position), + DecimalPosition::normalize($cards->last()->order_position) + ))->toBeTrue('Card moved to bottom should have position > last card'); // Edge Case 3: Move BETWEEN (both non-null) - $newCard3 = Task::factory()->create(['status' => 'todo', 'order_position' => 'm']); + $newCard3 = Task::factory()->create(['status' => 'todo', 'order_position' => DecimalPosition::forEmptyColumn()]); $this->board->call( 'moveCard', (string) $newCard3->id, @@ -226,28 +235,26 @@ function detectInversions(string $modelClass, string $columnValue, string $posit (string) $cards->get(1)->id // beforeCardId=second card ); $newCard3->refresh(); - expect(strcmp($cards->get(0)->order_position, $newCard3->order_position))->toBeLessThan( - 0, - 'Card moved between should have position > first card' - ); - expect(strcmp($newCard3->order_position, $cards->get(1)->order_position))->toBeLessThan( - 0, - 'Card moved between should have position < second card' - ); + expect(DecimalPosition::greaterThan( + DecimalPosition::normalize($newCard3->order_position), + DecimalPosition::normalize($cards->get(0)->order_position) + ))->toBeTrue('Card moved between should have position > first card'); + expect(DecimalPosition::lessThan( + DecimalPosition::normalize($newCard3->order_position), + DecimalPosition::normalize($cards->get(1)->order_position) + ))->toBeTrue('Card moved between should have position < second card'); }); it('stresses parameter order with rapid alternating insertions', function () { $cards = collect(); + $position = DecimalPosition::forEmptyColumn(); for ($i = 0; $i < 5; $i++) { - $rank = $i === 0 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $cards->push(Task::factory()->create([ 'title' => "Card {$i}", 'status' => 'todo', - 'order_position' => $rank->get(), + 'order_position' => $position, ])); + $position = DecimalPosition::after($position); } // Rapidly insert 20 cards, alternating between positions @@ -255,7 +262,7 @@ function detectInversions(string $modelClass, string $columnValue, string $posit $newCard = Task::factory()->create([ 'title' => "Rapid Card {$round}", 'status' => 'todo', - 'order_position' => 'm', + 'order_position' => DecimalPosition::forEmptyColumn(), ]); // Alternate between inserting at different positions @@ -274,12 +281,14 @@ function detectInversions(string $modelClass, string $columnValue, string $posit $newCard->refresh(); // Verify correct placement EVERY time - expect(strcmp($afterCard->fresh()->order_position, $newCard->order_position))->toBeLessThan( - 0, + $afterCardPos = DecimalPosition::normalize($afterCard->fresh()->order_position); + $newCardPos = DecimalPosition::normalize($newCard->order_position); + $beforeCardPos = DecimalPosition::normalize($beforeCard->fresh()->order_position); + + expect(DecimalPosition::lessThan($afterCardPos, $newCardPos))->toBeTrue( "Round {$round}: Card should be after {$afterCard->title}" ); - expect(strcmp($newCard->order_position, $beforeCard->fresh()->order_position))->toBeLessThan( - 0, + expect(DecimalPosition::lessThan($newCardPos, $beforeCardPos))->toBeTrue( "Round {$round}: Card should be before {$beforeCard->title}" ); } diff --git a/tests/Feature/PerformanceRegressionTest.php b/tests/Feature/PerformanceRegressionTest.php index d3a1ddd..c655b3b 100644 --- a/tests/Feature/PerformanceRegressionTest.php +++ b/tests/Feature/PerformanceRegressionTest.php @@ -1,7 +1,7 @@ [500, 0.5], // 500ms ]); - it('tracks position string length growth over time', function ($cardCount) { + it('tracks position value growth over time', function ($cardCount) { // Create cards sequentially $cards = collect(); - $lengthMetrics = []; + $position = DecimalPosition::forEmptyColumn(); for ($i = 1; $i <= $cardCount; $i++) { - $rank = $i === 1 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); - $card = Task::factory()->create([ 'status' => 'todo', - 'order_position' => $rank->get(), + 'order_position' => $position, ]); $cards->push($card); - - // Track metrics at checkpoints - if ($i % 25 === 0) { - $lengths = $cards->pluck('order_position')->map(fn ($p) => strlen($p)); - $lengthMetrics[$i] = [ - 'avg' => round($lengths->avg(), 2), - 'max' => $lengths->max(), - ]; - } + $position = DecimalPosition::after($position); } - // Verify growth is linear, not exponential - $checkpoints = array_keys($lengthMetrics); - for ($i = 1; $i < count($checkpoints); $i++) { - $prev = $lengthMetrics[$checkpoints[$i - 1]]; - $curr = $lengthMetrics[$checkpoints[$i]]; - - // Growth should be gradual (max increase of 2 chars per 25 cards) - $avgGrowth = $curr['avg'] - $prev['avg']; - expect($avgGrowth)->toBeLessThan( - 2, - 'Average position length growth should be gradual' - ); - } + // Verify growth is linear (each position increases by DEFAULT_GAP) + $positions = $cards->pluck('order_position'); + $firstPos = (float) $positions->first(); + $lastPos = (float) $positions->last(); + + // Expected: lastPos ≈ firstPos + (cardCount - 1) * DEFAULT_GAP + $expectedLast = $firstPos + ($cardCount - 1) * (float) DecimalPosition::DEFAULT_GAP; + expect(abs($lastPos - $expectedLast))->toBeLessThan( + 1, + 'Position growth should be linear (constant gap increment)' + ); - dump("Length metrics for {$cardCount} cards:", $lengthMetrics); + // Verify all positions unique + $uniquePositions = $cards->pluck('order_position')->unique()->count(); + expect($uniquePositions)->toBe($cardCount, 'All positions should be unique'); })->with([ '100 cards' => 100, '200 cards' => 200, @@ -107,12 +95,6 @@ // Performance baselines expect($avgDuration)->toBeLessThan(0.1, 'Average operation should be < 100ms'); expect($maxDuration)->toBeLessThan(0.3, 'Max operation should be < 300ms'); - - dump('Bulk operations performance:', [ - 'avg_duration_ms' => round($avgDuration * 1000, 2), - 'max_duration_ms' => round($maxDuration * 1000, 2), - 'total_operations' => 50, - ]); }); it('validates database query performance under load', function () { @@ -131,6 +113,7 @@ $start = microtime(true); $orderedTasks = Task::where('status', 'todo') ->orderBy('order_position') + ->orderBy('id') ->get(); $metrics['query_ordered'] = microtime(true) - $start; @@ -146,11 +129,6 @@ "{$operation} should complete within 50ms (took " . round($duration * 1000, 2) . 'ms)' ); } - - dump('Database query performance:', array_map( - fn ($d) => round($d * 1000, 2) . 'ms', - $metrics - )); }); it('establishes memory usage baselines', function () { @@ -179,39 +157,33 @@ 10, "Memory usage should be under 10MB (used {$memoryUsedMB}MB)" ); - - dump('Memory usage:', [ - 'before_mb' => round($beforeMemory / 1024 / 1024, 2), - 'after_mb' => round($afterMemory / 1024 / 1024, 2), - 'used_mb' => $memoryUsedMB, - ]); }); it('validates position generation performance', function () { // Benchmark position generation algorithms $metrics = []; - // 1. Empty sequence position + // 1. Empty column position $start = microtime(true); for ($i = 0; $i < 100; $i++) { - $pos = Rank::forEmptySequence()->get(); + $pos = DecimalPosition::forEmptyColumn(); } - $metrics['empty_sequence'] = (microtime(true) - $start) / 100; + $metrics['empty_column'] = (microtime(true) - $start) / 100; // 2. After position - $lastPos = Rank::forEmptySequence(); + $lastPos = DecimalPosition::forEmptyColumn(); $start = microtime(true); for ($i = 0; $i < 100; $i++) { - $lastPos = Rank::after($lastPos); + $lastPos = DecimalPosition::after($lastPos); } $metrics['after_position'] = (microtime(true) - $start) / 100; // 3. Between positions - $pos1 = Rank::fromString('a'); - $pos2 = Rank::fromString('b'); + $pos1 = DecimalPosition::forEmptyColumn(); + $pos2 = DecimalPosition::after($pos1); $start = microtime(true); for ($i = 0; $i < 100; $i++) { - $pos = Rank::betweenRanks($pos1, $pos2)->get(); + $pos = DecimalPosition::between($pos1, $pos2); } $metrics['between_positions'] = (microtime(true) - $start) / 100; @@ -222,10 +194,5 @@ "{$operation} should be < 1ms per operation" ); } - - dump('Position generation performance (avg per operation):', array_map( - fn ($d) => round($d * 1000000, 2) . 'Îŧs', - $metrics - )); }); }); diff --git a/tests/Feature/PositionInversionReproductionTest.php b/tests/Feature/PositionInversionReproductionTest.php index 66b9b24..12b1b63 100644 --- a/tests/Feature/PositionInversionReproductionTest.php +++ b/tests/Feature/PositionInversionReproductionTest.php @@ -1,7 +1,7 @@ push(Task::factory()->create([ 'title' => "Card {$i}", 'status' => 'todo', - 'order_position' => Rank::forEmptySequence()->get(), + 'order_position' => $position, ])); + $position = DecimalPosition::after($position); } // Simulate rapid back-and-forth movements (simulates user indecision) $targetCard = $cards->get(2); - $bugReproduced = false; for ($j = 0; $j < 10; $j++) { - try { - // Move card 3 between card 1 and card 2 repeatedly - $this->board->call( - 'moveCard', - (string) $targetCard->id, - 'todo', - (string) $cards->get(0)->id, // afterCardId - (string) $cards->get(1)->id // beforeCardId - ); - - // Refresh positions - $cards = $cards->map(fn ($card) => $card->fresh()); - } catch (\Relaticle\Flowforge\Exceptions\PrevGreaterThanOrEquals $e) { - dump("BUG REPRODUCED at move #{$j}: " . $e->getMessage()); - $bugReproduced = true; - - break; - } - } - - // Check for inversions if no exception was thrown - if (! $bugReproduced) { - $inversions = detectInversions(Task::class, 'todo'); - - if (count($inversions) > 0) { - dump('INVERSION REPRODUCED!', $inversions); - $bugReproduced = true; - } + // Move card 3 between card 1 and card 2 repeatedly + $this->board->call( + 'moveCard', + (string) $targetCard->id, + 'todo', + (string) $cards->get(0)->id, // afterCardId + (string) $cards->get(1)->id // beforeCardId + ); + + // Refresh positions + $cards = $cards->map(fn ($card) => $card->fresh()); } - // This test succeeds when it reproduces the bug - expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from rapid moves'); + // With DecimalPosition, no inversions should occur + $inversions = detectInversions(Task::class, 'todo'); + expect($inversions)->toBeEmpty('No inversions should occur with decimal positioning'); }); - it('reproduces inversions from inserting many cards between two existing cards', function () { + it('handles inserting many cards between two existing cards without issues', function () { // Create initial boundary cards $firstCard = Task::factory()->create([ 'title' => 'First Card', 'status' => 'todo', - 'order_position' => Rank::forEmptySequence()->get(), // Gets 'm' + 'order_position' => DecimalPosition::forEmptyColumn(), ]); + $secondPos = DecimalPosition::after($firstCard->order_position); $lastCard = Task::factory()->create([ 'title' => 'Last Card', 'status' => 'todo', - 'order_position' => Rank::after(Rank::fromString($firstCard->order_position))->get(), + 'order_position' => $secondPos, ]); - // Insert 50 cards between these two - $insertedCards = collect(); - $bugReproduced = false; - + // Insert 50 cards between these two - decimal midpoint never fails for ($i = 1; $i <= 50; $i++) { $newCard = Task::factory()->create([ 'title' => "Inserted Card {$i}", 'status' => 'todo', - 'order_position' => Rank::forEmptySequence()->get(), // Temporary position + 'order_position' => DecimalPosition::forEmptyColumn(), ]); - try { - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - (string) $firstCard->id, - (string) $lastCard->id - ); - - $newCard->refresh(); - $insertedCards->push($newCard); - - // Check after every 10 insertions - if ($i % 10 === 0) { - $inversions = detectInversions(Task::class, 'todo'); - if (count($inversions) > 0) { - dump("INVERSION DETECTED after {$i} insertions!", $inversions); - $bugReproduced = true; - - break; - } - } - } catch (\Relaticle\Flowforge\Exceptions\PrevGreaterThanOrEquals $e) { - dump("BUG REPRODUCED at insertion #{$i}: " . $e->getMessage()); - $bugReproduced = true; - - break; - } - } + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + (string) $firstCard->id, + (string) $lastCard->id + ); - // Final check if no bug found yet - if (! $bugReproduced) { - $inversions = detectInversions(Task::class, 'todo'); - if (count($inversions) > 0) { - dump('INVERSIONS DETECTED in final check!', $inversions); - $bugReproduced = true; + $newCard->refresh(); + + // Check every 10 insertions + if ($i % 10 === 0) { + $inversions = detectInversions(Task::class, 'todo'); + expect($inversions)->toBeEmpty("No inversions after {$i} insertions"); } } - // This test succeeds when it reproduces the bug - expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from many insertions'); + // Final check + $inversions = detectInversions(Task::class, 'todo'); + expect($inversions)->toBeEmpty('No inversions after 50 insertions'); }); - it('reproduces inversions from concurrent-like operations (simulated)', function () { + it('handles concurrent-like operations without inversions', function () { // Create 10 cards $cards = collect(); - for ($i = 1; $i <= 10; $i++) { - $lastRank = $i === 1 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); + $position = DecimalPosition::forEmptyColumn(); + for ($i = 1; $i <= 10; $i++) { $cards->push(Task::factory()->create([ 'title' => "Card {$i}", 'status' => 'todo', - 'order_position' => $lastRank->get(), + 'order_position' => $position, ])); + $position = DecimalPosition::after($position); } // Simulate concurrent operations: multiple cards moving at "same time" - // We'll move 3 different cards to 3 different positions simultaneously (in succession) $operations = [ ['card' => $cards->get(2), 'after' => $cards->get(5), 'before' => $cards->get(6)], ['card' => $cards->get(7), 'after' => $cards->get(1), 'before' => $cards->get(2)], ['card' => $cards->get(4), 'after' => $cards->get(8), 'before' => $cards->get(9)], ]; - $bugReproduced = false; - - foreach ($operations as $index => $op) { - try { - $this->board->call( - 'moveCard', - (string) $op['card']->id, - 'todo', - (string) $op['after']->id, - (string) $op['before']->id - ); - } catch (\Relaticle\Flowforge\Exceptions\PrevGreaterThanOrEquals $e) { - dump("BUG REPRODUCED at operation #{$index}: " . $e->getMessage()); - $bugReproduced = true; - - break; - } + foreach ($operations as $op) { + $this->board->call( + 'moveCard', + (string) $op['card']->id, + 'todo', + (string) $op['after']->id, + (string) $op['before']->id + ); } - // Check for inversions if no exception was thrown - if (! $bugReproduced) { - $inversions = detectInversions(Task::class, 'todo'); - - if (count($inversions) > 0) { - dump('CONCURRENT OPERATIONS CAUSED INVERSIONS!', $inversions); - $bugReproduced = true; - } - } - - // This test succeeds when it reproduces the bug - expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from concurrent operations'); + // No inversions should occur with decimal positioning + $inversions = detectInversions(Task::class, 'todo'); + expect($inversions)->toBeEmpty('No inversions from concurrent-like operations'); }); - it('stress tests position system with 100 random moves', function () { + it('handles 100 random moves without inversions', function () { // Create 20 cards $cards = collect(); - for ($i = 1; $i <= 20; $i++) { - $lastRank = $i === 1 - ? Rank::forEmptySequence() - : Rank::after(Rank::fromString($cards->last()->order_position)); + $position = DecimalPosition::forEmptyColumn(); + for ($i = 1; $i <= 20; $i++) { $cards->push(Task::factory()->create([ 'title' => "Card {$i}", 'status' => 'todo', - 'order_position' => $lastRank->get(), + 'order_position' => $position, ])); + $position = DecimalPosition::after($position); } // Perform 100 random moves - $bugReproduced = false; for ($move = 1; $move <= 100; $move++) { - // Pick random card and random position $cardToMove = $cards->random(); $otherCards = $cards->where('id', '!=', $cardToMove->id); @@ -215,168 +154,119 @@ $afterCard = $otherCards->random(); $beforeCard = $otherCards->where('id', '!=', $afterCard->id)->random(); - try { - $this->board->call( - 'moveCard', - (string) $cardToMove->id, - 'todo', - (string) $afterCard->id, - (string) $beforeCard->id - ); - - // Refresh all cards - $cards = $cards->map(fn ($card) => $card->fresh()); - - // Check for inversions every 20 moves - if ($move % 20 === 0) { - $inversions = detectInversions(Task::class, 'todo'); - if (count($inversions) > 0) { - dump("INVERSION FOUND after move #{$move}!", $inversions); - $bugReproduced = true; - - break; - } - } - } catch (\Exception $e) { - // If we get PrevGreaterThanOrEquals exception, we've reproduced the bug! - if (str_contains($e->getMessage(), 'Previous Rank')) { - dump("BUG REPRODUCED at move #{$move}: " . $e->getMessage()); - $bugReproduced = true; - - break; - } - - throw $e; + $this->board->call( + 'moveCard', + (string) $cardToMove->id, + 'todo', + (string) $afterCard->id, + (string) $beforeCard->id + ); + + // Refresh all cards + $cards = $cards->map(fn ($card) => $card->fresh()); + + // Check for inversions every 20 moves + if ($move % 20 === 0) { + $inversions = detectInversions(Task::class, 'todo'); + expect($inversions)->toBeEmpty("No inversions after {$move} moves"); } } - // Final check if no bug found yet - if (! $bugReproduced) { - $inversions = detectInversions(Task::class, 'todo'); - - if (count($inversions) > 0) { - dump('FINAL CHECK: Inversions detected!', $inversions); - $bugReproduced = true; - } - } - - // This test succeeds when it reproduces the bug - expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from random moves'); + // Final check + $inversions = detectInversions(Task::class, 'todo'); + expect($inversions)->toBeEmpty('No inversions after 100 random moves'); }); - it('tests the exact scenario from production data - now fixed', function () { - // Based on diagnostic output, we know these inversions existed: - // Card #019b18e5-a8b6-7350-9a8c-6534f48280df (pos: "VU") comes before - // Card #019b18e5-a8b7-73ec-9be1-89c01264fab3 (pos: "T") - - // Create positions that would previously cause issues + it('correctly positions cards using decimal midpoint', function () { + // Create two boundary cards with a gap $card1 = Task::factory()->create([ - 'title' => 'Card with position T', + 'title' => 'Card with position 65535', 'status' => 'review', - 'order_position' => 'T', + 'order_position' => '65535.0000000000', ]); $card2 = Task::factory()->create([ - 'title' => 'Card with position VU', + 'title' => 'Card with position 131070', 'status' => 'review', - 'order_position' => 'VU', + 'order_position' => '131070.0000000000', ]); - // According to strcmp, "T" < "VU" - these are in correct lexicographic order - $comparison = strcmp('T', 'VU'); - expect($comparison)->toBeLessThan(0, 'T should be lexicographically less than VU'); - - // Now try to move a card between them - with the fix, this should succeed + // Insert a card between them $newCard = Task::factory()->create([ 'title' => 'New card to insert', 'status' => 'review', - 'order_position' => Rank::forEmptySequence()->get(), + 'order_position' => DecimalPosition::forEmptyColumn(), ]); - // With the fix, inserting between properly ordered positions should work $this->board->call( 'moveCard', (string) $newCard->id, 'review', - (string) $card1->id, // afterCardId (position "T") - (string) $card2->id // beforeCardId (position "VU") + (string) $card1->id, + (string) $card2->id ); $newCard->refresh(); - // Verify the new card is positioned correctly between T and VU - expect(strcmp($card1->order_position, $newCard->order_position))->toBeLessThan(0, 'New card should be after T'); - expect(strcmp($newCard->order_position, $card2->order_position))->toBeLessThan(0, 'New card should be before VU'); - }); + // Verify the new card is positioned at the midpoint + $card1Pos = DecimalPosition::normalize($card1->order_position); + $card2Pos = DecimalPosition::normalize($card2->order_position); + $newPos = DecimalPosition::normalize($newCard->order_position); - it('verifies rank string growth leads to inversions', function () { - // Theory: After many insertions, rank strings grow longer - // and eventually two adjacent ranks can become inverted + expect(DecimalPosition::lessThan($card1Pos, $newPos))->toBeTrue('New card should be after card1'); + expect(DecimalPosition::lessThan($newPos, $card2Pos))->toBeTrue('New card should be before card2'); + }); - $prevCard = Task::factory()->create([ - 'title' => 'Boundary Card 1', + it('maintains precision after many bisections', function () { + // Create two boundary cards + $firstCard = Task::factory()->create([ + 'title' => 'First Card', 'status' => 'todo', - 'order_position' => 'a', + 'order_position' => '65535.0000000000', ]); - $nextCard = Task::factory()->create([ - 'title' => 'Boundary Card 2', + $lastCard = Task::factory()->create([ + 'title' => 'Last Card', 'status' => 'todo', - 'order_position' => 'b', + 'order_position' => '131070.0000000000', ]); - $positions = [$prevCard->order_position, $nextCard->order_position]; - $bugReproduced = false; - - // Insert 100 cards between 'a' and 'b' - for ($i = 1; $i <= 100; $i++) { + // Insert 30 cards between them (forcing 30 bisections) + for ($i = 1; $i <= 30; $i++) { $newCard = Task::factory()->create([ 'title' => "Insert {$i}", 'status' => 'todo', - 'order_position' => Rank::forEmptySequence()->get(), + 'order_position' => DecimalPosition::forEmptyColumn(), ]); - // Get current boundary cards - $prevCard = $prevCard->fresh(); - $nextCard = $nextCard->fresh(); - - try { - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - (string) $prevCard->id, - (string) $nextCard->id - ); - - $newCard->refresh(); - $positions[] = $newCard->order_position; - - // Track position string lengths - if ($i % 25 === 0) { - $avgLength = collect($positions)->avg(fn ($p) => strlen($p)); - dump("After {$i} insertions, average position length: {$avgLength}"); - } - } catch (\Relaticle\Flowforge\Exceptions\PrevGreaterThanOrEquals $e) { - dump("BUG REPRODUCED at insertion #{$i}: " . $e->getMessage()); - dump('Positions created so far:', $positions); - $bugReproduced = true; - - break; - } - } + $this->board->call( + 'moveCard', + (string) $newCard->id, + 'todo', + (string) $firstCard->id, + (string) $lastCard->id + ); - // Check if any inversions exist (if no exception was thrown) - if (! $bugReproduced) { - $inversions = detectInversions(Task::class, 'todo'); + $newCard->refresh(); - if (count($inversions) > 0) { - dump('Position lengths that caused inversions:', collect($positions)->map(fn ($p) => strlen($p))->all()); - $bugReproduced = true; - } + // The new card becomes the new boundary + $lastCard = $newCard; } - // This test succeeds when it reproduces the bug - expect($bugReproduced)->toBeTrue('Successfully reproduced position inversion bug from rank string growth'); + // All cards should still be in correct order + $inversions = detectInversions(Task::class, 'todo'); + expect($inversions)->toBeEmpty('No inversions after 30 bisections'); + + // Verify we can still distinguish positions + $tasks = Task::where('status', 'todo') + ->orderBy('order_position') + ->orderBy('id') + ->get(); + + for ($i = 1; $i < $tasks->count(); $i++) { + $prev = DecimalPosition::normalize($tasks[$i - 1]->order_position); + $curr = DecimalPosition::normalize($tasks[$i]->order_position); + expect(DecimalPosition::lessThan($prev, $curr))->toBeTrue("Position {$i} should be less than position " . ($i + 1)); + } }); }); diff --git a/tests/Unit/DecimalPositionServiceTest.php b/tests/Unit/DecimalPositionServiceTest.php new file mode 100644 index 0000000..f08d3e3 --- /dev/null +++ b/tests/Unit/DecimalPositionServiceTest.php @@ -0,0 +1,376 @@ +toBe('65535'); + }); + }); + + describe('after', function () { + it('adds the default gap to the position', function () { + expect(DecimalPosition::after('65535'))->toBe('131070.0000000000'); + }); + + it('handles zero position', function () { + expect(DecimalPosition::after('0'))->toBe('65535.0000000000'); + }); + + it('handles negative position', function () { + expect(DecimalPosition::after('-65535'))->toBe('0.0000000000'); + }); + + it('handles decimal position', function () { + expect(DecimalPosition::after('100.5'))->toBe('65635.5000000000'); + }); + }); + + describe('before', function () { + it('subtracts the default gap from the position', function () { + expect(DecimalPosition::before('65535'))->toBe('0.0000000000'); + }); + + it('can go negative', function () { + expect(DecimalPosition::before('0'))->toBe('-65535.0000000000'); + }); + + it('handles decimal position', function () { + expect(DecimalPosition::before('100.5'))->toBe('-65434.5000000000'); + }); + }); + + describe('between (with jitter - non-deterministic)', function () { + it('calculates position near midpoint between two positions', function () { + $pos = DecimalPosition::between('65535', '131070'); + // Should be between the two bounds (exact value varies due to jitter) + expect(bccomp($pos, '65535', 10))->toBeGreaterThan(0); + expect(bccomp($pos, '131070', 10))->toBeLessThan(0); + }); + + it('produces position within bounds for close positions', function () { + $pos = DecimalPosition::between('100', '101'); + expect(bccomp($pos, '100', 10))->toBeGreaterThan(0); + expect(bccomp($pos, '101', 10))->toBeLessThan(0); + }); + + it('handles very close positions within bounds', function () { + $pos = DecimalPosition::between('100', '100.001'); + expect(bccomp($pos, '100', 10))->toBeGreaterThan(0); + expect(bccomp($pos, '100.001', 10))->toBeLessThan(0); + }); + + it('handles zero and positive within bounds', function () { + $pos = DecimalPosition::between('0', '65535'); + expect(bccomp($pos, '0', 10))->toBeGreaterThan(0); + expect(bccomp($pos, '65535', 10))->toBeLessThan(0); + }); + + it('handles negative and positive within bounds', function () { + $pos = DecimalPosition::between('-100', '100'); + expect(bccomp($pos, '-100', 10))->toBeGreaterThan(0); + expect(bccomp($pos, '100', 10))->toBeLessThan(0); + }); + }); + + describe('calculate', function () { + it('returns default gap when both positions are null', function () { + expect(DecimalPosition::calculate(null, null))->toBe('65535'); + }); + + it('returns position after when only afterPos is provided', function () { + expect(DecimalPosition::calculate('65535', null))->toBe('131070.0000000000'); + }); + + it('returns position before when only beforePos is provided', function () { + expect(DecimalPosition::calculate(null, '65535'))->toBe('0.0000000000'); + }); + + it('returns position between bounds when both provided (with jitter)', function () { + $pos = DecimalPosition::calculate('65535', '131070'); + // Should be between the two bounds (uses between() with jitter) + expect(bccomp($pos, '65535', 10))->toBeGreaterThan(0); + expect(bccomp($pos, '131070', 10))->toBeLessThan(0); + }); + }); + + describe('needsRebalancing', function () { + it('returns true when gap is below minimum', function () { + expect(DecimalPosition::needsRebalancing('100', '100.00005'))->toBeTrue(); + }); + + it('returns true when gap is exactly at minimum', function () { + expect(DecimalPosition::needsRebalancing('100', '100.0001'))->toBeFalse(); + }); + + it('returns false when gap is above minimum', function () { + expect(DecimalPosition::needsRebalancing('100', '200'))->toBeFalse(); + }); + + it('returns false for normal gaps', function () { + expect(DecimalPosition::needsRebalancing('65535', '131070'))->toBeFalse(); + }); + + it('returns true for extremely close positions', function () { + expect(DecimalPosition::needsRebalancing('100', '100.00001'))->toBeTrue(); + }); + }); + + describe('generateSequence', function () { + it('generates empty array for zero count', function () { + expect(DecimalPosition::generateSequence(0))->toBe([]); + }); + + it('generates single position for count of 1', function () { + $positions = DecimalPosition::generateSequence(1); + expect($positions)->toBe(['65535.0000000000']); + }); + + it('generates sequential positions for count of 3', function () { + $positions = DecimalPosition::generateSequence(3); + expect($positions)->toBe([ + '65535.0000000000', + '131070.0000000000', + '196605.0000000000', + ]); + }); + + it('generates correct positions for larger count', function () { + $positions = DecimalPosition::generateSequence(5); + expect($positions)->toHaveCount(5); + expect($positions[0])->toBe('65535.0000000000'); + expect($positions[4])->toBe('327675.0000000000'); + }); + }); + + describe('precision stress tests', function () { + it('handles 33 sequential midpoint calculations without precision loss', function () { + $lower = '65535'; + $upper = '131070'; + + for ($i = 0; $i < 33; $i++) { + // Use betweenExact for deterministic testing of precision + $mid = DecimalPosition::betweenExact($lower, $upper); + expect(bccomp($mid, $lower, 10))->toBeGreaterThan(0); + expect(bccomp($mid, $upper, 10))->toBeLessThan(0); + $upper = $mid; + } + }); + + it('correctly identifies when rebalancing is needed after many bisections', function () { + $lower = '65535'; + $upper = '131070'; + + // Keep bisecting until rebalancing is needed (use betweenExact for deterministic test) + $bisections = 0; + while (!DecimalPosition::needsRebalancing($lower, $upper) && $bisections < 100) { + $upper = DecimalPosition::betweenExact($lower, $upper); + $bisections++; + } + + // Should need rebalancing after many bisections (30+ is excellent) + expect($bisections)->toBeGreaterThanOrEqual(30); + expect($bisections)->toBeLessThan(50); + }); + + it('maintains ordering after many operations with jitter', function () { + $positions = ['65535']; + + // Insert 20 items at various positions + for ($i = 0; $i < 20; $i++) { + if ($i % 2 === 0) { + // Insert at end + $positions[] = DecimalPosition::after(end($positions)); + } else { + // Insert in middle (with jitter) + $idx = intdiv(count($positions), 2); + $newPos = DecimalPosition::between($positions[$idx - 1], $positions[$idx]); + array_splice($positions, $idx, 0, [$newPos]); + } + } + + // Verify all positions are in ascending order when sorted + $sorted = $positions; + usort($sorted, fn($a, $b) => bccomp($a, $b, 10)); + + // With jitter, positions might not be in the expected order + // But when sorted, they should still be valid + for ($i = 0; $i < count($sorted) - 1; $i++) { + expect(bccomp($sorted[$i], $sorted[$i + 1], 10))->toBeLessThan(0, + "Position {$i} should be less than position " . ($i + 1)); + } + }); + }); + + describe('edge cases', function () { + it('handles very large positions', function () { + $large = '9999999999'; + $after = DecimalPosition::after($large); + expect(bccomp($after, $large, 10))->toBeGreaterThan(0); + }); + + it('handles very small decimal differences within precision', function () { + // Use values within SCALE=10 precision (minimum resolvable diff is 0.0000000002) + $a = '100.0000000100'; + $b = '100.0000000200'; + $mid = DecimalPosition::betweenExact($a, $b); + // Midpoint should be 100.0000000150 + expect($mid)->toBe('100.0000000150'); + expect(bccomp($mid, $a, 10))->toBeGreaterThan(0); + expect(bccomp($mid, $b, 10))->toBeLessThan(0); + }); + + it('correctly compares positions', function () { + expect(bccomp('100.5', '100.6', 10))->toBeLessThan(0); + expect(bccomp('100.6', '100.5', 10))->toBeGreaterThan(0); + expect(bccomp('100.5', '100.5', 10))->toBe(0); + }); + }); + + describe('betweenExact (deterministic midpoint)', function () { + it('returns the exact midpoint for testing purposes', function () { + expect(DecimalPosition::betweenExact('65535', '131070'))->toBe('98302.5000000000'); + }); + + it('is deterministic - same inputs always produce same output', function () { + $pos1 = DecimalPosition::betweenExact('65535', '131070'); + $pos2 = DecimalPosition::betweenExact('65535', '131070'); + $pos3 = DecimalPosition::betweenExact('65535', '131070'); + + expect($pos1)->toBe($pos2)->toBe($pos3); + }); + + it('handles edge cases identically to old between', function () { + expect(DecimalPosition::betweenExact('100', '101'))->toBe('100.5000000000'); + expect(DecimalPosition::betweenExact('0', '65535'))->toBe('32767.5000000000'); + expect(DecimalPosition::betweenExact('-100', '100'))->toBe('0.0000000000'); + }); + }); + + describe('between with jitter (collision prevention)', function () { + it('generates position within valid bounds', function () { + $lower = '65535'; + $upper = '131070'; + + // Test 100 times to ensure bounds are always respected + for ($i = 0; $i < 100; $i++) { + $pos = DecimalPosition::between($lower, $upper); + + expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0, + "Position {$pos} should be greater than lower bound {$lower}"); + expect(bccomp($pos, $upper, 10))->toBeLessThan(0, + "Position {$pos} should be less than upper bound {$upper}"); + } + }); + + it('generates unique positions for concurrent insertions at same target', function () { + $positions = collect(); + + // Simulate 1000 concurrent insertions at the same target position + for ($i = 0; $i < 1000; $i++) { + $positions->push(DecimalPosition::between('65535', '131070')); + } + + $uniqueCount = $positions->unique()->count(); + + // All 1000 positions should be unique due to jitter + expect($uniqueCount)->toBe(1000, + "Expected 1000 unique positions but got {$uniqueCount}. Jitter should prevent collisions."); + }); + + it('maintains ordering despite jitter', function () { + $lower = '65535'; + $upper = '131070'; + + // Generate 100 positions between the same bounds + $positions = []; + for ($i = 0; $i < 100; $i++) { + $positions[] = DecimalPosition::between($lower, $upper); + } + + // All should be greater than lower and less than upper + foreach ($positions as $pos) { + expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0); + expect(bccomp($pos, $upper, 10))->toBeLessThan(0); + } + }); + + it('handles small gaps with jitter still within bounds', function () { + // Small gap: 100.0 to 100.1 (gap of 0.1) + $lower = '100'; + $upper = '100.1'; + + for ($i = 0; $i < 50; $i++) { + $pos = DecimalPosition::between($lower, $upper); + + expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0, + "Position {$pos} should be > {$lower}"); + expect(bccomp($pos, $upper, 10))->toBeLessThan(0, + "Position {$pos} should be < {$upper}"); + } + }); + + it('handles very small gaps gracefully', function () { + // Very small gap: 100 to 100.001 (gap of 0.001) + $lower = '100'; + $upper = '100.001'; + + for ($i = 0; $i < 20; $i++) { + $pos = DecimalPosition::between($lower, $upper); + + expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0); + expect(bccomp($pos, $upper, 10))->toBeLessThan(0); + } + }); + }); + + describe('generateBetween (bulk position generation)', function () { + it('generates empty array for count less than 1', function () { + expect(DecimalPosition::generateBetween('100', '200', 0))->toBe([]); + expect(DecimalPosition::generateBetween('100', '200', -1))->toBe([]); + }); + + it('generates correct number of positions', function () { + $positions = DecimalPosition::generateBetween('65535', '131070', 5); + expect($positions)->toHaveCount(5); + }); + + it('generates all unique positions', function () { + $positions = DecimalPosition::generateBetween('65535', '131070', 100); + + $uniqueCount = count(array_unique($positions)); + expect($uniqueCount)->toBe(100, 'All 100 bulk positions should be unique'); + }); + + it('generates positions within bounds', function () { + $lower = '65535'; + $upper = '131070'; + $positions = DecimalPosition::generateBetween($lower, $upper, 10); + + foreach ($positions as $pos) { + expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0, + "Position {$pos} should be > {$lower}"); + expect(bccomp($pos, $upper, 10))->toBeLessThan(0, + "Position {$pos} should be < {$upper}"); + } + }); + + it('generates evenly distributed positions', function () { + $lower = '0'; + $upper = '100'; + $positions = DecimalPosition::generateBetween($lower, $upper, 4); + + // Positions should be roughly at 20, 40, 60, 80 (with some jitter) + // Check they're in ascending order when sorted + $sorted = $positions; + usort($sorted, fn($a, $b) => bccomp($a, $b, 10)); + + // First should be closer to 20, last closer to 80 + expect((float) $sorted[0])->toBeGreaterThan(10)->toBeLessThan(30); + expect((float) $sorted[3])->toBeGreaterThan(70)->toBeLessThan(90); + }); + }); +}); diff --git a/tests/Unit/PositionRebalancerServiceTest.php b/tests/Unit/PositionRebalancerServiceTest.php new file mode 100644 index 0000000..27ecaf4 --- /dev/null +++ b/tests/Unit/PositionRebalancerServiceTest.php @@ -0,0 +1,155 @@ +shouldReceive('cloneWithout')->andReturnSelf(); + $queryMock->shouldReceive('cloneWithoutBindings')->andReturnSelf(); + + $mock = Mockery::mock(Builder::class)->makePartial(); + + // Set the internal query property so __clone works + $reflection = new ReflectionClass($mock); + if ($reflection->hasProperty('query')) { + $property = $reflection->getProperty('query'); + $property->setAccessible(true); + $property->setValue($mock, $queryMock); + } + + // Mock the fluent methods + $mock->shouldReceive('where')->andReturnSelf(); + $mock->shouldReceive('whereNotNull')->andReturnSelf(); + $mock->shouldReceive('orderBy')->andReturnSelf(); + $mock->shouldReceive('pluck')->andReturn(collect($positions)); + + return $mock; + } + + describe('needsRebalancing', function () { + it('returns false for empty query', function () { + $rebalancer = new PositionRebalancer; + $mock = createMockBuilder([]); + + expect($rebalancer->needsRebalancing($mock, 'status', 'todo', 'position'))->toBeFalse(); + }); + + it('returns false for single item', function () { + $rebalancer = new PositionRebalancer; + $mock = createMockBuilder(['65535']); + + expect($rebalancer->needsRebalancing($mock, 'status', 'todo', 'position'))->toBeFalse(); + }); + + it('returns false for well-spaced positions', function () { + $rebalancer = new PositionRebalancer; + $mock = createMockBuilder([ + '65535', + '131070', + '196605', + ]); + + expect($rebalancer->needsRebalancing($mock, 'status', 'todo', 'position'))->toBeFalse(); + }); + + it('returns true for positions with gap below MIN_GAP', function () { + $rebalancer = new PositionRebalancer; + $mock = createMockBuilder([ + '100.0000000000', + '100.0000000050', // Gap of 0.00000000050 - below MIN_GAP (0.0001) + '200.0000000000', + ]); + + expect($rebalancer->needsRebalancing($mock, 'status', 'todo', 'position'))->toBeTrue(); + }); + }); + + describe('getGapStatistics', function () { + it('returns empty stats for empty column', function () { + $rebalancer = new PositionRebalancer; + $mock = createMockBuilder([]); + + $stats = $rebalancer->getGapStatistics($mock, 'status', 'todo', 'position'); + + expect($stats['count'])->toBe(0); + expect($stats['min_gap'])->toBeNull(); + expect($stats['max_gap'])->toBeNull(); + expect($stats['avg_gap'])->toBeNull(); + expect($stats['small_gaps'])->toBe(0); + }); + + it('returns empty stats for single item', function () { + $rebalancer = new PositionRebalancer; + $mock = createMockBuilder(['65535']); + + $stats = $rebalancer->getGapStatistics($mock, 'status', 'todo', 'position'); + + expect($stats['count'])->toBe(1); + expect($stats['min_gap'])->toBeNull(); + expect($stats['small_gaps'])->toBe(0); + }); + + it('calculates correct statistics for evenly spaced positions', function () { + $rebalancer = new PositionRebalancer; + $mock = createMockBuilder([ + '65535', + '131070', + '196605', + ]); + + $stats = $rebalancer->getGapStatistics($mock, 'status', 'todo', 'position'); + + expect($stats['count'])->toBe(3); + expect($stats['min_gap'])->toBe('65535.0000000000'); + expect($stats['max_gap'])->toBe('65535.0000000000'); + expect($stats['avg_gap'])->toBe('65535.0000000000'); + expect($stats['small_gaps'])->toBe(0); + }); + + it('detects small gaps correctly', function () { + $rebalancer = new PositionRebalancer; + $mock = createMockBuilder([ + '100.0000000000', + '100.0000000010', // Tiny gap + '100.0000000020', // Another tiny gap + '65635.0000000000', // Big gap + ]); + + $stats = $rebalancer->getGapStatistics($mock, 'status', 'todo', 'position'); + + expect($stats['count'])->toBe(4); + expect($stats['small_gaps'])->toBe(2); // Two gaps below MIN_GAP + }); + }); + + describe('generateSequence integration', function () { + it('generates properly spaced positions for rebalancing', function () { + $positions = DecimalPosition::generateSequence(5); + + expect($positions)->toHaveCount(5); + expect($positions[0])->toBe('65535.0000000000'); + expect($positions[1])->toBe('131070.0000000000'); + expect($positions[2])->toBe('196605.0000000000'); + expect($positions[3])->toBe('262140.0000000000'); + expect($positions[4])->toBe('327675.0000000000'); + + // All gaps should be DEFAULT_GAP + for ($i = 1; $i < count($positions); $i++) { + $gap = DecimalPosition::gap($positions[$i - 1], $positions[$i]); + expect($gap)->toBe('65535.0000000000'); + } + }); + }); +}); diff --git a/tests/Unit/RankServiceTest.php b/tests/Unit/RankServiceTest.php deleted file mode 100644 index 858367e..0000000 --- a/tests/Unit/RankServiceTest.php +++ /dev/null @@ -1,327 +0,0 @@ -get())->toBeString() - ->and(strlen($rank->get()))->toBeGreaterThan(0); - }); - - it('handles single card scenario', function () { - $cardA = Rank::forEmptySequence(); - - expect($cardA->get())->toBeString(); - }); - - describe('Two Cards Scenario (A, B)', function () { - beforeEach(function () { - $this->cardA = Rank::forEmptySequence(); - $this->cardB = Rank::after($this->cardA); - }); - - it('creates two cards in sequence', function () { - expect($this->cardA->get())->toBeLessThan($this->cardB->get()); - }); - - it('moves A after B (A->B)', function () { - $newA = Rank::after($this->cardB); - - expect($this->cardB->get())->toBeLessThan($newA->get()); - }); - - it('moves B before A (B->A)', function () { - $newB = Rank::before($this->cardA); - - expect($newB->get())->toBeLessThan($this->cardA->get()); - }); - }); - - describe('Three Cards Scenario (A, B, C)', function () { - beforeEach(function () { - $this->cardA = Rank::forEmptySequence(); - $this->cardB = Rank::after($this->cardA); - $this->cardC = Rank::after($this->cardB); - }); - - it('creates three cards in sequence', function () { - expect($this->cardA->get())->toBeLessThan($this->cardB->get()) - ->and($this->cardB->get())->toBeLessThan($this->cardC->get()); - }); - - it('moves A between B and C', function () { - $newA = Rank::betweenRanks($this->cardB, $this->cardC); - - expect($this->cardB->get())->toBeLessThan($newA->get()) - ->and($newA->get())->toBeLessThan($this->cardC->get()); - }); - - it('moves C between A and B', function () { - $newC = Rank::betweenRanks($this->cardA, $this->cardB); - - expect($this->cardA->get())->toBeLessThan($newC->get()) - ->and($newC->get())->toBeLessThan($this->cardB->get()); - }); - - it('moves B to the end', function () { - $newB = Rank::after($this->cardC); - - expect($this->cardC->get())->toBeLessThan($newB->get()); - }); - - it('moves B to the beginning', function () { - $newB = Rank::before($this->cardA); - - expect($newB->get())->toBeLessThan($this->cardA->get()); - }); - }); - - describe('Four Cards Scenario (A, B, C, D)', function () { - beforeEach(function () { - $this->cardA = Rank::forEmptySequence(); - $this->cardB = Rank::after($this->cardA); - $this->cardC = Rank::after($this->cardB); - $this->cardD = Rank::after($this->cardC); - - $this->originalOrder = [ - 'A' => $this->cardA->get(), - 'B' => $this->cardB->get(), - 'C' => $this->cardC->get(), - 'D' => $this->cardD->get(), - ]; - }); - - it('creates four cards in sequence', function () { - expect($this->cardA->get())->toBeLessThan($this->cardB->get()) - ->and($this->cardB->get())->toBeLessThan($this->cardC->get()) - ->and($this->cardC->get())->toBeLessThan($this->cardD->get()); - }); - - it('moves A after B (A->B)', function () { - $newA = Rank::betweenRanks($this->cardB, $this->cardC); - - expect($this->cardB->get())->toBeLessThan($newA->get()) - ->and($newA->get())->toBeLessThan($this->cardC->get()); - }); - - it('moves B after A (B->A)', function () { - $newB = Rank::before($this->cardA); - - expect($newB->get())->toBeLessThan($this->cardA->get()); - }); - - it('moves A after B then B after A (multiple swaps)', function () { - // A->B - $newA = Rank::betweenRanks($this->cardB, $this->cardC); - expect($this->cardB->get())->toBeLessThan($newA->get()) - ->and($newA->get())->toBeLessThan($this->cardC->get()); - - // B->A (using original A position) - $newB = Rank::before($this->cardA); - expect($newB->get())->toBeLessThan($this->cardA->get()); - }); - - it('moves A to end', function () { - $newA = Rank::after($this->cardD); - - expect($this->cardD->get())->toBeLessThan($newA->get()); - }); - - it('moves D to beginning', function () { - $newD = Rank::before($this->cardA); - - expect($newD->get())->toBeLessThan($this->cardA->get()); - }); - - it('moves B between C and D', function () { - $newB = Rank::betweenRanks($this->cardC, $this->cardD); - - expect($this->cardC->get())->toBeLessThan($newB->get()) - ->and($newB->get())->toBeLessThan($this->cardD->get()); - }); - - it('moves C between A and B', function () { - $newC = Rank::betweenRanks($this->cardA, $this->cardB); - - expect($this->cardA->get())->toBeLessThan($newC->get()) - ->and($newC->get())->toBeLessThan($this->cardB->get()); - }); - - describe('Complex Movement Sequences', function () { - it('performs multiple random movements', function () { - $cards = [ - 'A' => $this->cardA, - 'B' => $this->cardB, - 'C' => $this->cardC, - 'D' => $this->cardD, - ]; - - // Move A after C - $cards['A'] = Rank::betweenRanks($this->cardC, $this->cardD); - - // Move B to beginning - $cards['B'] = Rank::before($this->cardA); - - // Move D between original A and new A positions - $cards['D'] = Rank::betweenRanks($this->cardA, $cards['A']); - - // All cards should be unique - $ranks = array_map(fn ($card) => $card->get(), $cards); - expect(array_unique($ranks))->toHaveCount(4); - - // Check that ordering is valid for each moved card - expect($cards['B']->get())->toBeLessThan($this->cardA->get()); - expect($this->cardC->get())->toBeLessThan($cards['A']->get()) - ->and($cards['A']->get())->toBeLessThan($this->cardD->get()); - expect($this->cardA->get())->toBeLessThan($cards['D']->get()) - ->and($cards['D']->get())->toBeLessThan($cards['A']->get()); - }); - }); - }); - - describe('Stress Testing', function () { - it('handles 100 sequential insertions', function () { - $cards = [Rank::forEmptySequence()]; - - for ($i = 1; $i < 100; $i++) { - $cards[] = Rank::after(end($cards)); - } - - // Verify all are unique and in order - $ranks = array_map(fn ($card) => $card->get(), $cards); - expect(array_unique($ranks))->toHaveCount(100); - - $sortedRanks = $ranks; - sort($sortedRanks); - expect($ranks)->toBe($sortedRanks); - }); - - it('handles many insertions with cascading positions', function () { - $first = Rank::forEmptySequence(); - $last = Rank::after($first); - $insertions = [$first]; - - // Create a cascading series of insertions - for ($i = 0; $i < 10; $i++) { - $newRank = Rank::betweenRanks($insertions[count($insertions) - 1], $last); - $insertions[] = $newRank; - } - - // Verify all are unique and properly ordered - $ranks = array_map(fn ($card) => $card->get(), $insertions); - expect(array_unique($ranks))->toHaveCount(11); - - // Check ordering - for ($i = 0; $i < count($ranks) - 1; $i++) { - expect($ranks[$i])->toBeLessThan($ranks[$i + 1]); - } - }); - }); - - describe('Kanban Board Simulation', function () { - beforeEach(function () { - // Simulate 3 columns with tasks - $this->todoColumn = [ - Rank::forEmptySequence(), - Rank::after(Rank::forEmptySequence()), - ]; - - $this->inProgressColumn = [ - Rank::fromString('m1'), - Rank::fromString('m2'), - ]; - - $this->doneColumn = [ - Rank::fromString('x1'), - Rank::fromString('x2'), - ]; - }); - - it('moves task from todo to in-progress', function () { - $taskToMove = $this->todoColumn[0]; - - // Move to end of in-progress column - $newRank = Rank::after(end($this->inProgressColumn)); - - expect(end($this->inProgressColumn)->get())->toBeLessThan($newRank->get()); - }); - - it('moves task to beginning of column', function () { - $taskToMove = $this->doneColumn[1]; - - // Move to beginning of todo column - $newRank = Rank::before($this->todoColumn[0]); - - expect($newRank->get())->toBeLessThan($this->todoColumn[0]->get()); - }); - - it('reorders within same column', function () { - // Move second task in todo to first position - $newRank = Rank::before($this->todoColumn[0]); - - expect($newRank->get())->toBeLessThan($this->todoColumn[0]->get()); - }); - }); - - describe('Edge Cases and Error Handling', function () { - it('throws exception when prev >= next in betweenRanks', function () { - $first = Rank::forEmptySequence(); - $second = Rank::after($first); - - expect(fn () => Rank::betweenRanks($second, $first)) - ->toThrow(Relaticle\Flowforge\Exceptions\PrevGreaterThanOrEquals::class); - }); - - it('throws exception for invalid characters', function () { - expect(fn () => Rank::fromString('invalid!')) - ->toThrow(Relaticle\Flowforge\Exceptions\InvalidChars::class); - }); - - it('throws exception for rank ending with MIN_CHAR', function () { - expect(fn () => Rank::fromString('a0')) - ->toThrow(Relaticle\Flowforge\Exceptions\LastCharCantBeEqualToMinChar::class); - }); - }); - - describe('Real-world Performance Characteristics', function () { - it('maintains reasonable rank lengths under normal usage', function () { - $cards = [Rank::forEmptySequence()]; - - // Simulate typical usage - adding cards and occasional reordering - for ($i = 0; $i < 20; $i++) { - $cards[] = Rank::after(end($cards)); - - // Occasionally insert between existing cards (keep array sorted) - if ($i % 5 === 0 && count($cards) > 2) { - // Sort cards to ensure proper ordering before insertion - usort($cards, fn ($a, $b) => strcmp($a->get(), $b->get())); - $randomIndex = random_int(0, count($cards) - 2); - $newCard = Rank::betweenRanks($cards[$randomIndex], $cards[$randomIndex + 1]); - $cards[] = $newCard; - } - } - - // Most ranks should be reasonable length - $longRanks = array_filter($cards, fn ($card) => strlen($card->get()) > 10); - expect($longRanks)->toHaveCount(0, 'Ranks should stay reasonable length under normal usage'); - }); - - it('generates lexicographically ordered ranks', function () { - $first = Rank::forEmptySequence(); - $second = Rank::after($first); - $between = Rank::betweenRanks($first, $second); - - // Test PHP string comparison matches our ordering - expect(strcmp($first->get(), $between->get()))->toBeLessThan(0) - ->and(strcmp($between->get(), $second->get()))->toBeLessThan(0); - }); - }); -}); diff --git a/tests/database/migrations/2024_01_01_000000_create_tasks_table.php b/tests/database/migrations/2024_01_01_000000_create_tasks_table.php index 675d91a..9a8aa62 100644 --- a/tests/database/migrations/2024_01_01_000000_create_tasks_table.php +++ b/tests/database/migrations/2024_01_01_000000_create_tasks_table.php @@ -2,7 +2,6 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; return new class extends Migration @@ -14,23 +13,16 @@ public function up(): void $table->string('title'); $table->string('status')->default('todo'); - $this->definePositionColumn($table); + // DECIMAL(20,10) for position - 10 integer digits + 10 decimal places + // Supports ~33 bisections before precision loss, with 65535 gap + $table->decimal('order_position', 20, 10)->nullable(); + + // Unique constraint per column to detect position collisions + // Combined with jitter, this enables retry logic for concurrent safety + $table->unique(['status', 'order_position'], 'unique_position_per_column'); $table->string('priority')->default('medium'); $table->timestamps(); }); } - - private function definePositionColumn(Blueprint $table): void - { - $driver = DB::connection()->getDriverName(); - - $positionColumn = $table->string('order_position', 64)->nullable(); - - match ($driver) { - 'pgsql' => $positionColumn->collation('C'), - 'mysql' => $positionColumn->collation('utf8mb4_bin'), - default => null, - }; - } }; From 533e88d113e80ada6a1cf1ff062cb4bbe20a81a8 Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Fri, 26 Dec 2025 17:03:48 +0400 Subject: [PATCH 5/6] remove tests --- tests/Datasets/TaskMovements.php | 126 ---- .../Feature/CharacterSpaceExhaustionTest.php | 323 -------- tests/Feature/ColumnColorTest.php | 120 --- .../Feature/ConcurrentOperationStressTest.php | 302 -------- tests/Feature/DragDropFunctionalityTest.php | 697 ------------------ .../JavaScriptPhpParameterFlowTest.php | 424 ----------- tests/Feature/ParameterCombinationTest.php | 280 ------- tests/Feature/ParameterOrderMutationTest.php | 299 -------- tests/Feature/PerformanceRegressionTest.php | 198 ----- .../PositionInversionReproductionTest.php | 272 ------- tests/Feature/RepairPositionsCommandTest.php | 298 -------- tests/Unit/DecimalPositionServiceTest.php | 376 ---------- tests/Unit/PositionRebalancerServiceTest.php | 155 ---- 13 files changed, 3870 deletions(-) delete mode 100644 tests/Datasets/TaskMovements.php delete mode 100644 tests/Feature/CharacterSpaceExhaustionTest.php delete mode 100644 tests/Feature/ColumnColorTest.php delete mode 100644 tests/Feature/ConcurrentOperationStressTest.php delete mode 100644 tests/Feature/DragDropFunctionalityTest.php delete mode 100644 tests/Feature/JavaScriptPhpParameterFlowTest.php delete mode 100644 tests/Feature/ParameterCombinationTest.php delete mode 100644 tests/Feature/ParameterOrderMutationTest.php delete mode 100644 tests/Feature/PerformanceRegressionTest.php delete mode 100644 tests/Feature/PositionInversionReproductionTest.php delete mode 100644 tests/Feature/RepairPositionsCommandTest.php delete mode 100644 tests/Unit/DecimalPositionServiceTest.php delete mode 100644 tests/Unit/PositionRebalancerServiceTest.php diff --git a/tests/Datasets/TaskMovements.php b/tests/Datasets/TaskMovements.php deleted file mode 100644 index aa37981..0000000 --- a/tests/Datasets/TaskMovements.php +++ /dev/null @@ -1,126 +0,0 @@ - ['todo', 'in_progress'], - 'todo_to_completed' => ['todo', 'completed'], - 'in_progress_to_completed' => ['in_progress', 'completed'], - 'in_progress_to_todo' => ['in_progress', 'todo'], - 'completed_to_todo' => ['completed', 'todo'], - 'completed_to_in_progress' => ['completed', 'in_progress'], -]); - -dataset('rapid_move_sequences', [ - 'indecisive_user' => [['in_progress', 'completed', 'todo']], - 'complex_workflow' => [['completed', 'in_progress', 'todo', 'completed']], - 'back_and_forth' => [['in_progress', 'todo', 'in_progress', 'completed']], - 'same_column_moves' => [['todo', 'todo', 'todo', 'in_progress']], -]); - -dataset('board_sizes', [ - 'small_team' => 25, - 'medium_team' => 50, - 'large_team' => 100, - 'enterprise' => 250, -]); - -dataset('performance_benchmarks', [ - 'small_board' => [10, 0.1], // 10 cards: < 100ms - 'medium_board' => [50, 0.2], // 50 cards: < 200ms - 'large_board' => [100, 0.3], // 100 cards: < 300ms - 'huge_board' => [250, 0.5], // 250 cards: < 500ms -]); - -dataset('reordering_patterns', [ - 'simple_reorder' => [['after', 'before', 'after']], - 'all_before' => [['before', 'before', 'before']], - 'all_after' => [['after', 'after', 'after']], - 'complex_reorder' => [['after', 'before', 'after', 'before', 'after']], -]); - -dataset('cascade_depths', [ - 'light_usage' => 5, - 'normal_usage' => 10, - 'heavy_usage' => 15, - 'extreme_usage' => 25, -]); - -dataset('team_collaboration_scenarios', [ - 'daily_standup' => [ - ['status' => 'in_progress'], // Dev 1 starts task - ['status' => 'in_progress'], // Dev 2 starts task - ['status' => 'completed'], // Dev 3 finishes task - ['status' => 'todo'], // PM moves task back - ], - 'sprint_planning' => [ - ['status' => 'todo'], // Reprioritize - ['status' => 'todo'], // Reprioritize - ['status' => 'in_progress'], // Start urgent task - ['status' => 'completed'], // Complete quick win - ], - 'crisis_response' => [ - ['status' => 'in_progress'], // All hands on critical bug - ['status' => 'in_progress'], - ['status' => 'in_progress'], - ['status' => 'in_progress'], - ], -]); - -dataset('stress_operation_counts', [ - 'light_load' => 50, - 'medium_load' => 100, - 'heavy_load' => 200, -]); - -dataset('edge_case_scenarios', [ - 'move_all_to_first', - 'circular_moves', - 'mass_revert', -]); - -dataset('position_corruption_types', [ - 'null_positions', - 'duplicate_positions', - 'invalid_positions', -]); - -// Production board state factory -function createProductionBoardState(): array -{ - $position = DecimalPosition::forEmptyColumn(); - $tasks = []; - - // Generate tasks with proper decimal positions - $taskData = [ - ['Fix critical security vulnerability', 'todo', 'high'], - ['Implement user authentication', 'todo', 'high'], - ['Add payment processing', 'todo', 'high'], - ['Build user dashboard', 'todo', 'medium'], - ['Implement search functionality', 'todo', 'medium'], - ['Add email notifications', 'todo', 'medium'], - ['Add dark mode theme', 'todo', 'low'], - ['Implement keyboard shortcuts', 'todo', 'low'], - ['Optimize database queries', 'in_progress', 'high'], - ['Refactor API endpoints', 'in_progress', 'medium'], - ['Update documentation', 'in_progress', 'low'], - ['Set up CI/CD pipeline', 'completed', 'high'], - ['Implement logging system', 'completed', 'medium'], - ['Create deployment scripts', 'completed', 'medium'], - ]; - - foreach ($taskData as [$title, $status, $priority]) { - $tasks[] = [ - 'title' => $title, - 'status' => $status, - 'order_position' => $position, - 'priority' => $priority, - ]; - - $position = DecimalPosition::after($position); - } - - return $tasks; -} - -dataset('production_board_states', fn () => [createProductionBoardState()]); diff --git a/tests/Feature/CharacterSpaceExhaustionTest.php b/tests/Feature/CharacterSpaceExhaustionTest.php deleted file mode 100644 index 1b9aa0f..0000000 --- a/tests/Feature/CharacterSpaceExhaustionTest.php +++ /dev/null @@ -1,323 +0,0 @@ -board = Livewire::test(TestBoard::class); -}); - -describe('Decimal Position Gap Tests - Ensuring Precision Never Fails', function () { - it('stresses 100 sequential insertions at bottom of column', function () { - // STRESS TEST: Test DecimalPosition with 100 sequential insertions - // Expected: Positions grow linearly, all positions unique - // This tests DecimalPosition::after() under heavy sequential usage - - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - - // Create 100 cards sequentially at bottom - for ($i = 1; $i <= 100; $i++) { - $card = Task::factory()->create([ - 'title' => "Sequential #{$i}", - 'status' => 'todo', - 'order_position' => $position, - ]); - - $cards->push($card); - $position = DecimalPosition::after($position); - - // Check for inversions at checkpoints - if (in_array($i, [10, 20, 50, 75, 100])) { - $inversions = detectInversions(Task::class, 'todo'); - expect($inversions)->toBeEmpty( - "No inversions after {$i} sequential insertions" - ); - } - } - - // Verify all positions are unique - $uniquePositions = $cards->pluck('order_position')->unique()->count(); - expect($uniquePositions)->toBe(100, 'All 100 positions should be unique'); - - // Verify positions are in ascending order - $positions = Task::where('status', 'todo') - ->orderBy('order_position') - ->orderBy('id') - ->pluck('order_position') - ->toArray(); - - for ($i = 0; $i < count($positions) - 1; $i++) { - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($positions[$i]), - DecimalPosition::normalize($positions[$i + 1]) - ))->toBeTrue("Position {$i} should be less than position " . ($i + 1)); - } - }); - - it('monitors position growth patterns with 500 sequential cards', function () { - // STRESS TEST: Create 500 cards sequentially - // Expected: Linear growth in position values, not exponential - // This tests DecimalPosition::after() under heavy sequential usage - - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - - for ($i = 1; $i <= 500; $i++) { - $card = Task::factory()->create([ - 'title' => "Sequential Card {$i}", - 'status' => 'in_progress', - 'order_position' => $position, - ]); - - $cards->push($card); - $position = DecimalPosition::after($position); - } - - // Verify all cards created - expect($cards->count())->toBe(500, 'All 500 cards should be created'); - - // Verify no inversions in sequential creation - $inversions = detectInversions(Task::class, 'in_progress'); - expect($inversions)->toBeEmpty( - 'Sequential creation should never produce inversions' - ); - - // Verify positions are unique - $uniquePositions = $cards->pluck('order_position')->unique()->count(); - expect($uniquePositions)->toBe(500, 'All positions should be unique'); - }); - - it('tests position calculation near minimum boundary', function () { - // BOUNDARY TEST: Test positions near zero - // Create a card with small position and insert before it - - $boundaryCard = Task::factory()->create([ - 'title' => 'Near Zero Boundary', - 'status' => 'review', - 'order_position' => '1000.0000000000', - ]); - - $newCard = Task::factory()->create([ - 'title' => 'Insert Before Boundary', - 'status' => 'review', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - // Move card to be BEFORE the boundary card - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'review', - null, // afterCardId=null (move to top) - (string) $boundaryCard->id // beforeCardId - ); - - $newCard->refresh(); - - // Verify new position is less than boundary - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($newCard->order_position), - DecimalPosition::normalize($boundaryCard->order_position) - ))->toBeTrue('Position should be < boundary when moved before it'); - }); - - it('tests position calculation near large values', function () { - // BOUNDARY TEST: Test positions with large values - // Create a card with large position and insert after it - - $boundaryCard = Task::factory()->create([ - 'title' => 'Large Position', - 'status' => 'review', - 'order_position' => '9999999999.0000000000', // Large position - ]); - - $newCard = Task::factory()->create([ - 'title' => 'Insert After Large', - 'status' => 'review', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - // Move card to be AFTER the boundary card - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'review', - (string) $boundaryCard->id, // afterCardId - null // beforeCardId=null (move to bottom) - ); - - $newCard->refresh(); - - // Verify new position is greater than boundary - expect(DecimalPosition::greaterThan( - DecimalPosition::normalize($newCard->order_position), - DecimalPosition::normalize($boundaryCard->order_position) - ))->toBeTrue('Position should be > boundary when moved after it'); - }); - - it('verifies progressive bisection insertions never fail', function () { - // BISECTION TEST: Insert cards progressively, subdividing space - // With decimal positions, midpoint calculation never fails - - // Create boundary cards - $cards = collect([ - Task::factory()->create([ - 'title' => 'Start', - 'status' => 'done', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]), - ]); - - $secondPosition = DecimalPosition::after(DecimalPosition::forEmptyColumn()); - $cards->push(Task::factory()->create([ - 'title' => 'End', - 'status' => 'done', - 'order_position' => $secondPosition, - ])); - - // Insert 30 cards between first and second (forces bisection) - for ($i = 1; $i <= 30; $i++) { - // Get current sorted cards - $sortedCards = $cards->sortBy('order_position')->values(); - $afterCard = $sortedCards->first(); - $beforeCard = $sortedCards->get(1); - - $newCard = Task::factory()->create([ - 'title' => "Bisection #{$i}", - 'status' => 'done', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'done', - (string) $afterCard->id, - (string) $beforeCard->id - ); - - $newCard->refresh(); - $cards->push($newCard); - - // Verify correct placement - $afterPos = DecimalPosition::normalize($afterCard->order_position); - $newPos = DecimalPosition::normalize($newCard->order_position); - $beforePos = DecimalPosition::normalize($beforeCard->fresh()->order_position); - - expect(DecimalPosition::lessThan($afterPos, $newPos))->toBeTrue( - "Bisection {$i}: new position should be > afterCard" - ); - expect(DecimalPosition::lessThan($newPos, $beforePos))->toBeTrue( - "Bisection {$i}: new position should be < beforeCard" - ); - } - - // Verify all positions unique - $allPositions = Task::where('status', 'done')->pluck('order_position'); - $uniqueCount = $allPositions->unique()->count(); - expect($uniqueCount)->toBe( - $allPositions->count(), - 'All positions should be unique after 30 bisections' - ); - - // Verify positions are properly ordered when sorted - $sortedPositions = Task::where('status', 'done') - ->orderBy('order_position') - ->orderBy('id') - ->pluck('order_position') - ->toArray(); - - for ($i = 0; $i < count($sortedPositions) - 1; $i++) { - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($sortedPositions[$i]), - DecimalPosition::normalize($sortedPositions[$i + 1]) - ))->toBeTrue("Sorted position {$i} should be < position " . ($i + 1)); - } - }); - - it('validates position uniqueness with systematic insertions', function () { - // UNIQUENESS TEST: Ensure all positions remain unique with systematic insertions - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - - // Create 50 cards sequentially - for ($i = 1; $i <= 50; $i++) { - $card = Task::factory()->create([ - 'title' => "Card #{$i}", - 'status' => 'backlog', - 'order_position' => $position, - ]); - - $cards->push($card); - $position = DecimalPosition::after($position); - } - - // Verify ALL positions are unique - $allPositions = Task::where('status', 'backlog')->pluck('order_position'); - $uniquePositions = $allPositions->unique(); - - expect($uniquePositions->count())->toBe( - 50, - 'All 50 positions must be unique - no duplicates allowed' - ); - - expect($uniquePositions->count())->toBe( - $allPositions->count(), - 'Unique count should match total count' - ); - - // Verify no inversions - $inversions = detectInversions(Task::class, 'backlog'); - expect($inversions)->toBeEmpty( - 'No inversions should exist in sequential creation' - ); - - // Verify positions are properly ordered - $positions = $cards->pluck('order_position')->toArray(); - for ($i = 0; $i < count($positions) - 1; $i++) { - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($positions[$i]), - DecimalPosition::normalize($positions[$i + 1]) - ))->toBeTrue("Position {$i} should be < position " . ($i + 1)); - } - }); - - it('tests deep bisection without precision loss', function () { - // PRECISION TEST: Deeply bisect to test decimal precision - // DECIMAL(20,10) should support ~33 bisections before MIN_GAP - - $lower = DecimalPosition::forEmptyColumn(); // 65535 - $upper = DecimalPosition::after($lower); // 131070 - - $bisectionCount = 0; - $lastMid = null; - - // Keep bisecting until we can't anymore or hit 50 iterations - while ($bisectionCount < 50) { - $mid = DecimalPosition::between($lower, $upper); - - // Verify the midpoint is actually between lower and upper - if (! DecimalPosition::lessThan($lower, $mid) || ! DecimalPosition::lessThan($mid, $upper)) { - break; // Precision exhausted - } - - // Check if we've hit MIN_GAP (rebalancing would be needed) - if (DecimalPosition::needsRebalancing($lower, $mid)) { - break; - } - - $lastMid = $mid; - $upper = $mid; // Narrow the gap - $bisectionCount++; - } - - // DECIMAL(20,10) should support at least 25-30 bisections - expect($bisectionCount)->toBeGreaterThanOrEqual( - 25, - "Should support at least 25 bisections before precision concerns (got {$bisectionCount})" - ); - }); -}); diff --git a/tests/Feature/ColumnColorTest.php b/tests/Feature/ColumnColorTest.php deleted file mode 100644 index 3579fe2..0000000 --- a/tests/Feature/ColumnColorTest.php +++ /dev/null @@ -1,120 +0,0 @@ -color('primary'); - - expect($column->getColor())->toBe('primary'); - }); - - it('can set tailwind colors on columns by name', function () { - $colors = ['red', 'blue', 'green', 'amber', 'purple', 'pink', 'gray']; - - foreach ($colors as $color) { - $column = Column::make('test')->color($color); - expect($column->getColor())->toBe($color); - } - }); - - it('can set Color constants directly', function () { - $column = Column::make('test') - ->color(Color::Red); - - $color = $column->getColor(); - expect($color)->toBeArray() - ->and($color)->toHaveKey(50) - ->and($color)->toHaveKey(500) - ->and($color)->toHaveKey(900); - }); - - it('can set hex colors on columns', function () { - $column = Column::make('test') - ->color('#ff0000'); - - expect($column->getColor())->toBe('#ff0000'); - }); - - it('can set default color on columns', function () { - $column = Column::make('test') - ->defaultColor('gray'); - - expect($column->getColor())->toBe('gray'); - }); - - it('uses default color when no color is set', function () { - $column = Column::make('test') - ->defaultColor('info'); - - expect($column->getColor())->toBe('info'); - }); - - it('prefers explicit color over default color', function () { - $column = Column::make('test') - ->defaultColor('gray') - ->color('primary'); - - expect($column->getColor())->toBe('primary'); - }); -}); - -describe('ColorResolver', function () { - it('resolves semantic colors', function () { - expect(ColorResolver::resolve('primary'))->toBe('primary') - ->and(ColorResolver::resolve('danger'))->toBe('danger') - ->and(ColorResolver::resolve('success'))->toBe('success') - ->and(ColorResolver::isSemantic('primary'))->toBeTrue() - ->and(ColorResolver::isSemantic('danger'))->toBeTrue(); - }); - - it('resolves Tailwind color names', function () { - $redColor = ColorResolver::resolve('red'); - expect($redColor)->toBeArray() - ->and($redColor)->toHaveKey(500) - ->and($redColor[500])->toContain('oklch'); - - $blueColor = ColorResolver::resolve('blue'); - expect($blueColor)->toBeArray() - ->and($blueColor)->toHaveKey(500); - }); - - it('resolves Color constants', function () { - $color = ColorResolver::resolve(Color::Green); - expect($color)->toBeArray() - ->and($color)->toBe(Color::Green) - ->and($color)->toHaveKey(500); - }); - - it('resolves hex colors', function () { - $color = ColorResolver::resolve('#ff0000'); - expect($color)->toBeArray() - ->and($color)->toHaveKey(500); - }); - - it('handles invalid colors gracefully', function () { - expect(ColorResolver::resolve('invalid-color'))->toBeNull() - ->and(ColorResolver::resolve('not-a-color'))->toBeNull() - ->and(ColorResolver::resolve('#gggggg'))->toBeNull() - ->and(ColorResolver::resolve(''))->toBeNull() - ->and(ColorResolver::resolve(null))->toBeNull(); - }); - - it('is case-insensitive for Tailwind colors', function () { - expect(ColorResolver::resolve('RED'))->toBeArray() - ->and(ColorResolver::resolve('Red'))->toBeArray() - ->and(ColorResolver::resolve('red'))->toBeArray(); - }); - - it('correctly identifies semantic vs non-semantic colors', function () { - expect(ColorResolver::isSemantic('primary'))->toBeTrue() - ->and(ColorResolver::isSemantic(Color::Red))->toBeFalse() - ->and(ColorResolver::isSemantic('#ff0000'))->toBeFalse() - ->and(ColorResolver::isSemantic('red'))->toBeFalse(); - }); -}); diff --git a/tests/Feature/ConcurrentOperationStressTest.php b/tests/Feature/ConcurrentOperationStressTest.php deleted file mode 100644 index 84f1c9f..0000000 --- a/tests/Feature/ConcurrentOperationStressTest.php +++ /dev/null @@ -1,302 +0,0 @@ -board = Livewire::test(TestBoard::class); -}); - -describe('Concurrent Operation Stress Tests - Simulating Real-World Concurrency', function () { - it('handles rapid successive moves of same card (20+ times)', function () { - // STRESS TEST: Move the same card 20+ times rapidly - // Simulates a user repeatedly changing their mind or network lag causing multiple requests - - // Create cards in each column - $todoCards = Task::factory()->count(5)->create(['status' => 'todo']); - $inProgressCards = Task::factory()->count(5)->create(['status' => 'in_progress']); - $completedCards = Task::factory()->count(5)->create(['status' => 'completed']); - - $targetCard = $todoCards->first(); - $statuses = ['todo', 'in_progress', 'completed']; - - // Perform 20 rapid successive moves - for ($i = 0; $i < 20; $i++) { - $randomStatus = $statuses[array_rand($statuses)]; - - $this->board->call('moveCard', (string) $targetCard->id, $randomStatus); - $targetCard->refresh(); - - // Verify card has valid position after each move - expect($targetCard->order_position)->not()->toBeNull() - ->and($targetCard->status)->toBe($randomStatus); - } - - // Verify positions are properly sorted in each column (using decimal comparison) - foreach ($statuses as $status) { - $positions = Task::where('status', $status) - ->orderBy('order_position') - ->orderBy('id') - ->pluck('order_position') - ->toArray(); - - // Check positions are in ascending order - for ($i = 0; $i < count($positions) - 1; $i++) { - $current = DecimalPosition::normalize($positions[$i]); - $next = DecimalPosition::normalize($positions[$i + 1]); - expect(DecimalPosition::lessThan($current, $next))->toBeTrue( - "Positions should be sorted in {$status} column after 20 rapid moves" - ); - } - } - - // Verify final card state is valid - $targetCard->refresh(); - expect($targetCard->order_position)->not()->toBeNull(); - }); - - it('simulates concurrent-like operations with interleaved moves', function () { - // CONCURRENCY SIMULATION: Multiple cards moving simultaneously (interleaved) - // Create 20 cards spread across columns - $cards = collect(); - foreach (['todo', 'in_progress', 'completed'] as $status) { - $statusCards = Task::factory()->count(7)->create(['status' => $status]); - $cards = $cards->merge($statusCards); - } - - // Simulate 5 "concurrent" operations by interleaving them - // In real concurrency, these would execute simultaneously - $operations = []; - for ($i = 0; $i < 5; $i++) { - $card = $cards->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - $operations[] = ['card' => $card, 'status' => $newStatus]; - } - - // Execute all operations - foreach ($operations as $op) { - $this->board->call('moveCard', (string) $op['card']->id, $op['status']); - } - - // Verify positions are properly sorted in each column - foreach (['todo', 'in_progress', 'completed'] as $status) { - $positions = Task::where('status', $status) - ->orderBy('order_position') - ->orderBy('id') - ->pluck('order_position') - ->toArray(); - - // Check positions are in ascending order - for ($i = 0; $i < count($positions) - 1; $i++) { - $current = DecimalPosition::normalize($positions[$i]); - $next = DecimalPosition::normalize($positions[$i + 1]); - expect(DecimalPosition::lessThan($current, $next))->toBeTrue( - "Positions should be sorted in {$status} column after concurrent operations" - ); - } - } - - // Verify all positions are unique in each column - foreach (['todo', 'in_progress', 'completed'] as $status) { - $positions = Task::where('status', $status) - ->pluck('order_position') - ->toArray(); - - $uniqueCount = count(array_unique($positions)); - expect($uniqueCount)->toBe( - count($positions), - "All positions in {$status} should be unique" - ); - } - }); - - it('mass reorders entire column (reverse all cards)', function () { - // STRESS TEST: Reverse order of all cards in a column - // Simulates bulk reordering operations - - // Create 20 cards in sequential order - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - for ($i = 1; $i <= 20; $i++) { - $card = Task::factory()->create([ - 'title' => "Card {$i}", - 'status' => 'todo', - 'order_position' => $position, - ]); - - $cards->push($card); - $position = DecimalPosition::after($position); - } - - // Reverse the order by moving each card to the top - $reversedCards = $cards->reverse(); - foreach ($reversedCards as $card) { - // Move to top (afterCardId=null, beforeCardId=first card) - $firstCard = Task::where('status', 'todo') - ->orderBy('order_position') - ->orderBy('id') - ->first(); - - $this->board->call( - 'moveCard', - (string) $card->id, - 'todo', - null, // afterCardId=null (move to top) - (string) $firstCard->id // beforeCardId=first card - ); - } - - // Verify all positions are valid and unique - $positions = Task::where('status', 'todo') - ->pluck('order_position') - ->toArray(); - - expect(count(array_unique($positions)))->toBe( - 20, - 'All 20 positions should be unique after mass reorder' - ); - - // Verify no inversions - $sortedPositions = Task::where('status', 'todo') - ->orderBy('order_position') - ->orderBy('id') - ->pluck('order_position') - ->toArray(); - - for ($i = 0; $i < count($sortedPositions) - 1; $i++) { - $current = DecimalPosition::normalize($sortedPositions[$i]); - $next = DecimalPosition::normalize($sortedPositions[$i + 1]); - expect(DecimalPosition::lessThan($current, $next))->toBeTrue( - "Position {$i} should be < position " . ($i + 1) . ' after mass reorder' - ); - } - }); - - it('handles simultaneous moves to same column from different columns', function () { - // CONCURRENCY SCENARIO: Multiple cards moving into same destination column - // Create cards in different columns - $todoCards = Task::factory()->count(5)->create(['status' => 'todo']); - $inProgressCards = Task::factory()->count(5)->create(['status' => 'in_progress']); - $completedCards = Task::factory()->count(5)->create(['status' => 'completed']); - - // Move 3 cards from different columns all to 'in_progress' - $this->board->call('moveCard', (string) $todoCards->get(0)->id, 'in_progress'); - $this->board->call('moveCard', (string) $todoCards->get(1)->id, 'in_progress'); - $this->board->call('moveCard', (string) $completedCards->get(0)->id, 'in_progress'); - - // Verify positions are properly sorted in target column - $positions = Task::where('status', 'in_progress') - ->orderBy('order_position') - ->orderBy('id') - ->pluck('order_position') - ->toArray(); - - for ($i = 0; $i < count($positions) - 1; $i++) { - $current = DecimalPosition::normalize($positions[$i]); - $next = DecimalPosition::normalize($positions[$i + 1]); - expect(DecimalPosition::lessThan($current, $next))->toBeTrue( - 'Positions should be sorted in in_progress column' - ); - } - - // Verify all positions unique in target column - $positions = Task::where('status', 'in_progress') - ->pluck('order_position') - ->toArray(); - - expect(count(array_unique($positions)))->toBe( - count($positions), - 'All positions in in_progress should be unique' - ); - }); - - it('stress tests alternating column movements (ping-pong pattern)', function () { - // STRESS TEST: Move cards back and forth between columns rapidly - // Simulates indecisive users or workflow state changes - - $card1 = Task::factory()->create(['status' => 'todo']); - $card2 = Task::factory()->create(['status' => 'in_progress']); - $card3 = Task::factory()->create(['status' => 'completed']); - - // Perform 30 ping-pong movements - for ($i = 0; $i < 30; $i++) { - // Card 1: todo <-> in_progress - $this->board->call( - 'moveCard', - (string) $card1->id, - $i % 2 === 0 ? 'in_progress' : 'todo' - ); - - // Card 2: in_progress <-> completed - $this->board->call( - 'moveCard', - (string) $card2->id, - $i % 2 === 0 ? 'completed' : 'in_progress' - ); - - // Card 3: completed <-> todo - $this->board->call( - 'moveCard', - (string) $card3->id, - $i % 2 === 0 ? 'todo' : 'completed' - ); - } - - // Verify all cards have valid positions - foreach ([$card1, $card2, $card3] as $card) { - $card->refresh(); - expect($card->order_position)->not()->toBeNull(); - } - - // Verify positions are properly sorted in each column - foreach (['todo', 'in_progress', 'completed'] as $status) { - $positions = Task::where('status', $status) - ->orderBy('order_position') - ->orderBy('id') - ->pluck('order_position') - ->toArray(); - - for ($i = 0; $i < count($positions) - 1; $i++) { - $current = DecimalPosition::normalize($positions[$i]); - $next = DecimalPosition::normalize($positions[$i + 1]); - expect(DecimalPosition::lessThan($current, $next))->toBeTrue( - "Positions should be sorted in {$status} after ping-pong movements" - ); - } - } - }); - - it('validates data consistency under high-frequency operations', function () { - // CONSISTENCY TEST: Verify database state remains consistent under stress - // Create baseline - $cards = Task::factory()->count(30)->create(); - - $initialCount = Task::count(); - $initialProjectIds = Task::pluck('project_id')->filter()->unique()->count(); - - // Perform 50 high-frequency operations - for ($i = 0; $i < 50; $i++) { - $card = $cards->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - - $this->board->call('moveCard', (string) $card->id, $newStatus); - } - - // Verify data integrity - $finalCount = Task::count(); - expect($finalCount)->toBe($initialCount, 'Card count should remain stable'); - - // Verify relationships intact - $finalProjectIds = Task::pluck('project_id')->filter()->unique()->count(); - expect($finalProjectIds)->toBe( - $initialProjectIds, - 'Project relationships should remain intact' - ); - - // Verify no null positions - $nullPositions = Task::whereNull('order_position')->count(); - expect($nullPositions)->toBe(0, 'No cards should have null positions'); - }); -}); diff --git a/tests/Feature/DragDropFunctionalityTest.php b/tests/Feature/DragDropFunctionalityTest.php deleted file mode 100644 index 96bfbea..0000000 --- a/tests/Feature/DragDropFunctionalityTest.php +++ /dev/null @@ -1,697 +0,0 @@ -users = User::factory()->count(6)->create([ - 'team' => 'Development', - ]); - - $this->projects = Project::factory()->count(3)->create([ - 'owner_id' => $this->users->random()->id, - ]); - - // Create tasks across different projects with realistic distribution - $this->tasks = collect(); - - // Project 1: Active development project with mixed priority tasks - $project1Tasks = Task::factory()->count(8)->create([ - 'project_id' => $this->projects->get(0)->id, - 'created_by' => $this->users->random()->id, - 'status' => 'todo', - ]); - $this->tasks = $this->tasks->merge($project1Tasks); - - // Project 2: Tasks in progress - $project2Tasks = Task::factory()->count(3)->create([ - 'project_id' => $this->projects->get(1)->id, - 'created_by' => $this->users->random()->id, - 'status' => 'in_progress', - 'assigned_to' => $this->users->random()->id, - ]); - $this->tasks = $this->tasks->merge($project2Tasks); - - // Project 3: Completed tasks - $project3Tasks = Task::factory()->count(3)->create([ - 'project_id' => $this->projects->get(2)->id, - 'created_by' => $this->users->random()->id, - 'status' => 'completed', - 'assigned_to' => $this->users->random()->id, - 'completed_at' => now()->subDays(rand(1, 30)), - ]); - $this->tasks = $this->tasks->merge($project3Tasks); - - $this->board = Livewire::test(TestBoard::class); - }); - - describe('Core Movement Functionality', function () { - it('executes basic card movements between columns', function (string $fromStatus, string $toStatus) { - $task = Task::where('status', $fromStatus)->first(); - expect($task)->not()->toBeNull(); - - $originalPosition = $task->order_position; - $originalAssignee = $task->assigned_to; - - $this->board->call('moveCard', (string) $task->id, $toStatus); - - $task->refresh(); - expect($task->status)->toBe($toStatus) - ->and($task->order_position)->not()->toBeNull() - ->and($task->assigned_to)->toBe($originalAssignee); // Assignee should not change - - // Position should be valid regardless of column change - expect($task->order_position)->toBeString()->not()->toBeEmpty(); - })->with('workflow_progressions'); - - it('handles rapid sequential movements without data corruption', function (array $moveSequence) { - $task = Task::where('status', 'todo')->first(); - expect($task)->not()->toBeNull(); - - $originalProjectId = $task->project_id; - $originalCreatedBy = $task->created_by; - - foreach ($moveSequence as $status) { - $this->board->call('moveCard', (string) $task->id, $status); - } - - $task->refresh(); - expect($task->status)->toBe(end($moveSequence)) - ->and($task->order_position)->not()->toBeNull()->toBeString() - ->and($task->project_id)->toBe($originalProjectId) - ->and($task->created_by)->toBe($originalCreatedBy); - })->with('rapid_move_sequences'); - - it('maintains all related data integrity during moves', function () { - $task = Task::with(['project', 'assignedUser', 'creator'])->where('status', 'todo')->first(); - - $originalTitle = $task->title; - $originalPriority = $task->priority; - $originalDescription = $task->description; - $originalLabels = $task->labels; - $originalDueDate = $task->due_date; - - $this->board->call('moveCard', (string) $task->id, 'in_progress'); - - $task->refresh(); - expect($task->title)->toBe($originalTitle) - ->and($task->priority)->toBe($originalPriority) - ->and($task->description)->toBe($originalDescription) - ->and($task->labels)->toEqual($originalLabels) - ->and($task->due_date?->format('Y-m-d'))->toBe($originalDueDate?->format('Y-m-d')) - ->and($task->status)->toBe('in_progress'); - }); - }); - - describe('Position-Based Drag & Drop with Real Data', function () { - it('handles complex multi-project positioning', function () { - // Test positioning across different projects in same column - $todoTasks = Task::where('status', 'todo')->with('project')->orderBy('order_position')->get(); - expect($todoTasks)->toHaveCount(8); - - $sourceCard = $todoTasks->first(); - $targetCard = $todoTasks->skip(3)->first(); // Fourth card, possibly different project - - // Move card maintaining project relationships - $this->board->call('moveCard', (string) $sourceCard->id, 'todo', null, (string) $targetCard->id); - - $sourceCard->refresh(); - - // beforeCardId places the card BEFORE the specified card (fixed implementation) - expect(strcmp($sourceCard->order_position, $targetCard->order_position))->toBeLessThan(0); - - // Project relationship should remain intact - expect($sourceCard->project_id)->not()->toBeNull(); - }); - - it('maintains proper ordering with mixed projects and priorities', function () { - // Create specific ordering scenario - $highPriorityTask = Task::factory()->create([ - 'status' => 'todo', - 'priority' => 'high', - 'project_id' => $this->projects->first()->id, - ]); - - $mediumPriorityTask = Task::factory()->create([ - 'status' => 'todo', - 'priority' => 'medium', - 'project_id' => $this->projects->last()->id, - ]); - - // Position high priority task before medium priority - $this->board->call('moveCard', (string) $highPriorityTask->id, 'todo', null, (string) $mediumPriorityTask->id); - - $highPriorityTask->refresh(); - $mediumPriorityTask->refresh(); - - expect(strcmp($highPriorityTask->order_position, $mediumPriorityTask->order_position))->toBeLessThan(0); - }); - }); - - describe('Production Workflow Scenarios', function () { - it('simulates realistic sprint planning with team assignments', function () { - $developer = $this->users->where('team', 'Development')->first(); - - // Sprint planning: Assign high priority tasks to developer - $sprintTasks = Task::where('status', 'todo') - ->where('priority', 'high') - ->take(3) - ->get(); - - foreach ($sprintTasks as $task) { - // Update assignment and move to in_progress - $task->update(['assigned_to' => $developer->id]); - $this->board->call('moveCard', (string) $task->id, 'in_progress'); - } - - // Verify sprint setup - $inProgressTasks = Task::where('status', 'in_progress')->get(); - expect($inProgressTasks->count())->toBeGreaterThanOrEqual(3); // At least the tasks we just moved - - foreach ($sprintTasks as $task) { - $task->refresh(); - expect($task->status)->toBe('in_progress') - ->and($task->assigned_to)->toBe($developer->id); - } - }); - - it('handles task completion with timestamps and metrics', function () { - $inProgressTask = Task::where('status', 'in_progress')->first(); - $inProgressTask->update([ - 'estimated_hours' => 8, - 'actual_hours' => null, - ]); - - $completionTime = now(); - - // Complete the task - $this->board->call('moveCard', (string) $inProgressTask->id, 'completed'); - - $inProgressTask->refresh(); - expect($inProgressTask->status)->toBe('completed') - ->and($inProgressTask->order_position)->not()->toBeNull(); - - // In real world, completion would update metrics - $inProgressTask->update([ - 'completed_at' => $completionTime, - 'actual_hours' => 10, - ]); - - expect($inProgressTask->completed_at)->not()->toBeNull(); - }); - - it('maintains referential integrity during bulk operations', function () { - // Record initial state with all relationships - $initialState = Task::with(['project', 'assignedUser', 'creator'])->get(); - $initialProjectIds = $initialState->pluck('project_id')->filter()->unique(); - $initialUserIds = $initialState->pluck('assigned_to')->filter()->unique(); - - // Perform bulk moves - $tasks = Task::all(); - for ($i = 0; $i < 25; $i++) { - $task = $tasks->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - $this->board->call('moveCard', (string) $task->id, $newStatus); - } - - // Verify relationships are maintained - $finalState = Task::with(['project', 'assignedUser', 'creator'])->get(); - expect($finalState->count())->toBe($initialState->count()); - - // Project assignments should remain unchanged - $finalProjectIds = $finalState->pluck('project_id')->filter()->unique(); - expect($finalProjectIds->sort()->values()->toArray()) - ->toEqual($initialProjectIds->sort()->values()->toArray()); - - // User assignments should remain unchanged - $finalUserIds = $finalState->pluck('assigned_to')->filter()->unique(); - expect($finalUserIds->sort()->values()->toArray()) - ->toEqual($initialUserIds->sort()->values()->toArray()); - }); - }); - - describe('Real-World Performance & Scale Testing', function () { - it('handles large team boards with multiple projects', function (int $additionalTasks) { - // Add more tasks to simulate large team environment - $projects = $this->projects; - $users = $this->users; - - Task::factory()->count($additionalTasks)->create([ - 'project_id' => $projects->random()->id, - 'assigned_to' => $users->random()->id, - 'created_by' => $users->random()->id, - ]); - - $totalTasks = Task::count(); - expect($totalTasks)->toBeGreaterThan($additionalTasks); - - // Test move performance on large board - $testCard = Task::inRandomOrder()->first(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - - $startTime = microtime(true); - $this->board->call('moveCard', (string) $testCard->id, $newStatus); - $duration = microtime(true) - $startTime; - - expect($duration)->toBeLessThan(0.5); // Should complete within 500ms - - $testCard->refresh(); - expect($testCard->status)->toBe($newStatus); - })->with([ - 'small_team' => 25, - 'medium_team' => 75, - 'large_team' => 150, - ]); - - it('validates database constraints under stress', function () { - $tasks = Task::all(); - - // Perform focused stress operations - for ($i = 0; $i < 20; $i++) { - $task = $tasks->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - - $this->board->call('moveCard', (string) $task->id, $newStatus); - - // Validate database integrity after each move - $task->refresh(); - expect($task->project_id)->not()->toBeNull() - ->and($task->created_by)->not()->toBeNull(); - } - - // Final integrity check - no orphaned data - $orphanedTasks = Task::whereNull('project_id')->count(); - expect($orphanedTasks)->toBe(0); - }); - }); - - describe('Error Handling & Recovery', function () { - it('handles concurrent modifications gracefully', function () { - $task = Task::first(); - - // Simulate concurrent modification (e.g., another user updates the task) - $task->update(['title' => 'Modified by another user']); - - // Move operation should still work - $this->board->call('moveCard', (string) $task->id, 'in_progress'); - - $task->refresh(); - expect($task->status)->toBe('in_progress') - ->and($task->title)->toBe('Modified by another user'); - }); - - it('maintains foreign key constraints during moves', function () { - $task = Task::with('project')->first(); - $originalProject = $task->project; - - // Move task through all statuses - $this->board->call('moveCard', (string) $task->id, 'in_progress'); - $this->board->call('moveCard', (string) $task->id, 'completed'); - $this->board->call('moveCard', (string) $task->id, 'todo'); - - $task->refresh(); - expect($task->project_id)->toBe($originalProject->id); - - // Verify project relationship still works - expect($task->project->name)->toBe($originalProject->name); - }); - - it('handles invalid references without corrupting data', function () { - expect(fn () => $this->board->call('moveCard', 'nonexistent-id', 'todo')) - ->toThrow(InvalidArgumentException::class); - - // Verify no data corruption occurred - $taskCount = Task::count(); - $userCount = User::count(); - $projectCount = Project::count(); - - expect($taskCount)->toBe($this->tasks->count()) - ->and($userCount)->toBe($this->users->count()) - ->and($projectCount)->toBe($this->projects->count()); - }); - }); - - describe('Advanced Position Management', function () { - it('prevents position collisions in high-frequency scenarios', function () { - // Simulate rapid task creation and movement (like importing tasks) - $newTasks = Task::factory()->count(10)->create([ - 'status' => 'todo', - 'project_id' => $this->projects->first()->id, - ]); - - // Move all new tasks rapidly - foreach ($newTasks as $task) { - $this->board->call('moveCard', (string) $task->id, 'in_progress'); - $this->board->call('moveCard', (string) $task->id, 'todo'); - } - - // Verify no position duplicates - $positions = Task::where('status', 'todo') - ->whereNotNull('order_position') - ->pluck('order_position') - ->toArray(); - - expect(array_unique($positions))->toHaveCount(count($positions)); - }); - - it('maintains ordering consistency with controlled reordering', function () { - // Get current todo tasks to work with - $existingTodos = Task::where('status', 'todo')->count(); - - // Create just a few additional tasks for controlled testing - $newTasks = Task::factory()->count(3)->create([ - 'status' => 'todo', - 'priority' => 'medium', - 'project_id' => $this->projects->first()->id, - ]); - - // Perform simple reordering - move one task at a time - $taskToMove = $newTasks->first(); - $targetTask = $newTasks->last(); - - // Move first task after last task (afterCardId=last, beforeCardId=null) - $this->board->call('moveCard', (string) $taskToMove->id, 'todo', (string) $targetTask->id, null); - - // Verify no duplicate positions in todo column - $todoPositions = Task::where('status', 'todo') - ->whereNotNull('order_position') - ->pluck('order_position') - ->toArray(); - - // Main test: ensure no position collisions - expect(array_unique($todoPositions))->toHaveCount(count($todoPositions)); - - // Verify specific task positioning worked - $taskToMove->refresh(); - $targetTask->refresh(); - expect($taskToMove->order_position)->not()->toBe($targetTask->order_position); - }); - }); - - describe('Team Collaboration Stress Testing', function () { - it('simulates realistic daily workflow with multiple team members', function () { - // Morning standup: Multiple developers pick up work - $developers = $this->users->where('team', 'Development'); - $backlogTasks = Task::where('status', 'todo')->where('priority', 'high')->get(); - - foreach ($backlogTasks->take(3) as $index => $task) { - $developer = $developers->get($index % $developers->count()); - $task->update(['assigned_to' => $developer->id]); - $this->board->call('moveCard', (string) $task->id, 'in_progress'); - } - - // Verify assignments and status changes - $activeWork = Task::where('status', 'in_progress')->get(); - expect($activeWork->count())->toBeGreaterThanOrEqual(3); - - // Mid-day: Some tasks completed, new ones started - $completableTasks = $activeWork->take(2); - foreach ($completableTasks as $task) { - $this->board->call('moveCard', (string) $task->id, 'completed'); - $task->update(['completed_at' => now()]); - } - - // End of day: Verify team productivity - $completedToday = Task::where('status', 'completed') - ->whereNotNull('completed_at') - ->get(); - - expect($completedToday->count())->toBeGreaterThanOrEqual(5); - }); - - it('handles project-based task isolation correctly', function () { - $project1 = $this->projects->first(); - $project2 = $this->projects->last(); - - // Move tasks from project 1 - $project1Tasks = Task::where('project_id', $project1->id)->get(); - foreach ($project1Tasks as $task) { - $this->board->call('moveCard', (string) $task->id, 'in_progress'); - } - - // Move tasks from project 2 - $project2Tasks = Task::where('project_id', $project2->id)->get(); - foreach ($project2Tasks as $task) { - $this->board->call('moveCard', (string) $task->id, 'completed'); - } - - // Verify project isolation is maintained - $project1TasksAfter = Task::where('project_id', $project1->id)->get(); - $project2TasksAfter = Task::where('project_id', $project2->id)->get(); - - expect($project1TasksAfter->every(fn ($task) => $task->status === 'in_progress'))->toBeTrue(); - expect($project2TasksAfter->every(fn ($task) => $task->status === 'completed'))->toBeTrue(); - }); - }); - - describe('Production Data Integrity & Constraints', function () { - it('validates all foreign key relationships remain intact', function () { - $allTasks = Task::with(['project', 'assignedUser', 'creator'])->get(); - - // Perform controlled moves (reduced for performance) - for ($i = 0; $i < 25; $i++) { - $task = $allTasks->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - $this->board->call('moveCard', (string) $task->id, $newStatus); - } - - // Comprehensive relationship validation - $finalTasks = Task::with(['project', 'assignedUser', 'creator'])->get(); - - foreach ($finalTasks as $task) { - // Core kanban fields should be valid - expect($task->status)->toBeIn(['todo', 'in_progress', 'completed']) - ->and($task->order_position)->not()->toBeNull(); - - // Relationships should be resolvable (no broken foreign keys) - if ($task->project_id) { - expect($task->project)->not()->toBeNull(); - } - if ($task->assigned_to) { - expect($task->assignedUser)->not()->toBeNull(); - } - if ($task->created_by) { - expect($task->creator)->not()->toBeNull(); - } - } - }); - - it('handles database constraints during edge case operations', function () { - // Test with tasks that have complex constraint scenarios - $constrainedTask = Task::factory()->create([ - 'status' => 'todo', - 'assigned_to' => $this->users->first()->id, - 'project_id' => $this->projects->first()->id, - 'due_date' => now()->addDays(7), - 'labels' => ['critical', 'security', 'hotfix'], - ]); - - // Multiple rapid moves with constrained data - for ($i = 0; $i < 10; $i++) { - $status = collect(['todo', 'in_progress', 'completed'])->random(); - $this->board->call('moveCard', (string) $constrainedTask->id, $status); - } - - $constrainedTask->refresh(); - - // All constraints should still be satisfied - expect($constrainedTask->assignedUser)->not()->toBeNull() - ->and($constrainedTask->project)->not()->toBeNull() - ->and($constrainedTask->labels)->toBeArray() - ->and($constrainedTask->due_date)->not()->toBeNull(); - }); - }); - - describe('Large Scale Stress Testing (500+ Cards)', function () { - it('handles 500+ cards in single environment', function () { - // Create large production environment with 500+ tasks - $project = $this->projects->first(); - $users = $this->users; - - // Create 500 additional tasks across different statuses - $largeBatch = Task::factory()->count(500)->create([ - 'project_id' => $project->id, - 'assigned_to' => $users->random()->id, - 'created_by' => $users->random()->id, - ]); - - $totalTasks = Task::count(); - expect($totalTasks)->toBeGreaterThanOrEqual(500, 'Should have at least 500 tasks'); - - // Test move performance with large dataset - $testCard = $largeBatch->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - - $startTime = microtime(true); - $this->board->call('moveCard', (string) $testCard->id, $newStatus); - $duration = microtime(true) - $startTime; - - // Should complete within 500ms even with 500+ cards - expect($duration)->toBeLessThan(0.5, 'Move should complete within 500ms at scale'); - - $testCard->refresh(); - expect($testCard->status)->toBe($newStatus); - - // Verify data integrity with large dataset - $orphanedTasks = Task::whereNull('project_id')->count(); - expect($orphanedTasks)->toBe(0, 'No orphaned tasks should exist'); - }); - - it('performs 100 operations on 250-card board', function () { - // Create medium-large board (250 cards) - $project = $this->projects->first(); - $users = $this->users; - - Task::factory()->count(250)->create([ - 'project_id' => $project->id, - 'assigned_to' => $users->random()->id, - 'created_by' => $users->random()->id, - ]); - - $totalTasks = Task::count(); - expect($totalTasks)->toBeGreaterThanOrEqual(250); - - // Perform 100 random move operations - for ($i = 0; $i < 100; $i++) { - $task = Task::inRandomOrder()->first(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - - $this->board->call('moveCard', (string) $task->id, $newStatus); - } - - // Verify no data corruption after 100 operations - $finalCount = Task::count(); - expect($finalCount)->toBe($totalTasks, 'Task count should remain stable'); - - // Verify no orphaned data - $orphanedTasks = Task::whereNull('project_id')->count(); - expect($orphanedTasks)->toBe(0); - }); - - it('validates position uniqueness across 300 cards in single column', function () { - // Create 300 cards all in 'todo' column - $project = $this->projects->first(); - - Task::factory()->count(300)->create([ - 'project_id' => $project->id, - 'status' => 'todo', - ]); - - // Get all positions in the todo column - $positions = Task::where('status', 'todo') - ->whereNotNull('order_position') - ->pluck('order_position') - ->toArray(); - - // Verify ALL positions are unique - $uniqueCount = count(array_unique($positions)); - expect($uniqueCount)->toBe( - count($positions), - 'All 300+ positions should be unique in single column' - ); - - // Verify positions can be sorted (no invalid characters) - $sortedPositions = $positions; - sort($sortedPositions); - expect(count($sortedPositions))->toBe(count($positions)); - }); - }); - - describe('Invariant Validation Under Stress', function () { - it('validates sorted positions invariant across 50 operations', function () { - // Create base dataset - $tasks = Task::factory()->count(50)->create([ - 'project_id' => $this->projects->first()->id, - 'status' => 'todo', - ]); - - // Perform 50 random moves, checking invariant after EACH move - for ($i = 0; $i < 50; $i++) { - $task = $tasks->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - - $this->board->call('moveCard', (string) $task->id, $newStatus); - - // INVARIANT: Positions in each column must be sortable and ordered - foreach (['todo', 'in_progress', 'completed'] as $status) { - $columnPositions = Task::where('status', $status) - ->whereNotNull('order_position') - ->orderBy('order_position') - ->pluck('order_position') - ->toArray(); - - // Verify positions are in ascending order - for ($j = 0; $j < count($columnPositions) - 1; $j++) { - expect(strcmp($columnPositions[$j], $columnPositions[$j + 1]))->toBeLessThan( - 0, - "Position {$j} should be < position " . ($j + 1) . " in {$status} column at operation {$i}" - ); - } - } - } - }); - - it('validates no duplicate positions invariant during bulk operations', function () { - // Create base dataset with mixed statuses - Task::factory()->count(75)->create([ - 'project_id' => $this->projects->first()->id, - ]); - - $allTasks = Task::all(); - - // Perform 30 operations, checking for duplicates after EACH operation - for ($i = 0; $i < 30; $i++) { - $task = $allTasks->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - - $this->board->call('moveCard', (string) $task->id, $newStatus); - - // INVARIANT: No duplicate positions within each column - foreach (['todo', 'in_progress', 'completed'] as $status) { - $positions = Task::where('status', $status) - ->whereNotNull('order_position') - ->pluck('order_position') - ->toArray(); - - $uniquePositions = array_unique($positions); - - expect(count($uniquePositions))->toBe( - count($positions), - "No duplicate positions allowed in {$status} column at operation {$i}" - ); - } - } - }); - - it('validates position validity invariant under stress', function () { - // Create dataset - Task::factory()->count(40)->create([ - 'project_id' => $this->projects->first()->id, - ]); - - $allTasks = Task::all(); - - // Perform 40 rapid sequential moves - for ($i = 0; $i < 40; $i++) { - $task = $allTasks->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - - $this->board->call('moveCard', (string) $task->id, $newStatus); - $task->refresh(); - - // INVARIANT: Every moved card must have valid position - expect($task->order_position)->not()->toBeNull() - ->and($task->order_position)->toBeString() - ->and(strlen($task->order_position))->toBeGreaterThan(0) - ->and(strlen($task->order_position))->toBeLessThan(1024); // Under MAX_RANK_LEN - } - }); - }); -}); diff --git a/tests/Feature/JavaScriptPhpParameterFlowTest.php b/tests/Feature/JavaScriptPhpParameterFlowTest.php deleted file mode 100644 index c520a5c..0000000 --- a/tests/Feature/JavaScriptPhpParameterFlowTest.php +++ /dev/null @@ -1,424 +0,0 @@ -board = Livewire::test(TestBoard::class); -}); - -describe('JavaScript → PHP Parameter Flow Validation', function () { - it('validates exact parameter flow matches flowforge.js:24-28', function () { - // This test mirrors EXACT JavaScript logic from flowforge.js - // Lines 24-28: - // const cardIndex = newOrder.indexOf(cardId); - // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; - // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; - // this.$wire.moveCard(cardId, targetColumn, afterCardId, beforeCardId) - - $position = DecimalPosition::forEmptyColumn(); - $cards = collect(['a', 'b', 'c', 'd', 'e'])->map( - function ($label) use (&$position) { - $card = Task::factory()->create([ - 'title' => "Card {$label}", - 'status' => 'todo', - 'order_position' => $position, - ]); - $position = DecimalPosition::after($position); - - return $card; - } - ); - - $cardToMove = $cards->get(0); // Moving card 'a' - $newOrderIndex = 2; // Wants to be at index 2 (between 'b' and 'c') - - // SIMULATE EXACT JAVASCRIPT CALCULATION: - // const cardIndex = newOrder.indexOf(cardId); // = 2 - // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; - $afterCardId = $newOrderIndex > 0 - ? $cards->get($newOrderIndex - 1)->id // Card 'b' (index 1) - : null; - - // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; - $beforeCardId = $newOrderIndex < $cards->count() - ? $cards->get($newOrderIndex)->id // Card 'c' (index 2) - : null; - - // JavaScript sends (line 28): - // this.$wire.moveCard(cardId, targetColumn, afterCardId, beforeCardId) - $this->board->call( - 'moveCard', - (string) $cardToMove->id, - 'todo', - (string) $afterCardId, // 3rd param: Card 'b' - (string) $beforeCardId // 4th param: Card 'c' - ); - - $cardToMove->refresh(); - $afterCard = $cards->get(1); // Card 'b' - $beforeCard = $cards->get(2); // Card 'c' - - // Verify: 'b' < movedCard < 'c' - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($afterCard->fresh()->order_position), - DecimalPosition::normalize($cardToMove->order_position) - ))->toBeTrue("Moved card should be after '{$afterCard->title}'"); - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($cardToMove->order_position), - DecimalPosition::normalize($beforeCard->fresh()->order_position) - ))->toBeTrue("Moved card should be before '{$beforeCard->title}'"); - }); - - it('tests JavaScript edge case: moving to TOP (index 0)', function () { - $position = DecimalPosition::forEmptyColumn(); - $cards = collect(['a', 'b', 'c'])->map( - function ($label) use (&$position) { - $card = Task::factory()->create([ - 'title' => "Card {$label}", - 'status' => 'todo', - 'order_position' => $position, - ]); - $position = DecimalPosition::after($position); - - return $card; - } - ); - - // Use a position after the existing cards to avoid unique constraint collision - $newCard = Task::factory()->create([ - 'title' => 'NewTop', - 'status' => 'todo', - 'order_position' => $position, // $position is already past card 'c' - ]); - - // SIMULATE JAVASCRIPT: Moving to index 0 (top) - $newOrderIndex = 0; - - // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; - $afterCardId = $newOrderIndex > 0 - ? $cards->get($newOrderIndex - 1)->id - : null; // null (no card before) - - // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; - $beforeCardId = $newOrderIndex < $cards->count() - ? $cards->get($newOrderIndex)->id - : null; // Card 'a' - - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - $afterCardId, // null - (string) $beforeCardId // Card 'a' - ); - - $newCard->refresh(); - - // Verify: newCard < 'a' - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($newCard->order_position), - DecimalPosition::normalize($cards->get(0)->fresh()->order_position) - ))->toBeTrue('Card moved to top should be before first card'); - }); - - it('tests JavaScript edge case: moving to BOTTOM (last index)', function () { - $position = DecimalPosition::forEmptyColumn(); - $cards = collect(['a', 'b', 'c'])->map( - function ($label) use (&$position) { - $card = Task::factory()->create([ - 'title' => "Card {$label}", - 'status' => 'todo', - 'order_position' => $position, - ]); - $position = DecimalPosition::after($position); - - return $card; - } - ); - - // Use a position after the existing cards to avoid unique constraint collision - $newCard = Task::factory()->create([ - 'title' => 'NewBottom', - 'status' => 'todo', - 'order_position' => $position, // $position is already past card 'c' - ]); - - // SIMULATE JAVASCRIPT: Moving to last index (bottom) - $newOrderIndex = $cards->count(); // = 3 (after last card) - - // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; - $afterCardId = $newOrderIndex > 0 - ? $cards->get($newOrderIndex - 1)->id // Card 'c' - : null; - - // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; - $beforeCardId = $newOrderIndex < $cards->count() - ? $cards->get($newOrderIndex)->id - : null; // null (no card after) - - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - (string) $afterCardId, // Card 'c' - $beforeCardId // null - ); - - $newCard->refresh(); - - // Verify: 'c' < newCard - expect(DecimalPosition::greaterThan( - DecimalPosition::normalize($newCard->order_position), - DecimalPosition::normalize($cards->last()->fresh()->order_position) - ))->toBeTrue('Card moved to bottom should be after last card'); - }); - - it('tests JavaScript edge case: moving BETWEEN cards (middle index)', function () { - $position = DecimalPosition::forEmptyColumn(); - $cards = collect(['a', 'b', 'c', 'd'])->map( - function ($label) use (&$position) { - $card = Task::factory()->create([ - 'title' => "Card {$label}", - 'status' => 'todo', - 'order_position' => $position, - ]); - $position = DecimalPosition::after($position); - - return $card; - } - ); - - // Use a position after the existing cards to avoid unique constraint collision - $newCard = Task::factory()->create([ - 'title' => 'NewMiddle', - 'status' => 'todo', - 'order_position' => $position, // $position is already past card 'd' - ]); - - // SIMULATE JAVASCRIPT: Moving to index 2 (between 'b' and 'c') - $newOrderIndex = 2; - - // const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null; - $afterCardId = $newOrderIndex > 0 - ? $cards->get($newOrderIndex - 1)->id // Card 'b' (index 1) - : null; - - // const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null; - $beforeCardId = $newOrderIndex < $cards->count() - ? $cards->get($newOrderIndex)->id // Card 'c' (index 2) - : null; - - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - (string) $afterCardId, // Card 'b' - (string) $beforeCardId // Card 'c' - ); - - $newCard->refresh(); - $afterCard = $cards->get(1); // Card 'b' - $beforeCard = $cards->get(2); // Card 'c' - - // Verify: 'b' < newCard < 'c' - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($afterCard->fresh()->order_position), - DecimalPosition::normalize($newCard->order_position) - ))->toBeTrue('Card should be after Card b'); - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($newCard->order_position), - DecimalPosition::normalize($beforeCard->fresh()->order_position) - ))->toBeTrue('Card should be before Card c'); - }); - - it('simulates browser drag-drop with exact index calculations', function () { - // Create ordered list like browser shows - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - for ($i = 1; $i <= 5; $i++) { - $cards->push(Task::factory()->create([ - 'title' => "Card {$i}", - 'status' => 'todo', - 'order_position' => $position, - ])); - $position = DecimalPosition::after($position); - } - - // Simulate dragging Card 1 to position between Card 3 and Card 4 - // Visual: C1, C2, C3, [NEW POSITION], C4, C5 - // In array: index 0, 1, 2, [3], 4, 5 - $cardToMove = $cards->get(0); - $targetIndex = 3; - - // EXACT JavaScript logic from flowforge.js:24-26 - $afterCardId = $targetIndex > 0 - ? $cards->get($targetIndex - 1)->id - : null; // Card 3 (index 2) - - $beforeCardId = $targetIndex < $cards->count() - ? $cards->get($targetIndex)->id - : null; // Card 4 (index 3) - - // JavaScript NOW sends (line 28): - // moveCard(cardId, column, afterCardId, beforeCardId) - $this->board->call( - 'moveCard', - (string) $cardToMove->id, - 'todo', - (string) $afterCardId, // Card 3 - (string) $beforeCardId // Card 4 - ); - - $cardToMove->refresh(); - $card3 = $cards->get(2)->fresh(); - $card4 = $cards->get(3)->fresh(); - - // Verify: Card3 < MovedCard < Card4 - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($card3->order_position), - DecimalPosition::normalize($cardToMove->order_position) - ))->toBeTrue('Moved card should be after Card 3'); - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($cardToMove->order_position), - DecimalPosition::normalize($card4->order_position) - ))->toBeTrue('Moved card should be before Card 4'); - }); - - it('tests all possible index positions in a 10-card column', function () { - // Create 10 cards - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - for ($i = 0; $i < 10; $i++) { - $cards->push(Task::factory()->create([ - 'title' => "Card {$i}", - 'status' => 'todo', - 'order_position' => $position, - ])); - $position = DecimalPosition::after($position); - } - - // Test moving a new card to EVERY possible index (0 through 10) - for ($targetIndex = 0; $targetIndex <= 10; $targetIndex++) { - // Use unique position for each new card to avoid unique constraint collision - $newCard = Task::factory()->create([ - 'title' => "New at index {$targetIndex}", - 'status' => 'todo', - 'order_position' => $position, // $position starts past card 9 - ]); - $position = DecimalPosition::after($position); // Increment for next iteration - - // SIMULATE JAVASCRIPT INDEX CALCULATION - $afterCardId = $targetIndex > 0 - ? $cards->get($targetIndex - 1)->id - : null; - - $beforeCardId = $targetIndex < $cards->count() - ? $cards->get($targetIndex)->id - : null; - - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - $afterCardId, - $beforeCardId - ); - - $newCard->refresh(); - - // Verify correct placement based on index - if ($targetIndex > 0) { - $afterCard = $cards->get($targetIndex - 1)->fresh(); - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($afterCard->order_position), - DecimalPosition::normalize($newCard->order_position) - ))->toBeTrue("At index {$targetIndex}, card should be after card at index " . ($targetIndex - 1)); - } - - if ($targetIndex < $cards->count()) { - $beforeCard = $cards->get($targetIndex)->fresh(); - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($newCard->order_position), - DecimalPosition::normalize($beforeCard->order_position) - ))->toBeTrue("At index {$targetIndex}, card should be before card at index {$targetIndex}"); - } - - // Clean up for next iteration - $newCard->delete(); - } - }); - - it('validates cross-column moves with JavaScript parameter logic', function () { - // Create cards in different columns - $todoPosition = DecimalPosition::forEmptyColumn(); - $todoCards = collect(['a', 'b', 'c'])->map( - function ($label) use (&$todoPosition) { - $card = Task::factory()->create([ - 'title' => "Todo {$label}", - 'status' => 'todo', - 'order_position' => $todoPosition, - ]); - $todoPosition = DecimalPosition::after($todoPosition); - - return $card; - } - ); - - $inProgressPosition = DecimalPosition::forEmptyColumn(); - $inProgressCards = collect(['d', 'e', 'f'])->map( - function ($label) use (&$inProgressPosition) { - $card = Task::factory()->create([ - 'title' => "InProgress {$label}", - 'status' => 'in_progress', - 'order_position' => $inProgressPosition, - ]); - $inProgressPosition = DecimalPosition::after($inProgressPosition); - - return $card; - } - ); - - // Move a todo card to in_progress column at index 1 - $cardToMove = $todoCards->first(); - $targetIndex = 1; - - // SIMULATE JAVASCRIPT for in_progress column - $afterCardId = $targetIndex > 0 - ? $inProgressCards->get($targetIndex - 1)->id // Card 'd' - : null; - - $beforeCardId = $targetIndex < $inProgressCards->count() - ? $inProgressCards->get($targetIndex)->id // Card 'e' - : null; - - $this->board->call( - 'moveCard', - (string) $cardToMove->id, - 'in_progress', // Moving to different column - (string) $afterCardId, // Card 'd' - (string) $beforeCardId // Card 'e' - ); - - $cardToMove->refresh(); - - // Verify moved to correct column - $status = $cardToMove->status instanceof \BackedEnum ? $cardToMove->status->value : $cardToMove->status; - expect($status)->toBe('in_progress'); - - // Verify positioned correctly: 'd' < movedCard < 'e' - $afterCard = $inProgressCards->get(0)->fresh(); - $beforeCard = $inProgressCards->get(1)->fresh(); - - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($afterCard->order_position), - DecimalPosition::normalize($cardToMove->order_position) - ))->toBeTrue('Moved card should be after Card d in new column'); - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($cardToMove->order_position), - DecimalPosition::normalize($beforeCard->order_position) - ))->toBeTrue('Moved card should be before Card e in new column'); - }); -}); diff --git a/tests/Feature/ParameterCombinationTest.php b/tests/Feature/ParameterCombinationTest.php deleted file mode 100644 index 423b540..0000000 --- a/tests/Feature/ParameterCombinationTest.php +++ /dev/null @@ -1,280 +0,0 @@ -board = Livewire::test(TestBoard::class); -}); - -describe('Parameter Combination Testing - Finding the RIGHT way', function () { - it('tests ALL 4 possible parameter combinations for inserting between cards', function () { - // Create 3 cards in sequence - $position = DecimalPosition::forEmptyColumn(); - $cardA = Task::factory()->create([ - 'title' => 'Card A', - 'status' => 'todo', - 'order_position' => $position, - ]); - $position = DecimalPosition::after($position); - - $cardB = Task::factory()->create([ - 'title' => 'Card B', - 'status' => 'todo', - 'order_position' => $position, - ]); - $position = DecimalPosition::after($position); - - $cardC = Task::factory()->create([ - 'title' => 'Card C', - 'status' => 'todo', - 'order_position' => $position, - ]); - $position = DecimalPosition::after($position); // Continue after cardC for new cards - - // We want to insert NEW card between A and B - // Expected result: A < NEW < B - - $results = []; - - // Combination 1: (newCard, column, afterCardId=A, beforeCardId=B) - try { - $new1 = Task::factory()->create(['title' => 'New1', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - $this->board->call('moveCard', (string) $new1->id, 'todo', (string) $cardA->id, (string) $cardB->id); - $new1->refresh(); - $results['Combo1_after=A_before=B'] = [ - 'success' => true, - 'position' => $new1->order_position, - 'correct_order' => DecimalPosition::lessThan( - DecimalPosition::normalize($cardA->order_position), - DecimalPosition::normalize($new1->order_position) - ) && DecimalPosition::lessThan( - DecimalPosition::normalize($new1->order_position), - DecimalPosition::normalize($cardB->order_position) - ), - ]; - } catch (\Exception $e) { - $results['Combo1_after=A_before=B'] = ['success' => false, 'error' => $e->getMessage()]; - } - - // Combination 2: (newCard, column, afterCardId=B, beforeCardId=A) - REVERSED - try { - $new2 = Task::factory()->create(['title' => 'New2', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - $this->board->call('moveCard', (string) $new2->id, 'todo', (string) $cardB->id, (string) $cardA->id); - $new2->refresh(); - $results['Combo2_after=B_before=A'] = [ - 'success' => true, - 'position' => $new2->order_position, - 'correct_order' => DecimalPosition::lessThan( - DecimalPosition::normalize($cardA->order_position), - DecimalPosition::normalize($new2->order_position) - ) && DecimalPosition::lessThan( - DecimalPosition::normalize($new2->order_position), - DecimalPosition::normalize($cardB->order_position) - ), - ]; - } catch (\Exception $e) { - $results['Combo2_after=B_before=A'] = ['success' => false, 'error' => $e->getMessage()]; - } - - // Combination 3: (newCard, column, beforeCardId=B, afterCardId=A) - Swapped param order - try { - $new3 = Task::factory()->create(['title' => 'New3', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - // This is how JavaScript calls it! - $this->board->call('moveCard', (string) $new3->id, 'todo', (string) $cardB->id, (string) $cardA->id); - $new3->refresh(); - $results['Combo3_JS_order_before=B_after=A'] = [ - 'success' => true, - 'position' => $new3->order_position, - 'correct_order' => DecimalPosition::lessThan( - DecimalPosition::normalize($cardA->order_position), - DecimalPosition::normalize($new3->order_position) - ) && DecimalPosition::lessThan( - DecimalPosition::normalize($new3->order_position), - DecimalPosition::normalize($cardB->order_position) - ), - ]; - } catch (\Exception $e) { - $results['Combo3_JS_order_before=B_after=A'] = ['success' => false, 'error' => $e->getMessage()]; - } - - // Combination 4: (newCard, column, beforeCardId=A, afterCardId=B) - Different swap - try { - $new4 = Task::factory()->create(['title' => 'New4', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - $this->board->call('moveCard', (string) $new4->id, 'todo', (string) $cardA->id, (string) $cardB->id); - $new4->refresh(); - $results['Combo4_before=A_after=B'] = [ - 'success' => true, - 'position' => $new4->order_position, - 'correct_order' => DecimalPosition::lessThan( - DecimalPosition::normalize($cardA->order_position), - DecimalPosition::normalize($new4->order_position) - ) && DecimalPosition::lessThan( - DecimalPosition::normalize($new4->order_position), - DecimalPosition::normalize($cardB->order_position) - ), - ]; - } catch (\Exception $e) { - $results['Combo4_before=A_after=B'] = ['success' => false, 'error' => $e->getMessage()]; - } - - // Find which combination works - $workingCombos = array_filter($results, fn ($r) => $r['success'] === true && ($r['correct_order'] ?? false)); - - expect(count($workingCombos))->toBeGreaterThan(0, 'At least one combination should work correctly'); - }); - - it('tests moving to TOP of column - both parameter orders', function () { - $position = DecimalPosition::forEmptyColumn(); - $cardA = Task::factory()->create(['title' => 'Card A', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - $cardB = Task::factory()->create(['title' => 'Card B', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); // Continue for new cards - - // Want: NEW < A < B - $results = []; - - // Test 1: afterCardId=null, beforeCardId=A - try { - $new1 = Task::factory()->create(['title' => 'NewTop1', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - $this->board->call('moveCard', (string) $new1->id, 'todo', null, (string) $cardA->id); - $new1->refresh(); - $results['after=null_before=A'] = [ - 'success' => true, - 'position' => $new1->order_position, - 'correct' => DecimalPosition::lessThan( - DecimalPosition::normalize($new1->order_position), - DecimalPosition::normalize($cardA->order_position) - ), - ]; - } catch (\Exception $e) { - $results['after=null_before=A'] = ['success' => false, 'error' => $e->getMessage()]; - } - - // Test 2: beforeCardId=A, afterCardId=null (JS order) - try { - $new2 = Task::factory()->create(['title' => 'NewTop2', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - $this->board->call('moveCard', (string) $new2->id, 'todo', (string) $cardA->id, null); - $new2->refresh(); - $results['before=A_after=null'] = [ - 'success' => true, - 'position' => $new2->order_position, - 'correct' => DecimalPosition::lessThan( - DecimalPosition::normalize($new2->order_position), - DecimalPosition::normalize($cardA->order_position) - ), - ]; - } catch (\Exception $e) { - $results['before=A_after=null'] = ['success' => false, 'error' => $e->getMessage()]; - } - - $workingCombos = array_filter($results, fn ($r) => $r['success'] === true && ($r['correct'] ?? false)); - - expect(count($workingCombos))->toBeGreaterThan(0); - }); - - it('tests moving to BOTTOM of column - both parameter orders', function () { - $position = DecimalPosition::forEmptyColumn(); - $cardA = Task::factory()->create(['title' => 'Card A', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - $cardB = Task::factory()->create(['title' => 'Card B', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); // Continue for new cards - - // Want: A < B < NEW - $results = []; - - // Test 1: afterCardId=B, beforeCardId=null - try { - $new1 = Task::factory()->create(['title' => 'NewBottom1', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - $this->board->call('moveCard', (string) $new1->id, 'todo', (string) $cardB->id, null); - $new1->refresh(); - $results['after=B_before=null'] = [ - 'success' => true, - 'position' => $new1->order_position, - 'correct' => DecimalPosition::greaterThan( - DecimalPosition::normalize($new1->order_position), - DecimalPosition::normalize($cardB->order_position) - ), - ]; - } catch (\Exception $e) { - $results['after=B_before=null'] = ['success' => false, 'error' => $e->getMessage()]; - } - - // Test 2: beforeCardId=null, afterCardId=B (JS order) - try { - $new2 = Task::factory()->create(['title' => 'NewBottom2', 'status' => 'todo', 'order_position' => $position]); - $position = DecimalPosition::after($position); - $this->board->call('moveCard', (string) $new2->id, 'todo', null, (string) $cardB->id); - $new2->refresh(); - $results['before=null_after=B'] = [ - 'success' => true, - 'position' => $new2->order_position, - 'correct' => DecimalPosition::greaterThan( - DecimalPosition::normalize($new2->order_position), - DecimalPosition::normalize($cardB->order_position) - ), - ]; - } catch (\Exception $e) { - $results['before=null_after=B'] = ['success' => false, 'error' => $e->getMessage()]; - } - - $workingCombos = array_filter($results, fn ($r) => $r['success'] === true && ($r['correct'] ?? false)); - - expect(count($workingCombos))->toBeGreaterThan(0); - }); - - it('simulates exact browser drag-and-drop behavior', function () { - // Create ordered list like browser shows - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - for ($i = 1; $i <= 5; $i++) { - $cards->push(Task::factory()->create([ - 'title' => "Card {$i}", - 'status' => 'todo', - 'order_position' => $position, - ])); - $position = DecimalPosition::after($position); - } - - // Simulate dragging Card 1 to position between Card 3 and Card 4 - // Visual: C1, C2, C3, [NEW POSITION], C4, C5 - // In array: index 0, 1, 2, [3], 4 - $cardToMove = $cards->get(0); - $targetIndex = 3; - - // JavaScript logic from flowforge.js: - $afterCardId = $targetIndex > 0 ? $cards->get($targetIndex - 1)->id : null; // Card 3 - $beforeCardId = $targetIndex < $cards->count() ? $cards->get($targetIndex)->id : null; // Card 4 - - // JavaScript NOW sends: moveCard(cardId, column, afterCardId, beforeCardId) - FIXED! - $this->board->call( - 'moveCard', - (string) $cardToMove->id, - 'todo', - (string) $afterCardId, // 3rd param (after fix) - (string) $beforeCardId // 4th param (after fix) - ); - - $cardToMove->refresh(); - $card3 = $cards->get(2)->fresh(); - $card4 = $cards->get(3)->fresh(); - - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($card3->order_position), - DecimalPosition::normalize($cardToMove->order_position) - ))->toBeTrue('Moved card should be after Card 3'); - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($cardToMove->order_position), - DecimalPosition::normalize($card4->order_position) - ))->toBeTrue('Moved card should be before Card 4'); - }); -}); diff --git a/tests/Feature/ParameterOrderMutationTest.php b/tests/Feature/ParameterOrderMutationTest.php deleted file mode 100644 index 39f13c9..0000000 --- a/tests/Feature/ParameterOrderMutationTest.php +++ /dev/null @@ -1,299 +0,0 @@ -board = Livewire::test(TestBoard::class); -}); - -/** - * Helper to detect position inversions in a column - */ -function detectInversions(string $modelClass, string $columnValue, string $positionField = 'order_position'): array -{ - $records = $modelClass::query() - ->where('status', $columnValue) - ->whereNotNull($positionField) - ->orderBy('id') - ->get(); - - $inversions = []; - for ($i = 0; $i < $records->count() - 1; $i++) { - $current = $records[$i]; - $next = $records[$i + 1]; - - $currentPos = DecimalPosition::normalize($current->getAttribute($positionField)); - $nextPos = DecimalPosition::normalize($next->getAttribute($positionField)); - - // Check if positions are inverted (should be current < next) - if (DecimalPosition::compare($currentPos, $nextPos) >= 0) { - $inversions[] = [ - 'current_id' => $current->id, - 'current_pos' => $currentPos, - 'next_id' => $next->id, - 'next_pos' => $nextPos, - ]; - } - } - - return $inversions; -} - -describe('Parameter Order Mutation Tests - Decimal Positioning', function () { - it('documents correct parameter order with decimal positions', function () { - // DOCUMENTATION TEST: Shows how parameters work with decimal positions - // This test verifies the fix is working correctly - - $cardA = Task::factory()->create([ - 'title' => 'Card A', - 'status' => 'todo', - 'order_position' => '65535.0000000000', - ]); - - $cardB = Task::factory()->create([ - 'title' => 'Card B', - 'status' => 'todo', - 'order_position' => '131070.0000000000', - ]); - - $cardC = Task::factory()->create([ - 'title' => 'Card C', - 'status' => 'todo', - 'order_position' => '196605.0000000000', - ]); - - $newCard = Task::factory()->create([ - 'title' => 'New Card', - 'status' => 'todo', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - // CORRECT PARAMETER ORDER: - // moveCard(cardId, column, afterCardId, beforeCardId) - // afterCardId = card BEFORE the new position (visually above) - // beforeCardId = card AFTER the new position (visually below) - - // Want: A < NewCard < B - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - (string) $cardA->id, // afterCardId: card A (comes before new position) - (string) $cardB->id // beforeCardId: card B (comes after new position) - ); - - $newCard->refresh(); - - // Verify correct placement using decimal comparison - $cardAPos = DecimalPosition::normalize($cardA->order_position); - $newCardPos = DecimalPosition::normalize($newCard->order_position); - $cardBPos = DecimalPosition::normalize($cardB->order_position); - - $isCorrect = DecimalPosition::lessThan($cardAPos, $newCardPos) - && DecimalPosition::lessThan($newCardPos, $cardBPos); - - expect($isCorrect)->toBeTrue( - 'Card should be between A and B' - ); - }); - - it('proves midpoint calculation never fails (unlike old Rank service)', function () { - // IMPROVEMENT TEST: With decimal positions, midpoint always works - // No more PrevGreaterThanOrEquals exception! - - $cardA = Task::factory()->create([ - 'status' => 'todo', - 'order_position' => '65535.0000000000', - ]); - - $cardB = Task::factory()->create([ - 'status' => 'todo', - 'order_position' => '131070.0000000000', - ]); - - // Decimal midpoint calculation always succeeds - $midpoint = DecimalPosition::between( - DecimalPosition::normalize($cardA->order_position), - DecimalPosition::normalize($cardB->order_position) - ); - - // The midpoint should be between the two positions - expect(DecimalPosition::lessThan(DecimalPosition::normalize($cardA->order_position), $midpoint))->toBeTrue(); - expect(DecimalPosition::lessThan($midpoint, DecimalPosition::normalize($cardB->order_position)))->toBeTrue(); - }); - - it('validates parameter semantic meanings under stress', function () { - // Create 10 cards in sequence - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - for ($i = 0; $i < 10; $i++) { - $cards->push(Task::factory()->create([ - 'title' => "Card {$i}", - 'status' => 'todo', - 'order_position' => $position, - ])); - $position = DecimalPosition::after($position); - } - - // Test EVERY adjacent pair with correct parameter semantics - for ($i = 0; $i < $cards->count() - 1; $i++) { - $newCard = Task::factory()->create([ - 'title' => "New Card {$i}", - 'status' => 'todo', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - $afterCard = $cards->get($i); - $beforeCard = $cards->get($i + 1); - - // CORRECT ORDER: afterCardId, beforeCardId - // afterCard = visually ABOVE (smaller position) - // beforeCard = visually BELOW (larger position) - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - (string) $afterCard->id, // 3rd param: card BEFORE new position - (string) $beforeCard->id // 4th param: card AFTER new position - ); - - $newCard->refresh(); - $afterCard = $afterCard->fresh(); - $beforeCard = $beforeCard->fresh(); - - // Invariant: afterCard < newCard < beforeCard - $afterCardPos = DecimalPosition::normalize($afterCard->order_position); - $newCardPos = DecimalPosition::normalize($newCard->order_position); - $beforeCardPos = DecimalPosition::normalize($beforeCard->order_position); - - expect(DecimalPosition::lessThan($afterCardPos, $newCardPos))->toBeTrue( - "After inserting between {$afterCard->title} and {$beforeCard->title}, " . - 'new card position should be > afterCard' - ); - - expect(DecimalPosition::lessThan($newCardPos, $beforeCardPos))->toBeTrue( - "After inserting between {$afterCard->title} and {$beforeCard->title}, " . - 'new card position should be < beforeCard' - ); - } - }); - - it('verifies correct behavior for all edge cases', function () { - $position = DecimalPosition::forEmptyColumn(); - $cards = collect(['a', 'b', 'c'])->map( - function ($label) use (&$position) { - $card = Task::factory()->create([ - 'status' => 'todo', - 'order_position' => $position, - ]); - $position = DecimalPosition::after($position); - - return $card; - } - ); - - // Edge Case 1: Move to TOP (afterCardId=null, beforeCardId=firstCard) - $newCard1 = Task::factory()->create(['status' => 'todo', 'order_position' => DecimalPosition::forEmptyColumn()]); - $this->board->call( - 'moveCard', - (string) $newCard1->id, - 'todo', - null, // afterCardId=null (no card before) - (string) $cards->get(0)->id // beforeCardId=first card - ); - $newCard1->refresh(); - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($newCard1->order_position), - DecimalPosition::normalize($cards->get(0)->order_position) - ))->toBeTrue('Card moved to top should have position < first card'); - - // Edge Case 2: Move to BOTTOM (afterCardId=lastCard, beforeCardId=null) - $newCard2 = Task::factory()->create(['status' => 'todo', 'order_position' => DecimalPosition::forEmptyColumn()]); - $this->board->call( - 'moveCard', - (string) $newCard2->id, - 'todo', - (string) $cards->last()->id, // afterCardId=last card - null // beforeCardId=null (no card after) - ); - $newCard2->refresh(); - expect(DecimalPosition::greaterThan( - DecimalPosition::normalize($newCard2->order_position), - DecimalPosition::normalize($cards->last()->order_position) - ))->toBeTrue('Card moved to bottom should have position > last card'); - - // Edge Case 3: Move BETWEEN (both non-null) - $newCard3 = Task::factory()->create(['status' => 'todo', 'order_position' => DecimalPosition::forEmptyColumn()]); - $this->board->call( - 'moveCard', - (string) $newCard3->id, - 'todo', - (string) $cards->get(0)->id, // afterCardId=first card - (string) $cards->get(1)->id // beforeCardId=second card - ); - $newCard3->refresh(); - expect(DecimalPosition::greaterThan( - DecimalPosition::normalize($newCard3->order_position), - DecimalPosition::normalize($cards->get(0)->order_position) - ))->toBeTrue('Card moved between should have position > first card'); - expect(DecimalPosition::lessThan( - DecimalPosition::normalize($newCard3->order_position), - DecimalPosition::normalize($cards->get(1)->order_position) - ))->toBeTrue('Card moved between should have position < second card'); - }); - - it('stresses parameter order with rapid alternating insertions', function () { - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - for ($i = 0; $i < 5; $i++) { - $cards->push(Task::factory()->create([ - 'title' => "Card {$i}", - 'status' => 'todo', - 'order_position' => $position, - ])); - $position = DecimalPosition::after($position); - } - - // Rapidly insert 20 cards, alternating between positions - for ($round = 0; $round < 20; $round++) { - $newCard = Task::factory()->create([ - 'title' => "Rapid Card {$round}", - 'status' => 'todo', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - // Alternate between inserting at different positions - $targetIndex = $round % ($cards->count() - 1); - $afterCard = $cards->get($targetIndex); - $beforeCard = $cards->get($targetIndex + 1); - - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - (string) $afterCard->id, - (string) $beforeCard->id - ); - - $newCard->refresh(); - - // Verify correct placement EVERY time - $afterCardPos = DecimalPosition::normalize($afterCard->fresh()->order_position); - $newCardPos = DecimalPosition::normalize($newCard->order_position); - $beforeCardPos = DecimalPosition::normalize($beforeCard->fresh()->order_position); - - expect(DecimalPosition::lessThan($afterCardPos, $newCardPos))->toBeTrue( - "Round {$round}: Card should be after {$afterCard->title}" - ); - expect(DecimalPosition::lessThan($newCardPos, $beforeCardPos))->toBeTrue( - "Round {$round}: Card should be before {$beforeCard->title}" - ); - } - }); - - // NOTE: More aggressive stress testing of random moves is covered in - // ConcurrentOperationStressTest.php - this test was too unreliable here -}); diff --git a/tests/Feature/PerformanceRegressionTest.php b/tests/Feature/PerformanceRegressionTest.php deleted file mode 100644 index c655b3b..0000000 --- a/tests/Feature/PerformanceRegressionTest.php +++ /dev/null @@ -1,198 +0,0 @@ -board = Livewire::test(TestBoard::class); -}); - -describe('Performance Regression Baselines - Prevent Future Slowdowns', function () { - it('benchmarks move operations at different scales', function ($cardCount, $maxDuration) { - // Create cards - Task::factory()->count($cardCount)->create(['status' => 'todo']); - - $testCard = Task::inRandomOrder()->first(); - $newStatus = 'in_progress'; - - // Measure move duration - $startTime = microtime(true); - $this->board->call('moveCard', (string) $testCard->id, $newStatus); - $duration = microtime(true) - $startTime; - - // Verify within performance threshold - expect($duration)->toBeLessThan( - $maxDuration, - "Move with {$cardCount} cards should complete within {$maxDuration}s (took {$duration}s)" - ); - - $testCard->refresh(); - expect($testCard->status)->toBe($newStatus); - })->with([ - '50 cards' => [50, 0.1], // 100ms - '100 cards' => [100, 0.2], // 200ms - '250 cards' => [250, 0.3], // 300ms - '500 cards' => [500, 0.5], // 500ms - ]); - - it('tracks position value growth over time', function ($cardCount) { - // Create cards sequentially - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - - for ($i = 1; $i <= $cardCount; $i++) { - $card = Task::factory()->create([ - 'status' => 'todo', - 'order_position' => $position, - ]); - - $cards->push($card); - $position = DecimalPosition::after($position); - } - - // Verify growth is linear (each position increases by DEFAULT_GAP) - $positions = $cards->pluck('order_position'); - $firstPos = (float) $positions->first(); - $lastPos = (float) $positions->last(); - - // Expected: lastPos ≈ firstPos + (cardCount - 1) * DEFAULT_GAP - $expectedLast = $firstPos + ($cardCount - 1) * (float) DecimalPosition::DEFAULT_GAP; - expect(abs($lastPos - $expectedLast))->toBeLessThan( - 1, - 'Position growth should be linear (constant gap increment)' - ); - - // Verify all positions unique - $uniquePositions = $cards->pluck('order_position')->unique()->count(); - expect($uniquePositions)->toBe($cardCount, 'All positions should be unique'); - })->with([ - '100 cards' => 100, - '200 cards' => 200, - ]); - - it('benchmarks bulk operations performance', function () { - // Create baseline - Task::factory()->count(100)->create(); - - $tasks = Task::all(); - - // Benchmark 50 rapid moves - $durations = []; - for ($i = 0; $i < 50; $i++) { - $task = $tasks->random(); - $newStatus = collect(['todo', 'in_progress', 'completed'])->random(); - - $start = microtime(true); - $this->board->call('moveCard', (string) $task->id, $newStatus); - $durations[] = microtime(true) - $start; - } - - $avgDuration = array_sum($durations) / count($durations); - $maxDuration = max($durations); - - // Performance baselines - expect($avgDuration)->toBeLessThan(0.1, 'Average operation should be < 100ms'); - expect($maxDuration)->toBeLessThan(0.3, 'Max operation should be < 300ms'); - }); - - it('validates database query performance under load', function () { - // Create large dataset - Task::factory()->count(300)->create(); - - // Measure query performance for common operations - $metrics = []; - - // 1. Query all tasks in a column - $start = microtime(true); - $todoTasks = Task::where('status', 'todo')->get(); - $metrics['query_column'] = microtime(true) - $start; - - // 2. Query with ordering - $start = microtime(true); - $orderedTasks = Task::where('status', 'todo') - ->orderBy('order_position') - ->orderBy('id') - ->get(); - $metrics['query_ordered'] = microtime(true) - $start; - - // 3. Count operations - $start = microtime(true); - $count = Task::where('status', 'todo')->count(); - $metrics['count_query'] = microtime(true) - $start; - - // All queries should be fast (< 50ms) - foreach ($metrics as $operation => $duration) { - expect($duration)->toBeLessThan( - 0.05, - "{$operation} should complete within 50ms (took " . round($duration * 1000, 2) . 'ms)' - ); - } - }); - - it('establishes memory usage baselines', function () { - $beforeMemory = memory_get_usage(true); - - // Create 200 cards and perform operations - Task::factory()->count(200)->create(); - $tasks = Task::all(); - - // Perform 30 operations - for ($i = 0; $i < 30; $i++) { - $task = $tasks->random(); - $this->board->call( - 'moveCard', - (string) $task->id, - collect(['todo', 'in_progress', 'completed'])->random() - ); - } - - $afterMemory = memory_get_usage(true); - $memoryUsed = $afterMemory - $beforeMemory; - $memoryUsedMB = round($memoryUsed / 1024 / 1024, 2); - - // Memory usage should be reasonable (< 10MB for 200 cards + 30 ops) - expect($memoryUsedMB)->toBeLessThan( - 10, - "Memory usage should be under 10MB (used {$memoryUsedMB}MB)" - ); - }); - - it('validates position generation performance', function () { - // Benchmark position generation algorithms - $metrics = []; - - // 1. Empty column position - $start = microtime(true); - for ($i = 0; $i < 100; $i++) { - $pos = DecimalPosition::forEmptyColumn(); - } - $metrics['empty_column'] = (microtime(true) - $start) / 100; - - // 2. After position - $lastPos = DecimalPosition::forEmptyColumn(); - $start = microtime(true); - for ($i = 0; $i < 100; $i++) { - $lastPos = DecimalPosition::after($lastPos); - } - $metrics['after_position'] = (microtime(true) - $start) / 100; - - // 3. Between positions - $pos1 = DecimalPosition::forEmptyColumn(); - $pos2 = DecimalPosition::after($pos1); - $start = microtime(true); - for ($i = 0; $i < 100; $i++) { - $pos = DecimalPosition::between($pos1, $pos2); - } - $metrics['between_positions'] = (microtime(true) - $start) / 100; - - // All operations should be < 1ms on average - foreach ($metrics as $operation => $avgDuration) { - expect($avgDuration)->toBeLessThan( - 0.001, - "{$operation} should be < 1ms per operation" - ); - } - }); -}); diff --git a/tests/Feature/PositionInversionReproductionTest.php b/tests/Feature/PositionInversionReproductionTest.php deleted file mode 100644 index 12b1b63..0000000 --- a/tests/Feature/PositionInversionReproductionTest.php +++ /dev/null @@ -1,272 +0,0 @@ -board = Livewire::test(TestBoard::class); -}); - -// Note: detectInversions() helper function is defined in ParameterOrderMutationTest.php - -describe('Decimal Position System - No Inversions', function () { - it('handles rapid sequential moves between same positions without inversions', function () { - // Create 5 cards in todo column - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - for ($i = 1; $i <= 5; $i++) { - $cards->push(Task::factory()->create([ - 'title' => "Card {$i}", - 'status' => 'todo', - 'order_position' => $position, - ])); - $position = DecimalPosition::after($position); - } - - // Simulate rapid back-and-forth movements (simulates user indecision) - $targetCard = $cards->get(2); - - for ($j = 0; $j < 10; $j++) { - // Move card 3 between card 1 and card 2 repeatedly - $this->board->call( - 'moveCard', - (string) $targetCard->id, - 'todo', - (string) $cards->get(0)->id, // afterCardId - (string) $cards->get(1)->id // beforeCardId - ); - - // Refresh positions - $cards = $cards->map(fn ($card) => $card->fresh()); - } - - // With DecimalPosition, no inversions should occur - $inversions = detectInversions(Task::class, 'todo'); - expect($inversions)->toBeEmpty('No inversions should occur with decimal positioning'); - }); - - it('handles inserting many cards between two existing cards without issues', function () { - // Create initial boundary cards - $firstCard = Task::factory()->create([ - 'title' => 'First Card', - 'status' => 'todo', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - $secondPos = DecimalPosition::after($firstCard->order_position); - $lastCard = Task::factory()->create([ - 'title' => 'Last Card', - 'status' => 'todo', - 'order_position' => $secondPos, - ]); - - // Insert 50 cards between these two - decimal midpoint never fails - for ($i = 1; $i <= 50; $i++) { - $newCard = Task::factory()->create([ - 'title' => "Inserted Card {$i}", - 'status' => 'todo', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - (string) $firstCard->id, - (string) $lastCard->id - ); - - $newCard->refresh(); - - // Check every 10 insertions - if ($i % 10 === 0) { - $inversions = detectInversions(Task::class, 'todo'); - expect($inversions)->toBeEmpty("No inversions after {$i} insertions"); - } - } - - // Final check - $inversions = detectInversions(Task::class, 'todo'); - expect($inversions)->toBeEmpty('No inversions after 50 insertions'); - }); - - it('handles concurrent-like operations without inversions', function () { - // Create 10 cards - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - - for ($i = 1; $i <= 10; $i++) { - $cards->push(Task::factory()->create([ - 'title' => "Card {$i}", - 'status' => 'todo', - 'order_position' => $position, - ])); - $position = DecimalPosition::after($position); - } - - // Simulate concurrent operations: multiple cards moving at "same time" - $operations = [ - ['card' => $cards->get(2), 'after' => $cards->get(5), 'before' => $cards->get(6)], - ['card' => $cards->get(7), 'after' => $cards->get(1), 'before' => $cards->get(2)], - ['card' => $cards->get(4), 'after' => $cards->get(8), 'before' => $cards->get(9)], - ]; - - foreach ($operations as $op) { - $this->board->call( - 'moveCard', - (string) $op['card']->id, - 'todo', - (string) $op['after']->id, - (string) $op['before']->id - ); - } - - // No inversions should occur with decimal positioning - $inversions = detectInversions(Task::class, 'todo'); - expect($inversions)->toBeEmpty('No inversions from concurrent-like operations'); - }); - - it('handles 100 random moves without inversions', function () { - // Create 20 cards - $cards = collect(); - $position = DecimalPosition::forEmptyColumn(); - - for ($i = 1; $i <= 20; $i++) { - $cards->push(Task::factory()->create([ - 'title' => "Card {$i}", - 'status' => 'todo', - 'order_position' => $position, - ])); - $position = DecimalPosition::after($position); - } - - // Perform 100 random moves - for ($move = 1; $move <= 100; $move++) { - $cardToMove = $cards->random(); - $otherCards = $cards->where('id', '!=', $cardToMove->id); - - if ($otherCards->count() < 2) { - continue; - } - - $afterCard = $otherCards->random(); - $beforeCard = $otherCards->where('id', '!=', $afterCard->id)->random(); - - $this->board->call( - 'moveCard', - (string) $cardToMove->id, - 'todo', - (string) $afterCard->id, - (string) $beforeCard->id - ); - - // Refresh all cards - $cards = $cards->map(fn ($card) => $card->fresh()); - - // Check for inversions every 20 moves - if ($move % 20 === 0) { - $inversions = detectInversions(Task::class, 'todo'); - expect($inversions)->toBeEmpty("No inversions after {$move} moves"); - } - } - - // Final check - $inversions = detectInversions(Task::class, 'todo'); - expect($inversions)->toBeEmpty('No inversions after 100 random moves'); - }); - - it('correctly positions cards using decimal midpoint', function () { - // Create two boundary cards with a gap - $card1 = Task::factory()->create([ - 'title' => 'Card with position 65535', - 'status' => 'review', - 'order_position' => '65535.0000000000', - ]); - - $card2 = Task::factory()->create([ - 'title' => 'Card with position 131070', - 'status' => 'review', - 'order_position' => '131070.0000000000', - ]); - - // Insert a card between them - $newCard = Task::factory()->create([ - 'title' => 'New card to insert', - 'status' => 'review', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'review', - (string) $card1->id, - (string) $card2->id - ); - - $newCard->refresh(); - - // Verify the new card is positioned at the midpoint - $card1Pos = DecimalPosition::normalize($card1->order_position); - $card2Pos = DecimalPosition::normalize($card2->order_position); - $newPos = DecimalPosition::normalize($newCard->order_position); - - expect(DecimalPosition::lessThan($card1Pos, $newPos))->toBeTrue('New card should be after card1'); - expect(DecimalPosition::lessThan($newPos, $card2Pos))->toBeTrue('New card should be before card2'); - }); - - it('maintains precision after many bisections', function () { - // Create two boundary cards - $firstCard = Task::factory()->create([ - 'title' => 'First Card', - 'status' => 'todo', - 'order_position' => '65535.0000000000', - ]); - - $lastCard = Task::factory()->create([ - 'title' => 'Last Card', - 'status' => 'todo', - 'order_position' => '131070.0000000000', - ]); - - // Insert 30 cards between them (forcing 30 bisections) - for ($i = 1; $i <= 30; $i++) { - $newCard = Task::factory()->create([ - 'title' => "Insert {$i}", - 'status' => 'todo', - 'order_position' => DecimalPosition::forEmptyColumn(), - ]); - - $this->board->call( - 'moveCard', - (string) $newCard->id, - 'todo', - (string) $firstCard->id, - (string) $lastCard->id - ); - - $newCard->refresh(); - - // The new card becomes the new boundary - $lastCard = $newCard; - } - - // All cards should still be in correct order - $inversions = detectInversions(Task::class, 'todo'); - expect($inversions)->toBeEmpty('No inversions after 30 bisections'); - - // Verify we can still distinguish positions - $tasks = Task::where('status', 'todo') - ->orderBy('order_position') - ->orderBy('id') - ->get(); - - for ($i = 1; $i < $tasks->count(); $i++) { - $prev = DecimalPosition::normalize($tasks[$i - 1]->order_position); - $curr = DecimalPosition::normalize($tasks[$i]->order_position); - expect(DecimalPosition::lessThan($prev, $curr))->toBeTrue("Position {$i} should be less than position " . ($i + 1)); - } - }); -}); diff --git a/tests/Feature/RepairPositionsCommandTest.php b/tests/Feature/RepairPositionsCommandTest.php deleted file mode 100644 index 2d3e4e4..0000000 --- a/tests/Feature/RepairPositionsCommandTest.php +++ /dev/null @@ -1,298 +0,0 @@ -id(); - $table->string('title'); - $table->string('status'); - $table->string('position')->nullable(); - $table->integer('team_id')->nullable(); - $table->timestamps(); - }); -}); - -afterEach(function () { - Schema::dropIfExists('test_tasks'); -}); - -test('repair command shows help and usage', function () { - $result = Artisan::call('flowforge:repair-positions', ['--help' => true]); - - expect($result)->toBe(0); - expect(Artisan::output()) - ->toContain('Interactive command to repair and regenerate position fields') - ->toContain('--dry-run') - ->toContain('--ids') - ->toContain('--where'); -}); - -test('repair command handles non-existent model class', function () { - // We can't easily test interactive prompts, but we can test the validation logic - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - $method = $reflection->getMethod('validateModelClass'); - $method->setAccessible(true); - - $result = $method->invoke($command, 'NonExistentModel'); - expect($result)->toContain('does not exist'); - - $result = $method->invoke($command, 'stdClass'); - expect($result)->toContain('is not an Eloquent model'); - - $result = $method->invoke($command, 'TestTask'); - expect($result)->toBeNull(); -}); - -test('repair command analyzes positions correctly', function () { - // Create test data with various position states - DB::table('test_tasks')->insert([ - ['id' => 1, 'title' => 'Task 1', 'status' => 'todo', 'position' => 'a0', 'team_id' => 1], - ['id' => 2, 'title' => 'Task 2', 'status' => 'todo', 'position' => 'a1', 'team_id' => 1], - ['id' => 3, 'title' => 'Task 3', 'status' => 'todo', 'position' => null, 'team_id' => 1], // Missing position - ['id' => 4, 'title' => 'Task 4', 'status' => 'in_progress', 'position' => 'a0', 'team_id' => 1], // Duplicate position - ['id' => 5, 'title' => 'Task 5', 'status' => 'in_progress', 'position' => 'a0', 'team_id' => 1], // Duplicate position - ['id' => 6, 'title' => 'Task 6', 'status' => 'done', 'position' => 'a2', 'team_id' => 2], - ]); - - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - $method = $reflection->getMethod('analyzePositions'); - $method->setAccessible(true); - - $analysis = $method->invoke($command, 'TestTask', 'status', 'position'); - - expect($analysis['total'])->toBe(6); - expect($analysis['null_positions'])->toBe(1); - expect($analysis['duplicates'])->toBe(1); // One duplicate position ('a0') - expect($analysis['groups'])->toEqual([ - 'todo' => 3, - 'in_progress' => 2, - 'done' => 1, - ]); -}); - -test('repair command analyzes positions with filtering', function () { - // Create test data - DB::table('test_tasks')->insert([ - ['id' => 1, 'title' => 'Task 1', 'status' => 'todo', 'position' => 'a0', 'team_id' => 1], - ['id' => 2, 'title' => 'Task 2', 'status' => 'todo', 'position' => null, 'team_id' => 1], - ['id' => 3, 'title' => 'Task 3', 'status' => 'todo', 'position' => 'a0', 'team_id' => 2], // Different team - ['id' => 4, 'title' => 'Task 4', 'status' => 'done', 'position' => 'a1', 'team_id' => 1], - ]); - - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - - // Test ID filtering - $applyFiltersMethod = $reflection->getMethod('applyFilters'); - $applyFiltersMethod->setAccessible(true); - - $analyzeMethod = $reflection->getMethod('analyzePositions'); - $analyzeMethod->setAccessible(true); - - // Mock command options for ID filtering - $command->expects($this->any()) - ->method('option') - ->willReturnMap([ - ['ids', '1,2'], - ['where', null], - ]); - - $baseQuery = (new TestTask)->newQuery(); - $filteredQuery = $applyFiltersMethod->invoke($command, $baseQuery); - - $analysis = $analyzeMethod->invoke($command, 'TestTask', 'status', 'position', $filteredQuery); - - expect($analysis['total'])->toBe(2); - expect($analysis['null_positions'])->toBe(1); -})->skip('Mocking command options is complex in this context'); - -test('repair command generates positions correctly', function () { - // Create test records using Eloquent Collection - $records = new \Illuminate\Database\Eloquent\Collection([ - (object) ['id' => 1, 'position' => null], - (object) ['id' => 2, 'position' => null], - (object) ['id' => 3, 'position' => null], - ]); - - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - $method = $reflection->getMethod('generatePositions'); - $method->setAccessible(true); - - $positions = $method->invoke($command, $records, 'regenerate'); - - expect($positions)->toHaveCount(3); - expect($positions)->toHaveKeys([1, 2, 3]); - - // Check that positions are valid fractional ranks - $positionValues = array_values($positions); - expect($positionValues[0])->toBeString()->not()->toBeEmpty(); // Valid rank format - expect($positionValues[1] > $positionValues[0])->toBeTrue(); // Ascending order - expect($positionValues[2] > $positionValues[1])->toBeTrue(); // Ascending order - - // Debug: Let's see what the actual format is - // dump($positionValues); // Uncomment to see actual values -}); - -test('repair command finds duplicate positions correctly', function () { - // Create test data with duplicates - DB::table('test_tasks')->insert([ - ['id' => 1, 'title' => 'Task 1', 'status' => 'todo', 'position' => 'a0'], - ['id' => 2, 'title' => 'Task 2', 'status' => 'todo', 'position' => 'a1'], - ['id' => 3, 'title' => 'Task 3', 'status' => 'todo', 'position' => 'a0'], // Duplicate - ['id' => 4, 'title' => 'Task 4', 'status' => 'todo', 'position' => 'a2'], - ['id' => 5, 'title' => 'Task 5', 'status' => 'todo', 'position' => 'a2'], // Duplicate - ]); - - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - $method = $reflection->getMethod('getDuplicatePositions'); - $method->setAccessible(true); - - $query = (new TestTask)->where('status', 'todo'); - $duplicates = $method->invoke($command, $query, 'position'); - - expect($duplicates)->toHaveCount(2); - expect($duplicates)->toContain('a0'); - expect($duplicates)->toContain('a2'); -}); - -test('repair command applies changes correctly', function () { - // Create test data - DB::table('test_tasks')->insert([ - ['id' => 1, 'title' => 'Task 1', 'status' => 'todo', 'position' => null], - ['id' => 2, 'title' => 'Task 2', 'status' => 'todo', 'position' => null], - ]); - - $changes = [ - 'todo' => [ - 1 => 'a0', - 2 => 'a1', - ], - ]; - - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - $method = $reflection->getMethod('applyChanges'); - $method->setAccessible(true); - - $method->invoke($command, 'TestTask', 'position', $changes); - - // Verify changes were applied - $task1 = DB::table('test_tasks')->where('id', 1)->first(); - $task2 = DB::table('test_tasks')->where('id', 2)->first(); - - expect($task1->position)->toBe('a0'); - expect($task2->position)->toBe('a1'); -}); - -test('repair command handles empty results gracefully', function () { - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - - $analyzeMethod = $reflection->getMethod('analyzePositions'); - $analyzeMethod->setAccessible(true); - - $analysis = $analyzeMethod->invoke($command, 'TestTask', 'status', 'position'); - - expect($analysis['total'])->toBe(0); - expect($analysis['null_positions'])->toBe(0); - expect($analysis['duplicates'])->toBe(0); - expect($analysis['groups'])->toBeEmpty(); -}); - -test('repair command validates model fields correctly', function () { - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - $method = $reflection->getMethod('validateFields'); - $method->setAccessible(true); - - $model = new TestTask; - - // This should return true (just shows warning) since our test model has fillable fields - $result = $method->invoke($command, $model, 'status', 'position'); - expect($result)->toBeTrue(); -}); - -test('repair command calculates changes for different strategies', function () { - // Create test data with mixed states - DB::table('test_tasks')->insert([ - ['id' => 1, 'title' => 'Task 1', 'status' => 'todo', 'position' => 'a0'], - ['id' => 2, 'title' => 'Task 2', 'status' => 'todo', 'position' => null], // Missing - ['id' => 3, 'title' => 'Task 3', 'status' => 'todo', 'position' => 'a0'], // Duplicate - ['id' => 4, 'title' => 'Task 4', 'status' => 'done', 'position' => 'a1'], - ]); - - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - $method = $reflection->getMethod('calculateChanges'); - $method->setAccessible(true); - - // Test fix_missing strategy - $changes = $method->invoke($command, 'TestTask', 'status', 'position', 'fix_missing'); - expect($changes['todo'])->toHaveCount(1); // Only the missing position record - expect($changes['todo'])->toHaveKey(2); // ID 2 has missing position - - // Test regenerate strategy - $changes = $method->invoke($command, 'TestTask', 'status', 'position', 'regenerate'); - expect($changes['todo'])->toHaveCount(3); // All todo records - expect($changes['done'])->toHaveCount(1); // All done records -}); - -test('repair command handles enum conversion correctly', function () { - // Create a mock enum-like object - $mockEnum = new class - { - public $value = 'todo'; - - public function __toString(): string - { - return $this->value; - } - }; - - // Test the conversion logic - $stringKey = is_object($mockEnum) && method_exists($mockEnum, 'value') ? $mockEnum->value : (string) $mockEnum; - expect($stringKey)->toBe('todo'); - - // Test with regular string - $regularString = 'in_progress'; - $stringKey = is_object($regularString) && method_exists($regularString, 'value') ? $regularString->value : (string) $regularString; - expect($stringKey)->toBe('in_progress'); -}); - -test('repair command filter parsing works correctly', function () { - $command = new \Relaticle\Flowforge\Commands\RepairPositionsCommand; - $reflection = new ReflectionClass($command); - - // Test WHERE clause parsing - $testCases = [ - 'team_id=5' => ['team_id', '=', '5'], - 'priority>3' => ['priority', '>', '3'], - 'status!=done' => ['status', '!=', 'done'], - 'count<=10' => ['count', '<=', '10'], - ]; - - foreach ($testCases as $where => $expected) { - if (preg_match('/^(\w+)\s*([=<>!]+)\s*(.+)$/', $where, $matches)) { - [, $column, $operator, $value] = $matches; - expect([$column, $operator, $value])->toBe($expected); - } - } -}); diff --git a/tests/Unit/DecimalPositionServiceTest.php b/tests/Unit/DecimalPositionServiceTest.php deleted file mode 100644 index f08d3e3..0000000 --- a/tests/Unit/DecimalPositionServiceTest.php +++ /dev/null @@ -1,376 +0,0 @@ -toBe('65535'); - }); - }); - - describe('after', function () { - it('adds the default gap to the position', function () { - expect(DecimalPosition::after('65535'))->toBe('131070.0000000000'); - }); - - it('handles zero position', function () { - expect(DecimalPosition::after('0'))->toBe('65535.0000000000'); - }); - - it('handles negative position', function () { - expect(DecimalPosition::after('-65535'))->toBe('0.0000000000'); - }); - - it('handles decimal position', function () { - expect(DecimalPosition::after('100.5'))->toBe('65635.5000000000'); - }); - }); - - describe('before', function () { - it('subtracts the default gap from the position', function () { - expect(DecimalPosition::before('65535'))->toBe('0.0000000000'); - }); - - it('can go negative', function () { - expect(DecimalPosition::before('0'))->toBe('-65535.0000000000'); - }); - - it('handles decimal position', function () { - expect(DecimalPosition::before('100.5'))->toBe('-65434.5000000000'); - }); - }); - - describe('between (with jitter - non-deterministic)', function () { - it('calculates position near midpoint between two positions', function () { - $pos = DecimalPosition::between('65535', '131070'); - // Should be between the two bounds (exact value varies due to jitter) - expect(bccomp($pos, '65535', 10))->toBeGreaterThan(0); - expect(bccomp($pos, '131070', 10))->toBeLessThan(0); - }); - - it('produces position within bounds for close positions', function () { - $pos = DecimalPosition::between('100', '101'); - expect(bccomp($pos, '100', 10))->toBeGreaterThan(0); - expect(bccomp($pos, '101', 10))->toBeLessThan(0); - }); - - it('handles very close positions within bounds', function () { - $pos = DecimalPosition::between('100', '100.001'); - expect(bccomp($pos, '100', 10))->toBeGreaterThan(0); - expect(bccomp($pos, '100.001', 10))->toBeLessThan(0); - }); - - it('handles zero and positive within bounds', function () { - $pos = DecimalPosition::between('0', '65535'); - expect(bccomp($pos, '0', 10))->toBeGreaterThan(0); - expect(bccomp($pos, '65535', 10))->toBeLessThan(0); - }); - - it('handles negative and positive within bounds', function () { - $pos = DecimalPosition::between('-100', '100'); - expect(bccomp($pos, '-100', 10))->toBeGreaterThan(0); - expect(bccomp($pos, '100', 10))->toBeLessThan(0); - }); - }); - - describe('calculate', function () { - it('returns default gap when both positions are null', function () { - expect(DecimalPosition::calculate(null, null))->toBe('65535'); - }); - - it('returns position after when only afterPos is provided', function () { - expect(DecimalPosition::calculate('65535', null))->toBe('131070.0000000000'); - }); - - it('returns position before when only beforePos is provided', function () { - expect(DecimalPosition::calculate(null, '65535'))->toBe('0.0000000000'); - }); - - it('returns position between bounds when both provided (with jitter)', function () { - $pos = DecimalPosition::calculate('65535', '131070'); - // Should be between the two bounds (uses between() with jitter) - expect(bccomp($pos, '65535', 10))->toBeGreaterThan(0); - expect(bccomp($pos, '131070', 10))->toBeLessThan(0); - }); - }); - - describe('needsRebalancing', function () { - it('returns true when gap is below minimum', function () { - expect(DecimalPosition::needsRebalancing('100', '100.00005'))->toBeTrue(); - }); - - it('returns true when gap is exactly at minimum', function () { - expect(DecimalPosition::needsRebalancing('100', '100.0001'))->toBeFalse(); - }); - - it('returns false when gap is above minimum', function () { - expect(DecimalPosition::needsRebalancing('100', '200'))->toBeFalse(); - }); - - it('returns false for normal gaps', function () { - expect(DecimalPosition::needsRebalancing('65535', '131070'))->toBeFalse(); - }); - - it('returns true for extremely close positions', function () { - expect(DecimalPosition::needsRebalancing('100', '100.00001'))->toBeTrue(); - }); - }); - - describe('generateSequence', function () { - it('generates empty array for zero count', function () { - expect(DecimalPosition::generateSequence(0))->toBe([]); - }); - - it('generates single position for count of 1', function () { - $positions = DecimalPosition::generateSequence(1); - expect($positions)->toBe(['65535.0000000000']); - }); - - it('generates sequential positions for count of 3', function () { - $positions = DecimalPosition::generateSequence(3); - expect($positions)->toBe([ - '65535.0000000000', - '131070.0000000000', - '196605.0000000000', - ]); - }); - - it('generates correct positions for larger count', function () { - $positions = DecimalPosition::generateSequence(5); - expect($positions)->toHaveCount(5); - expect($positions[0])->toBe('65535.0000000000'); - expect($positions[4])->toBe('327675.0000000000'); - }); - }); - - describe('precision stress tests', function () { - it('handles 33 sequential midpoint calculations without precision loss', function () { - $lower = '65535'; - $upper = '131070'; - - for ($i = 0; $i < 33; $i++) { - // Use betweenExact for deterministic testing of precision - $mid = DecimalPosition::betweenExact($lower, $upper); - expect(bccomp($mid, $lower, 10))->toBeGreaterThan(0); - expect(bccomp($mid, $upper, 10))->toBeLessThan(0); - $upper = $mid; - } - }); - - it('correctly identifies when rebalancing is needed after many bisections', function () { - $lower = '65535'; - $upper = '131070'; - - // Keep bisecting until rebalancing is needed (use betweenExact for deterministic test) - $bisections = 0; - while (!DecimalPosition::needsRebalancing($lower, $upper) && $bisections < 100) { - $upper = DecimalPosition::betweenExact($lower, $upper); - $bisections++; - } - - // Should need rebalancing after many bisections (30+ is excellent) - expect($bisections)->toBeGreaterThanOrEqual(30); - expect($bisections)->toBeLessThan(50); - }); - - it('maintains ordering after many operations with jitter', function () { - $positions = ['65535']; - - // Insert 20 items at various positions - for ($i = 0; $i < 20; $i++) { - if ($i % 2 === 0) { - // Insert at end - $positions[] = DecimalPosition::after(end($positions)); - } else { - // Insert in middle (with jitter) - $idx = intdiv(count($positions), 2); - $newPos = DecimalPosition::between($positions[$idx - 1], $positions[$idx]); - array_splice($positions, $idx, 0, [$newPos]); - } - } - - // Verify all positions are in ascending order when sorted - $sorted = $positions; - usort($sorted, fn($a, $b) => bccomp($a, $b, 10)); - - // With jitter, positions might not be in the expected order - // But when sorted, they should still be valid - for ($i = 0; $i < count($sorted) - 1; $i++) { - expect(bccomp($sorted[$i], $sorted[$i + 1], 10))->toBeLessThan(0, - "Position {$i} should be less than position " . ($i + 1)); - } - }); - }); - - describe('edge cases', function () { - it('handles very large positions', function () { - $large = '9999999999'; - $after = DecimalPosition::after($large); - expect(bccomp($after, $large, 10))->toBeGreaterThan(0); - }); - - it('handles very small decimal differences within precision', function () { - // Use values within SCALE=10 precision (minimum resolvable diff is 0.0000000002) - $a = '100.0000000100'; - $b = '100.0000000200'; - $mid = DecimalPosition::betweenExact($a, $b); - // Midpoint should be 100.0000000150 - expect($mid)->toBe('100.0000000150'); - expect(bccomp($mid, $a, 10))->toBeGreaterThan(0); - expect(bccomp($mid, $b, 10))->toBeLessThan(0); - }); - - it('correctly compares positions', function () { - expect(bccomp('100.5', '100.6', 10))->toBeLessThan(0); - expect(bccomp('100.6', '100.5', 10))->toBeGreaterThan(0); - expect(bccomp('100.5', '100.5', 10))->toBe(0); - }); - }); - - describe('betweenExact (deterministic midpoint)', function () { - it('returns the exact midpoint for testing purposes', function () { - expect(DecimalPosition::betweenExact('65535', '131070'))->toBe('98302.5000000000'); - }); - - it('is deterministic - same inputs always produce same output', function () { - $pos1 = DecimalPosition::betweenExact('65535', '131070'); - $pos2 = DecimalPosition::betweenExact('65535', '131070'); - $pos3 = DecimalPosition::betweenExact('65535', '131070'); - - expect($pos1)->toBe($pos2)->toBe($pos3); - }); - - it('handles edge cases identically to old between', function () { - expect(DecimalPosition::betweenExact('100', '101'))->toBe('100.5000000000'); - expect(DecimalPosition::betweenExact('0', '65535'))->toBe('32767.5000000000'); - expect(DecimalPosition::betweenExact('-100', '100'))->toBe('0.0000000000'); - }); - }); - - describe('between with jitter (collision prevention)', function () { - it('generates position within valid bounds', function () { - $lower = '65535'; - $upper = '131070'; - - // Test 100 times to ensure bounds are always respected - for ($i = 0; $i < 100; $i++) { - $pos = DecimalPosition::between($lower, $upper); - - expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0, - "Position {$pos} should be greater than lower bound {$lower}"); - expect(bccomp($pos, $upper, 10))->toBeLessThan(0, - "Position {$pos} should be less than upper bound {$upper}"); - } - }); - - it('generates unique positions for concurrent insertions at same target', function () { - $positions = collect(); - - // Simulate 1000 concurrent insertions at the same target position - for ($i = 0; $i < 1000; $i++) { - $positions->push(DecimalPosition::between('65535', '131070')); - } - - $uniqueCount = $positions->unique()->count(); - - // All 1000 positions should be unique due to jitter - expect($uniqueCount)->toBe(1000, - "Expected 1000 unique positions but got {$uniqueCount}. Jitter should prevent collisions."); - }); - - it('maintains ordering despite jitter', function () { - $lower = '65535'; - $upper = '131070'; - - // Generate 100 positions between the same bounds - $positions = []; - for ($i = 0; $i < 100; $i++) { - $positions[] = DecimalPosition::between($lower, $upper); - } - - // All should be greater than lower and less than upper - foreach ($positions as $pos) { - expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0); - expect(bccomp($pos, $upper, 10))->toBeLessThan(0); - } - }); - - it('handles small gaps with jitter still within bounds', function () { - // Small gap: 100.0 to 100.1 (gap of 0.1) - $lower = '100'; - $upper = '100.1'; - - for ($i = 0; $i < 50; $i++) { - $pos = DecimalPosition::between($lower, $upper); - - expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0, - "Position {$pos} should be > {$lower}"); - expect(bccomp($pos, $upper, 10))->toBeLessThan(0, - "Position {$pos} should be < {$upper}"); - } - }); - - it('handles very small gaps gracefully', function () { - // Very small gap: 100 to 100.001 (gap of 0.001) - $lower = '100'; - $upper = '100.001'; - - for ($i = 0; $i < 20; $i++) { - $pos = DecimalPosition::between($lower, $upper); - - expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0); - expect(bccomp($pos, $upper, 10))->toBeLessThan(0); - } - }); - }); - - describe('generateBetween (bulk position generation)', function () { - it('generates empty array for count less than 1', function () { - expect(DecimalPosition::generateBetween('100', '200', 0))->toBe([]); - expect(DecimalPosition::generateBetween('100', '200', -1))->toBe([]); - }); - - it('generates correct number of positions', function () { - $positions = DecimalPosition::generateBetween('65535', '131070', 5); - expect($positions)->toHaveCount(5); - }); - - it('generates all unique positions', function () { - $positions = DecimalPosition::generateBetween('65535', '131070', 100); - - $uniqueCount = count(array_unique($positions)); - expect($uniqueCount)->toBe(100, 'All 100 bulk positions should be unique'); - }); - - it('generates positions within bounds', function () { - $lower = '65535'; - $upper = '131070'; - $positions = DecimalPosition::generateBetween($lower, $upper, 10); - - foreach ($positions as $pos) { - expect(bccomp($pos, $lower, 10))->toBeGreaterThan(0, - "Position {$pos} should be > {$lower}"); - expect(bccomp($pos, $upper, 10))->toBeLessThan(0, - "Position {$pos} should be < {$upper}"); - } - }); - - it('generates evenly distributed positions', function () { - $lower = '0'; - $upper = '100'; - $positions = DecimalPosition::generateBetween($lower, $upper, 4); - - // Positions should be roughly at 20, 40, 60, 80 (with some jitter) - // Check they're in ascending order when sorted - $sorted = $positions; - usort($sorted, fn($a, $b) => bccomp($a, $b, 10)); - - // First should be closer to 20, last closer to 80 - expect((float) $sorted[0])->toBeGreaterThan(10)->toBeLessThan(30); - expect((float) $sorted[3])->toBeGreaterThan(70)->toBeLessThan(90); - }); - }); -}); diff --git a/tests/Unit/PositionRebalancerServiceTest.php b/tests/Unit/PositionRebalancerServiceTest.php deleted file mode 100644 index 27ecaf4..0000000 --- a/tests/Unit/PositionRebalancerServiceTest.php +++ /dev/null @@ -1,155 +0,0 @@ -shouldReceive('cloneWithout')->andReturnSelf(); - $queryMock->shouldReceive('cloneWithoutBindings')->andReturnSelf(); - - $mock = Mockery::mock(Builder::class)->makePartial(); - - // Set the internal query property so __clone works - $reflection = new ReflectionClass($mock); - if ($reflection->hasProperty('query')) { - $property = $reflection->getProperty('query'); - $property->setAccessible(true); - $property->setValue($mock, $queryMock); - } - - // Mock the fluent methods - $mock->shouldReceive('where')->andReturnSelf(); - $mock->shouldReceive('whereNotNull')->andReturnSelf(); - $mock->shouldReceive('orderBy')->andReturnSelf(); - $mock->shouldReceive('pluck')->andReturn(collect($positions)); - - return $mock; - } - - describe('needsRebalancing', function () { - it('returns false for empty query', function () { - $rebalancer = new PositionRebalancer; - $mock = createMockBuilder([]); - - expect($rebalancer->needsRebalancing($mock, 'status', 'todo', 'position'))->toBeFalse(); - }); - - it('returns false for single item', function () { - $rebalancer = new PositionRebalancer; - $mock = createMockBuilder(['65535']); - - expect($rebalancer->needsRebalancing($mock, 'status', 'todo', 'position'))->toBeFalse(); - }); - - it('returns false for well-spaced positions', function () { - $rebalancer = new PositionRebalancer; - $mock = createMockBuilder([ - '65535', - '131070', - '196605', - ]); - - expect($rebalancer->needsRebalancing($mock, 'status', 'todo', 'position'))->toBeFalse(); - }); - - it('returns true for positions with gap below MIN_GAP', function () { - $rebalancer = new PositionRebalancer; - $mock = createMockBuilder([ - '100.0000000000', - '100.0000000050', // Gap of 0.00000000050 - below MIN_GAP (0.0001) - '200.0000000000', - ]); - - expect($rebalancer->needsRebalancing($mock, 'status', 'todo', 'position'))->toBeTrue(); - }); - }); - - describe('getGapStatistics', function () { - it('returns empty stats for empty column', function () { - $rebalancer = new PositionRebalancer; - $mock = createMockBuilder([]); - - $stats = $rebalancer->getGapStatistics($mock, 'status', 'todo', 'position'); - - expect($stats['count'])->toBe(0); - expect($stats['min_gap'])->toBeNull(); - expect($stats['max_gap'])->toBeNull(); - expect($stats['avg_gap'])->toBeNull(); - expect($stats['small_gaps'])->toBe(0); - }); - - it('returns empty stats for single item', function () { - $rebalancer = new PositionRebalancer; - $mock = createMockBuilder(['65535']); - - $stats = $rebalancer->getGapStatistics($mock, 'status', 'todo', 'position'); - - expect($stats['count'])->toBe(1); - expect($stats['min_gap'])->toBeNull(); - expect($stats['small_gaps'])->toBe(0); - }); - - it('calculates correct statistics for evenly spaced positions', function () { - $rebalancer = new PositionRebalancer; - $mock = createMockBuilder([ - '65535', - '131070', - '196605', - ]); - - $stats = $rebalancer->getGapStatistics($mock, 'status', 'todo', 'position'); - - expect($stats['count'])->toBe(3); - expect($stats['min_gap'])->toBe('65535.0000000000'); - expect($stats['max_gap'])->toBe('65535.0000000000'); - expect($stats['avg_gap'])->toBe('65535.0000000000'); - expect($stats['small_gaps'])->toBe(0); - }); - - it('detects small gaps correctly', function () { - $rebalancer = new PositionRebalancer; - $mock = createMockBuilder([ - '100.0000000000', - '100.0000000010', // Tiny gap - '100.0000000020', // Another tiny gap - '65635.0000000000', // Big gap - ]); - - $stats = $rebalancer->getGapStatistics($mock, 'status', 'todo', 'position'); - - expect($stats['count'])->toBe(4); - expect($stats['small_gaps'])->toBe(2); // Two gaps below MIN_GAP - }); - }); - - describe('generateSequence integration', function () { - it('generates properly spaced positions for rebalancing', function () { - $positions = DecimalPosition::generateSequence(5); - - expect($positions)->toHaveCount(5); - expect($positions[0])->toBe('65535.0000000000'); - expect($positions[1])->toBe('131070.0000000000'); - expect($positions[2])->toBe('196605.0000000000'); - expect($positions[3])->toBe('262140.0000000000'); - expect($positions[4])->toBe('327675.0000000000'); - - // All gaps should be DEFAULT_GAP - for ($i = 1; $i < count($positions); $i++) { - $gap = DecimalPosition::gap($positions[$i - 1], $positions[$i]); - expect($gap)->toBe('65535.0000000000'); - } - }); - }); -}); From 88abe0d7d69a13e37a12ecb3cee3133bb7095d5f Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Fri, 26 Dec 2025 17:37:14 +0400 Subject: [PATCH 6/6] feat: enhance position calculation with error handling and jitter mechanism --- src/Concerns/HasCardSchema.php | 2 + src/Concerns/InteractsWithBoard.php | 50 +--- src/Services/DecimalPosition.php | 45 +++- src/Services/PositionRebalancer.php | 2 +- tests/Feature/CardMovementIntegrationTest.php | 222 ++++++++++++++++ .../ConcurrentPositionInsertionTest.php | 191 ++++++++++++++ tests/Feature/PerformanceTest.php | 148 +++++++++++ tests/Feature/PositionRebalancingTest.php | 235 +++++++++++++++++ tests/Feature/RetryMechanismTest.php | 151 +++++++++++ tests/Unit/DecimalPositionTest.php | 237 ++++++++++++++++++ 10 files changed, 1222 insertions(+), 61 deletions(-) create mode 100644 tests/Feature/CardMovementIntegrationTest.php create mode 100644 tests/Feature/ConcurrentPositionInsertionTest.php create mode 100644 tests/Feature/PerformanceTest.php create mode 100644 tests/Feature/PositionRebalancingTest.php create mode 100644 tests/Feature/RetryMechanismTest.php create mode 100644 tests/Unit/DecimalPositionTest.php diff --git a/src/Concerns/HasCardSchema.php b/src/Concerns/HasCardSchema.php index 793dfe5..20f8b5d 100644 --- a/src/Concerns/HasCardSchema.php +++ b/src/Concerns/HasCardSchema.php @@ -32,6 +32,7 @@ public function getCardSchema(Model $record): ?Schema } $livewire = $this->getLivewire(); + /** @phpstan-ignore argument.type (Filament Schema expects HasSchemas&Livewire\Component but getLivewire returns HasBoard) */ $schema = Schema::make($livewire)->record($record); return $this->evaluate($this->cardSchemaBuilder, ['schema' => $schema]); @@ -42,6 +43,7 @@ public function getCardSchema(Model $record): ?Schema */ protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array { + /** @phpstan-ignore argument.type (Filament Schema expects HasSchemas&Livewire\Component but getLivewire returns HasBoard) */ return match ($parameterName) { 'schema' => [Schema::make($this->getLivewire())], default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName), diff --git a/src/Concerns/InteractsWithBoard.php b/src/Concerns/InteractsWithBoard.php index d353333..eed9e6b 100644 --- a/src/Concerns/InteractsWithBoard.php +++ b/src/Concerns/InteractsWithBoard.php @@ -259,7 +259,7 @@ protected function calculateAndUpdatePositionWithRetry( // Log the conflict for monitoring Log::info('Position conflict detected, retrying', [ - 'card_id' => $card->id, + 'card_id' => $card->getKey(), 'target_column' => $targetColumnId, 'attempt' => $attempt, 'max_attempts' => $maxAttempts, @@ -302,54 +302,6 @@ protected function isDuplicatePositionError(QueryException $e): bool str_contains($e->getMessage(), 'UNIQUE constraint failed'); } - /** - * Execute position update with retry mechanism for race conditions. - * Handles cases where rapid card movements cause stale data issues. - * - * @template T - * - * @param callable(): T $callback - * @return T - */ - protected function withPositionRetry(callable $callback, string $cardId, string $targetColumnId, int $maxAttempts = 3): mixed - { - $baseDelay = 50; // milliseconds - $lastException = null; - - for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { - try { - return $callback(); - } catch (QueryException $e) { - if (! $this->isDuplicatePositionError($e)) { - throw $e; - } - - $lastException = $e; - - Log::info('Position conflict detected, retrying', [ - 'card_id' => $cardId, - 'target_column' => $targetColumnId, - 'attempt' => $attempt, - 'max_attempts' => $maxAttempts, - ]); - - if ($attempt >= $maxAttempts) { - throw new MaxRetriesExceededException( - "Failed to move card after {$maxAttempts} attempts due to position conflicts", - previous: $e - ); - } - - $delay = $baseDelay * pow(2, $attempt - 1); - usleep($delay * 1000); - - continue; - } - } - - throw $lastException ?? new \RuntimeException('Unexpected retry loop exit'); - } - public function loadMoreItems(string $columnId, ?int $count = null): void { $count = $count ?? $this->getBoard()->getCardsPerColumn(); diff --git a/src/Services/DecimalPosition.php b/src/Services/DecimalPosition.php index 01e8ea2..04a9a82 100644 --- a/src/Services/DecimalPosition.php +++ b/src/Services/DecimalPosition.php @@ -4,6 +4,8 @@ namespace Relaticle\Flowforge\Services; +use InvalidArgumentException; + /** * Decimal-based position calculation using BCMath for precision. * Uses DECIMAL(20,10) storage - 10 integer digits + 10 decimal places. @@ -15,7 +17,7 @@ * a unique position to prevent collisions when multiple users move cards * to the same position simultaneously. */ -final class DecimalPosition +final readonly class DecimalPosition { /** * Default gap between positions (65,535). @@ -76,10 +78,18 @@ public static function before(string $position): string * * @param string $after Lower bound position (card above) * @param string $before Upper bound position (card below) - * @return string Position between bounds with jitter applied + * @return string Position between bounds with jitter applied + * + * @throws InvalidArgumentException When after >= before (invalid bounds) */ public static function between(string $after, string $before): string { + if (bccomp($after, $before, self::SCALE) >= 0) { + throw new InvalidArgumentException( + "Invalid bounds: after ({$after}) must be less than before ({$before})" + ); + } + // Calculate the exact midpoint $sum = bcadd($after, $before, self::SCALE); $midpoint = bcdiv($sum, '2', self::SCALE); @@ -106,10 +116,18 @@ public static function between(string $after, string $before): string * * @param string $after Lower bound position * @param string $before Upper bound position - * @return string Exact midpoint between bounds + * @return string Exact midpoint between bounds + * + * @throws InvalidArgumentException When after >= before (invalid bounds) */ public static function betweenExact(string $after, string $before): string { + if (bccomp($after, $before, self::SCALE) >= 0) { + throw new InvalidArgumentException( + "Invalid bounds: after ({$after}) must be less than before ({$before})" + ); + } + $sum = bcadd($after, $before, self::SCALE); return bcdiv($sum, '2', self::SCALE); @@ -172,7 +190,7 @@ public static function generateSequence(int $count): array * Normalize a position string to ensure consistent format. * Converts numeric values to properly scaled decimal strings. */ - public static function normalize(string|int|float $position): string + public static function normalize(string | int | float $position): string { return bcadd((string) $position, '0', self::SCALE); } @@ -220,7 +238,7 @@ public static function gap(string $lower, string $upper): string * @param string $after Lower bound position * @param string $before Upper bound position * @param int $count Number of positions to generate - * @return array Array of unique positions + * @return array Array of unique positions */ public static function generateBetween(string $after, string $before, int $count): array { @@ -252,7 +270,7 @@ public static function generateBetween(string $after, string $before, int $count * desired range using BCMath for precision. * * @param string $maxOffset Maximum absolute offset (positive number) - * @return string Random value in [-maxOffset, +maxOffset] + * @return string Random value in [-maxOffset, +maxOffset] */ private static function generateJitter(string $maxOffset): string { @@ -261,14 +279,19 @@ private static function generateJitter(string $maxOffset): string return '0.0000000000'; } - // Get 8 random bytes and convert to unsigned 64-bit integer + // Get 8 random bytes and convert to unsigned 64-bit string + // PHP's unpack('P') returns signed int for values >= 2^63, + // so we manually convert bytes to an unsigned decimal string $bytes = random_bytes(8); - $randomInt = unpack('P', $bytes)[1]; // Unsigned 64-bit little-endian + $randomUnsigned = '0'; + for ($i = 7; $i >= 0; $i--) { + $randomUnsigned = bcmul($randomUnsigned, '256', 0); + $randomUnsigned = bcadd($randomUnsigned, (string) ord($bytes[$i]), 0); + } - // Normalize to [0, 1] range - // PHP_INT_MAX is the max for signed, but we have unsigned so use 2^64 + // Normalize to [0, 1] range using 2^64 - 1 as max $maxUint64 = '18446744073709551615'; // 2^64 - 1 - $normalized = bcdiv((string) $randomInt, $maxUint64, self::SCALE); + $normalized = bcdiv($randomUnsigned, $maxUint64, self::SCALE); // Scale to [-1, 1] range $scaled = bcsub(bcmul($normalized, '2', self::SCALE), '1', self::SCALE); diff --git a/src/Services/PositionRebalancer.php b/src/Services/PositionRebalancer.php index c9767f5..9533d0e 100644 --- a/src/Services/PositionRebalancer.php +++ b/src/Services/PositionRebalancer.php @@ -16,7 +16,7 @@ * This service redistributes positions evenly using the default gap, ensuring * consistent spacing and preventing precision exhaustion after many insertions. */ -final class PositionRebalancer +final readonly class PositionRebalancer { /** * Rebalance all positions in a column. diff --git a/tests/Feature/CardMovementIntegrationTest.php b/tests/Feature/CardMovementIntegrationTest.php new file mode 100644 index 0000000..a7ddc26 --- /dev/null +++ b/tests/Feature/CardMovementIntegrationTest.php @@ -0,0 +1,222 @@ +toBe(DecimalPosition::DEFAULT_GAP); + }); + + test('card at top of column gets position before first card', function () { + // Create existing cards + Task::create(['title' => 'First', 'status' => 'todo', 'order_position' => '65535.0000000000']); + Task::create(['title' => 'Second', 'status' => 'todo', 'order_position' => '131070.0000000000']); + + // Calculate position at top (before first card) + $firstPosition = '65535.0000000000'; + $newPosition = DecimalPosition::calculate(null, $firstPosition); + + expect(bccomp($newPosition, $firstPosition, 10))->toBeLessThan(0); + }); + + test('card at bottom of column gets position after last card', function () { + // Create existing cards + Task::create(['title' => 'First', 'status' => 'todo', 'order_position' => '65535.0000000000']); + Task::create(['title' => 'Last', 'status' => 'todo', 'order_position' => '131070.0000000000']); + + // Calculate position at bottom (after last card) + $lastPosition = '131070.0000000000'; + $newPosition = DecimalPosition::calculate($lastPosition, null); + + expect(bccomp($newPosition, $lastPosition, 10))->toBeGreaterThan(0); + }); + + test('card between two cards gets position in middle', function () { + // Create reference cards + Task::create(['title' => 'First', 'status' => 'todo', 'order_position' => '1000.0000000000']); + Task::create(['title' => 'Third', 'status' => 'todo', 'order_position' => '2000.0000000000']); + + // Calculate position between + $newPosition = DecimalPosition::calculate('1000.0000000000', '2000.0000000000'); + + expect(bccomp($newPosition, '1000.0000000000', 10))->toBeGreaterThan(0) + ->and(bccomp($newPosition, '2000.0000000000', 10))->toBeLessThan(0); + }); +}); + +describe('card movement scenarios', function () { + beforeEach(function () { + // Create a column with 5 cards + Task::create(['title' => 'Card 1', 'status' => 'todo', 'order_position' => '65535.0000000000']); + Task::create(['title' => 'Card 2', 'status' => 'todo', 'order_position' => '131070.0000000000']); + Task::create(['title' => 'Card 3', 'status' => 'todo', 'order_position' => '196605.0000000000']); + Task::create(['title' => 'Card 4', 'status' => 'todo', 'order_position' => '262140.0000000000']); + Task::create(['title' => 'Card 5', 'status' => 'todo', 'order_position' => '327675.0000000000']); + }); + + test('move card from position 5 to position 2', function () { + $card5 = Task::where('title', 'Card 5')->first(); + $card1Position = '65535.0000000000'; + $card2Position = '131070.0000000000'; + + // Calculate new position between Card 1 and Card 2 + $newPosition = DecimalPosition::between($card1Position, $card2Position); + $card5->update(['order_position' => $newPosition]); + + // Verify order + $ordered = Task::where('status', 'todo') + ->orderBy('order_position') + ->pluck('title') + ->toArray(); + + expect($ordered)->toBe(['Card 1', 'Card 5', 'Card 2', 'Card 3', 'Card 4']); + }); + + test('move card from position 1 to end', function () { + $card1 = Task::where('title', 'Card 1')->first(); + $card5Position = '327675.0000000000'; + + // Calculate new position after Card 5 + $newPosition = DecimalPosition::after($card5Position); + $card1->update(['order_position' => $newPosition]); + + // Verify order + $ordered = Task::where('status', 'todo') + ->orderBy('order_position') + ->pluck('title') + ->toArray(); + + expect($ordered)->toBe(['Card 2', 'Card 3', 'Card 4', 'Card 5', 'Card 1']); + }); + + test('move card from middle to top', function () { + $card3 = Task::where('title', 'Card 3')->first(); + $card1Position = '65535.0000000000'; + + // Calculate new position before Card 1 + $newPosition = DecimalPosition::before($card1Position); + $card3->update(['order_position' => $newPosition]); + + // Verify order + $ordered = Task::where('status', 'todo') + ->orderBy('order_position') + ->pluck('title') + ->toArray(); + + expect($ordered)->toBe(['Card 3', 'Card 1', 'Card 2', 'Card 4', 'Card 5']); + }); + + test('move card to different column', function () { + $card3 = Task::where('title', 'Card 3')->first(); + + // Move to empty "in_progress" column + $newPosition = DecimalPosition::forEmptyColumn(); + $card3->update([ + 'status' => 'in_progress', + 'order_position' => $newPosition, + ]); + + // Verify card moved + expect($card3->refresh()->status)->toBe('in_progress') + ->and(DecimalPosition::normalize($card3->order_position))->toBe(DecimalPosition::normalize(DecimalPosition::DEFAULT_GAP)); + + // Verify original column order + $todoOrdered = Task::where('status', 'todo') + ->orderBy('order_position') + ->pluck('title') + ->toArray(); + + expect($todoOrdered)->toBe(['Card 1', 'Card 2', 'Card 4', 'Card 5']); + + // Verify new column + $inProgressOrdered = Task::where('status', 'in_progress') + ->orderBy('order_position') + ->pluck('title') + ->toArray(); + + expect($inProgressOrdered)->toBe(['Card 3']); + }); + + test('multiple moves maintain correct order', function () { + // Perform series of moves + $card5 = Task::where('title', 'Card 5')->first(); + $card1 = Task::where('title', 'Card 1')->first(); + $card3 = Task::where('title', 'Card 3')->first(); + + // Move Card 5 to position 2 + $newPos = DecimalPosition::between('65535.0000000000', '131070.0000000000'); + $card5->update(['order_position' => $newPos]); + + // Move Card 1 to position 4 + $card4Pos = DecimalPosition::normalize(Task::where('title', 'Card 4')->first()->order_position); + $originalCard5Pos = '327675.0000000000'; // Card 5's old position is now unused + $newPos2 = DecimalPosition::between($card4Pos, $originalCard5Pos); + $card1->update(['order_position' => $newPos2]); + + // Verify final order + $ordered = Task::where('status', 'todo') + ->orderBy('order_position') + ->pluck('title') + ->toArray(); + + // Card 5 moved between 1,2 → becomes position 2 + // Card 1 moved after 4 → becomes position 5 + // Order should be: 5, 2, 3, 4, 1 + expect($ordered)->toBe(['Card 5', 'Card 2', 'Card 3', 'Card 4', 'Card 1']); + }); +}); + +describe('edge cases', function () { + test('handles many consecutive insertions at same position', function () { + // Create two reference cards + $card1 = Task::create(['title' => 'Anchor 1', 'status' => 'todo', 'order_position' => '1000.0000000000']); + $card2 = Task::create(['title' => 'Anchor 2', 'status' => 'todo', 'order_position' => '2000.0000000000']); + + // Insert 30 cards between them + for ($i = 0; $i < 30; $i++) { + $pos = DecimalPosition::between('1000.0000000000', '2000.0000000000'); + Task::create([ + 'title' => "Insert {$i}", + 'status' => 'todo', + 'order_position' => $pos, + ]); + } + + // All should be unique and between bounds + $middleCards = Task::where('status', 'todo') + ->where('title', 'like', 'Insert%') + ->pluck('order_position') + ->map(fn ($p) => DecimalPosition::normalize($p)) + ->toArray(); + + expect(array_unique($middleCards))->toHaveCount(30); + + foreach ($middleCards as $pos) { + expect(bccomp($pos, '1000.0000000000', 10))->toBeGreaterThan(0) + ->and(bccomp($pos, '2000.0000000000', 10))->toBeLessThan(0); + } + }); + + test('handles negative positions correctly', function () { + // Create a card at position 0 + $card1 = Task::create(['title' => 'Zero', 'status' => 'todo', 'order_position' => '0.0000000000']); + + // Insert before it (should get negative position) + $negativePos = DecimalPosition::before('0.0000000000'); + $card2 = Task::create(['title' => 'Negative', 'status' => 'todo', 'order_position' => $negativePos]); + + // Verify order + $ordered = Task::where('status', 'todo') + ->orderBy('order_position') + ->pluck('title') + ->toArray(); + + expect($ordered)->toBe(['Negative', 'Zero']); + expect(bccomp(DecimalPosition::normalize($card2->order_position), '0', 10))->toBeLessThan(0); + }); +}); diff --git a/tests/Feature/ConcurrentPositionInsertionTest.php b/tests/Feature/ConcurrentPositionInsertionTest.php new file mode 100644 index 0000000..0a9f920 --- /dev/null +++ b/tests/Feature/ConcurrentPositionInsertionTest.php @@ -0,0 +1,191 @@ + 'Reference Card A', + 'status' => 'todo', + 'order_position' => '1000.0000000000', + ]); + + Task::create([ + 'title' => 'Reference Card B', + 'status' => 'todo', + 'order_position' => '2000.0000000000', + ]); +}); + +describe('concurrent position insertions', function () { + test('50 insertions at same position produce unique positions', function () { + $afterPos = '1000.0000000000'; + $beforePos = '2000.0000000000'; + $insertedPositions = []; + $failedInserts = 0; + + // Simulate 50 concurrent insertions using the jitter mechanism + for ($i = 0; $i < 50; $i++) { + $position = DecimalPosition::between($afterPos, $beforePos); + + try { + $task = Task::create([ + 'title' => "Concurrent Card {$i}", + 'status' => 'todo', + 'order_position' => $position, + ]); + $insertedPositions[] = $task->order_position; + } catch (\Illuminate\Database\QueryException $e) { + // If we hit a duplicate (extremely rare), count it + $failedInserts++; + } + } + + // All positions should be unique + expect(array_unique($insertedPositions))->toHaveCount(count($insertedPositions)) + ->and($failedInserts)->toBe(0); + + // All positions should be strictly between bounds + foreach ($insertedPositions as $position) { + $posStr = DecimalPosition::normalize($position); + expect(bccomp($posStr, $afterPos, 10))->toBeGreaterThan(0) + ->and(bccomp($posStr, $beforePos, 10))->toBeLessThan(0); + } + + // ORDER BY should give consistent results + $orderedTasks = Task::where('status', 'todo') + ->whereNotIn('id', [1, 2]) // Exclude reference cards + ->orderBy('order_position') + ->get(); + + expect($orderedTasks)->toHaveCount(50); + + // Verify ordering is consistent + $previousPosition = '0'; + foreach ($orderedTasks as $task) { + $posStr = DecimalPosition::normalize($task->order_position); + expect(bccomp($posStr, $previousPosition, 10)) + ->toBeGreaterThan(0); + $previousPosition = $posStr; + } + }); + + test('rapid successive insertions by same user dont collide', function () { + $afterPos = '1000.0000000000'; + $beforePos = '2000.0000000000'; + $insertedCount = 0; + + // Rapid fire 50 insertions without any delay + for ($i = 0; $i < 50; $i++) { + $position = DecimalPosition::between($afterPos, $beforePos); + + try { + Task::create([ + 'title' => "Rapid Card {$i}", + 'status' => 'todo', + 'order_position' => $position, + ]); + $insertedCount++; + } catch (\Illuminate\Database\QueryException) { + // Unique constraint violation - should never happen + } + } + + expect($insertedCount)->toBe(50); + + // Verify all positions are unique + $positions = Task::where('status', 'todo') + ->whereNotIn('id', [1, 2]) + ->pluck('order_position') + ->toArray(); + + expect(array_unique($positions))->toHaveCount(50); + }); + + test('unique constraint actually prevents duplicate positions', function () { + // Insert a card with a specific position + Task::create([ + 'title' => 'First Card', + 'status' => 'todo', + 'order_position' => '1500.0000000000', + ]); + + // Try to insert another card with the exact same position + expect(fn () => Task::create([ + 'title' => 'Duplicate Card', + 'status' => 'todo', + 'order_position' => '1500.0000000000', + ]))->toThrow(\Illuminate\Database\QueryException::class); + }); + + test('positions remain sortable after many insertions', function () { + $afterPos = '1000.0000000000'; + $beforePos = '2000.0000000000'; + + // Insert 100 cards + for ($i = 0; $i < 100; $i++) { + $position = DecimalPosition::between($afterPos, $beforePos); + Task::create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => $position, + ]); + } + + // Verify ORDER BY works correctly + $tasks = Task::where('status', 'todo') + ->orderBy('order_position') + ->get(); + + expect($tasks)->toHaveCount(102); // 100 + 2 reference cards + + // Verify strict ordering + $previousPosition = '-999999'; + foreach ($tasks as $task) { + $posStr = DecimalPosition::normalize($task->order_position); + expect(bccomp($posStr, $previousPosition, 10)) + ->toBeGreaterThan(0); + $previousPosition = $posStr; + } + }); +}); + +describe('position collision statistics', function () { + test('jitter produces statistically unique positions', function () { + $afterPos = '1000.0000000000'; + $beforePos = '2000.0000000000'; + $positions = []; + + // Generate 1000 positions without inserting + for ($i = 0; $i < 1000; $i++) { + $positions[] = DecimalPosition::between($afterPos, $beforePos); + } + + $uniquePositions = array_unique($positions); + + // With cryptographic jitter, we should have 100% unique positions + expect(count($uniquePositions))->toBe(1000); + + // Calculate position distribution around midpoint + $midpoint = DecimalPosition::betweenExact($afterPos, $beforePos); + $belowMidpoint = 0; + $aboveMidpoint = 0; + + foreach ($positions as $position) { + if (bccomp($position, $midpoint, 10) < 0) { + $belowMidpoint++; + } else { + $aboveMidpoint++; + } + } + + // Distribution should be roughly 50/50 (within reasonable variance) + expect($belowMidpoint)->toBeGreaterThan(400) + ->and($belowMidpoint)->toBeLessThan(600) + ->and($aboveMidpoint)->toBeGreaterThan(400) + ->and($aboveMidpoint)->toBeLessThan(600); + }); +}); diff --git a/tests/Feature/PerformanceTest.php b/tests/Feature/PerformanceTest.php new file mode 100644 index 0000000..107639a --- /dev/null +++ b/tests/Feature/PerformanceTest.php @@ -0,0 +1,148 @@ +toBeLessThan(0.5); // Allow some margin + }); + + test('10,000 exact midpoint calculations complete in < 50ms', function () { + $start = microtime(true); + + for ($i = 0; $i < 10_000; $i++) { + DecimalPosition::betweenExact('1000.0000000000', '2000.0000000000'); + } + + $elapsed = microtime(true) - $start; + + expect($elapsed)->toBeLessThan(0.1); + }); + + test('10,000 normalize operations complete in < 50ms', function () { + $values = ['1000', '1000.5', 1000, 1000.5, '-500']; + $start = microtime(true); + + for ($i = 0; $i < 10_000; $i++) { + DecimalPosition::normalize($values[$i % 5]); + } + + $elapsed = microtime(true) - $start; + + expect($elapsed)->toBeLessThan(0.1); + }); +}); + +describe('PositionRebalancer performance', function () { + test('rebalancing 100 cards completes in < 2 seconds', function () { + // Create 100 cards with positions + for ($i = 0; $i < 100; $i++) { + Task::create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => DecimalPosition::normalize($i * 100), + ]); + } + + $rebalancer = new PositionRebalancer; + + $start = microtime(true); + $count = $rebalancer->rebalanceColumn( + Task::query(), + 'status', + 'todo', + 'order_position' + ); + $elapsed = microtime(true) - $start; + + expect($count)->toBe(100) + ->and($elapsed)->toBeLessThan(2.0); + }); + + test('gap statistics on 100 cards completes in < 500ms', function () { + // Create 100 cards with positions + for ($i = 0; $i < 100; $i++) { + Task::create([ + 'title' => "Card {$i}", + 'status' => 'todo', + 'order_position' => DecimalPosition::normalize($i * 100), + ]); + } + + $rebalancer = new PositionRebalancer; + + $start = microtime(true); + $stats = $rebalancer->getGapStatistics( + Task::query(), + 'status', + 'todo', + 'order_position' + ); + $elapsed = microtime(true) - $start; + + expect($stats['count'])->toBe(100) + ->and($elapsed)->toBeLessThan(0.5); + }); +}); + +describe('sequence generation performance', function () { + test('generating 1000 sequential positions completes in < 50ms', function () { + $start = microtime(true); + + $positions = DecimalPosition::generateSequence(1000); + + $elapsed = microtime(true) - $start; + + expect($positions)->toHaveCount(1000) + ->and($elapsed)->toBeLessThan(0.1); + }); + + test('generating 100 between positions completes in < 50ms', function () { + $start = microtime(true); + + $positions = DecimalPosition::generateBetween('1000', '2000', 100); + + $elapsed = microtime(true) - $start; + + expect($positions)->toHaveCount(100) + ->and($elapsed)->toBeLessThan(0.1); + }); +}); + +describe('comparison performance', function () { + test('10,000 comparisons complete in < 50ms', function () { + $positions = []; + for ($i = 0; $i < 100; $i++) { + $positions[] = DecimalPosition::normalize($i * 1000); + } + + $start = microtime(true); + + $count = 0; + for ($i = 0; $i < 10_000; $i++) { + $a = $positions[$i % 100]; + $b = $positions[($i + 1) % 100]; + DecimalPosition::compare($a, $b); + DecimalPosition::lessThan($a, $b); + DecimalPosition::greaterThan($a, $b); + $count++; + } + + $elapsed = microtime(true) - $start; + + expect($count)->toBe(10_000) + ->and($elapsed)->toBeLessThan(0.1); + }); +}); diff --git a/tests/Feature/PositionRebalancingTest.php b/tests/Feature/PositionRebalancingTest.php new file mode 100644 index 0000000..b0b4bb0 --- /dev/null +++ b/tests/Feature/PositionRebalancingTest.php @@ -0,0 +1,235 @@ + 'Task 1', 'status' => 'todo', 'order_position' => '1000.0000000000']); + Task::create(['title' => 'Task 2', 'status' => 'todo', 'order_position' => '2000.0000000000']); + Task::create(['title' => 'Task 3', 'status' => 'todo', 'order_position' => '3000.0000000000']); +}); + +describe('PositionRebalancer::needsRebalancing()', function () { + test('detects gap below MIN_GAP', function () { + // Create tasks with very small gap + Task::create(['title' => 'Close 1', 'status' => 'in_progress', 'order_position' => '1000.0000000000']); + Task::create(['title' => 'Close 2', 'status' => 'in_progress', 'order_position' => '1000.00005']); // Gap < MIN_GAP + + $rebalancer = new PositionRebalancer; + + expect($rebalancer->needsRebalancing( + Task::query(), + 'status', + 'in_progress', + 'order_position' + ))->toBeTrue(); + }); + + test('returns false when gaps are healthy', function () { + $rebalancer = new PositionRebalancer; + + expect($rebalancer->needsRebalancing( + Task::query(), + 'status', + 'todo', + 'order_position' + ))->toBeFalse(); + }); + + test('returns false for empty column', function () { + $rebalancer = new PositionRebalancer; + + expect($rebalancer->needsRebalancing( + Task::query(), + 'status', + 'done', // No tasks in this column + 'order_position' + ))->toBeFalse(); + }); + + test('returns false for single item column', function () { + Task::create(['title' => 'Alone', 'status' => 'review', 'order_position' => '1000.0000000000']); + + $rebalancer = new PositionRebalancer; + + expect($rebalancer->needsRebalancing( + Task::query(), + 'status', + 'review', + 'order_position' + ))->toBeFalse(); + }); +}); + +describe('PositionRebalancer::rebalanceColumn()', function () { + test('redistributes positions evenly', function () { + $rebalancer = new PositionRebalancer; + + $count = $rebalancer->rebalanceColumn( + Task::query(), + 'status', + 'todo', + 'order_position' + ); + + expect($count)->toBe(3); + + // Verify positions are evenly spaced + $tasks = Task::where('status', 'todo')->orderBy('order_position')->get(); + + expect(DecimalPosition::normalize($tasks[0]->order_position))->toBe('65535.0000000000') + ->and(DecimalPosition::normalize($tasks[1]->order_position))->toBe('131070.0000000000') + ->and(DecimalPosition::normalize($tasks[2]->order_position))->toBe('196605.0000000000'); + }); + + test('maintains original order after rebalancing', function () { + // Create tasks with irregular positions + Task::create(['title' => 'A', 'status' => 'testing', 'order_position' => '100.0000000000']); + Task::create(['title' => 'B', 'status' => 'testing', 'order_position' => '100.0001000000']); + Task::create(['title' => 'C', 'status' => 'testing', 'order_position' => '100.0001500000']); + Task::create(['title' => 'D', 'status' => 'testing', 'order_position' => '100.0001600000']); + + // Get original order + $originalOrder = Task::where('status', 'testing') + ->orderBy('order_position') + ->pluck('title') + ->toArray(); + + // Rebalance + $rebalancer = new PositionRebalancer; + $rebalancer->rebalanceColumn(Task::query(), 'status', 'testing', 'order_position'); + + // Get new order + $newOrder = Task::where('status', 'testing') + ->orderBy('order_position') + ->pluck('title') + ->toArray(); + + expect($newOrder)->toBe($originalOrder); + }); + + test('returns zero for empty column', function () { + $rebalancer = new PositionRebalancer; + + $count = $rebalancer->rebalanceColumn( + Task::query(), + 'status', + 'nonexistent', + 'order_position' + ); + + expect($count)->toBe(0); + }); +}); + +describe('PositionRebalancer::findColumnsNeedingRebalancing()', function () { + test('identifies columns with small gaps', function () { + // Create column with healthy gaps + Task::create(['title' => 'Healthy 1', 'status' => 'done', 'order_position' => '1000.0000000000']); + Task::create(['title' => 'Healthy 2', 'status' => 'done', 'order_position' => '2000.0000000000']); + + // Create column with small gaps + Task::create(['title' => 'Cramped 1', 'status' => 'blocked', 'order_position' => '1000.0000000000']); + Task::create(['title' => 'Cramped 2', 'status' => 'blocked', 'order_position' => '1000.00005']); // Gap < MIN_GAP + + $rebalancer = new PositionRebalancer; + + $needsRebalancing = $rebalancer->findColumnsNeedingRebalancing( + Task::query(), + 'status', + 'order_position' + ); + + expect($needsRebalancing)->toContain('blocked') + ->and($needsRebalancing)->not->toContain('done') + ->and($needsRebalancing)->not->toContain('todo'); + }); +}); + +describe('PositionRebalancer::rebalanceAll()', function () { + test('processes all columns needing rebalancing', function () { + // Create multiple columns needing rebalancing + Task::create(['title' => 'Col1 A', 'status' => 'blocked', 'order_position' => '1000.0000000000']); + Task::create(['title' => 'Col1 B', 'status' => 'blocked', 'order_position' => '1000.00005']); + + Task::create(['title' => 'Col2 A', 'status' => 'review', 'order_position' => '2000.0000000000']); + Task::create(['title' => 'Col2 B', 'status' => 'review', 'order_position' => '2000.00003']); + + $rebalancer = new PositionRebalancer; + + $results = $rebalancer->rebalanceAll( + Task::query(), + 'status', + 'order_position' + ); + + expect($results)->toHaveKey('blocked') + ->and($results)->toHaveKey('review') + ->and($results['blocked'])->toBe(2) + ->and($results['review'])->toBe(2); + + // Verify gaps are now healthy + expect($rebalancer->needsRebalancing(Task::query(), 'status', 'blocked', 'order_position'))->toBeFalse() + ->and($rebalancer->needsRebalancing(Task::query(), 'status', 'review', 'order_position'))->toBeFalse(); + }); +}); + +describe('PositionRebalancer::getGapStatistics()', function () { + test('returns correct statistics for column', function () { + $rebalancer = new PositionRebalancer; + + $stats = $rebalancer->getGapStatistics( + Task::query(), + 'status', + 'todo', + 'order_position' + ); + + expect($stats['count'])->toBe(3) + ->and($stats['min_gap'])->toBe('1000.0000000000') + ->and($stats['max_gap'])->toBe('1000.0000000000') + ->and($stats['avg_gap'])->toBe('1000.0000000000') + ->and($stats['small_gaps'])->toBe(0); + }); + + test('returns nulls for single item column', function () { + Task::create(['title' => 'Solo', 'status' => 'solo_column', 'order_position' => '1000.0000000000']); + + $rebalancer = new PositionRebalancer; + + $stats = $rebalancer->getGapStatistics( + Task::query(), + 'status', + 'solo_column', + 'order_position' + ); + + expect($stats['count'])->toBe(1) + ->and($stats['min_gap'])->toBeNull() + ->and($stats['max_gap'])->toBeNull() + ->and($stats['avg_gap'])->toBeNull() + ->and($stats['small_gaps'])->toBe(0); + }); + + test('counts small gaps correctly', function () { + Task::create(['title' => 'A', 'status' => 'cramped', 'order_position' => '1000.0000000000']); + Task::create(['title' => 'B', 'status' => 'cramped', 'order_position' => '1000.00005']); // Small gap + Task::create(['title' => 'C', 'status' => 'cramped', 'order_position' => '2000.0000000000']); // Large gap + + $rebalancer = new PositionRebalancer; + + $stats = $rebalancer->getGapStatistics( + Task::query(), + 'status', + 'cramped', + 'order_position' + ); + + expect($stats['count'])->toBe(3) + ->and($stats['small_gaps'])->toBe(1); + }); +}); diff --git a/tests/Feature/RetryMechanismTest.php b/tests/Feature/RetryMechanismTest.php new file mode 100644 index 0000000..8d39997 --- /dev/null +++ b/tests/Feature/RetryMechanismTest.php @@ -0,0 +1,151 @@ +setAccessible(true); + $reflection->setValue($exception, ['23000', 19, 'UNIQUE constraint failed']); + + expect($helper->isDuplicatePositionError($exception))->toBeTrue(); + }); + + test('detects MySQL ER_DUP_ENTRY error', function () { + $helper = new RetryMechanismTestHelper; + + // MySQL duplicate entry error + $exception = new QueryException( + 'mysql', + 'INSERT INTO tasks ...', + [], + new PDOException("SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '100.5-todo' for key 'unique_position_per_column'") + ); + + $reflection = new ReflectionProperty($exception, 'errorInfo'); + $reflection->setAccessible(true); + $reflection->setValue($exception, ['23000', 1062, 'Duplicate entry']); + + expect($helper->isDuplicatePositionError($exception))->toBeTrue(); + }); + + test('detects PostgreSQL unique_violation error', function () { + $helper = new RetryMechanismTestHelper; + + // PostgreSQL unique violation error + $exception = new QueryException( + 'pgsql', + 'INSERT INTO tasks ...', + [], + new PDOException('SQLSTATE[23505]: Unique violation: duplicate key value violates unique constraint "unique_position_per_column"') + ); + + $reflection = new ReflectionProperty($exception, 'errorInfo'); + $reflection->setAccessible(true); + $reflection->setValue($exception, ['23505', 23505, 'duplicate key value']); + + expect($helper->isDuplicatePositionError($exception))->toBeTrue(); + }); + + test('detects error by message containing unique_position_per_column', function () { + $helper = new RetryMechanismTestHelper; + + // Generic error with constraint name in message + $exception = new QueryException( + 'sqlite', + 'INSERT INTO tasks ...', + [], + new PDOException('UNIQUE constraint failed: unique_position_per_column') + ); + + $reflection = new ReflectionProperty($exception, 'errorInfo'); + $reflection->setAccessible(true); + $reflection->setValue($exception, ['23000', 999, 'unknown error']); // Unknown error code + + expect($helper->isDuplicatePositionError($exception))->toBeTrue(); + }); + + test('returns false for non-duplicate errors', function () { + $helper = new RetryMechanismTestHelper; + + // Foreign key constraint error (not duplicate) + $exception = new QueryException( + 'mysql', + 'INSERT INTO tasks ...', + [], + new PDOException('SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails') + ); + + $reflection = new ReflectionProperty($exception, 'errorInfo'); + $reflection->setAccessible(true); + $reflection->setValue($exception, ['23000', 1452, 'foreign key constraint fails']); + + expect($helper->isDuplicatePositionError($exception))->toBeFalse(); + }); +}); + +describe('database unique constraint behavior', function () { + beforeEach(function () { + Task::create(['title' => 'Existing', 'status' => 'todo', 'order_position' => '1500.0000000000']); + }); + + test('unique constraint throws QueryException on duplicate', function () { + expect(fn () => Task::create([ + 'title' => 'Duplicate', + 'status' => 'todo', + 'order_position' => '1500.0000000000', + ]))->toThrow(QueryException::class); + }); + + test('same position in different columns is allowed', function () { + // Same position, different status (column) + $task = Task::create([ + 'title' => 'Different Column', + 'status' => 'in_progress', // Different column + 'order_position' => '1500.0000000000', // Same position + ]); + + expect($task->exists)->toBeTrue(); + }); + + test('null positions are allowed', function () { + // NULL is special in unique constraints - multiple NULLs are allowed + Task::create(['title' => 'Null 1', 'status' => 'todo', 'order_position' => null]); + $task2 = Task::create(['title' => 'Null 2', 'status' => 'todo', 'order_position' => null]); + + expect($task2->exists)->toBeTrue(); + }); +}); diff --git a/tests/Unit/DecimalPositionTest.php b/tests/Unit/DecimalPositionTest.php new file mode 100644 index 0000000..f14c277 --- /dev/null +++ b/tests/Unit/DecimalPositionTest.php @@ -0,0 +1,237 @@ +toBeGreaterThan(0) + ->and(bccomp($result, $before, 10))->toBeLessThan(0); + } + }); + + test('produces different results on successive calls (jitter verification)', function () { + $after = '1000.0000000000'; + $before = '2000.0000000000'; + + $results = []; + for ($i = 0; $i < 100; $i++) { + $results[] = DecimalPosition::between($after, $before); + } + + // All 100 results should be unique (cryptographic jitter) + $unique = array_unique($results); + expect(count($unique))->toBe(100); + }); + + test('throws InvalidArgumentException when after >= before', function () { + $after = '2000.0000000000'; + $before = '1000.0000000000'; + + DecimalPosition::between($after, $before); + })->throws(InvalidArgumentException::class, 'Invalid bounds: after (2000.0000000000) must be less than before (1000.0000000000)'); + + test('throws InvalidArgumentException when after equals before', function () { + $position = '1500.0000000000'; + + DecimalPosition::between($position, $position); + })->throws(InvalidArgumentException::class); +}); + +describe('betweenExact() deterministic', function () { + test('returns exact midpoint', function () { + expect(DecimalPosition::betweenExact('1000', '2000'))->toBe('1500.0000000000') + ->and(DecimalPosition::betweenExact('0', '100'))->toBe('50.0000000000') + ->and(DecimalPosition::betweenExact('100', '101'))->toBe('100.5000000000'); + }); + + test('returns consistent results (deterministic)', function () { + $results = []; + for ($i = 0; $i < 100; $i++) { + $results[] = DecimalPosition::betweenExact('1000', '2000'); + } + + // All 100 results should be identical + expect(array_unique($results))->toHaveCount(1) + ->and($results[0])->toBe('1500.0000000000'); + }); + + test('throws InvalidArgumentException when after >= before', function () { + DecimalPosition::betweenExact('2000', '1000'); + })->throws(InvalidArgumentException::class); +}); + +describe('needsRebalancing()', function () { + test('returns false for large gaps', function () { + expect(DecimalPosition::needsRebalancing('1000', '2000'))->toBeFalse() + ->and(DecimalPosition::needsRebalancing('0', '65535'))->toBeFalse(); + }); + + test('returns true when gap equals MIN_GAP', function () { + $after = '1000.0000000000'; + $before = bcadd($after, DecimalPosition::MIN_GAP, 10); + + expect(DecimalPosition::needsRebalancing($after, $before))->toBeFalse(); + }); + + test('returns true when gap is below MIN_GAP', function () { + $after = '1000.0000000000'; + $before = '1000.00009'; // Gap of 0.00009 < 0.0001 + + expect(DecimalPosition::needsRebalancing($after, $before))->toBeTrue(); + }); + + test('returns true for extremely small gaps', function () { + $after = '1000.0000000000'; + $before = '1000.0000000001'; + + expect(DecimalPosition::needsRebalancing($after, $before))->toBeTrue(); + }); +}); + +describe('generateSequence()', function () { + test('produces evenly spaced positions', function () { + $positions = DecimalPosition::generateSequence(5); + + expect($positions)->toHaveCount(5) + ->and($positions[0])->toBe('65535.0000000000') + ->and($positions[1])->toBe('131070.0000000000') + ->and($positions[2])->toBe('196605.0000000000') + ->and($positions[3])->toBe('262140.0000000000') + ->and($positions[4])->toBe('327675.0000000000'); + }); + + test('returns empty array for zero count', function () { + expect(DecimalPosition::generateSequence(0))->toBe([]); + }); + + test('returns single position for count of 1', function () { + $positions = DecimalPosition::generateSequence(1); + + expect($positions)->toHaveCount(1) + ->and($positions[0])->toBe('65535.0000000000'); + }); +}); + +describe('generateBetween()', function () { + test('produces N unique positions within bounds', function () { + $after = '1000.0000000000'; + $before = '2000.0000000000'; + $count = 10; + + $positions = DecimalPosition::generateBetween($after, $before, $count); + + expect($positions)->toHaveCount($count); + + // All positions should be unique + expect(array_unique($positions))->toHaveCount($count); + + // All positions should be strictly between bounds + foreach ($positions as $position) { + expect(bccomp($position, $after, 10))->toBeGreaterThan(0) + ->and(bccomp($position, $before, 10))->toBeLessThan(0); + } + }); + + test('returns empty array for zero count', function () { + expect(DecimalPosition::generateBetween('1000', '2000', 0))->toBe([]); + }); + + test('returns single position for count of 1', function () { + $positions = DecimalPosition::generateBetween('1000', '2000', 1); + + expect($positions)->toHaveCount(1); + }); +}); + +describe('normalize()', function () { + test('handles various input formats', function () { + expect(DecimalPosition::normalize('1000'))->toBe('1000.0000000000') + ->and(DecimalPosition::normalize('1000.5'))->toBe('1000.5000000000') + ->and(DecimalPosition::normalize(1000))->toBe('1000.0000000000') + ->and(DecimalPosition::normalize(1000.5))->toBe('1000.5000000000') + ->and(DecimalPosition::normalize('0'))->toBe('0.0000000000') + ->and(DecimalPosition::normalize('-1000'))->toBe('-1000.0000000000'); + }); +}); + +describe('comparison methods', function () { + test('compare() returns correct values', function () { + expect(DecimalPosition::compare('1000', '2000'))->toBe(-1) + ->and(DecimalPosition::compare('2000', '1000'))->toBe(1) + ->and(DecimalPosition::compare('1000', '1000'))->toBe(0); + }); + + test('lessThan() works correctly', function () { + expect(DecimalPosition::lessThan('1000', '2000'))->toBeTrue() + ->and(DecimalPosition::lessThan('2000', '1000'))->toBeFalse() + ->and(DecimalPosition::lessThan('1000', '1000'))->toBeFalse(); + }); + + test('greaterThan() works correctly', function () { + expect(DecimalPosition::greaterThan('2000', '1000'))->toBeTrue() + ->and(DecimalPosition::greaterThan('1000', '2000'))->toBeFalse() + ->and(DecimalPosition::greaterThan('1000', '1000'))->toBeFalse(); + }); +}); + +describe('gap()', function () { + test('calculates gap between positions', function () { + expect(DecimalPosition::gap('1000', '2000'))->toBe('1000.0000000000') + ->and(DecimalPosition::gap('0', '65535'))->toBe('65535.0000000000') + ->and(DecimalPosition::gap('100', '100.5'))->toBe('0.5000000000'); + }); +}); + +describe('forEmptyColumn()', function () { + test('returns DEFAULT_GAP', function () { + expect(DecimalPosition::forEmptyColumn())->toBe(DecimalPosition::DEFAULT_GAP); + }); +}); + +describe('after() and before()', function () { + test('after() adds DEFAULT_GAP', function () { + expect(DecimalPosition::after('1000'))->toBe('66535.0000000000') + ->and(DecimalPosition::after('0'))->toBe('65535.0000000000'); + }); + + test('before() subtracts DEFAULT_GAP', function () { + expect(DecimalPosition::before('100000'))->toBe('34465.0000000000') + ->and(DecimalPosition::before('65535'))->toBe('0.0000000000'); + }); + + test('before() can produce negative positions', function () { + expect(DecimalPosition::before('0'))->toBe('-65535.0000000000'); + }); +}); + +describe('calculate()', function () { + test('returns forEmptyColumn when both positions are null', function () { + expect(DecimalPosition::calculate(null, null))->toBe(DecimalPosition::DEFAULT_GAP); + }); + + test('returns after() when only afterPos is provided', function () { + $result = DecimalPosition::calculate('1000', null); + expect($result)->toBe('66535.0000000000'); + }); + + test('returns before() when only beforePos is provided', function () { + $result = DecimalPosition::calculate(null, '100000'); + expect($result)->toBe('34465.0000000000'); + }); + + test('returns between() when both positions are provided', function () { + $result = DecimalPosition::calculate('1000', '2000'); + + // Should be between bounds (with jitter) + expect(bccomp($result, '1000', 10))->toBeGreaterThan(0) + ->and(bccomp($result, '2000', 10))->toBeLessThan(0); + }); +});