diff --git a/lib/pangea/chat_settings/utils/bot_client_extension.dart b/lib/pangea/chat_settings/utils/bot_client_extension.dart index 134246ba5..bf934c9a9 100644 --- a/lib/pangea/chat_settings/utils/bot_client_extension.dart +++ b/lib/pangea/chat_settings/utils/bot_client_extension.dart @@ -153,9 +153,7 @@ extension BotClientExtension on Client { onError: (e, s) => ErrorHandler.logError( e: e, s: s, - data: { - 'userSettings': userSettings.toJson(), - }, + data: {'userSettings': userSettings.toJson()}, ), ); } diff --git a/lib/pangea/practice_activities/practice_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index 977321053..a8179ae6e 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -1,20 +1,27 @@ +import 'package:get_storage/get_storage.dart'; + import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/practice_activities/practice_selection.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:get_storage/get_storage.dart'; class _PracticeSelectionCacheEntry { final PracticeSelection selection; final DateTime timestamp; - _PracticeSelectionCacheEntry({required this.selection, required this.timestamp}); + _PracticeSelectionCacheEntry({ + required this.selection, + required this.timestamp, + }); bool get isExpired => DateTime.now().difference(timestamp).inDays > 1; - Map toJson() => {'selection': selection.toJson(), 'timestamp': timestamp.toIso8601String()}; + Map toJson() => { + 'selection': selection.toJson(), + 'timestamp': timestamp.toIso8601String(), + }; factory _PracticeSelectionCacheEntry.fromJson(Map json) { return _PracticeSelectionCacheEntry( @@ -27,7 +34,11 @@ class _PracticeSelectionCacheEntry { class PracticeSelectionRepo { static final GetStorage _storage = GetStorage('practice_selection_cache'); - static Future get(String eventId, String messageLanguage, List tokens) async { + static Future get( + String eventId, + String messageLanguage, + List tokens, + ) async { final userL2 = MatrixState.pangeaController.userController.userL2; if (userL2?.langCodeShort != messageLanguage.split("-").first) { return null; @@ -42,8 +53,12 @@ class PracticeSelectionRepo { return newEntry; } - static Future _fetch({required List tokens, required String langCode}) async { - if (langCode.split("-")[0] != MatrixState.pangeaController.userController.userL2?.langCodeShort) { + static Future _fetch({ + required List tokens, + required String langCode, + }) async { + if (langCode.split("-")[0] != + MatrixState.pangeaController.userController.userL2?.langCodeShort) { return PracticeSelection({}); } @@ -51,7 +66,10 @@ class PracticeSelectionRepo { if (eligibleTokens.isEmpty) { return PracticeSelection({}); } - final queue = await _fillActivityQueue(eligibleTokens, langCode.split('-')[0]); + final queue = await _fillActivityQueue( + eligibleTokens, + langCode.split('-')[0], + ); final selection = PracticeSelection(queue); return selection; } @@ -60,7 +78,9 @@ class PracticeSelectionRepo { try { final keys = List.from(_storage.getKeys()); for (final String key in keys) { - final cacheEntry = _PracticeSelectionCacheEntry.fromJson(_storage.read(key)); + final cacheEntry = _PracticeSelectionCacheEntry.fromJson( + _storage.read(key), + ); if (cacheEntry.isExpired) { _storage.remove(key); } @@ -74,7 +94,9 @@ class PracticeSelectionRepo { if (entry == null) return null; try { - return _PracticeSelectionCacheEntry.fromJson(_storage.read(eventId)).selection; + return _PracticeSelectionCacheEntry.fromJson( + _storage.read(eventId), + ).selection; } catch (e) { _storage.remove(eventId); return null; @@ -82,7 +104,10 @@ class PracticeSelectionRepo { } static void _setCached(String eventId, PracticeSelection entry) { - final cachedEntry = _PracticeSelectionCacheEntry(selection: entry, timestamp: DateTime.now()); + final cachedEntry = _PracticeSelectionCacheEntry( + selection: entry, + timestamp: DateTime.now(), + ); _storage.write(eventId, cachedEntry.toJson()); } @@ -97,9 +122,19 @@ class PracticeSelectionRepo { return queue; } - static int _sortTokens(PangeaToken a, PangeaToken b, int aScore, int bScore) => bScore.compareTo(aScore); + static int _sortTokens( + PangeaToken a, + PangeaToken b, + int aScore, + int bScore, + ) => bScore.compareTo(aScore); - static int _sortMorphTargets(PracticeTarget a, PracticeTarget b, int aScore, int bScore) => bScore.compareTo(aScore); + static int _sortMorphTargets( + PracticeTarget a, + PracticeTarget b, + int aScore, + int bScore, + ) => bScore.compareTo(aScore); static List _tokenToMorphTargets(PangeaToken t) { return t.morphsBasicallyEligibleForPracticeByPriority @@ -136,7 +171,11 @@ class PracticeSelectionRepo { return []; } - final scores = await _fetchPriorityScores(practiceTokens, activityType, language); + final scores = await _fetchPriorityScores( + practiceTokens, + activityType, + language, + ); practiceTokens.sort((a, b) => _sortTokens(a, b, scores[a]!, scores[b]!)); practiceTokens = practiceTokens.take(8).toList(); @@ -150,11 +189,25 @@ class PracticeSelectionRepo { ]; } - static Future> _buildMorphActivity(List tokens, String language) async { + static Future> _buildMorphActivity( + List tokens, + String language, + ) async { final List practiceTokens = List.from(tokens); final candidates = practiceTokens.expand(_tokenToMorphTargets).toList(); - final scores = await _fetchPriorityScores(practiceTokens, ActivityTypeEnum.morphId, language); - candidates.sort((a, b) => _sortMorphTargets(a, b, scores[a.tokens.first]!, scores[b.tokens.first]!)); + final scores = await _fetchPriorityScores( + practiceTokens, + ActivityTypeEnum.morphId, + language, + ); + candidates.sort( + (a, b) => _sortMorphTargets( + a, + b, + scores[a.tokens.first]!, + scores[b.tokens.first]!, + ), + ); final seenTexts = {}; final seenLemmas = {}; @@ -179,18 +232,24 @@ class PracticeSelectionRepo { final ids = tokens.map((t) => t.vocabConstructID).toList(); final idMap = {for (final token in tokens) token: token.vocabConstructID}; - final constructs = await MatrixState.pangeaController.matrixState.analyticsDataService.getConstructUses( - ids, - language, - ); + final constructs = await MatrixState + .pangeaController + .matrixState + .analyticsDataService + .getConstructUses(ids, language); for (final token in tokens) { final construct = constructs[idMap[token]]; - final lastUsed = construct?.lastUseByTypes(activityType.associatedUseTypes); + final lastUsed = construct?.lastUseByTypes( + activityType.associatedUseTypes, + ); - final daysSinceLastUsed = lastUsed == null ? 20 : DateTime.now().difference(lastUsed).inDays; + final daysSinceLastUsed = lastUsed == null + ? 20 + : DateTime.now().difference(lastUsed).inDays; - scores[token] = daysSinceLastUsed * (token.vocabConstructID.isContentWord ? 10 : 7); + scores[token] = + daysSinceLastUsed * (token.vocabConstructID.isContentWord ? 10 : 7); } return scores; } diff --git a/pubspec.lock b/pubspec.lock index d0bed7543..9cdac0e30 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1486,7 +1486,7 @@ packages: source: git version: "4.1.0" meta: - dependency: transitive + dependency: "direct main" description: name: meta sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" diff --git a/pubspec.yaml b/pubspec.yaml index fa51d0f34..81fd1a46d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -126,6 +126,7 @@ dependencies: rive: 0.11.11 flutter_tts: ^4.2.0 animated_flip_counter: ^0.3.4 + meta: ^1.17.0 # Pangea# dev_dependencies: diff --git a/test/pangea/update_bot_options_test.dart b/test/pangea/update_bot_options_test.dart index c143b84b7..ba9ec3eb6 100644 --- a/test/pangea/update_bot_options_test.dart +++ b/test/pangea/update_bot_options_test.dart @@ -1,9 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; import 'package:fluffychat/pangea/chat_settings/utils/bot_client_extension.dart'; import 'package:fluffychat/pangea/learning_settings/gender_enum.dart'; import 'package:fluffychat/pangea/learning_settings/language_level_type_enum.dart'; import 'package:fluffychat/pangea/user/user_model.dart'; -import 'package:flutter_test/flutter_test.dart'; void main() { // --------------------------------------------------------------------------- @@ -27,7 +28,11 @@ void main() { userGenders: const {userId: GenderEnum.woman}, ); - final result = buildUpdatedBotOptions(currentOptions: currentOptions, userSettings: baseSettings, userId: userId); + final result = buildUpdatedBotOptions( + currentOptions: currentOptions, + userSettings: baseSettings, + userId: userId, + ); expect(result, isNull); }); @@ -40,7 +45,11 @@ void main() { userGenders: const {userId: GenderEnum.woman}, ); - final result = buildUpdatedBotOptions(currentOptions: currentOptions, userSettings: baseSettings, userId: userId); + final result = buildUpdatedBotOptions( + currentOptions: currentOptions, + userSettings: baseSettings, + userId: userId, + ); expect(result, isNotNull); expect(result!.targetLanguage, 'es'); @@ -57,7 +66,11 @@ void main() { userGenders: const {userId: GenderEnum.woman}, ); - final result = buildUpdatedBotOptions(currentOptions: currentOptions, userSettings: baseSettings, userId: userId); + final result = buildUpdatedBotOptions( + currentOptions: currentOptions, + userSettings: baseSettings, + userId: userId, + ); expect(result, isNotNull); expect(result!.languageLevel, LanguageLevelTypeEnum.b1); @@ -71,7 +84,11 @@ void main() { userGenders: const {userId: GenderEnum.woman}, ); - final result = buildUpdatedBotOptions(currentOptions: currentOptions, userSettings: baseSettings, userId: userId); + final result = buildUpdatedBotOptions( + currentOptions: currentOptions, + userSettings: baseSettings, + userId: userId, + ); expect(result, isNotNull); expect(result!.targetVoice, 'voice_1'); @@ -85,14 +102,22 @@ void main() { userGenders: const {userId: GenderEnum.man}, ); - final result = buildUpdatedBotOptions(currentOptions: currentOptions, userSettings: baseSettings, userId: userId); + final result = buildUpdatedBotOptions( + currentOptions: currentOptions, + userSettings: baseSettings, + userId: userId, + ); expect(result, isNotNull); expect(result!.userGenders[userId], GenderEnum.woman); }); test('defaults to empty BotOptionsModel when currentOptions is null', () { - final result = buildUpdatedBotOptions(currentOptions: null, userSettings: baseSettings, userId: userId); + final result = buildUpdatedBotOptions( + currentOptions: null, + userSettings: baseSettings, + userId: userId, + ); expect(result, isNotNull); expect(result!.targetLanguage, 'es'); @@ -106,10 +131,17 @@ void main() { targetLanguage: 'fr', // different → triggers update languageLevel: LanguageLevelTypeEnum.b1, targetVoice: 'voice_1', - userGenders: const {'@other:server': GenderEnum.man, userId: GenderEnum.woman}, + userGenders: const { + '@other:server': GenderEnum.man, + userId: GenderEnum.woman, + }, ); - final result = buildUpdatedBotOptions(currentOptions: currentOptions, userSettings: baseSettings, userId: userId); + final result = buildUpdatedBotOptions( + currentOptions: currentOptions, + userSettings: baseSettings, + userId: userId, + ); expect(result, isNotNull); expect(result!.userGenders['@other:server'], GenderEnum.man); @@ -119,7 +151,11 @@ void main() { test('handles null userId gracefully', () { const currentOptions = BotOptionsModel(targetLanguage: 'fr'); - final result = buildUpdatedBotOptions(currentOptions: currentOptions, userSettings: baseSettings, userId: null); + final result = buildUpdatedBotOptions( + currentOptions: currentOptions, + userSettings: baseSettings, + userId: null, + ); expect(result, isNotNull); expect(result!.targetLanguage, 'es'); @@ -193,52 +229,58 @@ void main() { ); }); - test('remaining updates do NOT execute when priority update fails', () async { - final callLog = []; + test( + 'remaining updates do NOT execute when priority update fails', + () async { + final callLog = []; + + try { + await applyBotOptionUpdatesInOrder( + priorityUpdate: () async { + throw Exception('DM failed'); + }, + remainingUpdates: [ + () async { + callLog.add('room_a'); + }, + ], + ); + } catch (_) {} + + expect(callLog, isEmpty); + }, + ); + + test( + 'isolates errors in remaining updates and continues to next room', + () async { + final callLog = []; + final errors = []; - try { await applyBotOptionUpdatesInOrder( priorityUpdate: () async { - throw Exception('DM failed'); + callLog.add('dm'); }, remainingUpdates: [ () async { callLog.add('room_a'); }, + () async { + throw Exception('room_b failed'); + }, + () async { + callLog.add('room_c'); + }, ], + onError: (e, _) => errors.add(e), ); - } catch (_) {} - expect(callLog, isEmpty); - }); - - test('isolates errors in remaining updates and continues to next room', () async { - final callLog = []; - final errors = []; - - await applyBotOptionUpdatesInOrder( - priorityUpdate: () async { - callLog.add('dm'); - }, - remainingUpdates: [ - () async { - callLog.add('room_a'); - }, - () async { - throw Exception('room_b failed'); - }, - () async { - callLog.add('room_c'); - }, - ], - onError: (e, _) => errors.add(e), - ); - - // room_b's error didn't prevent room_c from running - expect(callLog, ['dm', 'room_a', 'room_c']); - expect(errors, hasLength(1)); - expect(errors.first, isA()); - }); + // room_b's error didn't prevent room_c from running + expect(callLog, ['dm', 'room_a', 'room_c']); + expect(errors, hasLength(1)); + expect(errors.first, isA()); + }, + ); test('works correctly when priority update is null', () async { final callLog = []; @@ -273,7 +315,10 @@ void main() { test('handles all null / empty gracefully', () async { // Should complete without error - await applyBotOptionUpdatesInOrder(priorityUpdate: null, remainingUpdates: []); + await applyBotOptionUpdatesInOrder( + priorityUpdate: null, + remainingUpdates: [], + ); }); test('multiple remaining errors are all reported', () async {