Skip to content
Open
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ abstract class CachedAsyncNotifier<T> extends AsyncNotifier<T?> {
}

void _updateState(T? newState, {bool updateTimestamp = true}) {
if (!ref.mounted) {
return;
}

if (newState == null) {
state = const AsyncData(null);
return;
Expand All @@ -50,6 +54,10 @@ abstract class CachedAsyncNotifier<T> extends AsyncNotifier<T?> {
}

void _updateError(Object error, [StackTrace? stackTrace]) {
if (!ref.mounted) {
return;
}

state = AsyncError(error, stackTrace ?? StackTrace.current);
Logger().e(
'Error in $runtimeType: $error',
Expand All @@ -64,12 +72,14 @@ abstract class CachedAsyncNotifier<T> extends AsyncNotifier<T?> {
}) async {
try {
final result = await operation();
if (result != null) {
if (result != null && ref.mounted) {
_updateState(result, updateTimestamp: updateTimestamp);
}
return result;
} catch (err, st) {
_updateError(err, st);
if (ref.mounted) {
_updateError(err, st);
}
rethrow;
}
}
Expand Down Expand Up @@ -125,6 +135,11 @@ abstract class CachedAsyncNotifier<T> extends AsyncNotifier<T?> {
try {
state = const AsyncLoading();
final result = await loadFromRemote();

if (!ref.mounted) {
return result;
}

if (result != null) {
_updateState(result);
Logger().d('✅ Refreshed $runtimeType from remote!');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ final calendarProvider =
);

class CalendarNotifier extends CachedAsyncNotifier<LocalizedEvents> {
CalendarNotifier({CalendarFetcherJson? fetcher}) : _fetcher = fetcher;

final CalendarFetcherJson? _fetcher;

CalendarFetcherJson get fetcher => _fetcher ?? CalendarFetcherJson();

@override
Duration? get cacheDuration => const Duration(days: 30);

@override
Future<LocalizedEvents> loadFromStorage() async {
final fetcher = CalendarFetcherJson();
final ptEvents = await fetcher.getCalendar('pt');
final enEvents = await fetcher.getCalendar('en');

Expand Down
202 changes: 202 additions & 0 deletions packages/uni_app/test/unit/providers/calendar_provider_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uni/controller/fetchers/calendar_fetcher_json.dart';
import 'package:uni/controller/local_storage/preferences_controller.dart';
import 'package:uni/model/entities/app_locale.dart';
import 'package:uni/model/entities/calendar_event.dart';
import 'package:uni/model/providers/riverpod/calendar_provider.dart';

class FakeCalendarFetcher extends CalendarFetcherJson {
bool throwError = false;

Map<String, List<CalendarEvent>> mockGroups = {'pt': [], 'en': []};

@override
Future<List<CalendarEvent>> getCalendar(String locale) async {
if (throwError) {
throw Exception('Fetcher failed');
}

return mockGroups[locale] ?? [];
}
}

void main() {
late ProviderContainer container;
late FakeCalendarFetcher fakeFetcher;

setUp(() async {
SharedPreferences.setMockInitialValues({});
PreferencesController.prefs = await SharedPreferences.getInstance();

fakeFetcher = FakeCalendarFetcher();

container = ProviderContainer(
overrides: [
calendarProvider.overrideWith(
() => CalendarNotifier(fetcher: fakeFetcher),
),
],
);
addTearDown(() => container.dispose());
});

group('CalendarNotifier Tests', () {
test('Must load English event successfully', () async {
final event = CalendarEvent(
name: 'Start of classes, others',
startDate: DateTime.parse('2025-09-15'),
endDate: DateTime.parse('2025-09-15'),
);

fakeFetcher.mockGroups['en'] = [event];

final data = await container.read(calendarProvider.future);
expect(
data?.getEvents(AppLocale.en).first.name,
equals('Start of classes, others'),
);
expect(data?.getEvents(AppLocale.pt), isEmpty);
});

test('Must load Portuguese event successfully', () async {
final event = CalendarEvent(
name: 'Início de aulas, outros',
startDate: DateTime.parse('2025-09-15'),
endDate: DateTime.parse('2025-09-15'),
);

fakeFetcher.mockGroups['pt'] = [event];

final data = await container.read(calendarProvider.future);
expect(
data?.getEvents(AppLocale.pt).first.name,
equals('Início de aulas, outros'),
);
expect(data?.getEvents(AppLocale.en), isEmpty);
});
});

test('Must return empty lists when no events are provided', () async {
fakeFetcher.mockGroups['en'] = [];
fakeFetcher.mockGroups['pt'] = [];

final data = await container.read(calendarProvider.future);

expect(data?.getEvents(AppLocale.en), isEmpty);
expect(data?.getEvents(AppLocale.pt), isEmpty);
expect(data?.getEvents(AppLocale.en).length, 0);
expect(data?.getEvents(AppLocale.pt).length, 0);
Comment on lines +89 to +90
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

Redundant test assertions. Lines 87-88 check if the lists are empty using isEmpty, and lines 89-90 check if the length is 0. These assertions are testing the same condition in different ways. Consider removing lines 89-90 as they add no additional test coverage.

Suggested change
expect(data?.getEvents(AppLocale.en).length, 0);
expect(data?.getEvents(AppLocale.pt).length, 0);

Copilot uses AI. Check for mistakes.
});
test('Must handle fetcher error gracefully', () async {
fakeFetcher.throwError = true;

await container.read(calendarProvider.future).catchError((_) => null);

final state = container.read(calendarProvider);

expect(state.hasError, isTrue);
expect(state.error.toString(), contains('Fetcher failed'));
});

test('Must reload from remote if cache is expired', () async {
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

The comment mentions "31 days" as the cache period, but the actual cacheDuration is 30 days as defined in CalendarNotifier. Update the comment to accurately reflect the 30-day cache duration.

Copilot uses AI. Check for mistakes.
final oldDate = DateTime.now().subtract(const Duration(days: 31));
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

The test uses 31 days for cache expiration, but the CalendarNotifier has a cacheDuration of 30 days (line 20 in calendar_provider.dart). This mismatch means the test is using an incorrect value. The test should use 30 days to match the actual cache duration, or use a value slightly larger than 30 days to ensure the cache is expired.

Suggested change
final oldDate = DateTime.now().subtract(const Duration(days: 31));
final oldDate = DateTime.now().subtract(const Duration(days: 30));

Copilot uses AI. Check for mistakes.
await PreferencesController.setLastDataClassUpdateTime(
'CalendarNotifier',
oldDate,
);

fakeFetcher.mockGroups['en'] = [
CalendarEvent(
name: 'Início de aulas, outros',
startDate: DateTime.parse('2025-09-15'),
endDate: DateTime.parse('2025-09-15'),
),
Comment on lines +110 to +115
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

The event name is in Portuguese ('Início de aulas, outros') but it's being added to the English events list (fakeFetcher.mockGroups['en']). This appears to be a copy-paste error. The event should either have an English name like 'Start of classes, others' to match the language, or should be added to the Portuguese events list instead.

Copilot uses AI. Check for mistakes.
];

final data = await container.read(calendarProvider.future);

await Future<void>.delayed(Duration.zero);

final dataPostLoading = PreferencesController.getLastDataClassUpdateTime(
'CalendarNotifier',
);

expect(dataPostLoading!.isAfter(oldDate), isTrue);
expect(
data?.getEvents(AppLocale.en).first.name,
equals('Início de aulas, outros'),
);
});

test('Data must update when the language changes', () async {
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

The test name suggests it validates behavior when the language changes, but it doesn't actually test a language change scenario. The test loads data once, invalidates the provider, and loads again, but both calls would fetch both PT and EN events simultaneously (as seen in loadFromStorage). This test actually validates that provider invalidation works, not that data updates when language changes. Consider renaming the test to better reflect what it actually tests, such as 'Must reload all events when provider is invalidated'.

Suggested change
test('Data must update when the language changes', () async {
test('Must reload all events when provider is invalidated', () async {

Copilot uses AI. Check for mistakes.
final eventPT = CalendarEvent(
name: 'Início de aulas, outros',
startDate: DateTime.parse('2025-09-15'),
endDate: DateTime.parse('2025-09-15'),
);

final eventEN = CalendarEvent(
name: 'Start of classes, others',
startDate: DateTime.parse('2025-09-15'),
endDate: DateTime.parse('2025-09-15'),
);

fakeFetcher.mockGroups['pt'] = [eventPT];
fakeFetcher.mockGroups['en'] = [eventEN];

final dataPt = await container.read(calendarProvider.future);

expect(
dataPt?.getEvents(AppLocale.pt).first.name,
equals('Início de aulas, outros'),
);

container.invalidate(calendarProvider);

final dataEn = await container.read(calendarProvider.future);

expect(
dataEn?.getEvents(AppLocale.en).first.name,
equals('Start of classes, others'),
);
});

test(
'Should not update timestamp when reading the same value on valid cache',
() async {
final now = DateTime.now();

await PreferencesController.setLastDataClassUpdateTime(
'CalendarNotifier',
now,
);

final eventPT = CalendarEvent(
name: 'Início de aulas, outros',
startDate: DateTime.parse('2025-09-15'),
endDate: DateTime.parse('2025-09-15'),
);

fakeFetcher.mockGroups['pt'] = [eventPT];

await container.read(calendarProvider.future);

final time1 = PreferencesController.getLastDataClassUpdateTime(
'CalendarNotifier',
);

await Future<void>.delayed(Duration.zero);

await container.read(calendarProvider.future);
final time2 = PreferencesController.getLastDataClassUpdateTime(
'CalendarNotifier',
);

await Future<void>.delayed(Duration.zero);

expect(time1, equals(time2));
},
);
}