Add atomic composition controls and representative stages

This commit is contained in:
NINI
2026-10-05 05:03:40 +09:00
parent 1e7c258631
commit 4c411e446d
56 changed files with 5911 additions and 1601 deletions
+129
View File
@@ -0,0 +1,129 @@
param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[string]$Serial='emulator-5554')
$ErrorActionPreference='Stop'
$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path
$sdk=if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
$adb=Join-Path $sdk 'platform-tools/adb.exe'
$checks=[Collections.Generic.List[object]]::new(); $commands=[Collections.Generic.List[object]]::new(); $recorders=[Collections.Generic.List[object]]::new()
$package='com.magnetismheroes.game'; $activity="$package/com.unity3d.player.UnityPlayerGameActivity"; $remote="/sdcard/Android/data/$package/files/progress.json"
$result=[ordered]@{milestone='Composition';sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);serial=$Serial;apkSha256=(Get-FileHash $ApkPath -Algorithm SHA256).Hash;status='failed';checks=@();commands=@()}
$networkBefore=$null
function Adb([string[]]$Arguments) {
$output=& $adb -s $Serial @Arguments 2>&1; $code=$LASTEXITCODE
$commands.Add(@{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 "Composition 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 (Join-Path $EvidenceDir "$Name.json") -Raw | ConvertFrom-Json }
function Fixture([int]$Stage) {
Adb @('shell','am','force-stop',$package) | Out-Null
$fixture=[ordered]@{schemaVersion=1;completedStageIds=@(1..($Stage-1));seenTutorialIds=@();puzzleStates=@();latestFreePlay=$null}
$path=Join-Path $EvidenceDir "fixture-$Stage.json"; $fixture | ConvertTo-Json -Depth 8 | Set-Content $path -Encoding utf8
Adb @('shell','mkdir','-p',"/sdcard/Android/data/$package/files") | Out-Null
Adb @('push',$path,$remote) | Out-Null
Adb @('shell','rm','-f',($remote+'.bak')) | Out-Null
Step "stage-$Stage-home" @('shell','am','start','-W','-n',$activity) 6500
Step "stage-$Stage-selection" @('shell','input','tap','1200','616')
}
function Restart([string]$Name) {
Adb @('shell','input','keyevent','3') | Out-Null; Adb @('shell','am','force-stop',$package) | Out-Null
Step "$Name-home" @('shell','am','start','-W','-n',$activity) 6500
}
function StartAudio([string]$Name,[int]$Seconds) {
$path=Join-Path $EvidenceDir "$Name.wav"
$arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-audio.ps1'),'-OutputPath',$path,'-Seconds',"$Seconds")
$process=Start-Process pwsh -ArgumentList ($arguments | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru
$recorders.Add(@{name=$Name;process=$process}); Start-Sleep -Seconds 2
}
function Touch([int[]]$Contacts) {
& (Join-Path $PSScriptRoot 'send-composition-touch.ps1') -Contacts $Contacts
$commands.Add(@{command=('EmulatorController.sendTouch contacts='+($Contacts -join ','));exitCode=0})
}
function SameJson($First,$Second) { return ($First | ConvertTo-Json -Depth 12 -Compress) -eq ($Second | ConvertTo-Json -Depth 12 -Compress) }
try {
Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit
$build=Get-Content (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json
Check 'build-provenance' ($build.status -eq 'passed' -and $build.exitCode -eq 0 -and !$build.sourceDirty -and !$build.sourceDirtyEnd -and $build.sourceCommit -eq $result.sourceCommit -and $build.apkSha256 -eq $result.apkSha256) $result.apkSha256
Adb @('install','-r',$ApkPath) | Out-Null; Adb @('logcat','-c') | Out-Null
$networkBefore=@{airplane=(Adb @('shell','settings','get','global','airplane_mode_on'));wifi=(Adb @('shell','settings','get','global','wifi_on'));data=(Adb @('shell','settings','get','global','mobile_data'))}
$result.networkBefore=$networkBefore
Adb @('shell','cmd','connectivity','airplane-mode','enable') | Out-Null; Adb @('shell','svc','wifi','disable') | Out-Null; Adb @('shell','svc','data','disable') | Out-Null
Check 'offline-real-device-network' ((Adb @('shell','settings','get','global','airplane_mode_on')) -eq '1' -and (Adb @('shell','settings','get','global','wifi_on')) -eq '0' -and (Adb @('shell','settings','get','global','mobile_data')) -eq '0' -and (Adb @('shell','dumpsys','connectivity')) -match 'Active default network: none') 'Airplane1 wifi0 data0; no active default network'
$result.device=@{model=(Adb @('shell','getprop','ro.product.model'));abi=(Adb @('shell','getprop','ro.product.cpu.abilist'));bridge=(Adb @('shell','getprop','ro.dalvik.vm.native.bridge'));api=(Adb @('shell','getprop','ro.build.version.sdk'))}
Check 'landscape-resolution' ((Adb @('shell','wm','size')) -match '1080x2400') 'Physical resolution1080x2400, landscape2400x1080'
Adb @('shell','pm','clear',$package) | Out-Null
Step 'new-save-home' @('shell','am','start','-W','-n',$activity) 6500
Step 'new-save-selection' @('shell','input','tap','1200','616')
Step 'new-save-locked-six' @('shell','input','tap','480','560')
$locked=Save 'new-save-after-locked-six'; Check 'new-save-six-remains-locked' ($locked.puzzleStates.Count -eq 0 -and $locked.completedStageIds.Count -eq 0) 'No puzzle created by tapping locked6'
Fixture 6; StartAudio 'audio-guide-merge' 10
Step 'six-initial-face-number' @('shell','input','tap','480','560') 1200
$sixBefore=Save 'six-before'; Check 'six-real-initial' ((($sixBefore.puzzleStates[0].groups | ForEach-Object {$_.ballIds.Count}) -join ',') -eq '1,1') 'Two initial singleton groups'
# Hold real Android touch while observing the contact candidate; no runtime control server.
Touch @(1009,510,401,1); Start-Sleep -Milliseconds 100; Touch @(1291,510,401,1); Step 'six-contact-candidate'
$preview=Save 'six-preview'; Check 'six-preview-not-saved' (SameJson $preview $sixBefore) 'Preview JSON equals committed initial JSON'
Touch @(1291,510,401,0); Step 'six-success-face-number'
$six=Save 'six-success'; Check 'six-final-two' ($six.completedStageIds -contains 6 -and $six.puzzleStates.Count -eq 0 -and $six.latestFreePlay.groups.Count -eq 1 -and $six.latestFreePlay.groups[0].ballIds.Count -eq 2) 'Completed6; one2 group; no unfinished'
Check 'six-ids-preserved' ((($six.latestFreePlay.balls.id | Sort-Object) -join ',') -eq (($sixBefore.puzzleStates[0].balls.id | Sort-Object) -join ',')) 'All original IDs preserved'
Fixture 10; StartAudio 'audio-guide-split' 12
Step 'ten-initial-face-number' @('shell','input','tap','1920','560') 1000
Step 'ten-choose-friend' @('shell','input','tap','1120','510'); Step 'ten-split-mode' @('shell','input','tap','312','832')
$tenBefore=Save 'ten-before'
Step 'ten-zero-selection-drag' @('shell','input','swipe','1120','510','850','510','500')
Check 'ten-zero-unchanged' (SameJson $tenBefore (Save 'ten-zero-after')) 'Zero selection preserves JSON'
Step 'ten-select-left' @('shell','input','tap','1120','510'); Step 'ten-select-right' @('shell','input','tap','1280','510')
Step 'ten-all-selection-drag' @('shell','input','swipe','1120','510','850','510','500')
Check 'ten-all-unchanged' (SameJson $tenBefore (Save 'ten-all-after')) 'Entire selection preserves JSON'
Step 'ten-deselect-right' @('shell','input','tap','1280','510'); Step 'ten-selected-count-face-number'
Touch @(1120,510,411,1); Start-Sleep -Milliseconds 100; Touch @(900,510,411,1); Step 'ten-split-preview'
# UI cancel is a separate product action; release first restores invalid/outside gesture via a real edge release.
Touch @(2500,510,411,0); Step 'ten-outside-cancel'
Check 'ten-outside-cancel-unchanged' (SameJson $tenBefore (Save 'ten-outside-after')) 'Off-board release keeps committed JSON'
Step 'ten-select-again' @('shell','input','tap','1120','510'); Step 'ten-split-again' @('shell','input','tap','312','832'); Step 'ten-selection-again' @('shell','input','tap','1120','510')
Touch @(1120,510,421,1); Touch @(1030,510,421,1); Touch @(1390,510,422,1)
Touch @(1030,510,421,0,1390,510,422,0); Step 'ten-pinch-switch'
$pinch=Save 'ten-pinch-after'; Check 'ten-pinch-no-split' ($pinch.puzzleStates[0].groups.Count -eq 1 -and (SameJson $pinch.puzzleStates[0].balls $tenBefore.puzzleStates[0].balls)) 'Pinch aborts split, all ball positions unchanged'
# Return to unchanged committed camera by restarting from the fresh fixture before the successful split.
Fixture 10; Step 'ten-success-start' @('shell','input','tap','1920','560'); Step 'ten-success-choose' @('shell','input','tap','1120','510')
Step 'ten-success-split' @('shell','input','tap','312','832'); Step 'ten-success-select' @('shell','input','tap','1120','510')
Step 'ten-success-drag' @('shell','input','swipe','1120','510','850','510','650')
$ten=Save 'ten-success'; Check 'ten-final-one-one-no-remerge' ($ten.completedStageIds -contains 10 -and $ten.puzzleStates.Count -eq 0 -and $ten.latestFreePlay.groups.Count -eq 2 -and @($ten.latestFreePlay.groups | Where-Object {$_.ballIds.Count -ne 1}).Count -eq 0) 'Completed10; exactly1,1; no auto-remerge'
Fixture 14; Step 'fourteen-initial' @('shell','input','tap','1200','366'); Step 'fourteen-choose-three' @('shell','input','tap','882','510')
Step 'fourteen-split-mode' @('shell','input','tap','312','832'); Step 'fourteen-select-end-one' @('shell','input','tap','882','510'); Step 'fourteen-select-end-two' @('shell','input','tap','1200','510')
Step 'fourteen-disconnected-selected' @(); Step 'fourteen-disconnected-split' @('shell','input','swipe','882','510','500','510','650')
$fourteenBefore=Save 'fourteen-unfinished-before'; $sizes=@($fourteenBefore.puzzleStates[0].groups | ForEach-Object {$_.ballIds.Count} | Sort-Object)
Check 'fourteen-disconnected-two-results' (($sizes -join ',') -eq '1,1,2') 'Selected disconnected ends form one2 friend; rest one1; original other1'
Restart 'fourteen-restore'; Step 'fourteen-restore-selection' @('shell','input','tap','1200','616'); Step 'fourteen-restored' @('shell','input','tap','1200','366')
Check 'fourteen-unfinished-restored' (SameJson $fourteenBefore (Save 'fourteen-unfinished-after')) 'All unfinished JSON including IDs positions groups camera restored'
Step 'fourteen-single-merge' @('shell','input','swipe','1041','510','1418','510','650')
$fourteen=Save 'fourteen-success'; Check 'fourteen-final-two-two' ($fourteen.completedStageIds -contains 14 -and $fourteen.puzzleStates.Count -eq 0 -and $fourteen.latestFreePlay.groups.Count -eq 2 -and @($fourteen.latestFreePlay.groups | Where-Object {$_.ballIds.Count -ne 2}).Count -eq 0) 'Exactly2,2 completes14 after split then merge'
Restart 'free-restore'; Step 'free-restored' @('shell','input','tap','1200','810')
Check 'free-json-restored' (SameJson $fourteen (Save 'free-after-restore')) 'Final free board restores all groups IDs positions camera'
Step 'free-rotate-underneath' @('shell','input','swipe','2100','650','2250','980','700')
foreach ($recorder in $recorders) {
if (!$recorder.process.WaitForExit(30000)) { throw 'PCM collection did not finish' }; $recorder.process.Refresh()
Check "pcm-recorder-$($recorder.name)" ($recorder.process.ExitCode -eq 0) "$($recorder.process.ExitCode)"
$id=if ($recorder.name -eq 'audio-guide-merge') {'guide_merge'} else {'guide_split'}
$comparison=Join-Path $EvidenceDir ($recorder.name+'-match.json')
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$id.wav" -CapturedPath (Join-Path $EvidenceDir ($recorder.name+'.wav')) -OutputPath $comparison
$match=Get-Content $comparison -Raw | ConvertFrom-Json; Check "pcm-$id" ($LASTEXITCODE -eq 0 -and $match.passed -and $match.normalizedCorrelation -ge .95) ($match | ConvertTo-Json -Compress)
}
Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8
Check 'no-runtime-fatal' (!(Get-Content (Join-Path $EvidenceDir 'android-runtime.log') -Raw | Select-String 'FATAL EXCEPTION|NullReferenceException|MissingReferenceException|IndexOutOfRangeException')) 'No fatal or missing-reference runtime exception'
$result.status='passed'
} catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red }
finally {
if ($networkBefore) {
Adb @('shell','cmd','connectivity','airplane-mode',$(if ($networkBefore.airplane -eq '1') {'enable'} else {'disable'})) | Out-Null
Adb @('shell','svc','wifi',$(if ($networkBefore.wifi -eq '1') {'enable'} else {'disable'})) | Out-Null
Adb @('shell','svc','data',$(if ($networkBefore.data -eq '1') {'enable'} else {'disable'})) | Out-Null
$result.networkAfter=@{airplane=(Adb @('shell','settings','get','global','airplane_mode_on'));wifi=(Adb @('shell','settings','get','global','wifi_on'));data=(Adb @('shell','settings','get','global','mobile_data'))}
}
$result.checks=$checks.ToArray(); $result.commands=$commands.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o')
$result | ConvertTo-Json -Depth 12 | Set-Content (Join-Path $EvidenceDir 'android-composition-evidence.json') -Encoding utf8
}
if ($result.status -ne 'passed') { exit 1 }; Write-Host "Composition actual Android checks passed: $($checks.Count). PNG visual review remains explicit."; exit 0
+29
View File
@@ -0,0 +1,29 @@
param([Parameter(Mandatory)][int[]]$Contacts, [int]$Port=8554)
$ErrorActionPreference='Stop'
if ($Contacts.Count -eq 0 -or $Contacts.Count % 4 -ne 0) { throw 'Contacts must contain repeated x,y,id,pressure quadruples.' }
[AppContext]::SetSwitch('System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport', $true)
$discovery=Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'Temp/avd/running') -Filter 'pid_*.ini' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
$tokenLine=Get-Content -LiteralPath $discovery.FullName | Where-Object { $_ -match '^grpc.token=' } | Select-Object -First 1
if (!$tokenLine) { throw 'Authenticated emulator discovery is required; tokens are never logged.' }
function Varint([int]$Value) {
[uint32]$number=$Value; $bytes=[Collections.Generic.List[byte]]::new()
while ($number -ge 128) { $bytes.Add([byte](($number -band 127) -bor 128)); $number=$number -shr 7 }
$bytes.Add([byte]$number); return ,$bytes.ToArray()
}
$message=[Collections.Generic.List[byte]]::new()
for ($offset=0; $offset -lt $Contacts.Count; $offset+=4) {
$touch=[Collections.Generic.List[byte]]::new()
for ($field=0; $field -lt 4; $field++) { $touch.Add([byte](8*($field+1))); $touch.AddRange([byte[]](Varint $Contacts[$offset+$field])) }
$message.Add(10); $message.AddRange([byte[]](Varint $touch.Count)); $message.AddRange($touch.ToArray())
}
$frame=[byte[]]::new($message.Count+5); $frame[1]=($message.Count -shr 24) -band 255; $frame[2]=($message.Count -shr 16) -band 255
$frame[3]=($message.Count -shr 8) -band 255; $frame[4]=$message.Count -band 255; $message.ToArray().CopyTo($frame,5)
$client=[Net.Http.HttpClient]::new(); $request=[Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::Post,"http://127.0.0.1:$Port/android.emulation.control.EmulatorController/sendTouch")
$request.Version=[Version]::new(2,0); $request.VersionPolicy=[Net.Http.HttpVersionPolicy]::RequestVersionExact
$request.Headers.Add('TE','trailers'); $request.Headers.Authorization=[Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer',$tokenLine.Substring('grpc.token='.Length))
$request.Content=[Net.Http.ByteArrayContent]::new($frame); $request.Content.Headers.ContentType=[Net.Http.Headers.MediaTypeHeaderValue]::new('application/grpc')
try {
$response=$client.SendAsync($request).GetAwaiter().GetResult(); $response.EnsureSuccessStatusCode() | Out-Null
$status=if ($response.TrailingHeaders.Contains('grpc-status')) { ($response.TrailingHeaders.GetValues('grpc-status') -join '') } else { '0' }
if ($status -ne '0') { throw "Emulator sendTouch grpc-status=$status" }
} finally { $request.Dispose(); $client.Dispose() }
+4
View File
@@ -1,4 +1,8 @@
param([Parameter(Mandatory)][string]$ApkPath, [string]$Milestone='FirstSlice', [Parameter(Mandatory)][string]$EvidenceDir, [string]$Serial='emulator-5554')
if ($Milestone -eq 'Composition') {
& (Join-Path $PSScriptRoot 'exercise-composition.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial
exit $LASTEXITCODE
}
$ErrorActionPreference = 'Stop'
$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
$adb = Join-Path $sdk 'platform-tools/adb.exe'