This commit is contained in:
Dmitry 2025-12-03 01:16:35 +03:00
parent 654b301df7
commit 4e07213e20
13 changed files with 981 additions and 306 deletions

View file

@ -25,6 +25,12 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
InputButtonsQuestionType.translation_and_image_original, InputButtonsQuestionType.translation_and_image_original,
}; };
/// Convert image ID to proper URL path
String? _imageIdToUrl(String? imageId) {
if (imageId == null || data.packId == null) return null;
return '/api/v2/packs/${data.packId}/cards/$imageId/image';
}
InputButtonsQuestionType _getRandomQuestionType() { InputButtonsQuestionType _getRandomQuestionType() {
final types = (possibleTypes?.toList() ?? InputButtonsQuestionType.values); final types = (possibleTypes?.toList() ?? InputButtonsQuestionType.values);
return types[random.nextInt(types.length)]; return types[random.nextInt(types.length)];
@ -47,7 +53,9 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
final questionType = type ?? _getRandomQuestionType(); final questionType = type ?? _getRandomQuestionType();
final questionText = final questionText =
questionType.translationQuestion ? answerItem.translation : null; questionType.translationQuestion ? answerItem.translation : null;
final questionImage = questionType.imageQuestion ? answerItem.image : null; final questionImage = questionType.imageQuestion
? _imageIdToUrl(answerItem.image)
: null;
String? questionAudio = null; String? questionAudio = null;
if (questionType.audioQuestion) { if (questionType.audioQuestion) {
@ -108,6 +116,7 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
); );
} }
Future<InputButtonsTestQuestionBody> verbTestQuestion( Future<InputButtonsTestQuestionBody> verbTestQuestion(
TestDataItem answerCard) async { TestDataItem answerCard) async {
final ends = ['o', 'as', 'a', 'a', 'a', 'amos', 'ais', 'an', 'an', 'an']; final ends = ['o', 'as', 'a', 'a', 'a', 'amos', 'ais', 'an', 'an', 'an'];

View file

@ -25,6 +25,12 @@ class SimpleQuestionGenerator implements QuestionGenerator {
SimpleQuestionType.audio_images, SimpleQuestionType.audio_images,
}; };
/// Convert image ID to proper URL path
String? _imageIdToUrl(String? imageId) {
if (imageId == null || data.packId == null) return null;
return '/api/v2/packs/${data.packId}/cards/$imageId/image';
}
SimpleQuestionType _getRandomQuestionType() { SimpleQuestionType _getRandomQuestionType() {
final types = (possibleTypes?.toList() ?? SimpleQuestionType.values); final types = (possibleTypes?.toList() ?? SimpleQuestionType.values);
return types[random.nextInt(types.length)]; return types[random.nextInt(types.length)];
@ -46,7 +52,9 @@ class SimpleQuestionGenerator implements QuestionGenerator {
: questionType.translationQuestion : questionType.translationQuestion
? answerCard.translation ? answerCard.translation
: null; : null;
final questionImage = questionType.imageQuestion ? answerCard.image : null; final questionImage = questionType.imageQuestion
? _imageIdToUrl(answerCard.image)
: null;
String? questionAudio = null; String? questionAudio = null;
if (questionType.audioQuestion) { if (questionType.audioQuestion) {
if (questionType.translationAnswers || questionType.imagesAnswers) { if (questionType.translationAnswers || questionType.imagesAnswers) {
@ -79,12 +87,13 @@ class SimpleQuestionGenerator implements QuestionGenerator {
) )
else if (questionType.imagesAnswers) else if (questionType.imagesAnswers)
...answers.map( ...answers.map(
(e) => TestButtonDto.image(e.id, e.image), (e) => TestButtonDto.image(e.id, _imageIdToUrl(e.image)),
) )
], ],
); );
} }
Future<SimpleTestQuestionBody> verbTestQuestion( Future<SimpleTestQuestionBody> verbTestQuestion(
TestDataItem answerCard, { TestDataItem answerCard, {
bool withAudio = true, bool withAudio = true,

View file

@ -1 +1 @@
1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf  asset1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf icons/ad.webp  asset icons/ad.webpicons/back.webp  asseticons/back.webpicons/cards.webp  asseticons/cards.webpicons/chat.webp  asseticons/chat.webpicons/clock.webp  asseticons/clock.webpicons/crown.webp  asseticons/crown.webpicons/down.webp  asseticons/down.webpicons/download.webp  asseticons/download.webpicons/exit.webp  asseticons/exit.webpicons/exit2.webp  asseticons/exit2.webpicons/exit3.webp  asseticons/exit3.webpicons/gift.webp  asseticons/gift.webpicons/google.webp  asseticons/google.webpicons/heart.webp  asseticons/heart.webpicons/heart_fill.webp  asseticons/heart_fill.webpicons/lock.webp  asseticons/lock.webpicons/mic_off.webp  asseticons/mic_off.webpicons/mic_on.webp  asseticons/mic_on.webpicons/next.webp  asseticons/next.webpicons/profile.webp  asseticons/profile.webpicons/settings.webp  asseticons/settings.webpicons/shuffle.webp  asseticons/shuffle.webpicons/skull.webp  asseticons/skull.webpicons/small_circle.webp  asseticons/small_circle.webpicons/sound_off.webp  asseticons/sound_off.webpicons/sound_on.webp  asseticons/sound_on.webpicons/telegram.webp  asseticons/telegram.webp icons/tg.webp  asset icons/tg.webpicons/view.webp  asseticons/view.webp2packages/cupertino_icons/assets/CupertinoIcons.ttf  asset2packages/cupertino_icons/assets/CupertinoIcons.ttf "fonts/Nunito-VariableFont_wght.ttf  asset"fonts/Nunito-VariableFont_wght.ttf icons/ad.webp  asset icons/ad.webpicons/back.webp  asseticons/back.webpicons/cards.webp  asseticons/cards.webpicons/chat.webp  asseticons/chat.webpicons/clock.webp  asseticons/clock.webpicons/crown.webp  asseticons/crown.webpicons/down.webp  asseticons/down.webpicons/download.webp  asseticons/download.webpicons/exit.webp  asseticons/exit.webpicons/exit2.webp  asseticons/exit2.webpicons/exit3.webp  asseticons/exit3.webpicons/gift.webp  asseticons/gift.webpicons/google.webp  asseticons/google.webpicons/heart.webp  asseticons/heart.webpicons/heart_fill.webp  asseticons/heart_fill.webpicons/lock.webp  asseticons/lock.webpicons/mic_off.webp  asseticons/mic_off.webpicons/mic_on.webp  asseticons/mic_on.webpicons/next.webp  asseticons/next.webpicons/profile.webp  asseticons/profile.webpicons/settings.webp  asseticons/settings.webpicons/shuffle.webp  asseticons/shuffle.webpicons/skull.webp  asseticons/skull.webpicons/small_circle.webp  asseticons/small_circle.webpicons/sound_off.webp  asseticons/sound_off.webpicons/sound_on.webp  asseticons/sound_on.webpicons/telegram.webp  asseticons/telegram.webp icons/tg.webp  asset icons/tg.webpicons/view.webp  asseticons/view.webp2packages/cupertino_icons/assets/CupertinoIcons.ttf  asset2packages/cupertino_icons/assets/CupertinoIcons.ttf

View file

@ -1 +1 @@
{"../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf":["../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf"],"icons/ad.webp":["icons/ad.webp"],"icons/back.webp":["icons/back.webp"],"icons/cards.webp":["icons/cards.webp"],"icons/chat.webp":["icons/chat.webp"],"icons/clock.webp":["icons/clock.webp"],"icons/crown.webp":["icons/crown.webp"],"icons/down.webp":["icons/down.webp"],"icons/download.webp":["icons/download.webp"],"icons/exit.webp":["icons/exit.webp"],"icons/exit2.webp":["icons/exit2.webp"],"icons/exit3.webp":["icons/exit3.webp"],"icons/gift.webp":["icons/gift.webp"],"icons/google.webp":["icons/google.webp"],"icons/heart.webp":["icons/heart.webp"],"icons/heart_fill.webp":["icons/heart_fill.webp"],"icons/lock.webp":["icons/lock.webp"],"icons/mic_off.webp":["icons/mic_off.webp"],"icons/mic_on.webp":["icons/mic_on.webp"],"icons/next.webp":["icons/next.webp"],"icons/profile.webp":["icons/profile.webp"],"icons/settings.webp":["icons/settings.webp"],"icons/shuffle.webp":["icons/shuffle.webp"],"icons/skull.webp":["icons/skull.webp"],"icons/small_circle.webp":["icons/small_circle.webp"],"icons/sound_off.webp":["icons/sound_off.webp"],"icons/sound_on.webp":["icons/sound_on.webp"],"icons/telegram.webp":["icons/telegram.webp"],"icons/tg.webp":["icons/tg.webp"],"icons/view.webp":["icons/view.webp"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]} {"fonts/Nunito-VariableFont_wght.ttf":["fonts/Nunito-VariableFont_wght.ttf"],"icons/ad.webp":["icons/ad.webp"],"icons/back.webp":["icons/back.webp"],"icons/cards.webp":["icons/cards.webp"],"icons/chat.webp":["icons/chat.webp"],"icons/clock.webp":["icons/clock.webp"],"icons/crown.webp":["icons/crown.webp"],"icons/down.webp":["icons/down.webp"],"icons/download.webp":["icons/download.webp"],"icons/exit.webp":["icons/exit.webp"],"icons/exit2.webp":["icons/exit2.webp"],"icons/exit3.webp":["icons/exit3.webp"],"icons/gift.webp":["icons/gift.webp"],"icons/google.webp":["icons/google.webp"],"icons/heart.webp":["icons/heart.webp"],"icons/heart_fill.webp":["icons/heart_fill.webp"],"icons/lock.webp":["icons/lock.webp"],"icons/mic_off.webp":["icons/mic_off.webp"],"icons/mic_on.webp":["icons/mic_on.webp"],"icons/next.webp":["icons/next.webp"],"icons/profile.webp":["icons/profile.webp"],"icons/settings.webp":["icons/settings.webp"],"icons/shuffle.webp":["icons/shuffle.webp"],"icons/skull.webp":["icons/skull.webp"],"icons/small_circle.webp":["icons/small_circle.webp"],"icons/sound_off.webp":["icons/sound_off.webp"],"icons/sound_on.webp":["icons/sound_on.webp"],"icons/telegram.webp":["icons/telegram.webp"],"icons/tg.webp":["icons/tg.webp"],"icons/view.webp":["icons/view.webp"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]}

View file

@ -1 +1 @@
[{"family":"MaterialIcons","fonts":[{"asset":"fonts/MaterialIcons-Regular.otf"}]},{"family":"Nunito","fonts":[{"asset":"../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf"}]},{"family":"packages/cupertino_icons/CupertinoIcons","fonts":[{"asset":"packages/cupertino_icons/assets/CupertinoIcons.ttf"}]}] [{"family":"MaterialIcons","fonts":[{"asset":"fonts/MaterialIcons-Regular.otf"}]},{"family":"Nunito","fonts":[{"asset":"fonts/Nunito-VariableFont_wght.ttf"}]},{"family":"packages/cupertino_icons/CupertinoIcons","fonts":[{"asset":"packages/cupertino_icons/assets/CupertinoIcons.ttf"}]}]

View file

@ -160,7 +160,7 @@ class _AppInitializerState extends State<_AppInitializer> {
if (!_isInitialized) { if (!_isInitialized) {
log('Showing loading screen', name: 'App'); log('Showing loading screen', name: 'App');
return const _LoadingScreen(message: 'Загрузка...'); return const _LoadingScreen(message: 'Loading...');
} }
log('Building main app', name: 'App'); log('Building main app', name: 'App');
@ -207,7 +207,7 @@ class _LoadingScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
home: Scaffold( home: Scaffold(
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).isDark ? AppTheme.dark.backgroundColor : AppTheme.light.backgroundColor,
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,

View file

@ -9,26 +9,7 @@ import '../../../di/app_scope/app_scope_container.dart';
import '../../../presentation/widgets/loading_view.dart'; import '../../../presentation/widgets/loading_view.dart';
import '../../../presentation/widgets/error_view.dart'; import '../../../presentation/widgets/error_view.dart';
/// Simple test result model /// Test page for displaying test info and launching the game
class TestResult {
final String testId;
final int correctAnswers;
final int incorrectAnswers;
final int totalQuestions;
final int timeTaken;
final DateTime completedAt;
const TestResult({
required this.testId,
required this.correctAnswers,
required this.incorrectAnswers,
required this.totalQuestions,
required this.timeTaken,
required this.completedAt,
});
}
/// Test page for displaying and taking a test
class TestPage extends StatefulWidget { class TestPage extends StatefulWidget {
const TestPage({ const TestPage({
required this.testId, required this.testId,
@ -46,12 +27,184 @@ class _TestPageState extends State<TestPage> {
bool _isLoading = true; bool _isLoading = true;
String? _errorMessage; String? _errorMessage;
// Test taking state @override
bool _isTestStarted = false; void initState() {
int _currentQuestionIndex = 0; super.initState();
final Map<int, String> _userAnswers = {}; _loadTest();
bool _isTestCompleted = false; }
TestResult? _testResult;
Future<void> _loadTest() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
final userScope = appScope?.userScopeHolder.scope;
if (userScope == null) {
throw Exception('Scope not available');
}
log('Loading test: ${widget.testId}', name: 'TestPage');
final test = await userScope.testsModule.testManager.loadTest(widget.testId);
if (mounted) {
setState(() {
_test = test;
_isLoading = false;
});
}
} catch (e, s) {
log(
'Error loading test',
error: e,
stackTrace: s,
name: 'TestPage',
);
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = 'Failed to load test: ${e.toString()}';
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_test?.name ?? 'Test'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const LoadingView(message: 'Loading test...');
}
if (_errorMessage != null) {
return ErrorView(
title: 'Failed to load test',
message: _errorMessage!,
onRetry: _loadTest,
);
}
if (_test == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text('Test not found'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadTest,
child: const Text('Retry'),
),
],
),
);
}
return _buildTestIntro();
}
Widget _buildTestIntro() {
final test = _test!;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
test.name,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Test Information',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
_buildInfoRow(Icons.quiz, 'Questions', '${test.questions.length}'),
const SizedBox(height: 8),
_buildInfoRow(Icons.timer, 'Estimated Time', '${test.questions.length * 2} minutes'),
const SizedBox(height: 8),
_buildInfoRow(Icons.help_outline, 'Type', 'Interactive Game'),
],
),
),
),
const SizedBox(height: 24),
Text(
'Instructions',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'• Answer each question as it appears\n'
'• Get instant feedback on your answers\n'
'• Complete all questions to see your results\n'
'• Your progress and statistics will be tracked',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _playGame(context),
icon: const Icon(Icons.games),
label: const Text('Start Test'),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
textStyle: const TextStyle(fontSize: 18),
),
),
),
],
),
);
}
Widget _buildInfoRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text(
'$label: ',
style: const TextStyle(fontWeight: FontWeight.w500),
),
Text(value),
],
);
}
void _playGame(BuildContext context) {
// Navigate to the game page
context.go('/game/${widget.testId}');
}
}
@override @override
void initState() { void initState() {

View file

@ -27,7 +27,11 @@ class PackCard extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final packColor = pack.color?.asColor ?? theme.colorScheme.primary; final packColor = pack.color?.asColor ?? theme.colorScheme.primary;
return Semantics( return Hero(
tag: 'pack-hero-${pack.id}',
child: Material(
color: Colors.transparent,
child: Semantics(
label: 'Pack: ${pack.title}. Tap to view details.', label: 'Pack: ${pack.title}. Tap to view details.',
button: true, button: true,
child: Stack( child: Stack(
@ -125,6 +129,8 @@ class PackCard extends StatelessWidget {
if (pack.tip != null) _buildPackTip(context, packColor), if (pack.tip != null) _buildPackTip(context, packColor),
], ],
), ),
),
),
); );
} }
@ -156,9 +162,7 @@ class PackCard extends StatelessWidget {
? MemoryImage(base64Decode(pack.imageBase64!)) ? MemoryImage(base64Decode(pack.imageBase64!))
: null); : null);
return Hero( return Container(
tag: 'pack-${pack.id}',
child: Container(
width: imageSize, width: imageSize,
height: imageSize, height: imageSize,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -178,7 +182,6 @@ class PackCard extends StatelessWidget {
), ),
) )
: null, : null,
),
); );
} }

View file

@ -23,7 +23,11 @@ class PackCardVertical extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final packColor = pack.color?.asColor ?? theme.colorScheme.primary; final packColor = pack.color?.asColor ?? theme.colorScheme.primary;
return Semantics( return Hero(
tag: 'pack-hero-${pack.id}',
child: Material(
color: Colors.transparent,
child: Semantics(
label: 'Pack: ${pack.title}. Tap to view details.', label: 'Pack: ${pack.title}. Tap to view details.',
button: true, button: true,
child: GestureDetector( child: GestureDetector(
@ -113,13 +117,13 @@ class PackCardVertical extends StatelessWidget {
), ),
), ),
), ),
),
),
); );
} }
Widget _buildPackImage(BuildContext context, Color packColor) { Widget _buildPackImage(BuildContext context, Color packColor) {
return Hero( return Container(
tag: 'pack-${pack.id}',
child: Container(
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: packColor.withOpacity(0.1), color: packColor.withOpacity(0.1),
@ -140,7 +144,6 @@ class PackCardVertical extends StatelessWidget {
color: packColor, color: packColor,
), ),
), ),
),
); );
} }

View file

@ -75,17 +75,22 @@ class PackDetailsHeader extends StatelessWidget {
); );
} }
Widget _buildTitleSection(BuildContext context, Color packColor) { return Hero(
// Try to get cached pack image tag: 'pack-hero-${pack.id}',
final userScope = ScopeProvider.of<UserScope>( child: Material(
context, color: Colors.transparent,
listen: false, child: Container(
); decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
final cachedImage = userScope?.imageCacheService.getPackImage(pack.id); border: Border(
bottom: BorderSide(
return Padding( color: packColor.withOpacity(0.3),
padding: const EdgeInsets.symmetric(horizontal: 16.0), width: 1,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -95,9 +100,7 @@ class PackDetailsHeader extends StatelessWidget {
const SizedBox(width: 16), const SizedBox(width: 16),
// Pack icon or image // Pack icon or image
Hero( Container(
tag: 'pack-${pack.id}',
child: Container(
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -118,7 +121,6 @@ class PackDetailsHeader extends StatelessWidget {
) )
: null, : null,
), ),
),
const SizedBox(width: 16), const SizedBox(width: 16),
@ -152,6 +154,9 @@ class PackDetailsHeader extends StatelessWidget {
), ),
], ],
), ),
),
),
),
); );
} }

View file

@ -61,6 +61,18 @@ class StudySessionResponse {
}); });
} }
void main() {
group('StatisticsStateManager', () {
late StatisticsStateManager stateManager;
late MockStatisticsService mockStatisticsService;
setUp(() {
mockStatisticsService = MockStatisticsService();
stateManager = StatisticsStateManager(
statisticsService: mockStatisticsService,
);
});
// Note: These are smoke tests that verify method signatures and basic structure // Note: These are smoke tests that verify method signatures and basic structure
// Full integration tests would require HTTP mocking setup // Full integration tests would require HTTP mocking setup

View file

@ -0,0 +1,207 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_web_v2/domain/services/auth_service.dart';
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
import 'package:mnemo_cards_web_v2/domain/services/pack_progress_service.dart';
import 'package:mnemo_cards_web_v2/domain/state/user_state_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mocktail/mocktail.dart';
/// Mock classes
class MockGoogleSignIn extends Mock implements GoogleSignIn {}
class MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {}
/// Integration test for authentication and pack progress flow
///
/// This test verifies the full user journey:
/// 1. User authenticates (auto-login or manual login)
/// 2. User state is properly managed
/// 3. User learns cards and progress is tracked
/// 4. Progress is persisted locally
/// 5. User logs out and data is cleared appropriately
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Auth and Progress Integration Tests', () {
late MockGoogleSignIn mockGoogleSignIn;
late MockHttpRepositoryV2 mockHttpRepository;
late AuthService authService;
late PackProgressService packProgressService;
late UserStateManager userStateManager;
late SharedPreferences sharedPreferences;
const testUser = UserDto(
id: 'test-user-123',
name: 'Test User',
email: 'test@example.com',
);
const testPackId = 'pack-abc-123';
const testCardId1 = 1;
const testCardId2 = 2;
setUp(() async {
// Initialize SharedPreferences with mock
SharedPreferences.setMockInitialValues({});
sharedPreferences = await SharedPreferences.getInstance();
// Create mocks
mockGoogleSignIn = MockGoogleSignIn();
mockHttpRepository = MockHttpRepositoryV2();
// Create services
authService = AuthService(
googleSignIn: mockGoogleSignIn,
httpRepository: mockHttpRepository,
);
packProgressService = PackProgressService(
sharedPreferences: sharedPreferences,
httpRepository: mockHttpRepository,
);
userStateManager = UserStateManager();
});
tearDown(() async {
await sharedPreferences.clear();
});
test('Complete user flow: login -> learn cards -> logout', () async {
// === STEP 1: User is initially guest ===
expect(userStateManager.state, equals(const UserState.guest()));
// === STEP 2: Auto-login attempt (no saved token) ===
when(mockHttpRepository.isAuthenticated())
.thenAnswer((_) async => false);
final autoLoginUser = await authService.autoLogin();
expect(autoLoginUser, isNull);
// === STEP 3: Manual login with Telegram ===
const telegramCode = 'telegram-auth-code-123';
final authResponse = AuthResponse(
user: testUser,
accessToken: 'test-access-token',
refreshToken: 'test-refresh-token',
);
when(mockHttpRepository.authenticateWithTelegram(telegramCode))
.thenAnswer((_) async => authResponse);
final loggedInUser = await authService.loginWithTelegram(telegramCode);
expect(loggedInUser, isNotNull);
expect(loggedInUser?.id, equals(testUser.id));
expect(loggedInUser?.name, equals(testUser.name));
// Update user state
userStateManager.setUser(loggedInUser!);
expect(userStateManager.state, equals(UserState.authenticated(testUser)));
// === STEP 4: User starts learning cards ===
// Check initial progress
expect(packProgressService.getPackProgress(testPackId), equals(0));
expect(packProgressService.isCardLearned(testPackId, testCardId1), isFalse);
// Mark first card as learned
await packProgressService.markCardLearned(testPackId, testCardId1);
expect(packProgressService.getPackProgress(testPackId), equals(1));
expect(packProgressService.isCardLearned(testPackId, testCardId1), isTrue);
expect(packProgressService.isCardLearned(testPackId, testCardId2), isFalse);
// Mark second card as learned
await packProgressService.markCardLearned(testPackId, testCardId2);
expect(packProgressService.getPackProgress(testPackId), equals(2));
expect(packProgressService.isCardLearned(testPackId, testCardId2), isTrue);
// Get all learned cards
final learnedCards = packProgressService.getLearnedCards(testPackId);
expect(learnedCards, contains(testCardId1));
expect(learnedCards, contains(testCardId2));
expect(learnedCards.length, equals(2));
// === STEP 5: Progress persists across service recreations ===
// Simulate app restart by creating new service instance
final newPackProgressService = PackProgressService(
sharedPreferences: sharedPreferences,
httpRepository: mockHttpRepository,
);
expect(newPackProgressService.getPackProgress(testPackId), equals(2));
expect(newPackProgressService.isCardLearned(testPackId, testCardId1), isTrue);
expect(newPackProgressService.isCardLearned(testPackId, testCardId2), isTrue);
// === STEP 6: User logs out ===
when(mockHttpRepository.logout()).thenAnswer((_) async => {});
when(mockHttpRepository.clearTokens()).thenAnswer((_) async => {});
await authService.logout();
userStateManager.logout();
expect(userStateManager.state, equals(const UserState.guest()));
// Progress data should still be preserved locally
// (in real app, you might want to clear it on logout)
expect(newPackProgressService.getPackProgress(testPackId), equals(2));
});
test('Duplicate card learning does not increase progress', () async {
// Mark card as learned
await packProgressService.markCardLearned(testPackId, testCardId1);
expect(packProgressService.getPackProgress(testPackId), equals(1));
// Mark the same card again
await packProgressService.markCardLearned(testPackId, testCardId1);
expect(packProgressService.getPackProgress(testPackId), equals(1)); // Still 1
// Learned cards set should contain only one card
final learnedCards = packProgressService.getLearnedCards(testPackId);
expect(learnedCards.length, equals(1));
});
test('Multiple packs have independent progress tracking', () async {
const pack1 = 'pack-1';
const pack2 = 'pack-2';
// Learn cards in pack 1
await packProgressService.markCardLearned(pack1, testCardId1);
await packProgressService.markCardLearned(pack1, testCardId2);
// Learn cards in pack 2
await packProgressService.markCardLearned(pack2, testCardId1);
// Verify independent progress
expect(packProgressService.getPackProgress(pack1), equals(2));
expect(packProgressService.getPackProgress(pack2), equals(1));
// Verify card status is pack-specific
expect(packProgressService.isCardLearned(pack1, testCardId1), isTrue);
expect(packProgressService.isCardLearned(pack2, testCardId1), isTrue);
expect(packProgressService.isCardLearned(pack1, testCardId2), isTrue);
expect(packProgressService.isCardLearned(pack2, testCardId2), isFalse);
});
test('User state transitions are correct', () async {
// Start as guest
expect(userStateManager.state.isGuest, isTrue);
expect(userStateManager.state.isAuthenticated, isFalse);
// Login
userStateManager.setUser(testUser);
expect(userStateManager.state.isGuest, isFalse);
expect(userStateManager.state.isAuthenticated, isTrue);
expect(userStateManager.state.user, equals(testUser));
// Logout
userStateManager.logout();
expect(userStateManager.state.isGuest, isTrue);
expect(userStateManager.state.isAuthenticated, isFalse);
expect(userStateManager.state.user, isNull);
});
});
}

View file

@ -0,0 +1,274 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
import 'package:mnemo_cards_web_v2/domain/services/pack_progress_service.dart';
import 'package:mnemo_cards_web_v2/domain/state/user_state_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mocktail/mocktail.dart';
/// Mock classes
class MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {}
/// Integration test for pack progress tracking and user state management
///
/// This test verifies:
/// 1. Pack progress can be tracked and persisted locally
/// 2. User state transitions work correctly
/// 3. Multiple packs have independent progress
/// 4. Data persists across service recreations (simulating app restarts)
///
/// This is a good example of an integration test because it tests
/// how multiple components work together (PackProgressService, UserStateManager,
/// SharedPreferences) rather than testing them in isolation.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Pack Progress and User State Integration Tests', () {
late MockHttpRepositoryV2 mockHttpRepository;
late PackProgressService packProgressService;
late UserStateManager userStateManager;
late SharedPreferences sharedPreferences;
final testUser = UserDto(
id: 123,
name: 'Test User',
email: 'test@example.com',
);
const testPackId = 'pack-abc-123';
const testCardId1 = 1;
const testCardId2 = 2;
const testCardId3 = 3;
setUp(() async {
// Initialize SharedPreferences with mock
SharedPreferences.setMockInitialValues({});
sharedPreferences = await SharedPreferences.getInstance();
// Create mocks
mockHttpRepository = MockHttpRepositoryV2();
// Create services
packProgressService = PackProgressService(
sharedPreferences: sharedPreferences,
httpRepository: mockHttpRepository,
);
userStateManager = UserStateManager();
});
tearDown(() async {
await sharedPreferences.clear();
});
test('Complete user flow: user state + learning progress', () async {
// === STEP 1: User starts as guest ===
expect(userStateManager.state, equals(const UserState.guest()));
expect(userStateManager.isGuest, isTrue);
expect(userStateManager.isAuthenticated, isFalse);
// === STEP 2: Guest user can still track progress locally ===
expect(packProgressService.getPackProgress(testPackId), equals(0));
await packProgressService.markCardLearned(testPackId, testCardId1);
expect(packProgressService.getPackProgress(testPackId), equals(1));
expect(packProgressService.isCardLearned(testPackId, testCardId1), isTrue);
// === STEP 3: User authenticates ===
userStateManager.setUser(testUser);
await Future.delayed(const Duration(milliseconds: 50));
expect(userStateManager.isAuthenticated, isTrue);
expect(userStateManager.user?.id, equals(testUser.id));
// Progress should still be there after authentication
expect(packProgressService.getPackProgress(testPackId), equals(1));
// === STEP 4: Authenticated user continues learning ===
await packProgressService.markCardLearned(testPackId, testCardId2);
await packProgressService.markCardLearned(testPackId, testCardId3);
expect(packProgressService.getPackProgress(testPackId), equals(3));
final learnedCards = packProgressService.getLearnedCards(testPackId);
expect(learnedCards, containsAll([testCardId1, testCardId2, testCardId3]));
expect(learnedCards.length, equals(3));
// === STEP 5: Simulate app restart (recreate services) ===
final newPackProgressService = PackProgressService(
sharedPreferences: sharedPreferences,
httpRepository: mockHttpRepository,
);
final newUserStateManager = UserStateManager();
// Progress should persist
expect(newPackProgressService.getPackProgress(testPackId), equals(3));
expect(newPackProgressService.isCardLearned(testPackId, testCardId1), isTrue);
expect(newPackProgressService.isCardLearned(testPackId, testCardId2), isTrue);
expect(newPackProgressService.isCardLearned(testPackId, testCardId3), isTrue);
// User state resets to guest (in real app, would be restored via auto-login)
expect(newUserStateManager.isGuest, isTrue);
// ===STEP 6: User logs out ===
userStateManager.logout();
await Future.delayed(const Duration(milliseconds: 50));
expect(userStateManager.isGuest, isTrue);
expect(userStateManager.user, isNull);
// Progress data is still preserved locally
expect(packProgressService.getPackProgress(testPackId), equals(3));
});
test('Duplicate card learning does not increase progress', () async {
// Mark card as learned
await packProgressService.markCardLearned(testPackId, testCardId1);
expect(packProgressService.getPackProgress(testPackId), equals(1));
// Mark the same card again (should be idempotent)
await packProgressService.markCardLearned(testPackId, testCardId1);
expect(packProgressService.getPackProgress(testPackId), equals(1));
// Verify only one card is in learned set
final learnedCards = packProgressService.getLearnedCards(testPackId);
expect(learnedCards.length, equals(1));
expect(learnedCards, contains(testCardId1));
});
test('Multiple packs have independent progress tracking', () async {
const pack1 = 'pack-1';
const pack2 = 'pack-2';
const pack3 = 'pack-3';
// Learn different cards in different packs
await packProgressService.markCardLearned(pack1, testCardId1);
await packProgressService.markCardLearned(pack1, testCardId2);
await packProgressService.markCardLearned(pack2, testCardId1);
await packProgressService.markCardLearned(pack3, testCardId1);
await packProgressService.markCardLearned(pack3, testCardId2);
await packProgressService.markCardLearned(pack3, testCardId3);
// Verify independent progress
expect(packProgressService.getPackProgress(pack1), equals(2));
expect(packProgressService.getPackProgress(pack2), equals(1));
expect(packProgressService.getPackProgress(pack3), equals(3));
// Verify card status is pack-specific
expect(packProgressService.isCardLearned(pack1, testCardId1), isTrue);
expect(packProgressService.isCardLearned(pack1, testCardId3), isFalse);
expect(packProgressService.isCardLearned(pack2, testCardId1), isTrue);
expect(packProgressService.isCardLearned(pack2, testCardId2), isFalse);
expect(packProgressService.isCardLearned(pack3, testCardId1), isTrue);
expect(packProgressService.isCardLearned(pack3, testCardId2), isTrue);
expect(packProgressService.isCardLearned(pack3, testCardId3), isTrue);
});
test('User state transitions work correctly', () async {
// Initial state: guest
expect(userStateManager.state, equals(const UserState.guest()));
expect(userStateManager.isGuest, isTrue);
expect(userStateManager.isAuthenticated, isFalse);
expect(userStateManager.user, isNull);
// Transition to authenticated
userStateManager.setUser(testUser);
await Future.delayed(const Duration(milliseconds: 50));
expect(userStateManager.isGuest, isFalse);
expect(userStateManager.isAuthenticated, isTrue);
expect(userStateManager.user, equals(testUser));
expect(userStateManager.user?.name, equals('Test User'));
// Logout back to guest
userStateManager.logout();
await Future.delayed(const Duration(milliseconds: 50));
expect(userStateManager.isGuest, isTrue);
expect(userStateManager.isAuthenticated, isFalse);
expect(userStateManager.user, isNull);
});
test('Pack progress increments correctly', () async {
const packId = 'test-pack';
// Initial progress is 0
expect(packProgressService.getPackProgress(packId), equals(0));
// Learn cards one by one
await packProgressService.markCardLearned(packId, 1);
expect(packProgressService.getPackProgress(packId), equals(1));
await packProgressService.markCardLearned(packId, 2);
expect(packProgressService.getPackProgress(packId), equals(2));
await packProgressService.markCardLearned(packId, 3);
expect(packProgressService.getPackProgress(packId), equals(3));
await packProgressService.markCardLearned(packId, 4);
expect(packProgressService.getPackProgress(packId), equals(4));
await packProgressService.markCardLearned(packId, 5);
expect(packProgressService.getPackProgress(packId), equals(5));
// Verify all cards are learned
final learnedCards = packProgressService.getLearnedCards(packId);
expect(learnedCards.length, equals(5));
for (int i = 1; i <= 5; i++) {
expect(packProgressService.isCardLearned(packId, i), isTrue);
}
});
test('Progress persists after SharedPreferences reload', () async {
// Learn some cards
await packProgressService.markCardLearned(testPackId, testCardId1);
await packProgressService.markCardLearned(testPackId, testCardId2);
expect(packProgressService.getPackProgress(testPackId), equals(2));
// Get the same SharedPreferences instance (simulating persistence)
final samePrefs = await SharedPreferences.getInstance();
// Create new service with same SharedPreferences
final newService = PackProgressService(
sharedPreferences: samePrefs,
httpRepository: mockHttpRepository,
);
// Progress should be restored
expect(newService.getPackProgress(testPackId), equals(2));
expect(newService.isCardLearned(testPackId, testCardId1), isTrue);
expect(newService.isCardLearned(testPackId, testCardId2), isTrue);
});
test('User can learn and unlearn workflow', () async {
// Set authenticated user
userStateManager.setUser(testUser);
// Learn cards
await packProgressService.markCardLearned(testPackId, testCardId1);
await packProgressService.markCardLearned(testPackId, testCardId2);
expect(packProgressService.getPackProgress(testPackId), equals(2));
// In a real app, you might have an "unlearn" or "reset progress" feature
// For now, we can verify the current state is correct
expect(packProgressService.getLearnedCards(testPackId).length, equals(2));
// User logs out
userStateManager.logout();
await Future.delayed(const Duration(milliseconds: 50));
expect(userStateManager.isGuest, isTrue);
// Progress remains (local data not cleared on logout)
expect(packProgressService.getPackProgress(testPackId), equals(2));
});
});
}