From 4a468bd0c3c2e1e76d0ed0a7c516e09f76f459ce Mon Sep 17 00:00:00 2001 From: AI Agent Date: Fri, 21 Nov 2025 05:04:32 +0000 Subject: [PATCH] feat(backend): Write Unit Tests for Statistics Models Task ID: BACKEND-007 Priority: medium Changes: Completed by: AI Agent Duration: 110754ms --- ai_docs/agent/backend/agent_state.json | 4 +- ai_docs/agent/backend/task_list.json | 2 +- .../test/models/achievement_model_test.dart | 590 ++++++++++++++++ .../test/models/pack_progress_model_test.dart | 415 ++++++++++++ .../test/models/study_session_model_test.dart | 640 ++++++++++++++++++ .../statistics/pack_progress_model.dart | 2 +- 6 files changed, 1649 insertions(+), 4 deletions(-) create mode 100644 mnemo_cards_backend/test/models/achievement_model_test.dart create mode 100644 mnemo_cards_backend/test/models/pack_progress_model_test.dart create mode 100644 mnemo_cards_backend/test/models/study_session_model_test.dart diff --git a/ai_docs/agent/backend/agent_state.json b/ai_docs/agent/backend/agent_state.json index bbef145..1eff644 100644 --- a/ai_docs/agent/backend/agent_state.json +++ b/ai_docs/agent/backend/agent_state.json @@ -1,7 +1,7 @@ { "component": "backend", - "current_task_id": "BACKEND-006", - "iteration_count": 4, + "current_task_id": "BACKEND-007", + "iteration_count": 5, "max_iterations": 10, "started_at": "2025-11-21T01:06:15.789515+00:00", "last_commit": null, diff --git a/ai_docs/agent/backend/task_list.json b/ai_docs/agent/backend/task_list.json index 5e83c8f..093eafa 100644 --- a/ai_docs/agent/backend/task_list.json +++ b/ai_docs/agent/backend/task_list.json @@ -149,7 +149,7 @@ "id": "BACKEND-007", "title": "Write Unit Tests for Statistics Models", "priority": "medium", - "status": "pending", + "status": "in_progress", "estimated_hours": 3.0, "description": "Write comprehensive unit tests for all statistics-related models (PackProgressModel, AchievementModel, StudySessionModel) including toDto/fromDto conversions, serialization, and edge cases.", "acceptance_criteria": [ diff --git a/mnemo_cards_backend/test/models/achievement_model_test.dart b/mnemo_cards_backend/test/models/achievement_model_test.dart new file mode 100644 index 0000000..b4f6983 --- /dev/null +++ b/mnemo_cards_backend/test/models/achievement_model_test.dart @@ -0,0 +1,590 @@ +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; +import 'package:test/test.dart'; + +void main() { + group('AchievementModel', () { + group('constructor', () { + test('creates model with default values', () { + final model = AchievementModel(); + + expect(model.id, ''); + expect(model.title, ''); + expect(model.description, ''); + expect(model.iconUrl, isNull); + expect(model.unlockedAt, isNull); + expect(model.type, AchievementType.firstWordLearned); + expect(model.progress, 0.0); + }); + + test('creates model with all parameters', () { + final now = DateTime.now(); + + final model = AchievementModel( + id: 'test_achievement', + title: 'Test Achievement', + description: 'Test Description', + iconUrl: 'https://example.com/icon.png', + unlockedAt: now, + type: AchievementType.streak7Days, + progress: 0.75, + ); + + expect(model.id, 'test_achievement'); + expect(model.title, 'Test Achievement'); + expect(model.description, 'Test Description'); + expect(model.iconUrl, 'https://example.com/icon.png'); + expect(model.unlockedAt, now); + expect(model.type, AchievementType.streak7Days); + expect(model.progress, 0.75); + }); + + test('creates model with null iconUrl', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + ); + + expect(model.iconUrl, isNull); + }); + + test('creates model with null unlockedAt', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + ); + + expect(model.unlockedAt, isNull); + }); + }); + + group('toDto', () { + test('converts model to DTO correctly', () { + final now = DateTime.now(); + + final model = AchievementModel( + id: 'test_achievement', + title: 'Test Achievement', + description: 'Test Description', + iconUrl: 'https://example.com/icon.png', + unlockedAt: now, + type: AchievementType.words100Learned, + progress: 0.85, + ); + + final dto = model.toDto(); + + expect(dto.id, 'test_achievement'); + expect(dto.title, 'Test Achievement'); + expect(dto.description, 'Test Description'); + expect(dto.iconUrl, 'https://example.com/icon.png'); + expect(dto.unlockedAt, now); + expect(dto.type, AchievementType.words100Learned); + expect(dto.progress, 0.85); + }); + + test('converts model with null values to DTO', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + ); + + final dto = model.toDto(); + + expect(dto.iconUrl, isNull); + expect(dto.unlockedAt, isNull); + }); + + test('converts all achievement types correctly', () { + for (final type in AchievementType.values) { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: type, + ); + + final dto = model.toDto(); + + expect(dto.type, type); + } + }); + }); + + group('fromDto', () { + test('creates model from DTO correctly', () { + final now = DateTime.now(); + + final dto = AchievementDto( + id: 'test_achievement', + title: 'Test Achievement', + description: 'Test Description', + iconUrl: 'https://example.com/icon.png', + unlockedAt: now, + type: AchievementType.streak30Days, + progress: 0.9, + ); + + final model = AchievementModel.fromDto(dto); + + expect(model.id, 'test_achievement'); + expect(model.title, 'Test Achievement'); + expect(model.description, 'Test Description'); + expect(model.iconUrl, 'https://example.com/icon.png'); + expect(model.unlockedAt, now); + expect(model.type, AchievementType.streak30Days); + expect(model.progress, 0.9); + }); + + test('creates model from DTO with null values', () { + final dto = AchievementDto( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.firstWordLearned, + ); + + final model = AchievementModel.fromDto(dto); + + expect(model.iconUrl, isNull); + expect(model.unlockedAt, isNull); + }); + + test('creates model from DTO for all achievement types', () { + for (final type in AchievementType.values) { + final dto = AchievementDto( + id: 'test', + title: 'Test', + description: 'Test', + type: type, + ); + + final model = AchievementModel.fromDto(dto); + + expect(model.type, type); + } + }); + }); + + group('toDto and fromDto roundtrip', () { + test('preserves all data through conversion', () { + final now = DateTime.now(); + + final original = AchievementModel( + id: 'test_achievement', + title: 'Test Achievement', + description: 'Test Description', + iconUrl: 'https://example.com/icon.png', + unlockedAt: now, + type: AchievementType.perfectTestScore, + progress: 0.95, + ); + + final dto = original.toDto(); + final restored = AchievementModel.fromDto(dto); + + expect(restored.id, original.id); + expect(restored.title, original.title); + expect(restored.description, original.description); + expect(restored.iconUrl, original.iconUrl); + expect(restored.unlockedAt, original.unlockedAt); + expect(restored.type, original.type); + expect(restored.progress, original.progress); + }); + }); + + group('isUnlocked', () { + test('returns false when unlockedAt is null', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + ); + + expect(model.isUnlocked, isFalse); + }); + + test('returns true when unlockedAt is set', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + unlockedAt: DateTime.now(), + ); + + expect(model.isUnlocked, isTrue); + }); + }); + + group('isLocked', () { + test('returns true when unlockedAt is null', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + ); + + expect(model.isLocked, isTrue); + }); + + test('returns false when unlockedAt is set', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + unlockedAt: DateTime.now(), + ); + + expect(model.isLocked, isFalse); + }); + }); + + group('unlock', () { + test('sets unlockedAt to current time', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + ); + + final beforeUnlock = DateTime.now(); + final unlocked = model.unlock(); + final afterUnlock = DateTime.now(); + + expect(unlocked.unlockedAt, isNotNull); + expect( + unlocked.unlockedAt!.isAfter(beforeUnlock.subtract(const Duration(seconds: 1))), + isTrue, + ); + expect( + unlocked.unlockedAt!.isBefore(afterUnlock.add(const Duration(seconds: 1))), + isTrue, + ); + }); + + test('sets progress to 1.0', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + progress: 0.5, + ); + + final unlocked = model.unlock(); + + expect(unlocked.progress, 1.0); + }); + + test('preserves other fields', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + iconUrl: 'https://example.com/icon.png', + type: AchievementType.streak7Days, + ); + + final unlocked = model.unlock(); + + expect(unlocked.id, model.id); + expect(unlocked.title, model.title); + expect(unlocked.description, model.description); + expect(unlocked.iconUrl, model.iconUrl); + expect(unlocked.type, model.type); + }); + }); + + group('updateProgress', () { + test('updates progress correctly', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + progress: 0.3, + ); + + final updated = model.updateProgress(0.7); + + expect(updated.progress, 0.7); + }); + + test('clamps progress to 0.0 minimum', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + ); + + final updated = model.updateProgress(-0.5); + + expect(updated.progress, 0.0); + }); + + test('clamps progress to 1.0 maximum', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + ); + + final updated = model.updateProgress(1.5); + + expect(updated.progress, 1.0); + }); + + test('preserves unlockedAt when updating progress', () { + final now = DateTime.now(); + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + unlockedAt: now, + ); + + final updated = model.updateProgress(0.5); + + expect(updated.unlockedAt, now); + }); + + test('preserves other fields', () { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + iconUrl: 'https://example.com/icon.png', + type: AchievementType.words50Learned, + ); + + final updated = model.updateProgress(0.6); + + expect(updated.id, model.id); + expect(updated.title, model.title); + expect(updated.description, model.description); + expect(updated.iconUrl, model.iconUrl); + expect(updated.type, model.type); + }); + }); + + group('category', () { + test('returns correct category for first_steps achievements', () { + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.firstWordLearned, + ).category, + 'first_steps', + ); + + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.firstTestCompleted, + ).category, + 'first_steps', + ); + + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.firstPackCompleted, + ).category, + 'first_steps', + ); + }); + + test('returns correct category for streaks achievements', () { + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.streak3Days, + ).category, + 'streaks', + ); + + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.streak100Days, + ).category, + 'streaks', + ); + }); + + test('returns correct category for words_mastery achievements', () { + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.words10Learned, + ).category, + 'words_mastery', + ); + + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.words1000Learned, + ).category, + 'words_mastery', + ); + }); + + test('returns correct category for performance achievements', () { + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.perfectTestScore, + ).category, + 'performance', + ); + + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.speedLearner, + ).category, + 'performance', + ); + }); + + test('returns correct category for dedication achievements', () { + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.dedicatedLearner, + ).category, + 'dedication', + ); + }); + + test('returns correct category for time_based achievements', () { + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.nightOwl, + ).category, + 'time_based', + ); + + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.earlyBird, + ).category, + 'time_based', + ); + }); + + test('returns correct category for special achievements', () { + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.consistentLearner, + ).category, + 'special', + ); + + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: AchievementType.languageMaster, + ).category, + 'special', + ); + }); + }); + + group('edge cases', () { + test('handles empty strings', () { + final model = AchievementModel( + id: '', + title: '', + description: '', + ); + + expect(model.id, ''); + expect(model.title, ''); + expect(model.description, ''); + }); + + test('handles very long strings', () { + final longString = 'a' * 1000; + final model = AchievementModel( + id: longString, + title: longString, + description: longString, + ); + + expect(model.id, longString); + expect(model.title, longString); + expect(model.description, longString); + }); + + test('handles progress at boundaries', () { + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + progress: 0.0, + ).progress, + 0.0, + ); + + expect( + AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + progress: 1.0, + ).progress, + 1.0, + ); + }); + + test('handles all achievement types', () { + for (final type in AchievementType.values) { + final model = AchievementModel( + id: 'test', + title: 'Test', + description: 'Test', + type: type, + ); + + expect(model.type, type); + expect(model.category, isNotEmpty); + } + }); + }); + }); +} diff --git a/mnemo_cards_backend/test/models/pack_progress_model_test.dart b/mnemo_cards_backend/test/models/pack_progress_model_test.dart new file mode 100644 index 0000000..82fa7ae --- /dev/null +++ b/mnemo_cards_backend/test/models/pack_progress_model_test.dart @@ -0,0 +1,415 @@ +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; +import 'package:test/test.dart'; + +void main() { + group('PackProgressModel', () { + group('constructor', () { + test('creates model with default values', () { + final model = PackProgressModel(); + + expect(model.packId, ''); + expect(model.totalCards, 0); + expect(model.learnedCards, 0); + expect(model.studyTimeMinutes, 0); + expect(model.lastStudyDate, isNull); + expect(model.firstStudyDate, isNull); + expect(model.cardAttempts, isEmpty); + expect(model.averageAccuracy, 0.0); + }); + + test('creates model with all parameters', () { + final now = DateTime.now(); + final yesterday = now.subtract(const Duration(days: 1)); + final cardAttempts = {'card1': 3, 'card2': 5}; + + final model = PackProgressModel( + packId: 'test_pack', + totalCards: 20, + learnedCards: 15, + studyTimeMinutes: 120, + lastStudyDate: now, + firstStudyDate: yesterday, + cardAttempts: cardAttempts, + averageAccuracy: 0.85, + ); + + expect(model.packId, 'test_pack'); + expect(model.totalCards, 20); + expect(model.learnedCards, 15); + expect(model.studyTimeMinutes, 120); + expect(model.lastStudyDate, now); + expect(model.firstStudyDate, yesterday); + expect(model.cardAttempts, cardAttempts); + expect(model.averageAccuracy, 0.85); + }); + + test('creates model with empty cardAttempts map', () { + final model = PackProgressModel( + packId: 'test_pack', + cardAttempts: const {}, + ); + + expect(model.cardAttempts, isEmpty); + }); + }); + + group('toDto', () { + test('converts model to DTO correctly', () { + final now = DateTime.now(); + final cardAttempts = {'card1': 2, 'card2': 4}; + + final model = PackProgressModel( + packId: 'test_pack', + totalCards: 10, + learnedCards: 7, + studyTimeMinutes: 60, + lastStudyDate: now, + firstStudyDate: now, + cardAttempts: cardAttempts, + averageAccuracy: 0.75, + ); + + final dto = model.toDto(); + + expect(dto.packId, 'test_pack'); + expect(dto.totalCards, 10); + expect(dto.learnedCards, 7); + expect(dto.studyTimeMinutes, 60); + expect(dto.lastStudyDate, now); + expect(dto.firstStudyDate, now); + expect(dto.cardAttempts, cardAttempts); + expect(dto.averageAccuracy, 0.75); + }); + + test('converts model with null dates to DTO', () { + final model = PackProgressModel( + packId: 'test_pack', + totalCards: 10, + ); + + final dto = model.toDto(); + + expect(dto.lastStudyDate, isNull); + expect(dto.firstStudyDate, isNull); + }); + + test('creates new map instance in DTO', () { + final cardAttempts = {'card1': 1}; + final model = PackProgressModel(cardAttempts: cardAttempts); + + final dto = model.toDto(); + + expect(dto.cardAttempts, equals(cardAttempts)); + expect(dto.cardAttempts, isNot(same(cardAttempts))); + }); + }); + + group('fromDto', () { + test('creates model from DTO correctly', () { + final now = DateTime.now(); + final cardAttempts = {'card1': 3, 'card2': 5}; + + final dto = PackProgressDto( + packId: 'test_pack', + totalCards: 20, + learnedCards: 15, + studyTimeMinutes: 120, + lastStudyDate: now, + firstStudyDate: now, + cardAttempts: cardAttempts, + averageAccuracy: 0.85, + ); + + final model = PackProgressModel.fromDto(dto); + + expect(model.packId, 'test_pack'); + expect(model.totalCards, 20); + expect(model.learnedCards, 15); + expect(model.studyTimeMinutes, 120); + expect(model.lastStudyDate, now); + expect(model.firstStudyDate, now); + expect(model.cardAttempts, cardAttempts); + expect(model.averageAccuracy, 0.85); + }); + + test('creates model from DTO with null dates', () { + final dto = PackProgressDto( + packId: 'test_pack', + totalCards: 10, + ); + + final model = PackProgressModel.fromDto(dto); + + expect(model.lastStudyDate, isNull); + expect(model.firstStudyDate, isNull); + }); + + test('creates new map instance from DTO', () { + final cardAttempts = {'card1': 1}; + final dto = PackProgressDto( + packId: 'test_pack', + totalCards: 10, + cardAttempts: cardAttempts, + ); + + final model = PackProgressModel.fromDto(dto); + + expect(model.cardAttempts, equals(cardAttempts)); + expect(model.cardAttempts, isNot(same(cardAttempts))); + }); + }); + + group('toDto and fromDto roundtrip', () { + test('preserves all data through conversion', () { + final now = DateTime.now(); + final cardAttempts = {'card1': 2, 'card2': 4, 'card3': 1}; + + final original = PackProgressModel( + packId: 'test_pack', + totalCards: 30, + learnedCards: 20, + studyTimeMinutes: 180, + lastStudyDate: now, + firstStudyDate: now.subtract(const Duration(days: 5)), + cardAttempts: cardAttempts, + averageAccuracy: 0.92, + ); + + final dto = original.toDto(); + final restored = PackProgressModel.fromDto(dto); + + expect(restored.packId, original.packId); + expect(restored.totalCards, original.totalCards); + expect(restored.learnedCards, original.learnedCards); + expect(restored.studyTimeMinutes, original.studyTimeMinutes); + expect(restored.lastStudyDate, original.lastStudyDate); + expect(restored.firstStudyDate, original.firstStudyDate); + expect(restored.cardAttempts, original.cardAttempts); + expect(restored.averageAccuracy, original.averageAccuracy); + }); + }); + + group('updateCardProgress', () { + test('updates card attempts correctly', () { + final model = PackProgressModel( + packId: 'test_pack', + totalCards: 10, + cardAttempts: {'card1': 2}, + ); + + final updated = model.updateCardProgress( + cardId: 'card1', + wasCorrect: true, + additionalTimeMinutes: 5, + ); + + expect(updated.cardAttempts['card1'], 3); + }); + + test('adds new card to attempts map', () { + final model = PackProgressModel( + packId: 'test_pack', + totalCards: 10, + ); + + final updated = model.updateCardProgress( + cardId: 'new_card', + wasCorrect: true, + additionalTimeMinutes: 5, + ); + + expect(updated.cardAttempts['new_card'], 1); + }); + + test('updates study time correctly', () { + final model = PackProgressModel( + packId: 'test_pack', + studyTimeMinutes: 10, + ); + + final updated = model.updateCardProgress( + cardId: 'card1', + wasCorrect: true, + additionalTimeMinutes: 5, + ); + + expect(updated.studyTimeMinutes, 15); + }); + + test('sets lastStudyDate to current time', () { + final model = PackProgressModel( + packId: 'test_pack', + ); + + final beforeUpdate = DateTime.now(); + final updated = model.updateCardProgress( + cardId: 'card1', + wasCorrect: true, + additionalTimeMinutes: 5, + ); + final afterUpdate = DateTime.now(); + + expect(updated.lastStudyDate, isNotNull); + expect( + updated.lastStudyDate!.isAfter(beforeUpdate.subtract(const Duration(seconds: 1))), + isTrue, + ); + expect( + updated.lastStudyDate!.isBefore(afterUpdate.add(const Duration(seconds: 1))), + isTrue, + ); + }); + + test('sets firstStudyDate if null', () { + final model = PackProgressModel( + packId: 'test_pack', + ); + + final updated = model.updateCardProgress( + cardId: 'card1', + wasCorrect: true, + additionalTimeMinutes: 5, + ); + + expect(updated.firstStudyDate, isNotNull); + }); + + test('preserves firstStudyDate if already set', () { + final originalDate = DateTime(2024, 1, 1); + final model = PackProgressModel( + packId: 'test_pack', + firstStudyDate: originalDate, + ); + + final updated = model.updateCardProgress( + cardId: 'card1', + wasCorrect: true, + additionalTimeMinutes: 5, + ); + + expect(updated.firstStudyDate, originalDate); + }); + }); + + group('hasStarted', () { + test('returns false for new pack', () { + final model = PackProgressModel(); + + expect(model.hasStarted, isFalse); + }); + + test('returns true when learnedCards > 0', () { + final model = PackProgressModel(learnedCards: 1); + + expect(model.hasStarted, isTrue); + }); + + test('returns true when studyTimeMinutes > 0', () { + final model = PackProgressModel(studyTimeMinutes: 1); + + expect(model.hasStarted, isTrue); + }); + + test('returns true when both learnedCards and studyTimeMinutes > 0', () { + final model = PackProgressModel( + learnedCards: 5, + studyTimeMinutes: 10, + ); + + expect(model.hasStarted, isTrue); + }); + }); + + group('progress', () { + test('returns 0.0 when totalCards is 0', () { + final model = PackProgressModel(); + + expect(model.progress, 0.0); + }); + + test('returns 0.0 when learnedCards is 0', () { + final model = PackProgressModel(totalCards: 10); + + expect(model.progress, 0.0); + }); + + test('calculates progress correctly', () { + final model = PackProgressModel( + totalCards: 10, + learnedCards: 5, + ); + + expect(model.progress, 0.5); + }); + + test('calculates progress for partial completion', () { + final model = PackProgressModel( + totalCards: 100, + learnedCards: 33, + ); + + expect(model.progress, 0.33); + }); + + test('returns 1.0 when all cards learned', () { + final model = PackProgressModel( + totalCards: 10, + learnedCards: 10, + ); + + expect(model.progress, 1.0); + }); + + test('handles learnedCards exceeding totalCards', () { + final model = PackProgressModel( + totalCards: 10, + learnedCards: 15, + ); + + expect(model.progress, 1.5); + }); + }); + + group('edge cases', () { + test('handles empty cardAttempts map', () { + final model = PackProgressModel(cardAttempts: const {}); + + expect(model.cardAttempts, isEmpty); + expect(model.toDto().cardAttempts, isEmpty); + }); + + test('handles large cardAttempts map', () { + final cardAttempts = Map.fromEntries( + List.generate(100, (i) => MapEntry('card$i', i)), + ); + + final model = PackProgressModel(cardAttempts: cardAttempts); + + expect(model.cardAttempts.length, 100); + expect(model.toDto().cardAttempts.length, 100); + }); + + test('handles zero averageAccuracy', () { + final model = PackProgressModel(averageAccuracy: 0.0); + + expect(model.averageAccuracy, 0.0); + expect(model.toDto().averageAccuracy, 0.0); + }); + + test('handles maximum averageAccuracy', () { + final model = PackProgressModel(averageAccuracy: 1.0); + + expect(model.averageAccuracy, 1.0); + expect(model.toDto().averageAccuracy, 1.0); + }); + + test('handles very large studyTimeMinutes', () { + final model = PackProgressModel(studyTimeMinutes: 999999); + + expect(model.studyTimeMinutes, 999999); + expect(model.toDto().studyTimeMinutes, 999999); + }); + }); + }); +} diff --git a/mnemo_cards_backend/test/models/study_session_model_test.dart b/mnemo_cards_backend/test/models/study_session_model_test.dart new file mode 100644 index 0000000..f6a43d4 --- /dev/null +++ b/mnemo_cards_backend/test/models/study_session_model_test.dart @@ -0,0 +1,640 @@ +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; +import 'package:test/test.dart'; + +void main() { + group('StudySessionModel', () { + group('constructor', () { + test('creates model with required parameters', () { + final startTime = DateTime.now(); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + ); + + expect(model.userId, 1); + expect(model.startTime, startTime); + expect(model.sessionId, isNull); + expect(model.endTime, isNull); + expect(model.wordsLearned, 0); + expect(model.testsCompleted, 0); + expect(model.accuracy, 0.0); + expect(model.packId, isNull); + expect(model.testId, isNull); + }); + + test('creates model with all parameters', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 30)); + + final model = StudySessionModel( + sessionId: 'session_123', + userId: 42, + startTime: startTime, + endTime: endTime, + wordsLearned: 10, + testsCompleted: 2, + accuracy: 0.85, + packId: 'pack_1', + testId: 'test_1', + ); + + expect(model.sessionId, 'session_123'); + expect(model.userId, 42); + expect(model.startTime, startTime); + expect(model.endTime, endTime); + expect(model.wordsLearned, 10); + expect(model.testsCompleted, 2); + expect(model.accuracy, 0.85); + expect(model.packId, 'pack_1'); + expect(model.testId, 'test_1'); + }); + + test('creates model with default values', () { + final startTime = DateTime.now(); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + ); + + expect(model.wordsLearned, 0); + expect(model.testsCompleted, 0); + expect(model.accuracy, 0.0); + }); + }); + + group('toDto', () { + test('converts model to DTO correctly', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 30)); + + final model = StudySessionModel( + sessionId: 'session_123', + userId: 42, + startTime: startTime, + endTime: endTime, + wordsLearned: 10, + testsCompleted: 2, + accuracy: 0.85, + packId: 'pack_1', + testId: 'test_1', + ); + + final dto = model.toDto(); + + expect(dto.sessionId, 'session_123'); + expect(dto.startTime, startTime); + expect(dto.endTime, endTime); + expect(dto.wordsLearned, 10); + expect(dto.testsCompleted, 2); + expect(dto.accuracy, 0.85); + expect(dto.packId, 'pack_1'); + expect(dto.testId, 'test_1'); + }); + + test('converts model with null values to DTO', () { + final startTime = DateTime.now(); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + ); + + final dto = model.toDto(); + + expect(dto.sessionId, isNull); + expect(dto.endTime, isNull); + expect(dto.packId, isNull); + expect(dto.testId, isNull); + }); + }); + + group('fromDto', () { + test('creates model from DTO correctly', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 30)); + + final dto = StudySessionDto( + sessionId: 'session_123', + startTime: startTime, + endTime: endTime, + wordsLearned: 10, + testsCompleted: 2, + accuracy: 0.85, + packId: 'pack_1', + testId: 'test_1', + ); + + final model = StudySessionModel.fromDto(dto, 42); + + expect(model.sessionId, 'session_123'); + expect(model.userId, 42); + expect(model.startTime, startTime); + expect(model.endTime, endTime); + expect(model.wordsLearned, 10); + expect(model.testsCompleted, 2); + expect(model.accuracy, 0.85); + expect(model.packId, 'pack_1'); + expect(model.testId, 'test_1'); + }); + + test('creates model from DTO with null values', () { + final startTime = DateTime.now(); + + final dto = StudySessionDto( + startTime: startTime, + ); + + final model = StudySessionModel.fromDto(dto, 1); + + expect(model.sessionId, isNull); + expect(model.endTime, isNull); + expect(model.packId, isNull); + expect(model.testId, isNull); + }); + }); + + group('toDto and fromDto roundtrip', () { + test('preserves all data through conversion', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 45)); + + final original = StudySessionModel( + sessionId: 'session_456', + userId: 100, + startTime: startTime, + endTime: endTime, + wordsLearned: 25, + testsCompleted: 3, + accuracy: 0.92, + packId: 'pack_2', + testId: 'test_2', + ); + + final dto = original.toDto(); + final restored = StudySessionModel.fromDto(dto, original.userId); + + expect(restored.sessionId, original.sessionId); + expect(restored.userId, original.userId); + expect(restored.startTime, original.startTime); + expect(restored.endTime, original.endTime); + expect(restored.wordsLearned, original.wordsLearned); + expect(restored.testsCompleted, original.testsCompleted); + expect(restored.accuracy, original.accuracy); + expect(restored.packId, original.packId); + expect(restored.testId, original.testId); + }); + }); + + group('isActive', () { + test('returns true when endTime is null', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + ); + + expect(model.isActive, isTrue); + }); + + test('returns false when endTime is set', () { + final startTime = DateTime.now(); + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: startTime.add(const Duration(minutes: 30)), + ); + + expect(model.isActive, isFalse); + }); + }); + + group('duration', () { + test('calculates duration correctly when ended', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 30)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: endTime, + ); + + expect(model.duration, const Duration(minutes: 30)); + }); + + test('calculates duration using current time when active', () { + final startTime = DateTime.now().subtract(const Duration(minutes: 15)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + ); + + final duration = model.duration; + expect(duration.inMinutes, greaterThanOrEqualTo(14)); + expect(duration.inMinutes, lessThanOrEqualTo(16)); + }); + }); + + group('durationMinutes', () { + test('returns duration in minutes correctly', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 45)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: endTime, + ); + + expect(model.durationMinutes, 45); + }); + + test('handles zero duration', () { + final startTime = DateTime.now(); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: startTime, + ); + + expect(model.durationMinutes, 0); + }); + }); + + group('sessionType', () { + test('returns Test Session when testId is set', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + testId: 'test_1', + ); + + expect(model.sessionType, 'Test Session'); + }); + + test('returns Pack Study when packId is set', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + packId: 'pack_1', + ); + + expect(model.sessionType, 'Pack Study'); + }); + + test('returns General Study when neither is set', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + ); + + expect(model.sessionType, 'General Study'); + }); + + test('prioritizes testId over packId', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + packId: 'pack_1', + testId: 'test_1', + ); + + expect(model.sessionType, 'Test Session'); + }); + }); + + group('productivityScore', () { + test('calculates productivity score correctly', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 30)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: endTime, + wordsLearned: 15, + ); + + expect(model.productivityScore, 0.5); // 15 words / 30 minutes + }); + + test('returns 0.0 when duration is 0', () { + final startTime = DateTime.now(); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: startTime, + wordsLearned: 10, + ); + + expect(model.productivityScore, 0.0); + }); + + test('handles active session', () { + final startTime = DateTime.now().subtract(const Duration(minutes: 10)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + wordsLearned: 5, + ); + + final score = model.productivityScore; + expect(score, greaterThan(0)); + expect(score, lessThanOrEqualTo(1.0)); + }); + }); + + group('isProductive', () { + test('returns true for productive session', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 10)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: endTime, + wordsLearned: 10, + accuracy: 0.8, + ); + + expect(model.isProductive, isTrue); + }); + + test('returns false when wordsLearned < 5', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 10)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: endTime, + wordsLearned: 3, + accuracy: 0.8, + ); + + expect(model.isProductive, isFalse); + }); + + test('returns false when accuracy < 0.7', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 10)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: endTime, + wordsLearned: 10, + accuracy: 0.6, + ); + + expect(model.isProductive, isFalse); + }); + + test('returns false when duration < 5 minutes', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 3)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: endTime, + wordsLearned: 10, + accuracy: 0.8, + ); + + expect(model.isProductive, isFalse); + }); + }); + + group('end', () { + test('sets endTime to current time', () { + final startTime = DateTime.now().subtract(const Duration(minutes: 10)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + ); + + final beforeEnd = DateTime.now(); + final ended = model.end( + wordsLearned: 10, + testsCompleted: 2, + accuracy: 0.85, + ); + final afterEnd = DateTime.now(); + + expect(ended.endTime, isNotNull); + expect( + ended.endTime!.isAfter(beforeEnd.subtract(const Duration(seconds: 1))), + isTrue, + ); + expect( + ended.endTime!.isBefore(afterEnd.add(const Duration(seconds: 1))), + isTrue, + ); + }); + + test('updates statistics correctly', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + ); + + final ended = model.end( + wordsLearned: 15, + testsCompleted: 3, + accuracy: 0.9, + ); + + expect(ended.wordsLearned, 15); + expect(ended.testsCompleted, 3); + expect(ended.accuracy, 0.9); + }); + + test('preserves other fields', () { + final model = StudySessionModel( + sessionId: 'session_123', + userId: 1, + startTime: DateTime.now(), + packId: 'pack_1', + testId: 'test_1', + ); + + final ended = model.end(); + + expect(ended.sessionId, model.sessionId); + expect(ended.userId, model.userId); + expect(ended.startTime, model.startTime); + expect(ended.packId, model.packId); + expect(ended.testId, model.testId); + }); + }); + + group('addProgress', () { + test('adds wordsLearned correctly', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + wordsLearned: 5, + ); + + final updated = model.addProgress(wordsLearned: 3); + + expect(updated.wordsLearned, 8); + }); + + test('adds testsCompleted correctly', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + testsCompleted: 1, + ); + + final updated = model.addProgress(testsCompleted: 2); + + expect(updated.testsCompleted, 3); + }); + + test('updates accuracy when provided', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + accuracy: 0.5, + ); + + final updated = model.addProgress(accuracy: 0.8); + + expect(updated.accuracy, 0.8); + }); + + test('preserves accuracy when not provided', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + accuracy: 0.7, + ); + + final updated = model.addProgress(wordsLearned: 5); + + expect(updated.accuracy, 0.7); + }); + + test('preserves endTime', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(minutes: 30)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: endTime, + ); + + final updated = model.addProgress(wordsLearned: 5); + + expect(updated.endTime, endTime); + }); + + test('preserves other fields', () { + final model = StudySessionModel( + sessionId: 'session_123', + userId: 1, + startTime: DateTime.now(), + packId: 'pack_1', + testId: 'test_1', + ); + + final updated = model.addProgress(wordsLearned: 5); + + expect(updated.sessionId, model.sessionId); + expect(updated.userId, model.userId); + expect(updated.startTime, model.startTime); + expect(updated.packId, model.packId); + expect(updated.testId, model.testId); + }); + }); + + group('edge cases', () { + test('handles zero wordsLearned', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + wordsLearned: 0, + ); + + expect(model.wordsLearned, 0); + expect(model.toDto().wordsLearned, 0); + }); + + test('handles zero testsCompleted', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + testsCompleted: 0, + ); + + expect(model.testsCompleted, 0); + expect(model.toDto().testsCompleted, 0); + }); + + test('handles zero accuracy', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + accuracy: 0.0, + ); + + expect(model.accuracy, 0.0); + expect(model.toDto().accuracy, 0.0); + }); + + test('handles maximum accuracy', () { + final model = StudySessionModel( + userId: 1, + startTime: DateTime.now(), + accuracy: 1.0, + ); + + expect(model.accuracy, 1.0); + expect(model.toDto().accuracy, 1.0); + }); + + test('handles very long session', () { + final startTime = DateTime.now(); + final endTime = startTime.add(const Duration(hours: 5)); + + final model = StudySessionModel( + userId: 1, + startTime: startTime, + endTime: endTime, + ); + + expect(model.durationMinutes, 300); + }); + + test('handles empty sessionId', () { + final model = StudySessionModel( + sessionId: '', + userId: 1, + startTime: DateTime.now(), + ); + + expect(model.sessionId, ''); + }); + + test('handles very large userId', () { + final model = StudySessionModel( + userId: 999999999, + startTime: DateTime.now(), + ); + + expect(model.userId, 999999999); + }); + }); + }); +} diff --git a/mnemo_cards_common_backend/lib/src/models/statistics/pack_progress_model.dart b/mnemo_cards_common_backend/lib/src/models/statistics/pack_progress_model.dart index c13cd29..60d56aa 100644 --- a/mnemo_cards_common_backend/lib/src/models/statistics/pack_progress_model.dart +++ b/mnemo_cards_common_backend/lib/src/models/statistics/pack_progress_model.dart @@ -75,7 +75,7 @@ class PackProgressModel { required int additionalTimeMinutes, }) { final currentAttempts = cardAttempts[cardId] ?? 0; - final newAttempts = {cardId: currentAttempts + 1, ...cardAttempts}; + final newAttempts = {...cardAttempts, cardId: currentAttempts + 1}; // Simple accuracy calculation - could be more sophisticated final totalAttempts = newAttempts.values.fold(0, (sum, attempts) => sum + attempts);