feat(ads): pure-Dart interstitial frequency policy with tests
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
/// Pure-Dart interstitial gate. No plugin imports so the monetization rules
|
||||||
|
/// are unit-tested headlessly. The service layer feeds it lifecycle events
|
||||||
|
/// and asks [canShowInterstitial] before every stage-end interstitial.
|
||||||
|
class AdFrequencyPolicy {
|
||||||
|
AdFrequencyPolicy({
|
||||||
|
this.minStagesBetween = 3,
|
||||||
|
this.minInterval = const Duration(seconds: 90),
|
||||||
|
this.firstStagesProtected = 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int minStagesBetween;
|
||||||
|
final Duration minInterval;
|
||||||
|
final int firstStagesProtected;
|
||||||
|
|
||||||
|
int _totalStagesCompleted = 0;
|
||||||
|
int _stagesSinceLastInterstitial = 0;
|
||||||
|
bool _rewardedShownThisRound = false;
|
||||||
|
DateTime? _lastInterstitialAt;
|
||||||
|
|
||||||
|
/// A new stage attempt began (fresh round). Clears the per-round rewarded
|
||||||
|
/// flag so last round's rewarded does not block this round's interstitial.
|
||||||
|
void onRoundStart() => _rewardedShownThisRound = false;
|
||||||
|
|
||||||
|
/// A stage finished (won or lost). Drives both counters.
|
||||||
|
void onStageCompleted() {
|
||||||
|
_totalStagesCompleted++;
|
||||||
|
_stagesSinceLastInterstitial++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The player watched a rewarded ad in the current round.
|
||||||
|
void onRewardedShown() => _rewardedShownThisRound = true;
|
||||||
|
|
||||||
|
/// An interstitial was actually shown. Resets the spacing counters.
|
||||||
|
void onInterstitialShown(DateTime now) {
|
||||||
|
_stagesSinceLastInterstitial = 0;
|
||||||
|
_lastInterstitialAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canShowInterstitial(DateTime now) {
|
||||||
|
if (_totalStagesCompleted <= firstStagesProtected) return false;
|
||||||
|
if (_rewardedShownThisRound) return false;
|
||||||
|
if (_stagesSinceLastInterstitial < minStagesBetween) return false;
|
||||||
|
final last = _lastInterstitialAt;
|
||||||
|
if (last != null && now.difference(last) < minInterval) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:block_seasons/services/ad_frequency_policy.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final t0 = DateTime(2026, 1, 1, 12, 0, 0);
|
||||||
|
|
||||||
|
AdFrequencyPolicy primed() {
|
||||||
|
// Past the 5-stage protection and the 3-stage gap, last ad long ago.
|
||||||
|
final p = AdFrequencyPolicy();
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
p.onRoundStart();
|
||||||
|
p.onStageCompleted();
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('first 5 stages are protected from interstitials', () {
|
||||||
|
final p = AdFrequencyPolicy();
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
p.onRoundStart();
|
||||||
|
p.onStageCompleted();
|
||||||
|
expect(p.canShowInterstitial(t0), isFalse, reason: 'stage ${i + 1}');
|
||||||
|
}
|
||||||
|
// 6th completed stage clears protection.
|
||||||
|
p.onRoundStart();
|
||||||
|
p.onStageCompleted();
|
||||||
|
expect(p.canShowInterstitial(t0), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('needs >=3 stages since the last interstitial', () {
|
||||||
|
final p = primed();
|
||||||
|
expect(p.canShowInterstitial(t0), isTrue);
|
||||||
|
p.onInterstitialShown(t0);
|
||||||
|
final later = t0.add(const Duration(seconds: 120));
|
||||||
|
// Only 2 stages since: still blocked even though time elapsed.
|
||||||
|
p.onRoundStart();
|
||||||
|
p.onStageCompleted();
|
||||||
|
p.onRoundStart();
|
||||||
|
p.onStageCompleted();
|
||||||
|
expect(p.canShowInterstitial(later), isFalse);
|
||||||
|
// 3rd stage opens the gate.
|
||||||
|
p.onRoundStart();
|
||||||
|
p.onStageCompleted();
|
||||||
|
expect(p.canShowInterstitial(later), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('needs >=90s since the last interstitial', () {
|
||||||
|
final p = primed();
|
||||||
|
p.onInterstitialShown(t0);
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
p.onRoundStart();
|
||||||
|
p.onStageCompleted();
|
||||||
|
}
|
||||||
|
expect(p.canShowInterstitial(t0.add(const Duration(seconds: 89))), isFalse);
|
||||||
|
expect(p.canShowInterstitial(t0.add(const Duration(seconds: 90))), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rewarded watched this round blocks the interstitial', () {
|
||||||
|
final p = primed();
|
||||||
|
expect(p.canShowInterstitial(t0), isTrue);
|
||||||
|
p.onRewardedShown();
|
||||||
|
expect(p.canShowInterstitial(t0), isFalse);
|
||||||
|
// Next round clears it.
|
||||||
|
p.onRoundStart();
|
||||||
|
p.onStageCompleted();
|
||||||
|
expect(p.canShowInterstitial(t0), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showing an interstitial resets the stage counter', () {
|
||||||
|
final p = primed();
|
||||||
|
p.onInterstitialShown(t0);
|
||||||
|
final later = t0.add(const Duration(seconds: 200));
|
||||||
|
expect(p.canShowInterstitial(later), isFalse); // 0 stages since
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user