mnemo_cards/mnemo_cards_backend/lib/tests/test_manager.dart
Dmitry cb781741f3
Some checks failed
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Backend CI / test (push) Has been cancelled
Mobile App CI / test (push) Has been cancelled
Deploy Telegram Bot / Deploy Telegram Bot (push) Has been cancelled
Backend CI / build (push) Has been cancelled
Mobile App CI / build-android (push) Has been cancelled
Mobile App CI / build-ios (push) Has been cancelled
stuff
2026-01-10 21:48:31 +03:00

951 lines
32 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'package:injectable/injectable.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_backend/storage/minio_config.dart';
import 'package:mnemo_cards_backend/storage/minio_service.dart';
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';
import 'package:drift/drift.dart' as drift;
import 'generators/pack_test_generator.dart';
@lazySingleton
class TestManager {
final AppDatabase _db;
final PackRepository _packRepository;
final TestRepository _testRepository;
final MinioService _minioService;
TestManager(
this._db,
this._packRepository,
this._testRepository,
this._minioService,
);
static final _uuidRegex = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
caseSensitive: false,
);
bool _isUuid(String value) => _uuidRegex.hasMatch(value.trim());
bool _isBase64OrDataUrlImage(String value) =>
CardImageStorage.tryParseBase64Image(value) != null;
String? _extractCardIdFromApiImageUrl(String value) {
final v = value.trim();
if (!CardImageStorage.isApiImageUrl(v)) return null;
final match = RegExp(
r'/cards/([^/]+)/image(?:Back)?$',
).firstMatch(v)?.group(1);
if (match == null) return null;
return _isUuid(match) ? match : null;
}
Future<void> _tryLinkCardToPack({
required String packId,
required String cardId,
}) async {
try {
final card = await _packRepository.getCardById(cardId);
if (card == null) return;
await _packRepository.addCardToPack(packId: packId, cardId: cardId);
} catch (_) {
// Best-effort only.
}
}
Future<String?> _convertBase64ToCard(
String base64Image,
String? packId,
) async {
try {
final companion = GameCardsCompanion.insert(
original: 'test_image',
translation: 'test_image',
image: '',
mnemo: const drift.Value('test_image'),
);
final cardId = await _packRepository.createCard(companion);
if (packId != null) {
await _tryLinkCardToPack(packId: packId, cardId: cardId);
}
final stored = await CardImageStorage.persistFromBase64(
cardId: cardId,
imageValue: base64Image,
preferredFileName: null,
isBack: false,
);
if (stored == null) return null;
final created = await _packRepository.getCardById(cardId);
if (created == null) return null;
// TODO: Refactor to use PackRepository.updateCard with GameCardModel
final card = await _db.packDao.getCardById(cardId);
if (card != null) {
await _packRepository.updateCard(
card.copyWith(
image: stored.fileName,
updatedAt: PgDateTime(DateTime.now()),
),
);
}
return cardId;
} catch (_) {
return null;
}
}
Future<String?> _normalizeImageValueForDb(
String? value, {
required String? packId,
}) async {
if (value == null) return null;
final v = value.trim();
if (v.isEmpty) return null;
// Remote URL: keep as is
if (CardImageStorage.isRemoteUrl(v)) return v;
// Check if path contains a full URL inside (malformed path like /api/.../cards/https://...)
// Extract the URL if found
final urlMatch = RegExp(r'https?://[^\s/]+[^\s]*').firstMatch(v);
if (urlMatch != null) {
final extractedUrl = urlMatch.group(0)!;
// Remove trailing /image if present
final cleanUrl = extractedUrl.endsWith('/image')
? extractedUrl.substring(0, extractedUrl.length - 6)
: extractedUrl;
// Validate it's a proper URL
final uri = Uri.tryParse(cleanUrl);
if (uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority) {
return cleanUrl;
}
}
// If it's already a valid UUID (object ID in MinIO), keep it
if (_isUuid(v)) {
return v;
}
// API URL: extract cardId if it's a valid UUID
final fromApi = _extractCardIdFromApiImageUrl(v);
if (fromApi != null && _isUuid(fromApi)) {
return fromApi; // Return as object ID
}
// Base64/data-url: upload to MinIO and return object ID
if (_isBase64OrDataUrlImage(v)) {
final parsed = CardImageStorage.tryParseBase64Image(v);
if (parsed != null) {
try {
final objectId = await _minioService.uploadFile(
bucket: MinioConfig.cardImagesBucket,
bytes: parsed.bytes,
contentType: parsed.contentType,
);
return objectId;
} catch (e) {
print('Error uploading test image to MinIO: $e');
// Fallback: try old method for backward compatibility
return _convertBase64ToCard(v, packId);
}
}
}
// Legacy: old filename format or other - keep as is for backward compatibility
return v;
}
/// Converts image value to presigned URL for display
Future<String?> _imageValueToApiUrl(
String? value, {
required String? packId,
}) async {
if (value == null) return null;
final v = value.trim();
if (v.isEmpty) return null;
// Already a URL: return as is
if (CardImageStorage.isRemoteUrl(v)) {
return v;
}
// Check if path contains a full URL inside (malformed path like /api/.../cards/https://...)
// Extract the URL if found
final urlMatch = RegExp(r'https?://[^\s/]+[^\s]*').firstMatch(v);
if (urlMatch != null) {
var extractedUrl = urlMatch.group(0)!;
// Remove trailing /image if present
if (extractedUrl.endsWith('/image')) {
extractedUrl = extractedUrl.substring(0, extractedUrl.length - 6);
}
// Validate it's a proper URL
final uri = Uri.tryParse(extractedUrl);
if (uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority) {
return extractedUrl;
}
}
// API URL: check if it contains a URL inside
if (CardImageStorage.isApiImageUrl(v)) {
// But first check if it contains a URL inside
// If so, extract it instead
final innerUrlMatch = RegExp(r'https?://[^\s/]+[^\s]*').firstMatch(v);
if (innerUrlMatch != null) {
var extractedUrl = innerUrlMatch.group(0)!;
if (extractedUrl.endsWith('/image')) {
extractedUrl = extractedUrl.substring(0, extractedUrl.length - 6);
}
final uri = Uri.tryParse(extractedUrl);
if (uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority) {
return extractedUrl;
}
}
// Otherwise, process as normal API URL (will be handled below)
}
// If it's a valid UUID (object ID in MinIO), generate presigned URL
if (_isUuid(v)) {
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.cardImagesBucket,
objectId: v,
);
return presignedUrl;
}
// Legacy: try to extract cardId from API URL
final cardId = _extractCardIdFromApiImageUrl(v);
if (cardId != null && packId != null) {
// If cardId is UUID, generate presigned URL
if (_isUuid(cardId)) {
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.cardImagesBucket,
objectId: cardId,
);
return presignedUrl;
}
// Old format: use API endpoint
return '/api/v2/packs/$packId/cards/$cardId/image';
}
// Don't leak base64 through user API.
if (_isBase64OrDataUrlImage(v)) return null;
return v;
}
/// Converts an audio value to a presigned URL if it's a UUID (objectId in MinIO),
/// or returns it as-is if it's already a remote URL.
///
/// Similar to _imageValueToApiUrl but for audio files stored in voice-audio bucket.
Future<String?> _audioValueToApiUrl(String? value) async {
if (value == null) return null;
final v = value.trim();
if (v.isEmpty) return null;
// Already a remote URL: return as is
if (VoiceStorage.isRemoteUrl(v)) {
return v;
}
// If it's a valid UUID (object ID in MinIO), generate presigned URL
if (_isUuid(v)) {
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.voiceAudioBucket,
objectId: v,
);
return presignedUrl;
}
// Legacy formats (base64, filenames) - don't leak through API
// Return null to indicate invalid/unsupported format
if (VoiceStorage.tryParseBase64Audio(v) != null) return null;
// For other formats (like legacy filenames), return null
// They should be migrated to UUID format
return null;
}
Future<TestStatisticsDto?> _testStatisticsDto(
String userId,
String testId,
) async {
final statistics = await _testRepository.getTestStatistics(userId, testId);
if (statistics == null) return null;
// Convert TestStatistic to TestStatisticsDto
// 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>?) ?? [];
// 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();
return TestStatisticsDto(
testId: statistics.testId,
words: AllWordsStatisticsDto(words: words),
attempts: attempts.length,
sessionToken: lastAttempt?['sessionToken'] as String?,
);
}
Future<TestDto?> fetchTest(String id, UserModel user) async {
final testId = id;
final test = await _db.testDao.getTestById(testId);
if (test == null) return null;
// Get packId for the test to convert image IDs to URLs
final packId = await _db.testDao.getPackIdForTest(testId);
final questions = await _db.testDao.getTestQuestions(testId);
final statistics = await _testStatisticsDto(user.id!, testId);
// Convert Test to TestDto
final questionsList = <AbstractTestQuestion>[];
for (final q in questions) {
// Build question JSON from separate fields
final questionJson = <String, dynamic>{
'questionType': q.questionType,
'id': q.id,
'word': q.word,
'answer': q.answer,
};
// Parse options (JSON array of buttons / matrix cards)
List<dynamic> buttons = [];
try {
buttons = json.decode(q.options) as List<dynamic>;
} catch (_) {
buttons = [];
}
questionJson['buttons'] = buttons;
// Parse uiData (image, text, audio, template, matrixSize, ...)
Map<String, dynamic> uiData = {};
try {
uiData = json.decode(q.uiData) as Map<String, dynamic>;
} catch (_) {
uiData = {};
}
var mutated = false;
if (uiData['image'] != null) {
final raw = uiData['image']?.toString();
final normalized = await _normalizeImageValueForDb(raw, packId: packId);
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
mutated = true;
}
if (normalized == null) {
uiData.remove('image');
} else {
// Keep image as objectId, add imageUrl as presigned URL
uiData['image'] = normalized;
final imageUrl = await _imageValueToApiUrl(
normalized,
packId: packId,
);
if (imageUrl != null) {
uiData['imageUrl'] = imageUrl;
}
}
} else {
// If image was removed, also remove imageUrl
uiData.remove('imageUrl');
}
// Normalize button images too (so response is always URLs, never base64).
// Keep image as objectId, add imageUrl as presigned URL
final normalizedButtons = <dynamic>[];
for (final b in buttons) {
if (b is Map) {
final buttonMap = Map<String, dynamic>.from(
b.map((k, v) => MapEntry(k.toString(), v)),
);
if (buttonMap['image'] != null) {
final raw = buttonMap['image']?.toString();
final normalized = await _normalizeImageValueForDb(
raw,
packId: packId,
);
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
mutated = true;
}
if (normalized == null) {
buttonMap.remove('image');
buttonMap.remove('imageUrl');
} else {
// Keep image as objectId, add imageUrl as presigned URL
buttonMap['image'] = normalized;
final imageUrl = await _imageValueToApiUrl(
normalized,
packId: packId,
);
if (imageUrl != null) {
buttonMap['imageUrl'] = imageUrl;
}
}
}
normalizedButtons.add(buttonMap);
} else {
normalizedButtons.add(b);
}
}
questionJson['buttons'] = normalizedButtons;
if (mutated) {
// TODO: Refactor to use TestRepository.updateTestQuestion
await _db.testDao.updateTestQuestion(
q.copyWith(
options: jsonEncode(normalizedButtons),
uiData: jsonEncode(uiData),
updatedAt: PgDateTime(DateTime.now()),
),
);
}
// Convert audio to presigned URL if it's a UUID (objectId in MinIO)
// Note: image and imageUrl were already set above during normalization
final uiDataForResponse = Map<String, dynamic>.from(uiData);
if (uiDataForResponse['audio'] != null) {
final audioUrl = await _audioValueToApiUrl(
uiDataForResponse['audio']?.toString(),
);
if (audioUrl != null) {
uiDataForResponse['audio'] = audioUrl;
} else {
// If conversion failed (e.g., base64 or invalid format), remove audio
uiDataForResponse.remove('audio');
}
}
// Buttons already have image (objectId) and imageUrl (presigned URL)
// from normalization above, no need to convert again
questionJson['buttons'] = normalizedButtons;
questionJson.addAll(uiDataForResponse);
// Matrix question: allow storing only config (matrixSize) and generate
// actual matrix cards from pack pool on-the-fly if buttons are missing.
if (q.questionType == TestQuestionType.matrix.name) {
final matrixSizeRaw = questionJson['matrixSize'];
final matrixSize = switch (matrixSizeRaw) {
int v => v,
num v => v.toInt(),
String v => int.tryParse(v) ?? 3,
_ => 3,
};
questionJson['matrixSize'] = matrixSize;
final currentButtons =
(questionJson['buttons'] as List<dynamic>?) ?? [];
if (currentButtons.isEmpty && packId != null) {
final pool = await _packRepository.getPackCards(packId);
final maxSize = sqrt(pool.length).floor().clamp(1, 4);
final resolvedSize = matrixSize.clamp(1, maxSize);
final total = resolvedSize * resolvedSize;
final rng = Random(
(testId.hashCode ^ q.id.hashCode ^ resolvedSize) & 0x7fffffff,
);
final shuffled = pool.toList()..shuffle(rng);
final selected = shuffled.take(total).toList();
final generatedButtons = selected
.map(
(c) => <String, dynamic>{
'id': c.id,
// Front side fields
'image': c.image,
'original': c.original,
'translation': c.translation,
// Back side fields - always include original and translation
'originalBack': c.original,
'translationBack': c.translation,
'imageBack': c.image,
},
)
.toList();
questionJson['buttons'] = generatedButtons;
if (selected.isNotEmpty) {
final target = selected[rng.nextInt(selected.length)];
questionJson['word'] = target.original;
questionJson['answer'] = target.id;
}
questionJson['matrixSize'] = resolvedSize;
} else if ((questionJson['answer'] as String?)?.isEmpty != false) {
// If matrix cards exist but answer is missing, derive initial target
// from the first card or from stages.
final stages = (uiDataForResponse['stages'] as List<dynamic>?) ?? [];
if (stages.isNotEmpty) {
// Извлекаем из stages[0]
final firstStage = stages.first;
if (firstStage is Map<String, dynamic>) {
final targetCardId = firstStage['targetCardId']?.toString();
final targetWord = firstStage['targetWord']?.toString();
if (targetCardId != null && targetCardId.isNotEmpty) {
questionJson['answer'] = targetCardId;
}
if (targetWord != null && targetWord.isNotEmpty) {
questionJson['word'] = targetWord;
}
}
} else {
// Fallback: извлекаем из первого card
final first = currentButtons.firstOrNull;
if (first is Map<String, dynamic>) {
final id = first['id']?.toString();
final original = first['original']?.toString();
if (id != null && id.isNotEmpty) {
questionJson['answer'] = id;
}
if (original != null && original.isNotEmpty) {
questionJson['word'] = original;
}
}
}
}
// Также проверяем word и answer из stages, если они пусты из БД
if ((questionJson['word'] as String?)?.isEmpty != false ||
(questionJson['answer'] as String?)?.isEmpty != false) {
final stages = (uiDataForResponse['stages'] as List<dynamic>?) ?? [];
if (stages.isNotEmpty) {
final firstStage = stages.first;
if (firstStage is Map<String, dynamic>) {
if ((questionJson['word'] as String?)?.isEmpty != false) {
final targetWord = firstStage['targetWord']?.toString();
if (targetWord != null && targetWord.isNotEmpty) {
questionJson['word'] = targetWord;
}
}
if ((questionJson['answer'] as String?)?.isEmpty != false) {
final targetCardId = firstStage['targetCardId']?.toString();
if (targetCardId != null && targetCardId.isNotEmpty) {
questionJson['answer'] = targetCardId;
}
}
}
}
}
}
// Convert button images to URLs (works for both TestButtonDto and matrix cards)
// Add imageUrl while keeping image (objectId) for admin
// All images now use the same cardImagesBucket
final updatedButtons = await Future.wait(
(questionJson['buttons'] as List<dynamic>? ?? []).map((button) async {
if (button is Map<String, dynamic>) {
final buttonMap = Map<String, dynamic>.from(button);
// Convert front image to presigned URL
if (buttonMap['image'] != null) {
final imageValue = buttonMap['image']?.toString();
final imageUrl = await _imageValueToApiUrl(
imageValue,
packId: packId,
);
if (imageUrl != null) {
buttonMap['imageUrl'] = imageUrl;
}
}
// Convert back image to presigned URL (for matrix cards)
if (buttonMap['imageBack'] != null) {
final imageBackValue = buttonMap['imageBack']?.toString();
final imageBackUrl = await _imageValueToApiUrl(
imageBackValue,
packId: packId,
);
if (imageBackUrl != null) {
buttonMap['imageBackUrl'] = imageBackUrl;
}
}
return buttonMap;
}
return button;
}),
);
questionJson['buttons'] = updatedButtons;
questionsList.add(AbstractTestQuestion.fromJson(questionJson));
}
final normalizedCover = await _normalizeImageValueForDb(
test.cover,
packId: packId,
);
final coverUrl = await _imageValueToApiUrl(normalizedCover, packId: packId);
return TestDto(
id: testId.toString(),
name: test.name,
color: test.color,
cover: normalizedCover, // Object ID (for admin)
coverUrl: coverUrl, // Presigned URL (for display)
version: test.version ?? '1.0',
time: test.time,
timeSubtitle: test.timeSubtitle,
questions: questionsList,
statistics: statistics,
);
}
Future<List<TestDto>> availableTests(UserModel userModel) async {
final tests = await _testRepository.getAllTests();
final testDtos = <TestDto>[];
for (final test in tests) {
final questions = await _testRepository.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(userModel.id!, test.id);
final questionsList = questions.map((q) {
// Build question JSON from separate fields
final questionJson = <String, dynamic>{
'questionType': q.questionType,
'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);
}).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;
}
Future<List<TestDto>> fetchPackTests(
UserModel user,
CardPackModel model,
) async {
final packId = model.id;
if (packId == null) return [];
final tests = await _testRepository.getTestsByPackId(packId);
final testDtos = <TestDto>[];
for (final test in tests) {
final questions = await _testRepository.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(user.id!, test.id);
final questionsList = await Future.wait(
questions.map((q) async {
// Build question JSON from separate fields
final questionJson = <String, dynamic>{
'questionType': q.questionType,
'id': q.id,
'word': q.word,
};
// Parse options (JSON array of buttons)
List<dynamic> buttons = [];
try {
buttons = json.decode(q.options) as List<dynamic>;
} catch (e) {
buttons = [];
}
// Convert button images to URLs (works for both TestButtonDto and matrix cards)
// Add imageUrl while keeping image (objectId) for admin
final updatedButtons = await Future.wait(
buttons.map((button) async {
if (button is Map<String, dynamic> && button['image'] != null) {
final buttonMap = Map<String, dynamic>.from(button);
final imageValue = buttonMap['image']?.toString();
// Convert image to presigned URL
final imageUrl = await _imageValueToApiUrl(
imageValue,
packId: packId,
);
if (imageUrl != null) {
buttonMap['imageUrl'] = imageUrl;
}
return buttonMap;
}
return button;
}),
);
questionJson['buttons'] = updatedButtons;
// Add answer
questionJson['answer'] = q.answer;
// Parse uiData (image, text, audio, template)
try {
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
// Convert question image to URL
if (uiData['image'] != null) {
final imageValue = uiData['image']?.toString();
final imageUrl = await _imageValueToApiUrl(
imageValue,
packId: packId,
);
if (imageUrl != null) {
uiData['imageUrl'] = imageUrl;
}
}
questionJson.addAll(uiData);
} catch (e) {
// If uiData is empty or invalid, ignore
}
return AbstractTestQuestion.fromJson(questionJson);
}),
);
final normalizedCover = await _normalizeImageValueForDb(
test.cover,
packId: packId,
);
final coverUrl = await _imageValueToApiUrl(
normalizedCover,
packId: packId,
);
testDtos.add(
TestDto(
id: test.id.toString(),
name: test.name,
color: test.color,
cover: normalizedCover, // Object ID (for admin)
coverUrl: coverUrl, // Presigned URL (for display)
version: test.version ?? '1.0',
time: test.time,
timeSubtitle: test.timeSubtitle,
questions: questionsList,
statistics: statistics,
),
);
}
return testDtos;
}
Future<void> refreshCustomCreationTestsData() async {
// Load custom creation test data from database
// For now, keep it simple
}
Future<void> updateGeneratedTests(CardPackModel model) async {
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;
// 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);
}
// Create creation test data
// Get voices for all cards to use UUID from MinIO instead of text
final testDataItems = await Future.wait(
cards.map((card) async {
// Get voices for this card
final voices = await _packRepository.getCardVoices(card.id.toString());
// Use first voice's voiceUrl if it's a valid UUID, otherwise null
String? audioUuid;
if (voices.isNotEmpty) {
final voiceUrl = voices.first.voiceUrl.trim();
// Check if voiceUrl is a valid UUID (object ID in MinIO)
if (_isUuid(voiceUrl)) {
audioUuid = voiceUrl;
}
}
return TestDataItem(
id: card.id.toString(),
original: card.original,
translation: card.translation,
image: card.image,
audio: audioUuid, // Use UUID from MinIO if available
);
}),
);
final creationTestData = CreationTestData(
items: testDataItems,
title: pack.title,
color: pack.color,
packId: packId.toString(),
);
// Generate test using PackTestGenerator
final generator = PackTestGenerator(creationTestData);
final testDto = await generator.generate(
name: '${pack.title} Test',
ratios: {
TestQuestionType.simple: 0.6,
TestQuestionType.input_buttons: 0.2,
TestQuestionType.matrix: 0.2,
},
multiply: 1.0,
);
// Save test to database and link it to the pack in one transaction.
await addTest(testDto, packId: packId);
}
Future<String> addTest(TestDto testDto, {String? packId}) async {
String? createdTestId;
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),
);
createdTestId = await _db.testDao.createTest(testCompanion);
final testId = createdTestId!;
if (packId != null) {
await _db.testDao.linkTestToPack(testId, packId);
}
// Create questions
int orderIndex = 0;
for (final question in testDto.questions) {
final questionJson = question.toJson();
// Extract key fields
var word = questionJson['word'] as String? ?? '';
var 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'];
if (questionJson['matrixSize'] != null)
uiData['matrixSize'] = questionJson['matrixSize'];
if (questionJson['stages'] != null)
uiData['stages'] = questionJson['stages'];
// Для матричных вопросов извлекаем word и answer из stages[0] если они пусты
if (question.questionType == TestQuestionType.matrix) {
final stages = (questionJson['stages'] as List<dynamic>?) ?? [];
if (stages.isNotEmpty) {
final firstStage = stages.first;
if (firstStage is Map<String, dynamic>) {
if (word.isEmpty) {
final targetWord = firstStage['targetWord']?.toString() ?? '';
if (targetWord.isNotEmpty) {
word = targetWord;
}
}
if (answer.isEmpty) {
final targetCardId =
firstStage['targetCardId']?.toString() ?? '';
if (targetCardId.isNotEmpty) {
answer = targetCardId;
}
}
}
}
}
final questionCompanion = TestQuestionsCompanion.insert(
testId: testId,
orderIndex: drift.Value(orderIndex++),
questionType: question.questionType.name,
word: word,
answer: answer,
options: drift.Value(json.encode(buttons)),
uiData: drift.Value(json.encode(uiData)),
);
await _testRepository.createTestQuestion(questionCompanion);
}
});
return createdTestId!;
}
}