mnemo_cards/mnemo_cards_backend/lib/tests/test_manager.dart

420 lines
13 KiB
Dart
Raw Normal View History

2025-11-16 11:25:27 +00:00
import 'dart:convert';
import 'package:injectable/injectable.dart';
2025-12-13 13:27:05 +00:00
import 'package:mnemo_cards_backend/database/database.dart';
2025-11-16 11:25:27 +00:00
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
2025-12-13 13:27:05 +00:00
import 'package:drift/drift.dart' as drift;
2025-11-16 11:25:27 +00:00
import 'generators/pack_test_generator.dart';
@lazySingleton
class TestManager {
2025-12-13 13:27:05 +00:00
final AppDatabase _db;
2025-11-16 11:25:27 +00:00
2025-12-13 14:48:00 +00:00
TestManager(this._db);
2025-11-16 11:25:27 +00:00
Future<TestStatisticsDto?> _testStatisticsDto(
2025-12-13 20:55:50 +00:00
String userId, String testId) async {
2025-12-13 13:27:05 +00:00
final statistics = await _db.testDao.getTestStatistics(userId, testId);
if (statistics == null) return null;
// Convert TestStatistic to TestStatisticsDto
2025-12-17 00:56:22 +00:00
// metadata is stored as JSON string with 'attempts' key
final metadataMap = statistics.metadata.isNotEmpty
? (json.decode(statistics.metadata) as Map<String, dynamic>? ?? {})
: <String, dynamic>{};
final attempts = (metadataMap['attempts'] as List<dynamic>?) ?? [];
2025-12-13 14:48:00 +00:00
// Get words from the last attempt, or empty list if no attempts
final lastAttempt = attempts.isNotEmpty
? attempts.last as Map<String, dynamic>?
: null;
final wordsJson = (lastAttempt?['words'] as List<dynamic>?) ?? [];
// Convert words JSON to WordStatisticsDto
final words = wordsJson
.map((w) => WordStatisticsDto.fromJson(w as Map<String, dynamic>))
.toList();
2025-12-13 13:27:05 +00:00
return TestStatisticsDto(
2025-12-13 14:48:00 +00:00
testId: statistics.testId,
words: AllWordsStatisticsDto(words: words),
attempts: attempts.length,
sessionToken: lastAttempt?['sessionToken'] as String?,
2025-12-13 13:27:05 +00:00
);
2025-11-16 11:25:27 +00:00
}
Future<TestDto?> fetchTest(String id, UserModel user) async {
2025-12-13 20:55:50 +00:00
final testId = id;
2025-12-13 13:27:05 +00:00
final test = await _db.testDao.getTestById(testId);
if (test == null) return null;
2025-12-17 01:42:58 +00:00
// Get packId for the test to convert image IDs to URLs
final packId = await _db.testDao.getPackIdForTest(testId);
// Helper function to convert image ID to URL
String? _convertImageToUrl(String? imageValue, String? packId) {
if (imageValue == null || packId == null) return imageValue;
// If it's already a proper URL, return as is
if (imageValue.startsWith('http://') ||
imageValue.startsWith('https://') ||
(imageValue.startsWith('/api/') && imageValue.contains('/cards/') && imageValue.endsWith('/image'))) {
return imageValue;
}
// If it looks like a UUID (card ID), convert to URL
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
// Otherwise, assume it's already a card ID and convert
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
2025-12-13 13:27:05 +00:00
final questions = await _db.testDao.getTestQuestions(testId);
final statistics = await _testStatisticsDto(user.id!, testId);
// Convert Test to TestDto
final questionsList = questions.map((q) {
2025-12-17 00:56:22 +00:00
// Build question JSON from separate fields
final questionJson = <String, dynamic>{
2025-12-13 13:27:05 +00:00
'questionType': q.questionType,
2025-12-17 00:56:22 +00:00
'id': q.id,
'word': q.word,
};
// Parse options (JSON array of buttons)
2025-12-17 01:42:58 +00:00
List<dynamic> buttons = [];
2025-12-17 00:56:22 +00:00
try {
2025-12-17 01:42:58 +00:00
buttons = json.decode(q.options) as List<dynamic>;
2025-12-17 00:56:22 +00:00
} catch (e) {
2025-12-17 01:42:58 +00:00
buttons = [];
2025-12-17 00:56:22 +00:00
}
2025-12-17 01:42:58 +00:00
// Convert button images to URLs
final buttonsWithUrls = buttons.map((button) {
if (button is Map<String, dynamic> && button['image'] != null) {
final buttonMap = Map<String, dynamic>.from(button);
buttonMap['image'] = _convertImageToUrl(
buttonMap['image'] as String?,
packId,
);
return buttonMap;
}
return button;
}).toList();
questionJson['buttons'] = buttonsWithUrls;
2025-12-17 00:56:22 +00:00
// Add answer
questionJson['answer'] = q.answer;
// Parse uiData (image, text, audio, template)
try {
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
2025-12-17 01:42:58 +00:00
// Convert question image to URL
if (uiData['image'] != null) {
uiData['image'] = _convertImageToUrl(
uiData['image'] as String?,
packId,
);
}
2025-12-17 00:56:22 +00:00
questionJson.addAll(uiData);
} catch (e) {
// If uiData is empty or invalid, ignore
}
return AbstractTestQuestion.fromJson(questionJson);
2025-12-13 13:27:05 +00:00
}).toList();
return TestDto(
id: testId.toString(),
name: test.name,
color: test.color,
cover: test.cover,
version: test.version ?? '1.0',
time: test.time,
timeSubtitle: test.timeSubtitle,
questions: questionsList,
statistics: statistics,
2025-11-16 11:25:27 +00:00
);
}
Future<List<TestDto>> availableTests(UserModel userModel) async {
2025-12-13 13:27:05 +00:00
final tests = await _db.testDao.getAllTests();
final testDtos = <TestDto>[];
for (final test in tests) {
final questions = await _db.testDao.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(userModel.id!, test.id);
final questionsList = questions.map((q) {
2025-12-17 00:56:22 +00:00
// Build question JSON from separate fields
final questionJson = <String, dynamic>{
2025-12-13 13:27:05 +00:00
'questionType': q.questionType,
2025-12-17 00:56:22 +00:00
'id': q.id,
'word': q.word,
};
// Parse options (JSON array of buttons)
try {
final options = json.decode(q.options) as List<dynamic>;
questionJson['buttons'] = options;
} catch (e) {
questionJson['buttons'] = [];
}
// Add answer
questionJson['answer'] = q.answer;
// Parse uiData (image, text, audio, template)
try {
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
questionJson.addAll(uiData);
} catch (e) {
// If uiData is empty or invalid, ignore
}
return AbstractTestQuestion.fromJson(questionJson);
2025-12-13 13:27:05 +00:00
}).toList();
testDtos.add(TestDto(
id: test.id.toString(),
name: test.name,
color: test.color,
cover: test.cover,
version: test.version ?? '1.0',
time: test.time,
timeSubtitle: test.timeSubtitle,
questions: questionsList,
statistics: statistics,
));
}
return testDtos;
2025-11-16 11:25:27 +00:00
}
Future<List<TestDto>> fetchPackTests(
UserModel user,
CardPackModel model,
) async {
2025-12-13 13:27:05 +00:00
final packId = model.id;
if (packId == null) return [];
final tests = await _db.testDao.getTestsByPackId(packId);
2025-12-17 01:42:58 +00:00
// Helper function to convert image ID to URL
String? _convertImageToUrl(String? imageValue, String? packId) {
if (imageValue == null || packId == null) return imageValue;
// If it's already a proper URL, return as is
if (imageValue.startsWith('http://') ||
imageValue.startsWith('https://') ||
(imageValue.startsWith('/api/') && imageValue.contains('/cards/') && imageValue.endsWith('/image'))) {
return imageValue;
}
// If it looks like a UUID (card ID), convert to URL
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
// Otherwise, assume it's already a card ID and convert
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
2025-12-13 13:27:05 +00:00
final testDtos = <TestDto>[];
for (final test in tests) {
final questions = await _db.testDao.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(user.id!, test.id);
final questionsList = questions.map((q) {
2025-12-17 00:56:22 +00:00
// Build question JSON from separate fields
final questionJson = <String, dynamic>{
2025-12-13 13:27:05 +00:00
'questionType': q.questionType,
2025-12-17 00:56:22 +00:00
'id': q.id,
'word': q.word,
};
// Parse options (JSON array of buttons)
2025-12-17 01:42:58 +00:00
List<dynamic> buttons = [];
2025-12-17 00:56:22 +00:00
try {
2025-12-17 01:42:58 +00:00
buttons = json.decode(q.options) as List<dynamic>;
2025-12-17 00:56:22 +00:00
} catch (e) {
2025-12-17 01:42:58 +00:00
buttons = [];
2025-12-17 00:56:22 +00:00
}
2025-12-17 01:42:58 +00:00
// Convert button images to URLs
final buttonsWithUrls = buttons.map((button) {
if (button is Map<String, dynamic> && button['image'] != null) {
final buttonMap = Map<String, dynamic>.from(button);
buttonMap['image'] = _convertImageToUrl(
buttonMap['image'] as String?,
packId,
);
return buttonMap;
}
return button;
}).toList();
questionJson['buttons'] = buttonsWithUrls;
2025-12-17 00:56:22 +00:00
// Add answer
questionJson['answer'] = q.answer;
// Parse uiData (image, text, audio, template)
try {
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
2025-12-17 01:42:58 +00:00
// Convert question image to URL
if (uiData['image'] != null) {
uiData['image'] = _convertImageToUrl(
uiData['image'] as String?,
packId,
);
}
2025-12-17 00:56:22 +00:00
questionJson.addAll(uiData);
} catch (e) {
// If uiData is empty or invalid, ignore
}
return AbstractTestQuestion.fromJson(questionJson);
2025-12-13 13:27:05 +00:00
}).toList();
testDtos.add(TestDto(
id: test.id.toString(),
name: test.name,
color: test.color,
cover: test.cover,
version: test.version ?? '1.0',
time: test.time,
timeSubtitle: test.timeSubtitle,
questions: questionsList,
statistics: statistics,
));
2025-11-16 11:25:27 +00:00
}
2025-12-13 13:27:05 +00:00
return testDtos;
2025-11-16 11:25:27 +00:00
}
Future<void> refreshCustomCreationTestsData() async {
2025-12-13 13:27:05 +00:00
// Load custom creation test data from database
// For now, keep it simple
2025-11-16 11:25:27 +00:00
}
Future<void> updateGeneratedTests(CardPackModel model) async {
2025-12-13 13:27:05 +00:00
final packId = model.id;
if (packId == null) return;
// Get pack data from database
final pack = await _db.packDao.getPackById(packId);
if (pack == null) return;
// Get all cards for the pack
final cards = await _db.packDao.getPackCards(packId);
if (cards.isEmpty) return;
2025-12-16 23:26:49 +00:00
// Delete old generated tests for this pack
final existingTests = await _db.testDao.getTestsByPackId(packId);
final oldGeneratedTests = existingTests
.where((test) => test.version == 'generated')
.toList();
for (final oldTest in oldGeneratedTests) {
await _db.testDao.softDeleteTest(oldTest.id);
}
2025-12-13 13:27:05 +00:00
// Create creation test data
final testDataItems = cards.map((card) {
return TestDataItem(
id: card.id.toString(),
original: card.original,
translation: card.translation,
image: card.image,
2025-12-13 14:48:00 +00:00
audio: card.original, // Use original as audio
2025-12-13 13:27:05 +00:00
);
}).toList();
final creationTestData = CreationTestData(
items: testDataItems,
2025-12-13 14:48:00 +00:00
title: pack.title,
2025-12-13 13:27:05 +00:00
color: pack.color,
packId: packId.toString(),
2025-11-16 11:25:27 +00:00
);
2025-12-13 13:27:05 +00:00
// Generate test using PackTestGenerator
final generator = PackTestGenerator(creationTestData);
final testDto = await generator.generate(
name: '${pack.title} Test',
ratios: {
TestQuestionType.simple: 0.7,
TestQuestionType.input_buttons: 0.3,
},
multiply: 1.0,
);
2025-12-18 20:40:48 +00:00
// Save test to database and link it to the pack in one transaction.
await addTest(
testDto,
packId: packId,
);
2025-11-16 11:25:27 +00:00
}
2025-12-18 20:40:48 +00:00
Future<String> addTest(
TestDto testDto, {
String? packId,
}) async {
String? createdTestId;
2025-12-13 13:27:05 +00:00
await _db.transaction(() async {
// Create test
final testCompanion = TestsCompanion.insert(
name: testDto.name,
color: drift.Value(testDto.color),
cover: drift.Value(testDto.cover),
version: drift.Value(testDto.version),
time: drift.Value(testDto.time),
timeSubtitle: drift.Value(testDto.timeSubtitle),
);
2025-12-18 20:40:48 +00:00
createdTestId = await _db.testDao.createTest(testCompanion);
final testId = createdTestId!;
if (packId != null) {
await _db.testDao.linkTestToPack(testId, packId);
}
2025-12-13 13:27:05 +00:00
// Create questions
2025-12-17 00:56:22 +00:00
int orderIndex = 0;
2025-12-13 13:27:05 +00:00
for (final question in testDto.questions) {
2025-12-17 00:56:22 +00:00
final questionJson = question.toJson();
// Extract key fields
final word = questionJson['word'] as String? ?? '';
final answer = questionJson['answer'] as String? ?? '';
final buttons = questionJson['buttons'] as List<dynamic>? ?? [];
// UI data (image, text, audio, template)
final uiData = <String, dynamic>{};
if (questionJson['image'] != null) uiData['image'] = questionJson['image'];
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
if (questionJson['audio'] != null) uiData['audio'] = questionJson['audio'];
if (questionJson['template'] != null) uiData['template'] = questionJson['template'];
2025-12-13 13:27:05 +00:00
final questionCompanion = TestQuestionsCompanion.insert(
testId: testId,
2025-12-17 00:56:22 +00:00
orderIndex: drift.Value(orderIndex++),
2025-12-13 13:27:05 +00:00
questionType: question.questionType.name,
2025-12-17 00:56:22 +00:00
word: word,
answer: answer,
options: drift.Value(json.encode(buttons)),
uiData: drift.Value(json.encode(uiData)),
2025-11-16 11:25:27 +00:00
);
2025-12-13 13:27:05 +00:00
await _db.testDao.createTestQuestion(questionCompanion);
2025-11-16 11:25:27 +00:00
}
});
2025-12-18 20:40:48 +00:00
return createdTestId!;
2025-11-16 11:25:27 +00:00
}
2025-12-13 13:27:05 +00:00
}