Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions test/notification_dedupe_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,42 @@ class _GatedSink {
void release() => _gate.complete();
}

/// TODAY's label, never a hardcoded date.
///
/// These keys are date-PREFIXED, and `FiredKeyStore` prunes dated flags older
/// than [FiredKeyStore.retentionDays] (14). A literal date is therefore a time
/// bomb: it works while it is recent, then on one particular morning ages out
/// of the retention window and every dedupe assertion in this file starts
/// failing at once -- with no code change and nothing to point at.
///
/// That is exactly what happened. This suite was written around a fixed date in
/// July, passed CI on 2026-08-04 while it was 12 days old, and began failing
/// once it fell outside the window. Anchoring to `todayLabel()` keeps the keys
/// inside the retention window permanently, which is the condition the dedupe
/// guard is actually specified against.
final String _today = todayLabel();
final String _tomorrow = _dayLabelOffset(1);

String _dayLabelOffset(int days) {
final d = DateTime.now().add(Duration(days: days));
return '${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
}
Comment on lines +80 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Derive both labels from one timestamp and the canonical formatter.

_dayLabelOffset calls DateTime.now() independently from _today. If initialization crosses local midnight, _tomorrow can skip a calendar day. The manual formatter can also diverge from todayLabel in lib/data/day_label.dart at Line [24].

Capture one DateTime and derive both labels with todayLabel.

Proposed fix
-final String _today = todayLabel();
-final String _tomorrow = _dayLabelOffset(1);
+final _testNow = DateTime.now();
+final String _today = todayLabel(_testNow);
+final String _tomorrow = _dayLabelOffset(_testNow, 1);

-String _dayLabelOffset(int days) {
-  final d = DateTime.now().add(Duration(days: days));
-  return '${d.year.toString().padLeft(4, '0')}-'
-      '${d.month.toString().padLeft(2, '0')}-'
-      '${d.day.toString().padLeft(2, '0')}';
-}
+String _dayLabelOffset(DateTime base, int days) =>
+    todayLabel(DateTime(base.year, base.month, base.day + days));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/notification_dedupe_test.dart` around lines 80 - 87, Update the test
label initialization around _today, _tomorrow, and _dayLabelOffset to capture a
single DateTime value, derive both offsets from that timestamp, and reuse the
canonical todayLabel formatter from lib/data/day_label.dart. Remove the manual
formatting in _dayLabelOffset while preserving the expected today and tomorrow
labels.


NotificationEvent _ev(
String dedupeKey, {
NotifCategory category = NotifCategory.health,
NotifPriority priority = NotifPriority.critical,
String date = '2026-07-23',
String? date,
}) =>
NotificationEvent(
dedupeKey: dedupeKey,
category: category,
priority: priority,
title: 't',
body: 'b',
date: date,
date: date ?? _today,
);

void main() {
Expand All @@ -101,7 +124,7 @@ void main() {
final sink = _FakeSink();
center.presentSink = sink.call;

final e = _ev('2026-07-23:irregular');
final e = _ev('$_today:irregular');
await center.emit(e);
await center.emit(e); // re-derive would re-emit the same key
await center.emit(e);
Expand All @@ -113,13 +136,13 @@ void main() {
final sink = _FakeSink();
center.presentSink = sink.call;

await center.emit(_ev('2026-07-23:irregular', date: '2026-07-23'));
await center.emit(_ev('2026-07-24:irregular', date: '2026-07-24'));
await center.emit(_ev('$_today:irregular', date: _today));
await center.emit(_ev('$_tomorrow:irregular', date: _tomorrow));

expect(sink.shown.length, 2);
expect(
sink.shown.map((e) => e.dedupeKey),
containsAll(['2026-07-23:irregular', '2026-07-24:irregular']),
containsAll(['$_today:irregular', '$_tomorrow:irregular']),
);
});

Expand All @@ -128,14 +151,14 @@ void main() {
// the same on-disk store that survives an app restart on-device.
final sink1 = _FakeSink();
center.presentSink = sink1.call;
await center.emit(_ev('2026-07-23:illness'));
await center.emit(_ev('$_today:illness'));
expect(sink1.shown.length, 1);

// Second "session": same persisted store — the key is still remembered,
// so it must NOT fire again.
final sink2 = _FakeSink();
center.presentSink = sink2.call;
await center.emit(_ev('2026-07-23:illness'));
await center.emit(_ev('$_today:illness'));
expect(sink2.shown, isEmpty);
});

Expand All @@ -144,11 +167,11 @@ void main() {
// still lets it fire.
final denied = _FakeSink(grant: false);
center.presentSink = denied.call;
await center.emit(_ev('2026-07-23:temp'));
await center.emit(_ev('$_today:temp'));

final granted = _FakeSink();
center.presentSink = granted.call;
await center.emit(_ev('2026-07-23:temp'));
await center.emit(_ev('$_today:temp'));
expect(granted.shown.length, 1);
});
});
Expand All @@ -159,12 +182,12 @@ void main() {
final sink = _FakeSink();
center.presentSink = sink.call;

await center.emit(_ev('2026-07-23:illness', category: NotifCategory.health));
await center.emit(_ev('$_today:illness', category: NotifCategory.health));
expect(sink.shown, isEmpty);

// Re-enabling the category later must let the key fire — the gate, not the
// dedupe guard, suppressed it, so no key should have been recorded.
expect(await const FiredKeyStore().hasFired('2026-07-23:illness'), isFalse);
expect(await const FiredKeyStore().hasFired('$_today:illness'), isFalse);
});

test('quiet hours suppress a non-critical event', () async {
Expand All @@ -178,7 +201,7 @@ void main() {
center.presentSink = sink.call;

await center.emit(_ev(
'2026-07-23:recovery',
'$_today:recovery',
category: NotifCategory.recovery,
priority: NotifPriority.normal,
));
Expand All @@ -195,7 +218,7 @@ void main() {
final sink = _FakeSink();
center.presentSink = sink.call;

final e = _ev('2026-07-23:illness', priority: NotifPriority.critical);
final e = _ev('$_today:illness', priority: NotifPriority.critical);
await center.emit(e);
await center.emit(e);
expect(sink.shown.length, 1);
Expand All @@ -208,7 +231,7 @@ void main() {
final sink = _GatedSink();
center.presentSink = sink.call;

final e = _ev('2026-07-23:irregular');
final e = _ev('$_today:irregular');
final f1 = center.emit(e);
final f2 = center.emit(e);
// Order on the sink's entry signal, not a timer: once the first emit is
Expand All @@ -228,8 +251,8 @@ void main() {
final sink = _GatedSink();
center.presentSink = sink.call;

final f1 = center.emit(_ev('2026-07-23:a'));
final f2 = center.emit(_ev('2026-07-23:b'));
final f1 = center.emit(_ev('$_today:a'));
final f2 = center.emit(_ev('$_today:b'));
// First emit is parked inside present; the second is held on the lock, so
// its record-key write can only run after the first's — no interleaving.
await sink.entered;
Expand All @@ -239,8 +262,8 @@ void main() {
expect(sink.calls, 2);
// Independent per-key flags: neither key clobbered the other.
const store = FiredKeyStore();
expect(await store.hasFired('2026-07-23:a'), isTrue);
expect(await store.hasFired('2026-07-23:b'), isTrue);
expect(await store.hasFired('$_today:a'), isTrue);
expect(await store.hasFired('$_today:b'), isTrue);
});
});

Expand All @@ -249,11 +272,11 @@ void main() {
// (normal) priority, no route. It used to call presentEvent directly,
// bypassing both the gate and the dedupe guard — now it goes through emit.
NotificationEvent highStress() => NotificationEvent(
dedupeKey: '2026-07-23:high_stress',
dedupeKey: '$_today:high_stress',
category: NotifCategory.health,
title: 'High Stress Detected',
body: 'Your stress score is 82. Consider taking a moment to breathe.',
date: '2026-07-23',
date: _today,
);

test('dedupes on repeat (was previously re-alerting per screen visit)',
Expand Down