From 4081a1c1ed21b69152ee8f97084b8d7b3806b77b Mon Sep 17 00:00:00 2001 From: NINI Date: Mon, 5 Oct 2026 22:24:13 +0900 Subject: [PATCH] fix(presentation): reuse counting views and gate native QA readiness --- .../Tests/PlayMode/ContentPlayTests.cs | 28 +++++++ .../Presentation/BoardPresenter.cs | 16 ++++ tools/android/exercise-content.ps1 | 84 +++++++++++++++++-- tools/android/record-native-audio.ps1 | 13 +++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/ContentPlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/ContentPlayTests.cs index aa285cb..05fed1d 100644 --- a/Assets/MagnetismHeroes/Tests/PlayMode/ContentPlayTests.cs +++ b/Assets/MagnetismHeroes/Tests/PlayMode/ContentPlayTests.cs @@ -55,6 +55,34 @@ namespace MagnetismHeroes.Tests yield return null; } [UnityTearDown] public IEnumerator TearDown() { Object.Destroy(_app.gameObject); yield return null; } + [UnityTest] public IEnumerator CountingRefreshPreservesViewsAndClearsMarkersInSameFreeBoard() + { + _app.StartStage(20); yield return null; + while (_app.Session.Board.Groups.Length > 1) + { + var groups=_app.Session.Board.Groups; + _app.CommitMove(groups[0].id,new Position3(.1f,0,0),groups[1].id); + } + var views=_app.Presenter.Balls.ToDictionary(pair=>pair.Key,pair=>pair.Value); + var badge=_app.Presenter.GetComponentInChildren(); + var positions=views.ToDictionary(pair=>pair.Key,pair=>pair.Value.transform.position); + foreach (string id in views.Keys) + { + _app.CountBall(id); + foreach (var pair in views) + { + Assert.That(_app.Presenter.Balls[pair.Key], Is.SameAs(pair.Value), "Counting must retain each ball instance."); + Assert.That(pair.Value.transform.position, Is.EqualTo(positions[pair.Key])); + var marker=(GameObject)typeof(BallView).GetField("_countMarker",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).GetValue(pair.Value); + Assert.That(marker.activeSelf, Is.EqualTo(_app.Session.Board.CountedBallIds.Contains(pair.Key))); + } + Assert.That(_app.Presenter.GetComponentInChildren(), Is.SameAs(badge)); + } + Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); + Assert.That(_app.Voice.CurrentClipId, Is.EqualTo("count_01")); + Assert.That(_app.Voice.PendingCount, Is.EqualTo(10)); + yield return null; + } [UnityTest] public IEnumerator EveryCountingBallCanBeFoundPickedAndDirectlyDraggedAfterOrbit() { var proof=new List(); diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs b/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs index 53e8701..fd57bc4 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs @@ -25,7 +25,23 @@ namespace MagnetismHeroes.UnityRuntime.Presentation && board.Balls.All(ball => _balls.ContainsKey(ball.id)); var previous = _balls.Values.GroupBy(ball => ball.GroupId).ToDictionary(group => group.Key, group => group.Select(ball => ball.BallId).OrderBy(id => id).ToArray()); var contacts = _balls.Values.Where(ball => ball.ContactCandidate).Select(ball => ball.BallId).ToArray(); + bool reuse = board != null && _balls.Count == board.BallCount && _badges.Count == board.Groups.Length + && board.Groups.All(group => previous.TryGetValue(group.id, out var ids) + && ids.SequenceEqual(group.ballIds.OrderBy(id => id))) + && board.Balls.All(ball => _balls.TryGetValue(ball.id, out var view) + && view.transform.position == ToVector(ball.position)); _board = board; + if (reuse) + { + CancelPreview(); + foreach (var ball in _balls.Values) + { + ball.Selected = false; ball.Hinted = false; + ball.Bind(ball.BallId, ball.GroupId, board.CountedBallIds.Contains(ball.BallId)); + } + PositionBadges(); + return; + } foreach (var view in _balls.Values) { view.gameObject.SetActive(false); Destroy(view.gameObject); } foreach (var badge in _badges) { badge.gameObject.SetActive(false); Destroy(badge.gameObject); } _balls.Clear(); _badges.Clear(); diff --git a/tools/android/exercise-content.ps1 b/tools/android/exercise-content.ps1 index f866612..a31c8e7 100644 --- a/tools/android/exercise-content.ps1 +++ b/tools/android/exercise-content.ps1 @@ -7,7 +7,7 @@ $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='Content';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 +$networkBefore=$null; $musicBefore=$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")}) @@ -42,9 +42,44 @@ 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 + 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++} + }} + $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-ge2-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;homeButtonSamples=$homeButton;readyUi=$ui;png=$png}) + } finally {$image.Dispose()} + 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 - Step "$Name-home" @('shell','am','start','-W','-n',$activity) 6500 + StartHome "$Name-home" } function Fixture([int]$Stage) { Adb @('shell','am','force-stop',$package) | Out-Null @@ -52,17 +87,32 @@ function Fixture([int]$Stage) { $path=Join-Path $EvidenceDir "fixture-$Stage.json"; $fixture | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $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 "fixture-$Stage-home" @('shell','am','start','-W','-n',$activity) 6500 + StartHome "fixture-$Stage-home" Step "fixture-$Stage-selection" @('shell','input','tap','1200','616') } 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}); Start-Sleep -Seconds 2 + $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' + 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.txt") -Encoding utf8 + $current=[regex]::Match($route,'(?s)- STREAM_MUSIC:.*?Current: ([^\r\n]+)').Groups[1].Value + 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(30000)) 'PCM capture finished'; $recorder.Refresh(); Check "audio-exit-$Name" ($recorder.ExitCode -eq 0) "$($recorder.ExitCode)" + 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" @@ -77,11 +127,17 @@ try { 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.sourceCommitEnd -eq $result.sourceCommit -and $build.apkSha256 -eq $result.apkSha256) $result.apkSha256 Adb @('wait-for-device') | Out-Null; Check 'boot-completed' ((Adb @('shell','getprop','sys.boot_completed')) -eq '1') 'Emulator boot completed' $result.device=@{model=(Adb @('shell','getprop','ro.product.model'));api=(Adb @('shell','getprop','ro.build.version.sdk'));abi=(Adb @('shell','getprop','ro.product.cpu.abilist'));bridge=(Adb @('shell','getprop','ro.dalvik.vm.native.bridge'))} + $audioBefore=Adb @('shell','dumpsys','audio') + $audioBefore | Set-Content -LiteralPath (Join-Path $EvidenceDir 'music-before.txt') -Encoding utf8 + $current=[regex]::Match($audioBefore,'(?s)- STREAM_MUSIC:.*?Current: ([^\r\n]+)').Groups[1].Value + $musicBefore=@{} + foreach ($match in [regex]::Matches($current,'([0-9a-f]+) \([^)]+\): (\d+)')) { $musicBefore[$match.Groups[1].Value]=[int]$match.Groups[2].Value } + Check 'music-original-routes' ($musicBefore.ContainsKey('2') -and $musicBefore.ContainsKey('8000') -and $musicBefore.ContainsKey('40000000')) $current Adb @('install','-r',$ApkPath) | Out-Null; Adb @('logcat','-c') | Out-Null; Adb @('shell','pm','clear',$package) | 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-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' - Step 'fresh-home' @('shell','am','start','-W','-n',$activity) 6500; Step 'fresh-30-selection' @('shell','input','tap','1200','616') + StartHome 'fresh-home'; Step 'fresh-30-selection' @('shell','input','tap','1200','616') $two=StagePoint 2; Step 'fresh-locked-two' @('shell','input','tap',"$($two[0])","$($two[1])") $locked=Save 'fresh-locked'; Check 'fresh-only-one' ($locked.completedStageIds.Count -eq 0 -and $locked.puzzleStates.Count -eq 0) 'Locked Stage2 tap opens no puzzle' StartAudio 'audio-one-offline' 20 @@ -151,6 +207,20 @@ try { $result.status='passed' } catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red } finally { + foreach ($recorder in $recorders) { + if (!$recorder.process.HasExited -and !$recorder.process.WaitForExit(($recorder.seconds+30)*1000)) { $result.status='failed'; $result.recorderError='Recorder did not finish before restoration' } + } + if ($musicBefore) { + try { + Adb @('shell','cmd','audio','set-volume','3',"$($musicBefore['2'])") | Out-Null + Adb @('shell','cmd','audio','set-device-volume','3',"$($musicBefore['8000'])",'32768') | Out-Null + Adb @('shell','cmd','audio','set-device-volume','3',"$($musicBefore['40000000'])",'1073741824') | Out-Null + $audioAfter=Adb @('shell','dumpsys','audio') + $audioAfter | Set-Content -LiteralPath (Join-Path $EvidenceDir 'music-after.txt') -Encoding utf8 + $current=[regex]::Match($audioAfter,'(?s)- STREAM_MUSIC:.*?Current: ([^\r\n]+)').Groups[1].Value + Check 'music-routes-restored' ($current -match "2 \(speaker\): $($musicBefore['2'])(,|$)" -and $current -match "8000 \(remote_submix\): $($musicBefore['8000'])(,|$)" -and $current -match "40000000 \(default\): $($musicBefore['40000000'])(,|$)") $current + } catch { $result.status='failed'; $result.volumeRestoreError=$_.Exception.Message } + } if ($networkBefore) { try { Adb @('shell','cmd','connectivity','airplane-mode',$(if ($networkBefore.airplane -eq '1') {'enable'} else {'disable'})) | Out-Null @@ -160,7 +230,7 @@ finally { Check 'network-restored' (Same $networkBefore $result.networkAfter) ($result.networkAfter | ConvertTo-Json -Compress) } catch { $result.status='failed'; $result.restoreError=$_.Exception.Message } } - foreach ($recorder in $recorders) { if (!$recorder.process.HasExited) { $recorder.process.WaitForExit(30000) | Out-Null } } + $result.checks=$checks.ToArray(); $result.commands=$commands.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o') $result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-content-evidence.json') -Encoding utf8 } diff --git a/tools/android/record-native-audio.ps1 b/tools/android/record-native-audio.ps1 index a4986f3..4924547 100644 --- a/tools/android/record-native-audio.ps1 +++ b/tools/android/record-native-audio.ps1 @@ -18,6 +18,19 @@ $oldAdb=$env:ADB try { $env:ADB=Join-Path $env:LOCALAPPDATA 'Android/Sdk/platform-tools/adb.exe' $process=Start-Process $exe -ArgumentList ($arguments | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput ($OutputPath+'.stdout') -RedirectStandardError ($OutputPath+'.stderr') -PassThru + $record.recorderPid=$process.Id + $ready=$false + for ($attempt=0; $attempt -lt 100; $attempt++) { + $process.Refresh() + if ($process.HasExited) { break } + if ((Get-Content -LiteralPath ($OutputPath+'.stdout') -Raw -ErrorAction SilentlyContinue) -match 'Recording started to wav file:') { + $record.readyUtc=[DateTime]::UtcNow.ToString('o') + @{recorderPid=$process.Id;readyUtc=$record.readyUtc;method=$record.method} | ConvertTo-Json | Set-Content -LiteralPath ($OutputPath+'.ready.json') -Encoding utf8 + $ready=$true; break + } + Start-Sleep -Milliseconds 100 + } + if (!$ready) { if (!$process.HasExited) { $process.Kill(); $process.WaitForExit() }; throw 'Android PCM recorder did not report recording started' } if (!$process.WaitForExit(($Seconds+20)*1000)) { $process.Kill();throw 'Android PCM 녹음 종료 시간 초과' } $process.Refresh();$record.exitCode=$process.ExitCode if ($process.ExitCode -ne 0) { throw "Android PCM recorder exit $($process.ExitCode)" }