feat(review): request a store review after a 3-star win, once

Adds an in-app review prompt gated by ReviewPromptPolicy: only after a
3-star stage win, once the player has cleared >=5 stages, at most once
ever (persisted reviewRequested flag). ReviewService swallows all
failures and only burns the one-shot when the store actually shows the
sheet, so an unavailable store retries on a later win. StoreReviewer
wraps in_app_review behind a Reviewer seam so unit tests skip platform
channels. 13 new tests; full suite 194 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 11:13:55 +09:00
parent 395e4a189b
commit cec4c3e427
11 changed files with 422 additions and 0 deletions
@@ -0,0 +1,66 @@
import 'package:block_seasons/services/review_prompt_policy.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const policy = ReviewPromptPolicy(); // minStagesWon: 5, requiredStars: 3
test('asks for a review on a 3-star win once enough stages are cleared', () {
expect(
policy.shouldRequest(
alreadyRequested: false,
won: true,
stars: 3,
totalStagesWon: 5,
),
isTrue,
);
});
test('never asks again once a review has been requested', () {
expect(
policy.shouldRequest(
alreadyRequested: true,
won: true,
stars: 3,
totalStagesWon: 20,
),
isFalse,
);
});
test('does not ask after a loss', () {
expect(
policy.shouldRequest(
alreadyRequested: false,
won: false,
stars: 0,
totalStagesWon: 10,
),
isFalse,
);
});
test('does not ask before enough stages are cleared', () {
expect(
policy.shouldRequest(
alreadyRequested: false,
won: true,
stars: 3,
totalStagesWon: 4, // one short of the 5-stage gate
),
isFalse,
);
});
test('does not ask on a win below the star bar (2 stars)', () {
expect(
policy.shouldRequest(
alreadyRequested: false,
won: true,
stars: 2,
totalStagesWon: 10,
),
isFalse,
);
});
}
+89
View File
@@ -0,0 +1,89 @@
import 'package:block_seasons/data/save_repository.dart';
import 'package:block_seasons/services/review_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeReviewer implements Reviewer {
_FakeReviewer({this.available = true, this.throwOnRequest = false});
bool available;
bool throwOnRequest;
int requestCalls = 0;
@override
Future<bool> isAvailable() async => available;
@override
Future<void> requestReview() async {
requestCalls++;
if (throwOnRequest) throw Exception('store unavailable');
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Future<SaveRepository> repoWithStagesCleared(int n) async {
SharedPreferences.setMockInitialValues({});
final repo = SaveRepository(await SharedPreferences.getInstance());
for (var i = 0; i < n; i++) {
await repo.recordResult(
seasonId: 'season_001', stageId: 's$i', stars: 3, score: 100);
}
return repo;
}
test('requests a review and marks the flag on a qualifying win', () async {
final repo = await repoWithStagesCleared(5);
final reviewer = _FakeReviewer();
final service = ReviewService(save: repo, reviewer: reviewer);
await service.maybeRequestAfterWin(stars: 3);
expect(reviewer.requestCalls, 1);
expect(repo.reviewRequested, isTrue);
});
test('never requests twice', () async {
final repo = await repoWithStagesCleared(5);
await repo.markReviewRequested();
final reviewer = _FakeReviewer();
final service = ReviewService(save: repo, reviewer: reviewer);
await service.maybeRequestAfterWin(stars: 3);
expect(reviewer.requestCalls, 0);
});
test('does not request when the policy is not satisfied', () async {
final repo = await repoWithStagesCleared(2); // below the 5-stage gate
final reviewer = _FakeReviewer();
final service = ReviewService(save: repo, reviewer: reviewer);
await service.maybeRequestAfterWin(stars: 3);
expect(reviewer.requestCalls, 0);
expect(repo.reviewRequested, isFalse);
});
test('does not burn the one-shot when the store is unavailable', () async {
final repo = await repoWithStagesCleared(5);
final reviewer = _FakeReviewer(available: false);
final service = ReviewService(save: repo, reviewer: reviewer);
await service.maybeRequestAfterWin(stars: 3);
expect(reviewer.requestCalls, 0);
expect(repo.reviewRequested, isFalse, reason: 'retry on a later win');
});
test('swallows reviewer errors without throwing', () async {
final repo = await repoWithStagesCleared(5);
final reviewer = _FakeReviewer(throwOnRequest: true);
final service = ReviewService(save: repo, reviewer: reviewer);
// Must complete normally despite the plugin throwing.
await service.maybeRequestAfterWin(stars: 3);
expect(reviewer.requestCalls, 1);
});
}