diff --git a/tools/android/check-count-audio-residual.ps1 b/tools/android/check-count-audio-residual.ps1 new file mode 100644 index 0000000..fca3e89 --- /dev/null +++ b/tools/android/check-count-audio-residual.ps1 @@ -0,0 +1,20 @@ +param([Parameter(Mandatory)][string]$CapturedPath,[Parameter(Mandatory)][string]$MatchDir,[Parameter(Mandatory)][string]$Prefix,[Parameter(Mandatory)][string]$OutputPath) +$ErrorActionPreference='Stop' +Add-Type -TypeDefinition @' +using System; +using System.IO; +public static class CountPcmResidual { + public static double Rms(string file,double start,double end) { + byte[] bytes=File.ReadAllBytes(file);int rate=BitConverter.ToInt32(bytes,24); + if(BitConverter.ToInt16(bytes,20)!=1||BitConverter.ToInt16(bytes,22)!=1||BitConverter.ToInt16(bytes,34)!=16||System.Text.Encoding.ASCII.GetString(bytes,36,4)!="data")throw new Exception("Canonical actual monoPCM16 required"); + int a=Math.Max(0,(int)Math.Ceiling(start*rate)),b=Math.Min((bytes.Length-44)/2,(int)Math.Floor(end*rate)); + if(b<=a)return 0;double sum=0;for(int i=a;i&1; $code=$LASTEXITCODE - $commands.Add(@{startedUtc=$started;finishedUtc=[DateTime]::UtcNow.ToString('o');command=('adb -s '+$Serial+' '+($Arguments -join ' '));exitCode=$code;output=($output -join "`n")}) - if ($code -ne 0) { throw "ADB exit $code : $($Arguments -join ' ')" }; return ($output -join "`n").Trim() -} -function Check([string]$Id,[bool]$Passed,[string]$Actual) { - $checks.Add(@{id=$Id;passed=$Passed;actual=$Actual}); if (!$Passed) { throw "Content check failed: $Id ($Actual)" } -} -function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500) { - & (Join-Path $PSScriptRoot 'observe.ps1') -Step $Name -AdbArguments $Arguments -Serial $Serial -EvidenceDir $EvidenceDir -DelayMilliseconds $Delay -} -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 BallPoint($Board,[string]$Id) { - $ball=@($Board.balls | Where-Object id -eq $Id)[0]; $camera=$Board.camera - $rotation=[Numerics.Quaternion]::new($camera.rotation.x,$camera.rotation.y,$camera.rotation.z,$camera.rotation.w) - $origin=[Numerics.Vector3]::new($camera.target.x,($camera.target.y+.65),$camera.target.z)+[Numerics.Vector3]::Transform([Numerics.Vector3]::new(0,0,-$camera.distance),$rotation) - $local=[Numerics.Vector3]::Transform([Numerics.Vector3]::new($ball.position.x,$ball.position.y,$ball.position.z)-$origin,[Numerics.Quaternion]::Inverse($rotation)) - $size=Adb @('shell','wm','size'); if ($size -notmatch '(\d+)x(\d+)') { throw "Unknown device resolution: $size" } - $width=[Math]::Max([int]$Matches[1],[int]$Matches[2]); $height=[Math]::Min([int]$Matches[1],[int]$Matches[2]) - $scale=($height/2)/([Math]::Tan(31*[Math]::PI/180)*$local.Z) - return @([int][Math]::Round(($width/2)+$local.X*$scale),[int][Math]::Round(($height/2)-$local.Y*$scale)) -} -function TapBall([string]$Name,$Board,[string]$Id) { $point=BallPoint $Board $Id; Step $Name @('shell','input','tap',"$($point[0])","$($point[1])") } -function MergeStage([string]$Name,[int]$Id) { - $save=Save "$Name-before"; $board=@($save.puzzleStates | Where-Object stageId -eq $Id)[0] - $start=BallPoint $board $board.groups[0].ballIds[0]; $end=BallPoint $board $board.groups[1].ballIds[0] - Step $Name @('shell','input','swipe',"$($start[0])","$($start[1])","$($end[0]-45)","$($end[1])",'650') -} -function StagePoint([int]$Id) { $index=$Id-1; return @([int][Math]::Round((.12+($index%6)*.152)*2400),[int][Math]::Round((1-(.76-[Math]::Floor($index/6)*.123))*1080)) } -function SelectStage([string]$Name,[int]$Id) { - Step "$Name-selection" @('shell','input','tap','1200','616') - $point=StagePoint $Id; Step "$Name-stage" @('shell','input','tap',"$($point[0])","$($point[1])") -} -function StartHome([string]$Name) { - Adb @('shell','input','keyevent','224') | Out-Null - Adb @('shell','am','start','-W','-n',$activity) | Out-Null - $observations=[Collections.Generic.List[object]]::new() - $ready=$false; $pauseRequested=$false - Add-Type -AssemblyName System.Drawing - for($attempt=0;$attempt-lt30;$attempt++) { - $pidText=Adb @('shell','pidof',$package) - $saveText=Adb @('shell',('if test -s '+$remote+'; then cat '+$remote+'; fi')) - $validSave=$false - try { $state=$saveText|ConvertFrom-Json -ErrorAction Stop; $validSave=$state.schemaVersion-eq1 -and $null-ne$state.completedStageIds -and $null-ne$state.puzzleStates } catch { } - Step "$Name-ready-$attempt" @() 1000 - $png=Join-Path $EvidenceDir "$Name-ready-$attempt.png" - $image=[Drawing.Bitmap]::new($png) - $sky=0;$gold=0;$dark=0 - try { - for($y=1;$y-lt20;$y++){for($x=1;$x-lt30;$x++) { - $pixel=$image.GetPixel([int]($image.Width*$x/30),[int]($image.Height*$y/20)) - if($pixel.R-ge150-and$pixel.G-ge180-and$pixel.B-ge210){$sky++} - if($pixel.R-ge200-and$pixel.G-ge130-and$pixel.B-le120){$gold++} - if($y-le6-and$pixel.R-lt110-and$pixel.G-lt110-and$pixel.B-lt110){$dark++} - }} - $dark=0 - for ($y=[int]($image.Height*.17); $y -lt [int]($image.Height*.28); $y+=4) { - for ($x=[int]($image.Width*.39); $x -lt [int]($image.Width*.61); $x+=4) { - $pixel=$image.GetPixel($x,$y) - if ($pixel.R -lt 110 -and $pixel.G -lt 110 -and $pixel.B -lt 110) { $dark++ } - } - } - $homeButton=0; foreach($px in @(.36,.64)){foreach($py in @(.53,.61)){$p=$image.GetPixel([int]($image.Width*$px),[int]($image.Height*$py));if($p.R-ge200-and$p.G-ge130-and$p.B-le120){$homeButton++}}} - $ui=$image.Width-gt$image.Height-and$sky-ge200-and$gold-ge10-and$dark-ge20-and$homeButton-eq4 - $observations.Add(@{attempt=$attempt;utc=[DateTime]::UtcNow.ToString('o');pid=$pidText;validAppSave=$validSave;landscape=($image.Width-gt$image.Height);skySamples=$sky;goldButtonSamples=$gold;titleDarkSamples=$dark;titleBoundsNormalized=@(.39,.17,.61,.28);titleStridePixels=4;homeButtonSamples=$homeButton;readyUi=$ui;png=$png}) - } finally {$image.Dispose()} - if ($pidText -match '^\d+' -and $ui -and !$validSave -and !$pauseRequested) { - $observations[$observations.Count-1] | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-first-home-without-save.json") -Encoding utf8 - Check "$Name-first-home-ui" $true 'Actual first Home title/button/PID observed before production pause creates save' - Adb @('shell','input','keyevent','3') | Out-Null - Start-Sleep -Milliseconds 500 - Adb @('shell','am','start','-W','-n',$activity) | Out-Null - $pauseRequested=$true - } - if($pidText-match'^\d+'-and$validSave-and$ui){$ready=$true;$saveText|Set-Content (Join-Path $EvidenceDir "$Name-ready-save.json") -Encoding utf8;break} - } - $observations.ToArray()|ConvertTo-Json -Depth 6|Set-Content (Join-Path $EvidenceDir "$Name-readiness.json") -Encoding utf8 - Check "$Name-home-ready" $ready 'Actual app PID, landscape home colors/button/title and app save JSON; bounded30 observations, black/Unity splash excluded, PNG retained for direct review' - Step $Name @() 0 -} - - - -function Restart([string]$Name) { - Adb @('shell','input','keyevent','3') | Out-Null; Adb @('shell','am','force-stop',$package) | Out-Null - StartHome "$Name-home" -} -function StartAudio([string]$Name,[int]$Seconds) { - $readyPath=Join-Path $EvidenceDir "$Name.wav.ready.json" - if (Test-Path -LiteralPath $readyPath) { Remove-Item -LiteralPath $readyPath } - $args=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-native-audio.ps1'),'-OutputPath',(Join-Path $EvidenceDir "$Name.wav"),'-Seconds',"$Seconds",'-Serial',$Serial) - $process=Start-Process pwsh -ArgumentList ($args | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru - $recorders.Add(@{name=$Name;process=$process;seconds=$Seconds;startedUtc=[DateTime]::UtcNow}) - $readyPath=Join-Path $EvidenceDir "$Name.wav.ready.json" - $ready=$false - for ($attempt=0; $attempt -lt 120; $attempt++) { - if ($process.HasExited) { break } - if (Test-Path -LiteralPath $readyPath) { $ready=$true; break } - Start-Sleep -Milliseconds 100 - } - Check "audio-ready-$Name" $ready 'Recorder reported actual Recording started before interaction' - for ($attempt=0; $attempt -lt 20; $attempt++) { - Adb @('shell','cmd','audio','set-volume','3','15') | Out-Null - $route=Adb @('shell','dumpsys','audio') - $route | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-active-route-$attempt.txt") -Encoding utf8 - $current=[regex]::Match($route,'(?s)- STREAM_MUSIC:.*?Current: ([^\r\n]+)').Groups[1].Value - if ($current -match '8000 \(remote_submix\): 15') { break } - Start-Sleep -Milliseconds 250 - } - $route | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-active-route.txt") -Encoding utf8 - Check "audio-active-route-$Name" ($current -match '8000 \(remote_submix\): 15') $current -} -function CompareAudio([string]$Name,[string[]]$Ids) { - $recorder=@($recorders | Where-Object name -eq $Name)[0].process - Check "audio-finished-$Name" ($recorder.WaitForExit((@($recorders | Where-Object name -eq $Name)[0].seconds+30)*1000)) 'PCM capture finished'; $recorder.Refresh(); Check "audio-exit-$Name" ($recorder.ExitCode -eq 0) "$($recorder.ExitCode)" - foreach ($id in $Ids) { - $reference=Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$id.wav" - $comparison=Join-Path $EvidenceDir "$Name-$id-match.json" - & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath $reference -CapturedPath (Join-Path $EvidenceDir "$Name.wav") -OutputPath $comparison -RequireFullSpeech - $exit=$LASTEXITCODE; $match=Get-Content -LiteralPath $comparison -Raw | ConvertFrom-Json - Check "pcm-$Name-$id" ($exit -eq 0 -and $match.passed -and $match.normalizedCorrelation -ge .95 -and $match.rmsPcm -gt 32) ($match | ConvertTo-Json -Compress) - } -} - +. (Join-Path $PSScriptRoot 'final-common.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial # This suite never pushes a save or injects completed stages. Every checkpoint is read from the APK. function Board($State,[int]$Stage) { return @($State.puzzleStates | Where-Object stageId -eq $Stage)[0] } function Counts($Board) { return (@($Board.groups | ForEach-Object {$_.ballIds.Count} | Sort-Object) -join ',') } @@ -257,7 +124,9 @@ try { Check 'two-unfinished-plus-free' ($three.puzzleStates.Count-eq2-and$three.latestFreePlay.sourceStageId-eq1-and(Same $partialBoard (Board $three 2))-and$three.completedStageIds.Count-eq1) 'Replay1, actual partially counted2, and independent Free1 coexist without fixture' Restart 'three-board-restart';Step 'free1-restored' @('shell','input','tap','1200','810') Check 'two-puzzles-free-whole-restored' (Same $three (Save 'two-unfinished-and-free-after')) 'Pause force-stop startup Home then Free restore exact full save' - Step 'free1-home' @('shell','input','tap','312','108');SelectStage 'partial2-restored' 2 + Step 'free1-home' @('shell','input','tap','312','108');SelectStage 'replay1-restored' 1 + Check 'replay1-independent-ui-restored' (Same (Board $three 1) (Board (Save 'replay1-restored-state') 1)) 'Second unfinished board independently entered through actual UI with exact altered positions' + Step 'replay1-restored-home' @('shell','input','tap','312','108');SelectStage 'partial2-restored' 2 Check 'partial2-exact-restored' (Same $partialBoard (Board (Save 'partial2-restored') 2)) 'OriginalIDs positions membership collected counted camera exact' } if($stage-eq20) { @@ -273,6 +142,9 @@ try { CompareAudio 'audio-ten-rapid' @(1..10|ForEach-Object{'count_{0:D2}'-f$_}) $matches=@(1..10|ForEach-Object{Get-Content (Join-Path $EvidenceDir ('audio-ten-rapid-count_{0:D2}-match.json'-f$_)) -Raw|ConvertFrom-Json}) for($i=1;$i-lt10;$i++){Check "rapid-fifo-$i" ($matches[$i-1].onsetSeconds+$matches[$i-1].referenceDurationSeconds-le$matches[$i].onsetSeconds+.015) 'Entire numeric clip completes before next onset'} + Check 'rapid-last-complete-before-record-end' ($matches[-1].onsetSeconds+$matches[-1].referenceDurationSeconds-le$matches[-1].capturedDurationSeconds) 'Entire tenth numeric clip retained after same-board Free transition' + & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'check-count-audio-residual.ps1') -CapturedPath (Join-Path $EvidenceDir 'audio-ten-rapid.wav') -MatchDir $EvidenceDir -Prefix 'audio-ten-rapid' -OutputPath (Join-Path $EvidenceDir 'rapid-no-extra-speech.json') + Check 'rapid-no-extra-or-duplicate-speech' ($LASTEXITCODE-eq0) 'All gaps and trailing PCM outside ten complete matched words are below PCM32 RMS' $result.speechMatches=$matches } else {CountRemaining $stage|Out-Null} } else { diff --git a/tools/android/exercise-final.ps1 b/tools/android/exercise-final.ps1 index f6b38ad..95702b1 100644 --- a/tools/android/exercise-final.ps1 +++ b/tools/android/exercise-final.ps1 @@ -28,7 +28,7 @@ try { Check "apk-notice-exact-bytes-$name" ($sha-eq(Get-FileHash -LiteralPath (Join-Path $projectRoot ('Assets/StreamingAssets/Notices/'+$name))).Hash) $sha } }finally{$zip.Dispose()} - foreach($suite in @(@{name='Fresh30';script='exercise-final-progression.ps1';report='android-final-progression-evidence.json'},@{name='ContentBoundary';script='exercise-content.ps1';report='android-content-evidence.json'})){ + foreach($suite in @(@{name='Fresh30';script='exercise-final-progression.ps1';report='android-final-progression-evidence.json'},@{name='FinalBoundary';script='exercise-final-boundaries.ps1';report='android-final-boundary-evidence.json'},@{name='ContentBoundary';script='exercise-content.ps1';report='android-content-evidence.json'})){ $childDir=Join-Path $EvidenceDir $suite.name;New-Item -ItemType Directory -Path $childDir -Force|Out-Null Copy-Item -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Destination (Join-Path $childDir 'androidbuild-evidence.json') $log=Join-Path $childDir 'outer.log';$command="pwsh -NoProfile -File tools/android/$($suite.script) -ApkPath $ApkPath -EvidenceDir $childDir -Serial $Serial" @@ -41,6 +41,8 @@ try { Check 'fresh30-native-commands-no-fixture-write' (@($child.commands|Where-Object {$_.command-match'\bpush\b'}).Count-eq0) 'Native progression never pushes save data' } } + & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'test-native-audio.ps1') -ReferencePath (Join-Path $projectRoot 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/guide_collect.wav') -CapturedPath (Join-Path $EvidenceDir 'ContentBoundary/audio-one-offline.wav') -WrongReferencePath (Join-Path $projectRoot 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/guide_split.wav') -EvidenceDir (Join-Path $EvidenceDir 'pcm-negative-controls') + Check 'pcm-full-speech-negative-controls' ($LASTEXITCODE-eq0) 'Actual fullspeech positive and clipped front/back/middle/duplicate/noise/wrongclip negatives' $record.sourceCommitEnd=(& git rev-parse HEAD);$record.sourceDirtyEnd=[bool](& git status --porcelain) Check 'unchanged-source-end' (!$record.sourceDirtyEnd-and$record.sourceCommitEnd-eq$record.sourceCommit) $record.sourceCommitEnd $record.status='passed' diff --git a/tools/android/final-common.ps1 b/tools/android/final-common.ps1 new file mode 100644 index 0000000..dc686bb --- /dev/null +++ b/tools/android/final-common.ps1 @@ -0,0 +1,131 @@ +param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[string]$Serial='emulator-5554') +$ErrorActionPreference='Stop' +$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path +$projectRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$adb=Join-Path $env:LOCALAPPDATA 'Android/Sdk/platform-tools/adb.exe' +$package='com.magnetismheroes.game'; $activity="$package/com.unity3d.player.UnityPlayerGameActivity" +$remote="/sdcard/Android/data/$package/files/progress.json" +$checks=[Collections.Generic.List[object]]::new(); $commands=[Collections.Generic.List[object]]::new(); $recorders=[Collections.Generic.List[object]]::new() +$result=[ordered]@{milestone='Final';sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);serial=$Serial;apkSha256=(Get-FileHash -LiteralPath $ApkPath -Algorithm SHA256).Hash;status='failed'} +$result.rendererProjection=@{fieldOfView=62;fixedWorldPivot=@(0,.65,0);rawCamera='Saved Board.camera JSON remains canonical';viewport='Landscape wm size max/min; remaining UI and physical touch helper assume sole 2400x1080 AVD'} +$networkBefore=$null; $musicBefore=$null +function Adb([string[]]$Arguments) { + $started=[DateTime]::UtcNow.ToString('o'); $output=& $adb -s $Serial @Arguments 2>&1; $code=$LASTEXITCODE + $commands.Add(@{startedUtc=$started;finishedUtc=[DateTime]::UtcNow.ToString('o');command=('adb -s '+$Serial+' '+($Arguments -join ' '));exitCode=$code;output=($output -join "`n")}) + if ($code -ne 0) { throw "ADB exit $code : $($Arguments -join ' ')" }; return ($output -join "`n").Trim() +} +function Check([string]$Id,[bool]$Passed,[string]$Actual) { + $checks.Add(@{id=$Id;passed=$Passed;actual=$Actual}); if (!$Passed) { throw "Content check failed: $Id ($Actual)" } +} +function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500) { + & (Join-Path $PSScriptRoot 'observe.ps1') -Step $Name -AdbArguments $Arguments -Serial $Serial -EvidenceDir $EvidenceDir -DelayMilliseconds $Delay +} +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 BallPoint($Board,[string]$Id) { + $ball=@($Board.balls | Where-Object id -eq $Id)[0]; $camera=$Board.camera + $rotation=[Numerics.Quaternion]::new($camera.rotation.x,$camera.rotation.y,$camera.rotation.z,$camera.rotation.w) + $origin=[Numerics.Vector3]::new($camera.target.x,($camera.target.y+.65),$camera.target.z)+[Numerics.Vector3]::Transform([Numerics.Vector3]::new(0,0,-$camera.distance),$rotation) + $local=[Numerics.Vector3]::Transform([Numerics.Vector3]::new($ball.position.x,$ball.position.y,$ball.position.z)-$origin,[Numerics.Quaternion]::Inverse($rotation)) + $size=Adb @('shell','wm','size'); if ($size -notmatch '(\d+)x(\d+)') { throw "Unknown device resolution: $size" } + $width=[Math]::Max([int]$Matches[1],[int]$Matches[2]); $height=[Math]::Min([int]$Matches[1],[int]$Matches[2]) + $scale=($height/2)/([Math]::Tan(31*[Math]::PI/180)*$local.Z) + return @([int][Math]::Round(($width/2)+$local.X*$scale),[int][Math]::Round(($height/2)-$local.Y*$scale)) +} +function TapBall([string]$Name,$Board,[string]$Id) { $point=BallPoint $Board $Id; Step $Name @('shell','input','tap',"$($point[0])","$($point[1])") } +function StagePoint([int]$Id) { $index=$Id-1; return @([int][Math]::Round((.12+($index%6)*.152)*2400),[int][Math]::Round((1-(.76-[Math]::Floor($index/6)*.123))*1080)) } +function SelectStage([string]$Name,[int]$Id) { + Step "$Name-selection" @('shell','input','tap','1200','616') + $point=StagePoint $Id; Step "$Name-stage" @('shell','input','tap',"$($point[0])","$($point[1])") +} +function StartHome([string]$Name) { + Adb @('shell','input','keyevent','224') | Out-Null + Adb @('shell','am','start','-W','-n',$activity) | Out-Null + $observations=[Collections.Generic.List[object]]::new() + $ready=$false; $pauseRequested=$false + Add-Type -AssemblyName System.Drawing + for($attempt=0;$attempt-lt30;$attempt++) { + $pidText=Adb @('shell','pidof',$package) + $saveText=Adb @('shell',('if test -s '+$remote+'; then cat '+$remote+'; fi')) + $validSave=$false + try { $state=$saveText|ConvertFrom-Json -ErrorAction Stop; $validSave=$state.schemaVersion-eq1 -and $null-ne$state.completedStageIds -and $null-ne$state.puzzleStates } catch { } + Step "$Name-ready-$attempt" @() 1000 + $png=Join-Path $EvidenceDir "$Name-ready-$attempt.png" + $image=[Drawing.Bitmap]::new($png) + $sky=0;$gold=0;$dark=0 + try { + for($y=1;$y-lt20;$y++){for($x=1;$x-lt30;$x++) { + $pixel=$image.GetPixel([int]($image.Width*$x/30),[int]($image.Height*$y/20)) + if($pixel.R-ge150-and$pixel.G-ge180-and$pixel.B-ge210){$sky++} + if($pixel.R-ge200-and$pixel.G-ge130-and$pixel.B-le120){$gold++} + if($y-le6-and$pixel.R-lt110-and$pixel.G-lt110-and$pixel.B-lt110){$dark++} + }} + $dark=0 + for ($y=[int]($image.Height*.17); $y -lt [int]($image.Height*.28); $y+=4) { + for ($x=[int]($image.Width*.39); $x -lt [int]($image.Width*.61); $x+=4) { + $pixel=$image.GetPixel($x,$y) + if ($pixel.R -lt 110 -and $pixel.G -lt 110 -and $pixel.B -lt 110) { $dark++ } + } + } + $homeButton=0; foreach($px in @(.36,.64)){foreach($py in @(.53,.61)){$p=$image.GetPixel([int]($image.Width*$px),[int]($image.Height*$py));if($p.R-ge200-and$p.G-ge130-and$p.B-le120){$homeButton++}}} + $ui=$image.Width-gt$image.Height-and$sky-ge200-and$gold-ge10-and$dark-ge20-and$homeButton-eq4 + $observations.Add(@{attempt=$attempt;utc=[DateTime]::UtcNow.ToString('o');pid=$pidText;validAppSave=$validSave;landscape=($image.Width-gt$image.Height);skySamples=$sky;goldButtonSamples=$gold;titleDarkSamples=$dark;titleBoundsNormalized=@(.39,.17,.61,.28);titleStridePixels=4;homeButtonSamples=$homeButton;readyUi=$ui;png=$png}) + } finally {$image.Dispose()} + if ($pidText -match '^\d+' -and $ui -and !$validSave -and !$pauseRequested) { + $observations[$observations.Count-1] | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-first-home-without-save.json") -Encoding utf8 + Check "$Name-first-home-ui" $true 'Actual first Home title/button/PID observed before production pause creates save' + Adb @('shell','input','keyevent','3') | Out-Null + Start-Sleep -Milliseconds 500 + Adb @('shell','am','start','-W','-n',$activity) | Out-Null + $pauseRequested=$true + } + if($pidText-match'^\d+'-and$validSave-and$ui){$ready=$true;$saveText|Set-Content (Join-Path $EvidenceDir "$Name-ready-save.json") -Encoding utf8;break} + } + $observations.ToArray()|ConvertTo-Json -Depth 6|Set-Content (Join-Path $EvidenceDir "$Name-readiness.json") -Encoding utf8 + Check "$Name-home-ready" $ready 'Actual app PID, landscape home colors/button/title and app save JSON; bounded30 observations, black/Unity splash excluded, PNG retained for direct review' + Step $Name @() 0 +} + + + +function Restart([string]$Name) { + Adb @('shell','input','keyevent','3') | Out-Null; Adb @('shell','am','force-stop',$package) | Out-Null + StartHome "$Name-home" +} +function StartAudio([string]$Name,[int]$Seconds) { + $readyPath=Join-Path $EvidenceDir "$Name.wav.ready.json" + if (Test-Path -LiteralPath $readyPath) { Remove-Item -LiteralPath $readyPath } + $args=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-native-audio.ps1'),'-OutputPath',(Join-Path $EvidenceDir "$Name.wav"),'-Seconds',"$Seconds",'-Serial',$Serial) + $process=Start-Process pwsh -ArgumentList ($args | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru + $recorders.Add(@{name=$Name;process=$process;seconds=$Seconds;startedUtc=[DateTime]::UtcNow}) + $readyPath=Join-Path $EvidenceDir "$Name.wav.ready.json" + $ready=$false + for ($attempt=0; $attempt -lt 120; $attempt++) { + if ($process.HasExited) { break } + if (Test-Path -LiteralPath $readyPath) { $ready=$true; break } + Start-Sleep -Milliseconds 100 + } + Check "audio-ready-$Name" $ready 'Recorder reported actual Recording started before interaction' + for ($attempt=0; $attempt -lt 20; $attempt++) { + Adb @('shell','cmd','audio','set-volume','3','15') | Out-Null + $route=Adb @('shell','dumpsys','audio') + $route | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-active-route-$attempt.txt") -Encoding utf8 + $current=[regex]::Match($route,'(?s)- STREAM_MUSIC:.*?Current: ([^\r\n]+)').Groups[1].Value + if ($current -match '8000 \(remote_submix\): 15') { break } + Start-Sleep -Milliseconds 250 + } + $route | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-active-route.txt") -Encoding utf8 + Check "audio-active-route-$Name" ($current -match '8000 \(remote_submix\): 15') $current +} +function CompareAudio([string]$Name,[string[]]$Ids) { + $recorder=@($recorders | Where-Object name -eq $Name)[0].process + Check "audio-finished-$Name" ($recorder.WaitForExit((@($recorders | Where-Object name -eq $Name)[0].seconds+30)*1000)) 'PCM capture finished'; $recorder.Refresh(); Check "audio-exit-$Name" ($recorder.ExitCode -eq 0) "$($recorder.ExitCode)" + foreach ($id in $Ids) { + $reference=Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$id.wav" + $comparison=Join-Path $EvidenceDir "$Name-$id-match.json" + & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath $reference -CapturedPath (Join-Path $EvidenceDir "$Name.wav") -OutputPath $comparison -RequireFullSpeech + $exit=$LASTEXITCODE; $match=Get-Content -LiteralPath $comparison -Raw | ConvertFrom-Json + Check "pcm-$Name-$id" ($exit -eq 0 -and $match.passed -and $match.normalizedCorrelation -ge .95 -and $match.rmsPcm -gt 32) ($match | ConvertTo-Json -Compress) + } +} + diff --git a/tools/android/test-final-assertions.ps1 b/tools/android/test-final-assertions.ps1 new file mode 100644 index 0000000..d525d11 --- /dev/null +++ b/tools/android/test-final-assertions.ps1 @@ -0,0 +1,26 @@ +param([Parameter(Mandatory)][string]$EvidenceDir) +$ErrorActionPreference='Stop' +New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null +$source=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'exercise-final-progression.ps1') +$statements=@{} +foreach($name in @('conserves-all-ids','actual-success','both-tutorials-persisted-once')){$statements[$name]=@($source|Where-Object {$_-match'^\s*Check '-and$_.Contains($name)})[0].Trim();if(!$statements[$name]){throw "Missing actual source assertion: $name"}} +$results=[Collections.Generic.List[object]]::new() +function Check([string]$Id,[bool]$Passed,[string]$Actual){if(!$Passed){throw "Actual source assertion rejected: $Id"}} +function Counts($Board){return (@($Board.groups|ForEach-Object {$_.ballIds.Count}|Sort-Object)-join',')} +function Board($State,[int]$Stage){return @($State.puzzleStates|Where-Object stageId -eq $Stage)[0]} +foreach($case in @('valid-ids','wrong-ids','valid-target','wrong-target','missing-completion','valid-tutorials','missing-tutorial','duplicate-tutorial')){ + $stage=13;$expected='2,3' + $original=[pscustomobject]@{balls=@([pscustomobject]@{id='a'},[pscustomobject]@{id='b'})} + $free=[pscustomobject]@{sourceStageId=13;balls=@([pscustomobject]@{id='b'},[pscustomobject]@{id='a'});groups=@([pscustomobject]@{ballIds=@('a','b')},[pscustomobject]@{ballIds=@('c','d','e')})} + $success=[pscustomobject]@{completedStageIds=@(1..13);puzzleStates=@();latestFreePlay=$free} + $complete=[pscustomobject]@{seenTutorialIds=@('split','merge')} + $kind=if($case.Contains('ids')){'conserves-all-ids'}elseif($case.Contains('tutorial')){'both-tutorials-persisted-once'}else{'actual-success'} + switch($case){'wrong-ids'{$free.balls[1].id='different'}'wrong-target'{$free.groups[1].ballIds=@('c','d')}'missing-completion'{$success.completedStageIds=@(1..12)}'missing-tutorial'{$complete.seenTutorialIds=@('merge')}'duplicate-tutorial'{$complete.seenTutorialIds=@('merge','split','split')}} + $expectedPass=$case.StartsWith('valid-');$actualPass=$false;$errorText=$null + try{& ([scriptblock]::Create($statements[$kind]));$actualPass=$true}catch{$errorText=$_.Exception.Message} + $results.Add(@{case=$case;sourceStatement=$statements[$kind];expectedPass=$expectedPass;actualPass=$actualPass;passed=($actualPass-eq$expectedPass);error=$errorText}) +} +$passed=@($results|Where-Object passed -ne $true).Count-eq0 +@{passed=$passed;sourceCommit=(& git rev-parse HEAD);checks=$results.ToArray()}|ConvertTo-Json -Depth 5|Set-Content -LiteralPath (Join-Path $EvidenceDir 'assertions-result.json') +if(!$passed){exit 1} +exit 0