param([string]$EvidenceDir='artifacts/verification/FirstSlice', [string]$Serial='emulator-5554') $ErrorActionPreference='Stop' $EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path $apkPath=(Resolve-Path -LiteralPath 'artifacts/android/FirstSlice/MagnetismHeroes.apk').Path $checks=[Collections.Generic.List[object]]::new() $recordings=[Collections.Generic.List[object]]::new() function Step([string]$Name,[string[]]$Arguments,[int]$Delay=800) { & (Join-Path $PSScriptRoot 'observe.ps1') -Step $Name -AdbArguments $Arguments -Serial $Serial -EvidenceDir $EvidenceDir -DelayMilliseconds $Delay } function Save-State([string]$Name) { Step $Name @('pull','/sdcard/Android/data/com.magnetismheroes.game/files/progress.json',(Join-Path $EvidenceDir "$Name.json")) return (Get-Content -LiteralPath (Join-Path $EvidenceDir "$Name.json") -Raw | ConvertFrom-Json) } function Assert-State([string]$Id,[bool]$Passed,[string]$Actual) { $checks.Add([ordered]@{ id=$Id; passed=$Passed; actual=$Actual }) if (!$Passed) { throw "저장 검증 실패: $Id ($Actual)" } } function Start-Audio([string]$Name,[int]$Seconds) { $audioPath=Join-Path $EvidenceDir "$Name.wav" $args=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-audio.ps1'),'-OutputPath',$audioPath,'-Seconds',"$Seconds") $process=Start-Process -FilePath 'pwsh' -ArgumentList ($args | ForEach-Object { '"'+$_+'"' }) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru $recordings.Add(@{ name=$Name; process=$process; command=('pwsh '+($args -join ' ')) }) Start-Sleep -Seconds 2 } function Restart([string]$Prefix) { Step "$Prefix-pause" @('shell','input','keyevent','3') Step "$Prefix-force-stop" @('shell','am','force-stop','com.magnetismheroes.game') Step "$Prefix-home" @('shell','am','start','-W','-n','com.magnetismheroes.game/com.unity3d.player.UnityPlayerGameActivity') 10000 } $result=[ordered]@{ sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain); apkSha256=(Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash; serial=$Serial; status='failed'; recordings=@(); checks=@() } try { Assert-State 'clean-source' (!$result.sourceDirty) $result.sourceCommit Step 'final-00-airplane-off' @('shell','cmd','connectivity','airplane-mode','disable') Step 'final-00-wifi-on' @('shell','svc','wifi','enable') Step 'final-00-data-on' @('shell','svc','data','enable') # 검증 담당자가 이 실행에서 설치한 앱 전용 데이터만 초기화합니다. Step 'final-00-reset-own-test-app' @('shell','pm','clear','com.magnetismheroes.game') Step 'final-01-home' @('shell','am','start','-W','-n','com.magnetismheroes.game/com.unity3d.player.UnityPlayerGameActivity') 10000 Step 'final-02-selection' @('shell','input','tap','1200','616') Start-Audio 'audio-online-guidecollect' 8 Step 'final-03-stage' @('shell','input','tap','1200','560') 2500 Step 'final-04-precollect-tap' @('shell','input','tap','930','510') $uncollected=Save-State 'final-04-uncollected' Assert-State 'direct-collection-required' ($uncollected.puzzleStates[0].collectedBallIds.Count -eq 0 -and $uncollected.puzzleStates[0].countedBallIds.Count -eq 0 -and $uncollected.completedStageIds.Count -eq 0) ($uncollected | ConvertTo-Json -Depth 8 -Compress) Start-Audio 'audio-online-guidecount' 8 Step 'final-05-direct-collection' @('shell','input','swipe','930','510','1200','510','800') 4000 Step 'final-06-camera-orbit' @('shell','input','swipe','1720','400','1860','470','600') $before=Save-State 'final-06-incomplete-before' Assert-State 'collected-with-empty-count' ($before.puzzleStates[0].collectedBallIds.Count -eq 1 -and $before.puzzleStates[0].countedBallIds.Count -eq 0) ($before | ConvertTo-Json -Depth 8 -Compress) Restart 'final-07' Step 'final-07-selection' @('shell','input','tap','1200','616') Step 'final-07-restored-incomplete' @('shell','input','tap','1200','560') $after=Save-State 'final-07-incomplete-after' Assert-State 'incomplete-json-restored' (($before | ConvertTo-Json -Depth 8 -Compress) -eq ($after | ConvertTo-Json -Depth 8 -Compress)) '배치/카메라/그룹/수집/빈 계수 전체 JSON 동일' Start-Audio 'audio-online-count01' 8 Step 'final-08-success-same-board' @('shell','input','tap','1182','514') 1500 $success=Save-State 'final-08-success' Assert-State 'completion-atomic-state' ($success.completedStageIds.Count -eq 1 -and $success.completedStageIds[0] -eq 1 -and $success.puzzleStates.Count -eq 0 -and $success.latestFreePlay.sourceStageId -eq 1) ($success | ConvertTo-Json -Depth 8 -Compress) Assert-State 'success-preserves-board-camera' (($before.puzzleStates[0].balls | ConvertTo-Json -Depth 6 -Compress) -eq ($success.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress) -and ($before.puzzleStates[0].camera | ConvertTo-Json -Depth 6 -Compress) -eq ($success.latestFreePlay.camera | ConvertTo-Json -Depth 6 -Compress)) '성공 전후 balls/camera 동일' Step 'final-09-free-move' @('shell','input','swipe','1182','514','1380','470','800') $freeBefore=Save-State 'final-09-free-before' Assert-State 'free-move-changes-position' (($freeBefore.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress) -ne ($success.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress)) '실제 자유 놀이 balls 위치 변경' Restart 'final-10' Start-Audio 'audio-free-restore' 8 Step 'final-10-continue-free' @('shell','input','tap','1200','810') $freeAfter=Save-State 'final-10-free-after' Assert-State 'free-json-restored' (($freeBefore | ConvertTo-Json -Depth 8 -Compress) -eq ($freeAfter | ConvertTo-Json -Depth 8 -Compress)) '자유 놀이 전체 JSON 동일' $restoreRecorder=$recordings | Where-Object name -eq 'audio-free-restore' | Select-Object -First 1 if (!$restoreRecorder.process.WaitForExit(30000)) { throw '복원 음성 검사 수집 미종료' } $silence=Get-Content -LiteralPath (Join-Path $EvidenceDir 'audio-free-restore.wav.json') -Raw | ConvertFrom-Json Assert-State 'restore-no-historical-voice' ($silence.packets -gt 0 -and $silence.pcmBytes -gt 0 -and $silence.nonSilentSamples -eq 0) ($silence | ConvertTo-Json -Compress) Step 'final-11-airplane-on' @('shell','cmd','connectivity','airplane-mode','enable') Step 'final-11-wifi-off' @('shell','svc','wifi','disable') Step 'final-11-data-off' @('shell','svc','data','disable') Step 'final-11-wifi-state' @('shell','settings','get','global','wifi_on') Step 'final-11-data-state' @('shell','settings','get','global','mobile_data') Step 'final-11-connectivity' @('shell','dumpsys','connectivity') Step 'final-11-airplane-state' @('shell','settings','get','global','airplane_mode_on') $wifiState=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-wifi-state-commands.json') -Raw | ConvertFrom-Json $dataState=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-data-state-commands.json') -Raw | ConvertFrom-Json $connectivity=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-connectivity-commands.json') -Raw | ConvertFrom-Json $airplaneState=Get-Content -LiteralPath (Join-Path $EvidenceDir 'final-11-airplane-state-commands.json') -Raw | ConvertFrom-Json Assert-State 'offline-network-disabled' ($wifiState.commands[0].output.Trim() -eq '0' -and $dataState.commands[0].output.Trim() -eq '0' -and $airplaneState.commands[0].output.Trim() -eq '1' -and $connectivity.commands[0].output -match 'Active default network: none') 'airplane=1, wifi=0, mobile_data=0, Active default network: none' Step 'final-12-home' @('shell','input','tap','310','108') Step 'final-12-selection' @('shell','input','tap','1200','616') Start-Audio 'audio-offline' 20 Step 'final-12-offline-stage' @('shell','input','tap','1200','560') 3000 Step 'final-12-offline-collect' @('shell','input','swipe','930','510','1200','510','800') 4500 $offlineCollected=Save-State 'final-12-offline-collected' Assert-State 'offline-collected-unfinished' ($offlineCollected.puzzleStates.Count -eq 1 -and $offlineCollected.puzzleStates[0].collectedBallIds.Count -eq 1 -and $offlineCollected.puzzleStates[0].countedBallIds.Count -eq 0) '오프라인 실제 수집됨/계수 전 0' Step 'final-12-offline-count-success' @('shell','input','tap','1182','510') 1500 $offlineSuccess=Save-State 'final-12-offline-success' Assert-State 'offline-collection-count-actually-success' ($offlineSuccess.completedStageIds.Count -eq 1 -and $offlineSuccess.completedStageIds[0] -eq 1 -and $offlineSuccess.puzzleStates.Count -eq 0 -and $offlineSuccess.latestFreePlay.sourceStageId -eq 1 -and ($offlineCollected.puzzleStates[0].balls | ConvertTo-Json -Depth 6 -Compress) -eq ($offlineSuccess.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress)) '완료1/미완료없음/수집 전후 같은 IDs와 배치 자유 전이' Step 'final-13-offline-free-move' @('shell','input','swipe','1182','510','1350','450','800') $offlineBefore=Save-State 'final-13-offline-before' Assert-State 'offline-free-move-changes-position' (($offlineBefore.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress) -ne ($offlineSuccess.latestFreePlay.balls | ConvertTo-Json -Depth 6 -Compress)) '오프라인 실제 자유 놀이 balls 위치 변경' Restart 'final-14' Step 'final-14-offline-continue' @('shell','input','tap','1200','810') $offlineAfter=Save-State 'final-14-offline-after' Assert-State 'offline-json-restored' (($offlineBefore | ConvertTo-Json -Depth 8 -Compress) -eq ($offlineAfter | ConvertTo-Json -Depth 8 -Compress)) '오프라인 자유 놀이 전체 JSON 동일' foreach ($recording in $recordings) { if (!$recording.process.WaitForExit(30000)) { throw "오디오 수집 미종료: $($recording.name)" } $recording.process.Refresh() $result.recordings += [ordered]@{ name=$recording.name; command=$recording.command; exitCode=$recording.process.ExitCode } Assert-State "audio-recorder-$($recording.name)" ($recording.process.ExitCode -eq 0) "$($recording.process.ExitCode)" } $result.status='passed' } catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red } finally { $result.checks=$checks.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o') $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-state-checks.json') -Encoding utf8 } if ($result.status -ne 'passed') { exit 1 } Write-Host "실제 Android 저장/복원 검사 $($checks.Count)개 통과. PNG 의미 검수는 별도입니다."