Fix backup recovery when primary progress save is missing
This commit is contained in:
@@ -87,6 +87,51 @@ namespace MagnetismHeroes.Tests
|
|||||||
Assert.That(store.TryLoad(out var restored, out error), Is.True, error);
|
Assert.That(store.TryLoad(out var restored, out error), Is.True, error);
|
||||||
Assert.That(restored.puzzleStates.Single().stageId, Is.EqualTo(1));
|
Assert.That(restored.puzzleStates.Single().stageId, Is.EqualTo(1));
|
||||||
}
|
}
|
||||||
|
[Test] public void MissingPrimaryRestoresEntireValidBackup()
|
||||||
|
{
|
||||||
|
var definitions = new[] { _stage,
|
||||||
|
new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 }),
|
||||||
|
new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) };
|
||||||
|
var session = new GameSession(definitions, new LocalProgressStore(_path, definitions));
|
||||||
|
session.StartStage(1); session.MoveGroup(session.Board.Groups[0].id, new Position3(1, 2, 3));
|
||||||
|
session.Count(session.Board.Balls[0].id); session.MarkTutorialSeen("merge");
|
||||||
|
session.StartStage(2); session.MoveAndMerge(session.Board.Groups[0].id, new Position3(2, 1, -3), session.Board.Groups[1].id);
|
||||||
|
session.Count(session.Board.Balls[0].id);
|
||||||
|
session.CommitCamera(new CameraState { target = new Position3(1, 2, 3), rotation = new Rotation4(0, .6f, 0, .8f), distance = 11 });
|
||||||
|
var store = new LocalProgressStore(_path, definitions);
|
||||||
|
Assert.That(store.TryLoad(out var expected, out var error), Is.True, error);
|
||||||
|
var six = new BoardRules(definitions[2]); six.TryMoveGroup(six.Groups[0].id, new Position3(-1, 2, 4));
|
||||||
|
expected.puzzleStates = expected.puzzleStates.Concat(new[] { six.ToPuzzleState(GameSession.DefaultCamera()) }).ToArray();
|
||||||
|
Assert.That(store.TrySave(expected, out error), Is.True, error);
|
||||||
|
var expectedJson = File.ReadAllText(_path);
|
||||||
|
Assert.That(store.TrySave(expected, out error), Is.True, error); File.Delete(_path);
|
||||||
|
Assert.That(store.TryLoad(out var loaded, out error), Is.True, error);
|
||||||
|
Assert.That(error, Does.Contain("Recovered valid backup"));
|
||||||
|
Assert.That(loaded.puzzleStates.Length, Is.EqualTo(2));
|
||||||
|
Assert.That(loaded.puzzleStates.Single(state => state.stageId == 2).countedBallIds.Length, Is.EqualTo(1));
|
||||||
|
Assert.That(store.TrySave(loaded, out error), Is.True, error);
|
||||||
|
Assert.That(File.ReadAllText(_path), Is.EqualTo(expectedJson), "Every contract field, including free play and tutorial progress, must survive.");
|
||||||
|
}
|
||||||
|
[Test] public void MissingPrimaryWithInvalidBackupPreservesEvidenceThroughTwoSaves()
|
||||||
|
{
|
||||||
|
File.WriteAllText(_path + ".bak", "distinct-backup-only-damage");
|
||||||
|
var store = Store(); Assert.That(store.TryLoad(out var loaded, out var error), Is.False);
|
||||||
|
Assert.That(loaded, Is.Null); Assert.That(error, Does.Contain("Invalid primary and backup"));
|
||||||
|
Assert.That(File.Exists(_path), Is.False);
|
||||||
|
Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("distinct-backup-only-damage"));
|
||||||
|
Assert.That(store.TrySave(new ProgressSave(), out error), Is.True, error);
|
||||||
|
Assert.That(store.TrySave(Puzzle(), out error), Is.True, error);
|
||||||
|
Assert.That(Directory.GetFiles(_directory, "*.bak.damaged-*").Select(File.ReadAllText), Does.Contain("distinct-backup-only-damage"));
|
||||||
|
Assert.That(store.TryLoad(out loaded, out error), Is.True, error);
|
||||||
|
Assert.That(loaded.puzzleStates.Single().stageId, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
[Test] public void MissingPrimaryAndBackupStartsFreshWithoutRecoveryError()
|
||||||
|
{
|
||||||
|
Assert.That(Store().TryLoad(out var loaded, out var error), Is.True, error);
|
||||||
|
Assert.That(error, Is.Null); Assert.That(loaded.puzzleStates, Is.Empty);
|
||||||
|
Assert.That(loaded.completedStageIds, Is.Empty); Assert.That(loaded.seenTutorialIds, Is.Empty);
|
||||||
|
Assert.That(loaded.latestFreePlay, Is.Null); Assert.That(Directory.GetFiles(_directory), Is.Empty);
|
||||||
|
}
|
||||||
[Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave()
|
[Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave()
|
||||||
{
|
{
|
||||||
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
|
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ namespace MagnetismHeroes.UnityRuntime.Persistence
|
|||||||
public bool TryLoad(out ProgressSave save, out string error)
|
public bool TryLoad(out ProgressSave save, out string error)
|
||||||
{
|
{
|
||||||
save = null; error = null;
|
save = null; error = null;
|
||||||
if (!File.Exists(_path)) { save = new ProgressSave(); return true; }
|
if (!File.Exists(_path) && !File.Exists(_path + ".bak")) { save = new ProgressSave(); return true; }
|
||||||
if (Read(_path, out save, out error)) return true;
|
if (Read(_path, out save, out error)) return true;
|
||||||
var originalError = error;
|
var originalError = error;
|
||||||
if (Read(_path + ".bak", out save, out var backupError))
|
if (Read(_path + ".bak", out save, out var backupError))
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ JSON v1은 앱 전용 `progress.json`에 확정 상태만 기록한다. 각 단
|
|||||||
|
|
||||||
확정 이동·합체·분리·계수·공급·반납, 카메라 조작 종료, 화면 이동, 일시정지에서 저장한다. 미확정 드래그와 선택·접촉 표시를 저장하지 않는다. 저장 실패는 현재 화면에 짧은 한국어 안내를 보여주고 실제 파일 오류를 개발 로그에 남긴다. 다음 저장 성공 시 안내를 비운다. 복원은 Home에서 시작하며 이전 계수나 성공 음성을 반복하지 않는다. 같은 보드의 자유놀이 조작 중에는 마지막 숫자 대기열을 보존하고 화면 이동·일시정지는 음성을 정리한다.
|
확정 이동·합체·분리·계수·공급·반납, 카메라 조작 종료, 화면 이동, 일시정지에서 저장한다. 미확정 드래그와 선택·접촉 표시를 저장하지 않는다. 저장 실패는 현재 화면에 짧은 한국어 안내를 보여주고 실제 파일 오류를 개발 로그에 남긴다. 다음 저장 성공 시 안내를 비운다. 복원은 Home에서 시작하며 이전 계수나 성공 음성을 반복하지 않는다. 같은 보드의 자유놀이 조작 중에는 마지막 숫자 대기열을 보존하고 화면 이동·일시정지는 음성을 정리한다.
|
||||||
|
|
||||||
임시 파일을 디스크에 flush한 뒤 primary를 교체한다. 유효 primary를 backup으로 보존하며 불완전 `.tmp`는 읽지 않는다. 손상 primary는 유효 backup 전체로 복구한다. 다음 저장 전에 손상 원본을 `.damaged-*`로 복사하며, 손상 primary를 유효 backup 위에 덮어쓰지 않는다. 둘 다 손상되면 정상 초기 상태와 안내를 표시하고 primary·backup의 서로 다른 원본을 각각 보관해 이후 정상 저장에서도 증거를 보존한다.
|
임시 파일을 디스크에 flush한 뒤 primary를 교체한다. 유효 primary를 backup으로 보존하며 불완전 `.tmp`는 읽지 않는다. 손상되었거나 없는 primary는 유효 backup 전체로 복구한다. primary·backup이 모두 없을 때만 새 진행을 시작한다. primary 없이 손상 backup만 남아 있으면 읽기 실패를 기록하고 정상 초기 상태로 진입하며 두 번의 후속 저장에서도 backup 원문을 보관한다. 다음 저장 전에 손상 원본을 `.damaged-*`로 복사하며, 손상 primary를 유효 backup 위에 덮어쓰지 않는다. 둘 다 손상되면 정상 초기 상태와 안내를 표시하고 primary·backup의 서로 다른 원본을 각각 보관해 이후 정상 저장에서도 증거를 보존한다.
|
||||||
|
|
||||||
검증 명령:
|
검증 명령:
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ try {
|
|||||||
Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8
|
Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8
|
||||||
$runtime=Get-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Raw
|
$runtime=Get-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Raw
|
||||||
Check 'actual-write-errors-logged' ($runtime -match '\[ProgressSave\].*(denied|directory|UnauthorizedAccess|Access)') 'Actual filesystem error in developer log'
|
Check 'actual-write-errors-logged' ($runtime -match '\[ProgressSave\].*(denied|directory|UnauthorizedAccess|Access)') 'Actual filesystem error in developer log'
|
||||||
|
# Missing primary must restore the whole backup, including both unfinished boards and latest Free.
|
||||||
|
Adb @('shell','am','force-stop',$package) | Out-Null
|
||||||
|
$missingExpected=Save 'missing-primary-valid-backup-before'
|
||||||
|
Check 'missing-primary-fixture-full-state' ($missingExpected.puzzleStates.Count -eq 2 -and $null -ne $missingExpected.latestFreePlay -and @($missingExpected.puzzleStates | Where-Object stageId -eq 2)[0].countedBallIds.Count -eq 1) 'Actual two unfinished boards, positive partial count and latest Free retained'
|
||||||
|
Adb @('push',(Join-Path $EvidenceDir 'missing-primary-valid-backup-before.json'),($remote+'.bak')) | Out-Null
|
||||||
|
Adb @('shell','rm',$remote) | Out-Null
|
||||||
|
Step 'missing-primary-valid-backup-home-notice' @('shell','am','start','-W','-n',$activity) 6500
|
||||||
|
$missingLoadLog=Adb @('logcat','-d','-s','Unity','AndroidRuntime')
|
||||||
|
$missingLoadLog | Set-Content -LiteralPath (Join-Path $EvidenceDir 'missing-primary-valid-load.log') -Encoding utf8
|
||||||
|
Check 'missing-primary-valid-recovery-logged' ($missingLoadLog -match '\[ProgressLoad\] Recovered valid backup:') 'Backup-only recovery reaches existing recovery notice branch'
|
||||||
|
Step 'missing-primary-valid-backup-free' @('shell','input','tap','1200','810')
|
||||||
|
Check 'missing-primary-valid-backup-whole-restore' (Same $missingExpected (Save 'missing-primary-valid-backup-after')) 'Every saved field restores from backup alone'
|
||||||
|
Step 'missing-primary-valid-backup-home' @('shell','input','tap','312','108')
|
||||||
SelectStage 'two-final-count' 2; $secondCount=Save 'two-before-final-count'; $secondBoard=@($secondCount.puzzleStates | Where-Object stageId -eq 2)[0]
|
SelectStage 'two-final-count' 2; $secondCount=Save 'two-before-final-count'; $secondBoard=@($secondCount.puzzleStates | Where-Object stageId -eq 2)[0]
|
||||||
$uncounted=@($secondBoard.balls | Where-Object {$secondBoard.countedBallIds -notcontains $_.id})[0]
|
$uncounted=@($secondBoard.balls | Where-Object {$secondBoard.countedBallIds -notcontains $_.id})[0]
|
||||||
$countAudio=RecordAudio 'audio-stage-two-final-count' 8
|
$countAudio=RecordAudio 'audio-stage-two-final-count' 8
|
||||||
@@ -177,6 +190,18 @@ try {
|
|||||||
Check 'both-damage-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Primary forensic and damaged backup retained'
|
Check 'both-damage-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Primary forensic and damaged backup retained'
|
||||||
Step 'both-damage-second-normal-save' @('shell','input','tap','1200','562'); $secondInitial=Save 'both-damaged-second-initial'
|
Step 'both-damage-second-normal-save' @('shell','input','tap','1200','562'); $secondInitial=Save 'both-damaged-second-initial'
|
||||||
Check 'both-damage-two-normal-saves-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak.damaged-*'))) -match 'damaged-both-backup' -and $secondInitial.puzzleStates.Count -eq 1) 'Both distinct damaged byte strings survive two successful normal writes'
|
Check 'both-damage-two-normal-saves-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak.damaged-*'))) -match 'damaged-both-backup' -and $secondInitial.puzzleStates.Count -eq 1) 'Both distinct damaged byte strings survive two successful normal writes'
|
||||||
|
Adb @('shell','am','force-stop',$package) | Out-Null
|
||||||
|
[IO.File]::WriteAllText($badBackup,'damaged-backup-only'); Adb @('push',$badBackup,($remote+'.bak')) | Out-Null
|
||||||
|
Adb @('shell','rm',$remote) | Out-Null; Adb @('logcat','-c') | Out-Null
|
||||||
|
Step 'missing-primary-invalid-backup-home-notice' @('shell','am','start','-W','-n',$activity) 6500
|
||||||
|
Check 'missing-primary-invalid-original-preserved-on-load' ((Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-backup-only' -and (Adb @('shell','ls',($remote+'*'))) -notmatch '(?m)/progress\.json$') 'Invalid backup bytes remain and no primary is created by failed load'
|
||||||
|
$invalidLoadLog=Adb @('logcat','-d','-s','Unity','AndroidRuntime')
|
||||||
|
$invalidLoadLog | Set-Content -LiteralPath (Join-Path $EvidenceDir 'missing-primary-invalid-load.log') -Encoding utf8
|
||||||
|
Check 'missing-primary-invalid-read-failure-logged' ($invalidLoadLog -match '\[ProgressLoad\] Invalid primary and backup:') 'Backup-only damage is recorded as read failure'
|
||||||
|
Step 'missing-primary-invalid-new-selection' @('shell','input','tap','1200','616'); $missingInitial=Save 'missing-primary-invalid-first-save'
|
||||||
|
Check 'missing-primary-invalid-normal-initial' ($missingInitial.completedStageIds.Count -eq 0 -and $missingInitial.puzzleStates.Count -eq 0 -and $missingInitial.seenTutorialIds.Count -eq 0 -and $null -eq $missingInitial.latestFreePlay) 'Invalid backup alone enters normal fresh progress'
|
||||||
|
Step 'missing-primary-invalid-second-save' @('shell','input','tap','1200','562'); $missingSecond=Save 'missing-primary-invalid-second-save'
|
||||||
|
Check 'missing-primary-invalid-two-saves-forensic-preserved' ((Adb @('shell','cat',($remote+'.bak.damaged-*'))) -match 'damaged-backup-only' -and $missingSecond.puzzleStates.Count -eq 1) 'Distinct backup-only damage survives two successful saves'
|
||||||
$result.sourceCommitEnd=(& git rev-parse HEAD); $result.sourceDirtyEnd=[bool](& git status --porcelain)
|
$result.sourceCommitEnd=(& git rev-parse HEAD); $result.sourceDirtyEnd=[bool](& git status --porcelain)
|
||||||
Check 'clean-source-end' (!$result.sourceDirtyEnd -and $result.sourceCommitEnd -eq $result.sourceCommit) $result.sourceCommitEnd
|
Check 'clean-source-end' (!$result.sourceDirtyEnd -and $result.sourceCommitEnd -eq $result.sourceCommit) $result.sourceCommitEnd
|
||||||
$result.status='passed'
|
$result.status='passed'
|
||||||
|
|||||||
Reference in New Issue
Block a user