diff --git a/tools/android/exercise-playground.ps1 b/tools/android/exercise-playground.ps1 index d7454c2..b45d80c 100644 --- a/tools/android/exercise-playground.ps1 +++ b/tools/android/exercise-playground.ps1 @@ -14,6 +14,30 @@ function NativeTouch([int[]]$Contacts) { if($actualExit-ne0){throw "Actual native gesture failed: $actualExit"} } function Clone($Value){return $Value|ConvertTo-Json -Depth 20|ConvertFrom-Json} +function Float32Record($Value,[string]$Parent=''){ + if($Value-is[Collections.IDictionary]){ + $keys=@($Value.Keys) + foreach($key in $keys){ + $isFloat=($Parent-cin@('position','target')-and$key-cin@('x','y','z'))-or($Parent-ceq'rotation'-and$key-cin@('x','y','z','w'))-or($key-ceq'distance'-and$keys-ccontains'target'-and$keys-ccontains'rotation') + if($isFloat){ + $number=$Value[$key] + if(($number-isnot[long]-and$number-isnot[double])-or![single]::IsFinite([single]$number)){throw 'Preserved DTO float must be a finite JSON number'} + $Value[$key]='float32:'+([BitConverter]::SingleToInt32Bits([single]$number)).ToString('X8') + }else{Float32Record $Value[$key] $key} + } + }elseif($Value-is[Collections.IList]){foreach($item in $Value){Float32Record $item $Parent}} +} +function Same($First,$Second){ + # Only DTO float fields use IEEE754 identity; all keys, array order and other values stay exact. + try{ + $records=@(foreach($value in @($First,$Second)){ + $copy=ConvertFrom-Json -InputObject (ConvertTo-Json -InputObject $value -Depth 20 -Compress) -AsHashtable -NoEnumerate + Float32Record $copy + ConvertTo-Json -InputObject $copy -Depth 20 -Compress + }) + return $records[0]-ceq$records[1] + }catch{return $false} +} function Slot($State,[string]$Type,[int]$Stage){if($Type-eq'free'){return $State.latestFreePlay};return @($State.puzzleStates|Where-Object stageId -eq $Stage)[0]} function SameBalls($First,$Second,[double]$Tolerance=.004){ if((($First.balls.id|Sort-Object)-join',')-ne(($Second.balls.id|Sort-Object)-join',')){return $false} diff --git a/tools/android/test-playground-contracts.ps1 b/tools/android/test-playground-contracts.ps1 index 1f3dfdb..1137cc4 100644 --- a/tools/android/test-playground-contracts.ps1 +++ b/tools/android/test-playground-contracts.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory)][string]$EvidenceDir,[string]$FinalSource=(Join-Path $PSScriptRoot 'exercise-final.ps1'),[string]$PlaygroundSource=(Join-Path $PSScriptRoot 'exercise-playground.ps1')) +param([Parameter(Mandatory)][string]$EvidenceDir,[string]$FinalSource=(Join-Path $PSScriptRoot 'exercise-final.ps1'),[string]$PlaygroundSource=(Join-Path $PSScriptRoot 'exercise-playground.ps1'),[string]$PreservationFixtureDir) $ErrorActionPreference='Stop' New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null $checks=[Collections.Generic.List[object]]::new() @@ -56,11 +56,55 @@ exit ([int]$env:PLAYGROUND_CONTROL_TOUCH_EXIT) $observedContacts=Get-Content -LiteralPath $env:PLAYGROUND_CONTROL_TOUCH_FILE -Raw|ConvertFrom-Json Probe "native-gesture-actual-child-exit-$exitCode" ($rejected-eq($exitCode-ne0)-and($observedContacts-join',')-eq'1200,500,71,1024,1300,500,72,1024'-and$commands[-1].exitCode-eq$exitCode) @{rejected=$rejected;contacts=$observedContacts;actualExit=$commands[-1].exitCode;error=$errorText} } - foreach($name in @('Clone','SameBalls','SameMembership','InBounds','FixtureState','Slot','BlueFriends')){ + foreach($name in @('Clone','Float32Record','Same','SameBalls','SameMembership','InBounds','FixtureState','Slot','BlueFriends','PreservesOtherSlots')){ $function=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq$name},$true) - . ([scriptblock]::Create($function.Extent.Text)) + if($function){. ([scriptblock]::Create($function.Extent.Text))} + elseif($name-eq'Same'){ + $commonAst=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'final-common.ps1'),[ref]$null,[ref]$null) + $commonSame=$commonAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq'Same'},$true) + . ([scriptblock]::Create($commonSame.Extent.Text)) + } + } + $floatSource=('{"schemaVersion":1,"completedStageIds":[1,2,3],"seenTutorialIds":["merge","split"],"puzzleStates":[{"stageId":2,"balls":[{"id":"float-a","position":{"x":0.0,"y":0.800000011920929,"z":0.0}},{"id":"float-b","position":{"x":1.0,"y":0.800000011920929,"z":0.0}}],"groups":[{"id":"float-group","ballIds":["float-a","float-b"]}],"collectedBallIds":["float-a"],"countedBallIds":["float-a"],"camera":{"target":{"x":0.0,"y":0.6000000238418579,"z":0.0},"rotation":{"x":0.258819043636322,"y":0.0,"z":0.0,"w":0.9659258127212524},"distance":8.123456001281738}}],"latestFreePlay":{"sourceStageId":6,"balls":[{"id":"free-a","position":{"x":0.0,"y":0.800000011920929,"z":0.0}}],"groups":[{"id":"free-group","ballIds":["free-a"]}],"camera":{"target":{"x":0.0,"y":0.6000000238418579,"z":0.0},"rotation":{"x":0.258819043636322,"y":0.0,"z":0.0,"w":0.9659258127212524},"distance":8.123456001281738}}}'|ConvertFrom-Json) + $floatProduction=Clone $floatSource + foreach($board in @($floatProduction.puzzleStates)+@($floatProduction.latestFreePlay)){ + foreach($ball in $board.balls){$ball.position.y=.8} + $board.camera.target.y=.6;$board.camera.rotation.x=.25881904;$board.camera.rotation.w=.9659258;$board.camera.distance=8.123456 + } + foreach($kind in @('same-float32','position-one-ulp','camera-one-ulp','distance-one-ulp','changed-id','changed-group','changed-source-stage','changed-count','changed-collect','changed-tutorial','changed-integer','wrong-number','array-order','field-case','string-number','signed-zero','nonfinite-number')){ + $actual=Clone $floatProduction + switch($kind){ + 'position-one-ulp'{$actual.puzzleStates[0].balls[0].position.y=[double][MathF]::BitIncrement([single]0.8)} + 'camera-one-ulp'{$actual.latestFreePlay.camera.rotation.w=[double][MathF]::BitIncrement([single]0.9659258)} + 'distance-one-ulp'{$actual.latestFreePlay.camera.distance=[double][MathF]::BitIncrement([single]8.123456)} + 'changed-id'{$actual.puzzleStates[0].balls[0].id='FLOAT-A'} + 'changed-group'{$actual.puzzleStates[0].groups[0].id='FLOAT-GROUP'} + 'changed-source-stage'{$actual.latestFreePlay.sourceStageId=7} + 'changed-count'{$actual.puzzleStates[0].countedBallIds=@('float-b')} + 'changed-collect'{$actual.puzzleStates[0].collectedBallIds=@('float-b')} + 'changed-tutorial'{$actual.seenTutorialIds[0]='MERGE'} + 'changed-integer'{$actual.schemaVersion=1.00000001} + 'wrong-number'{$actual.puzzleStates[0].balls=@($actual.puzzleStates[0].balls[0])} + 'array-order'{$actual.completedStageIds=@(3,2,1)} + 'field-case'{$position=$actual.puzzleStates[0].balls[0].position;$actual.puzzleStates[0].balls[0].position=[pscustomobject][ordered]@{x=$position.x;Y=$position.y;z=$position.z}} + 'string-number'{$actual.puzzleStates[0].balls[0].position.y='0.8'} + 'signed-zero'{$actual.puzzleStates[0].balls[0].position.x=[BitConverter]::Int64BitsToDouble([long]::MinValue)} + 'nonfinite-number'{$actual.puzzleStates[0].balls[0].position.y=[double]::PositiveInfinity} + } + $accepted=if($kind-eq'same-float32'){Same $floatSource $actual}else{Same $floatProduction $actual} + Probe "actual-preservation-float32-$kind" ($accepted-eq($kind-eq'same-float32')) @{accepted=$accepted;expectedPass=$kind-eq'same-float32'} + } + Probe 'actual-manual-camera-float32-exact' (Same $floatSource.latestFreePlay.camera $floatProduction.latestFreePlay.camera) 'Same float32 camera components and distance, different JSON decimal representation' + if($PreservationFixtureDir){ + $injectedPath=Join-Path $PreservationFixtureDir 'safe-free-injected.json';$enteredPath=Join-Path $PreservationFixtureDir 'safe-free-entered.json' + $injected=Get-Content -LiteralPath $injectedPath -Raw|ConvertFrom-Json;$entered=Get-Content -LiteralPath $enteredPath -Raw|ConvertFrom-Json + $accepted=$false;try{PreservesOtherSlots 'actual-native-failure' $injected $entered 'free' 1;$accepted=$true}catch{} + Probe 'actual-native-safe-free-other-puzzles-float32' $accepted @{injected=$injectedPath;injectedSha256=(Get-FileHash -LiteralPath $injectedPath).Hash;entered=$enteredPath;enteredSha256=(Get-FileHash -LiteralPath $enteredPath).Hash;scope='Replay actual failed preservation assertion only; native game still unevaluated'} + Probe 'actual-native-safe-free-whole-record-float32' (Same $injected $entered) 'Full actual saved record: canonical float32 values with exact keys, arrays and non-float values' + $changed=Clone $entered;$changed.puzzleStates[0].balls[0].position.y=[double][MathF]::BitIncrement([single]$changed.puzzleStates[0].balls[0].position.y) + $rejected=$false;try{PreservesOtherSlots 'actual-one-ulp-change' $injected $changed 'free' 1}catch{$rejected=$true} + Probe 'actual-native-other-puzzle-one-ulp-rejected' $rejected 'Same actual native record with one float32 ULP changed must fail preservation' } - function Same($First,$Second){return ($First|ConvertTo-Json -Depth 12 -Compress)-eq($Second|ConvertTo-Json -Depth 12 -Compress)} $fixtures=@{stages=@(@{stage=1;min=@{x=-8;y=0;z=-6};max=@{x=8;y=6;z=6}},@{stage=3;initial=@{stageId=3}},@{stage=6;initial=@{stageId=6}})} $original=@{schemaVersion=1;completedStageIds=@(1..30);seenTutorialIds=@('merge','split');puzzleStates=@();latestFreePlay=@{sourceStageId=30}} $first=@{balls=@(@{id='whole-id-a';position=@{x=0;y=1;z=0}},@{id='whole-id-b';position=@{x=1;y=1;z=0}});groups=@(@{id='whole-group';ballIds=@('whole-id-a','whole-id-b')})}