diff --git a/tools/android/exercise-final-progression.ps1 b/tools/android/exercise-final-progression.ps1 index e764df8..f0d6c4a 100644 --- a/tools/android/exercise-final-progression.ps1 +++ b/tools/android/exercise-final-progression.ps1 @@ -30,7 +30,7 @@ function RevealBall([int]$Stage,[string]$Id,[string]$Name) { for($view=0;$view-lt12;$view++){ $board=Board (Save "$Name-view-$view") $Stage if(BallClear $board $Id){return $board} - Orbit "$Name-orbit-$view" $board + Orbit "$Name-orbit-$view" $board -Pitch:($view%4-eq3) } throw "No safe first-hit view for actual ball $Id within a full native orbit." } @@ -46,17 +46,24 @@ function MergeReal([int]$Stage,[string]$Name,[int]$SourceCount=0,[int]$TargetCou [pscustomobject]@{sid=$sid;tid=$tid;depth=[Math]::Abs($delta.Z);size=$source.ballIds.Count} }}}}) | Sort-Object depth,size $pair=$null - foreach($candidate in $pairs){if($candidate.depth-lt.9-and(BallClear $board $candidate.sid)){$pair=$candidate;break}} + $offset=if($attempt%2-eq0){-35}else{35} + foreach($candidate in $pairs){ + if($candidate.depth-ge.9-or!(BallClear $board $candidate.sid)){continue} + $targetPoint=BallPoint $board $candidate.tid + if(WorldSafe @(($targetPoint[0]+$offset),$targetPoint[1])){$pair=$candidate;break} + } if($pair) { - $start=BallPoint $board $pair.sid; $end=BallPoint $board $pair.tid + $end=BallPoint $board $pair.tid # Try a short lateral contact offset, then orbit to expose another depth plane if blocked. - $offset=if($attempt%2-eq0){-35}else{35} - Step "$Name-drag-$attempt" @('shell','input','swipe',"$($start[0])","$($start[1])","$($end[0]+$offset)","$($end[1])",'500') - $after=Save "$Name-after-$attempt"; $newBoard=Board $after $Stage - if($null-eq$newBoard-or$newBoard.groups.Count-lt$oldCount){return $after} + $after=NativeMoveAndHold "$Name-drag-$attempt" $board $pair.sid @(($end[0]+$offset),$end[1]);$newBoard=Board $after $Stage + if($null-eq$newBoard-or$newBoard.groups.Count-lt$oldCount){ + $actual=if($newBoard){$newBoard}else{$after.latestFreePlay} + AssertNativeMerge "$Name-actual-$attempt" $board $actual $pair.sid $TargetCount + return $after + } $board=$newBoard } - Orbit "$Name-orbit-$attempt" $board + Orbit "$Name-orbit-$attempt" $board -Pitch:($attempt%4-eq3) } throw "Native merge bound reached for Stage $Stage; all actual attempts retained." } @@ -77,10 +84,13 @@ function SplitReal([int]$Stage,[int]$Selected,[string]$Name,[string]$OnlyId='') foreach($id in $ids){$board=RevealBall $Stage $id "$Name-select-reveal-$attempt-$id";TapBall "$Name-select-$attempt-$id" $board $id} $board=RevealBall $Stage $ids[-1] "$Name-drag-reveal-$attempt" $point=BallPoint $board $ids[-1] - Step "$Name-drag-$attempt" @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0])","$($point[1]-160)",'500') - $after=Save "$Name-after-$attempt"; $actual=Board $after $Stage + $end=@($point[0],($point[1]-160)) + if(!(WorldSafe $end)){$end=@($point[0],($point[1]+160))} + $after=NativeMoveAndHold "$Name-drag-$attempt" $board $ids[-1] $end;$actual=Board $after $Stage if($null-eq$actual){$actual=$after.latestFreePlay} if($actual.groups.Count-gt$board.groups.Count){ + Check "$Name-split-original-ids-$attempt" (Same @($board.balls.id|Sort-Object) @($actual.balls.id|Sort-Object)) 'Actual split retains all original identities exactly' + Check "$Name-split-manual-camera-$attempt" (Same $board.camera $actual.camera) 'Split assistance preserves the exact saved manual pose' if(@($actual.groups|Where-Object{(($_.ballIds|Sort-Object)-join',')-eq(($ids|Sort-Object)-join',')}).Count-ne1){ $commands.Add(@{command='Actual split selected IDs differ: preserve requested and actual memberships';expected=$ids;actualGroups=$actual.groups;utc=[DateTime]::UtcNow.ToString('o')}) if($null-eq(Board $after $Stage)){ diff --git a/tools/android/final-common.ps1 b/tools/android/final-common.ps1 index 2de30ab..9fdb5db 100644 --- a/tools/android/final-common.ps1 +++ b/tools/android/final-common.ps1 @@ -22,6 +22,54 @@ function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500) { } function Save([string]$Name) { Adb @('pull',$remote,(Join-Path $EvidenceDir "$Name.json")) | Out-Null; return Get-Content -LiteralPath (Join-Path $EvidenceDir "$Name.json") -Raw | ConvertFrom-Json } function Same($First,$Second) { return ($First | ConvertTo-Json -Depth 12 -Compress) -eq ($Second | ConvertTo-Json -Depth 12 -Compress) } +function NativePointer([int[]]$Contacts) { + $touchHelper=Join-Path $PSScriptRoot 'send-composition-touch.ps1' + & pwsh -NoProfile -CommandWithArgs '$ErrorActionPreference="Stop"; $touchHelper=$args[0]; $touchContacts=@($args[1..($args.Count-1)]|ForEach-Object {[int]$_}); & $touchHelper -Contacts $touchContacts; exit $LASTEXITCODE' $touchHelper @Contacts + $actualExit=$LASTEXITCODE + $commands.Add(@{command='EmulatorController.sendTouch';contacts=$Contacts;exitCode=$actualExit;utc=[DateTime]::UtcNow.ToString('o')}) + if($actualExit-ne0){throw "Actual native gesture failed: $actualExit"} +} +function NativeMoveAndHold([string]$Name,$Board,[string]$Id,[int[]]$End) { + $point=BallPoint $Board $Id + Check "$Name-safe-start-end" ((WorldSafe $point)-and(WorldSafe $End)) 'Current exact whole-ID projection and documented UI-safe endpoints' + $confirmed=Save "$Name-confirmed" + try { + NativePointer @($point[0],$point[1],79,1024);Start-Sleep -Milliseconds 100 + # One real Move precedes assistance. Hold the finger still as the camera changes; + # a 500ms interpolated swipe would reuse stale screen coordinates during rotation. + NativePointer @($End[0],$End[1],79,1024);Start-Sleep -Milliseconds 700 + Step "$Name-during-live-camera" @() 0 + Check "$Name-live-preview-not-saved" (Same $confirmed (Save "$Name-during-save")) 'Stationary native contact: preview and auto camera never overwrite confirmed state' + } finally { NativePointer @($End[0],$End[1],79,0) } + Step $Name @() 2500 + return Save "$Name-dropped" +} +function AssertNativeMerge([string]$Name,$Before,$After,[string]$SourceId,[int]$TargetCount=0) { + $idsBefore=@($Before.balls.id|Sort-Object);$idsAfter=@($After.balls.id|Sort-Object) + Check "$Name-exact-original-ids" (Same $idsBefore $idsAfter) 'No lost, duplicated or substituted IDs after actual native input' + Check "$Name-manual-camera-preserved" (Same $Before.camera $After.camera) 'Automatic assistance never persists as the manual camera' + $source=@($Before.groups|Where-Object {$_.ballIds-contains$SourceId})[0] + $merged=@($After.groups|Where-Object {$_.ballIds-contains$SourceId})[0] + $targets=@($Before.groups|Where-Object {$_.id-ne$source.id-and@($_.ballIds|Where-Object {$merged.ballIds-notcontains$_}).Count-eq0}) + Check "$Name-actual-source-and-target-membership" ($targets.Count-eq1-and$merged.ballIds.Count-eq($source.ballIds.Count+$targets[0].ballIds.Count)-and@($source.ballIds|Where-Object {$merged.ballIds-notcontains$_}).Count-eq0-and($TargetCount-eq0-or$targets[0].ballIds.Count-eq$TargetCount)) 'Grabbed source plus exactly one actual foreign group; requested target size retained' + $firstBefore=@($Before.balls|Where-Object id -eq $SourceId)[0].position + $firstAfter=@($After.balls|Where-Object id -eq $SourceId)[0].position + $delta=[Numerics.Vector3]::new($firstAfter.x-$firstBefore.x,$firstAfter.y-$firstBefore.y,$firstAfter.z-$firstBefore.z) + Check "$Name-requested-source-actually-moved" ($delta.Length()-gt.00001) "Actual grabbed source $SourceId moved in 3D" + foreach($ball in $Before.balls){ + $actual=@($After.balls|Where-Object id -eq $ball.id)[0].position + if($source.ballIds-contains$ball.id){ + $movement=[Numerics.Vector3]::new($actual.x-$ball.position.x,$actual.y-$ball.position.y,$actual.z-$ball.position.z) + Check "$Name-rigid-source-$($ball.id)" (($movement-$delta).Length()-lt.0001) 'Every source member shares the same actual translation' + }else{Check "$Name-stationary-foreign-$($ball.id)" (Same $ball.position $actual) 'Unselected foreign balls retain exact saved positions'} + } + $distance=[double]::PositiveInfinity + foreach($sid in $source.ballIds){foreach($tid in $targets[0].ballIds){ + $s=@($After.balls|Where-Object id -eq $sid)[0].position;$t=@($After.balls|Where-Object id -eq $tid)[0].position + $distance=[Math]::Min($distance,[Numerics.Vector3]::new($s.x-$t.x,$s.y-$t.y,$s.z-$t.z).Length()) + }} + Check "$Name-real-three-dimensional-contact" ($distance-ge.998-and$distance-le1.002) "Actual source/foreign unit-sphere contact distance=$distance; screen overlap alone is insufficient" +} function BallPoint($Board,[string]$Id) { $matching=@($Board.balls | Where-Object id -eq $Id) if ($matching.Count -ne 1) { throw "Native projection requires exactly one existing ball ID: $Id" } diff --git a/tools/android/test-final-live-drag.ps1 b/tools/android/test-final-live-drag.ps1 new file mode 100644 index 0000000..10fa37f --- /dev/null +++ b/tools/android/test-final-live-drag.ps1 @@ -0,0 +1,87 @@ +param([Parameter(Mandatory)][string]$EvidenceDir,[Parameter(Mandatory)][string]$Fixture,[string]$CommonSource=(Join-Path $PSScriptRoot 'final-common.ps1'),[string]$ProgressionSource=(Join-Path $PSScriptRoot 'exercise-final-progression.ps1')) +$ErrorActionPreference='Stop' +New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null +$checks=[Collections.Generic.List[object]]::new() +function Record([string]$Id,[bool]$Passed,$Actual){$checks.Add(@{id=$Id;passed=$Passed;actual=$Actual})} +function ReadFunction([string]$Path,[string]$Name){ + $ast=[Management.Automation.Language.Parser]::ParseFile($Path,[ref]$null,[ref]$null) + $function=$ast.Find({param($n)$n-is[Management.Automation.Language.FunctionDefinitionAst]-and$n.Name-eq$Name},$true) + if(!$function){throw "Missing production function: $Name"} + $expected=$function.Extent.Text.Replace("function $Name","function global:$Name") + . ([scriptblock]::Create($expected)) + $actual=(Get-Command -Name $Name -CommandType Function).ScriptBlock.Ast.Extent.Text + Record "$Name-loaded-production-function" ($actual-ceq$expected) @{source=$Path;expectedSha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($expected)));actualSha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($actual)))} + if($actual-cne$expected){throw "Production function is shadowed: $Name"} +} +$raw=Get-Content -LiteralPath $Fixture -Raw|ConvertFrom-Json +$board=@($raw.puzzleStates|Where-Object stageId -eq 5)[0] +if(!$board-or$board.groups.Count-ne2-or$board.balls.Count-ne5){throw 'Requires actual failed Stage5 final two-group save'} +$commands=[Collections.Generic.List[object]]::new();$script:pointCalls=0 +function Check([string]$Id,[bool]$Passed,[string]$Actual){if(!$Passed){throw "$Id : $Actual"}} +function Adb([string[]]$Arguments){return 'Physical size: 1080x2400'} +function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500){$commands.Add(@{name=$Name;arguments=$Arguments})} +function Save([string]$Name){return $raw} +function global:NativeMoveAndHold([string]$Name,$Board,[string]$Id,[int[]]$End){$commands.Add(@{nativeHeldMove=$true;id=$Id;end=$End});return $raw} +try{ + foreach($name in @('BallPoint','WorldSafe','BallClear','Same')){ReadFunction $CommonSource $name} + foreach($name in @('Board','Orbit','MergeReal')){ReadFunction $ProgressionSource $name} + $bound=$false;$replayError=$null;try{MergeReal 5 'replay-actual-stage5'|Out-Null}catch{$replayError=$_.Exception.Message;$bound=$replayError-match'Native merge bound reached'} + $pitch=@($commands|Where-Object {$_.arguments.Count-ge7-and$_.arguments[2]-eq'swipe'-and$_.arguments[3]-eq$_.arguments[5]-and$_.arguments[4]-ne$_.arguments[6]}) + Record 'failed-original-save-exercises-both-native-orbit-axes' ($bound-and$pitch.Count-gt0) @{boundedFailureRetained=$bound;pitchCommands=$pitch.Count;commands=$commands.Count;error=$replayError} + ReadFunction $CommonSource 'NativeMoveAndHold' + $script:touches=[Collections.Generic.List[object]]::new() + function NativePointer([int[]]$Contacts){$script:touches.Add(@($Contacts))} + function Start-Sleep {} + $commands.Clear() + $start=BallPoint $board 's05-b04';$end=@(($start[0]+40),$start[1]) + NativeMoveAndHold 'offline-held-drag' $board 's05-b04' $end|Out-Null + Record 'single-current-projection-move-held-then-released' ($script:touches.Count-eq3-and$script:touches[0][3]-eq1024-and$script:touches[1][0]-eq$end[0]-and$script:touches[1][3]-eq1024-and$script:touches[2][3]-eq0) $script:touches.ToArray() + foreach($case in @(@{id='missing';end=$end},@{id='s05-b04';end=@(312,108)},@{id='s05-b04';end=@(1200,900)})){ + $script:touches.Clear();$rejected=$false;try{NativeMoveAndHold 'invalid-held-drag' $board $case.id $case.end|Out-Null}catch{$rejected=$true} + Record 'unsafe-or-missing-ID-rejected-before-native-touch' ($rejected-and$script:touches.Count-eq0) $case + } + function Save([string]$Name){if($Name-match'during'){ $changed=$raw|ConvertTo-Json -Depth 20|ConvertFrom-Json;$changed.completedStageIds+=5;return $changed };return $raw} + $script:touches.Clear();$rejected=$false;try{NativeMoveAndHold 'changed-preview' $board 's05-b04' $end|Out-Null}catch{$rejected=$true} + Record 'persisted-preview-is-rejected-and-contact-released' ($rejected-and$script:touches.Count-eq3-and$script:touches[-1][3]-eq0) $script:touches.ToArray() + ReadFunction $CommonSource 'AssertNativeMerge' + $success=$board|ConvertTo-Json -Depth 20|ConvertFrom-Json + $source=@($board.groups|Where-Object {$_.ballIds-contains's05-b04'})[0] + $target=@($board.groups|Where-Object {$_.ballIds-notcontains's05-b04'})[0] + $anchor=@($success.balls|Where-Object id -eq 's05-b01')[0].position + @($success.balls|Where-Object id -eq 's05-b04')[0].position=[pscustomobject]@{x=($anchor.x-1);y=$anchor.y;z=$anchor.z} + $success.groups=@([pscustomobject]@{id='actual-merged';ballIds=@($source.ballIds)+@($target.ballIds)}) + $accepted=$true;$contactError=$null;try{AssertNativeMerge 'offline-valid-contact' $board $success 's05-b04' 0}catch{$accepted=$false;$contactError=$_.Exception.Message} + Record 'actual-ID-rigid-contact-oracle-positive-control' $accepted @{scope='Counterfactual offline geometry control; never native completion evidence';error=$contactError} + foreach($kind in @('missing-ID','duplicate-ID','wrong-membership','foreign-moved','deep-screen-overlap','manual-camera-changed','zero-source-motion')){ + $bad=$success|ConvertTo-Json -Depth 20|ConvertFrom-Json + switch($kind){ + 'missing-ID'{$bad.balls=@($bad.balls|Where-Object id -ne 's05-b04')} + 'duplicate-ID'{$bad.balls+=@($bad.balls[0])} + 'wrong-membership'{$bad.groups[0].ballIds=@($target.ballIds)} + 'foreign-moved'{$bad.balls[0].position.x+=.1} + 'deep-screen-overlap'{@($bad.balls|Where-Object id -eq 's05-b04')[0].position.z+=3} + 'manual-camera-changed'{$bad.camera.distance+=.1} + 'zero-source-motion'{@($bad.balls|Where-Object id -eq 's05-b04')[0].position=@($board.balls|Where-Object id -eq 's05-b04')[0].position} + } + $rejected=$false;try{AssertNativeMerge "offline-$kind" $board $bad 's05-b04' 0}catch{$rejected=$true} + Record 'invalid-native-result-is-rejected' $rejected $kind + } + $multi=$board|ConvertTo-Json -Depth 20|ConvertFrom-Json + $sourcePosition=@($board.balls|Where-Object id -eq 's05-b01')[0].position + $targetPosition=@($board.balls|Where-Object id -eq 's05-b04')[0].position + $translation=[Numerics.Vector3]::new($targetPosition.x+1-$sourcePosition.x,$targetPosition.y-$sourcePosition.y,$targetPosition.z-$sourcePosition.z) + foreach($ball in $multi.balls|Where-Object id -ne 's05-b04'){$ball.position.x+=$translation.X;$ball.position.y+=$translation.Y;$ball.position.z+=$translation.Z} + $multi.groups=$success.groups + $accepted=$true;$contactError=$null;try{AssertNativeMerge 'offline-four-source-contact' $board $multi 's05-b01' 1}catch{$accepted=$false;$contactError=$_.Exception.Message} + Record 'four-member-rigid-source-contact-positive-control' $accepted @{scope='Offline counterfactual control; all four source members translated equally';error=$contactError} + foreach($kind in @('partial-source-motion','wrong-target-size')){ + $bad=$multi|ConvertTo-Json -Depth 20|ConvertFrom-Json + $targetSize=1 + if($kind-eq'partial-source-motion'){@($bad.balls|Where-Object id -eq 's05-b02')[0].position.y+=.01}else{$targetSize=2} + $rejected=$false;try{AssertNativeMerge "offline-$kind" $board $bad 's05-b01' $targetSize}catch{$rejected=$true} + Record 'invalid-group-translation-or-target-size-is-rejected' $rejected $kind + } +}catch{Record 'production-function-evaluation' $false $_.Exception.Message} +$passed=$checks.Count-gt0-and@($checks|Where-Object passed -ne $true).Count-eq0 +@{passed=$passed;scope='Offline actual failed save replay and native-input/assertion negative controls; no ADB, no Unity, no native success claim';fixture=$Fixture;fixtureSha256=(Get-FileHash -LiteralPath $Fixture).Hash;commonSource=$CommonSource;commonSha256=(Get-FileHash -LiteralPath $CommonSource).Hash;progressionSource=$ProgressionSource;progressionSha256=(Get-FileHash -LiteralPath $ProgressionSource).Hash;checks=$checks.ToArray()}|ConvertTo-Json -Depth 12|Set-Content -LiteralPath (Join-Path $EvidenceDir 'live-drag-result.json') +if(!$passed){exit 1};exit 0