cyberhybridhub/server/test/integration/spelling_pipeline_test.dart
2026-06-05 15:10:03 +00:00

353 lines
9.9 KiB
Dart

@Tags(<String>['integration', 'postgres'])
library;
import 'dart:convert';
import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
import 'package:cyberhybridhub_server/question_dispatcher.dart';
import 'package:postgres/postgres.dart';
import 'package:test/test.dart';
import '../helpers/test_db.dart';
void main() {
TestDb? testDb;
setUpAll(() async {
testDb = await TestDb.open();
});
tearDown(() async {
if (testDb != null) {
await testDb!.truncateTradingTables();
}
});
tearDownAll(() async {
await testDb?.close();
});
Future<QuestionPipeline> _pipeline() async {
return QuestionPipeline(
questionsDb: testDb!.questionsDb,
questionService: testDb!.questionService(),
questionSourcePreferencesDb: testDb!.questionSourcePreferencesDb,
testMode: true,
);
}
test('maintainSpellingForUser creates spelling pipeline question', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-maintain-uid';
await testDb!.seedUser(uid);
final QuestionPipeline pipeline = await _pipeline();
await pipeline.maintainSpellingForUser(uid);
final Result rows = await testDb!.connection.execute(
Sql.named(
'''
SELECT pipeline_key, pipeline_step, source_tag
FROM questions
WHERE assigned_user_id = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(rows, hasLength(1));
expect(rows.first[0], PipelineKeys.spelling);
expect(rows.first[1], PipelineSteps.spellingWordQuiz);
expect(rows.first[2], 'spelling:word_quiz');
final Result meta = await testDb!.connection.execute(
Sql.named(
'''
SELECT metadata->>'spelling_item_id'
FROM questions
WHERE assigned_user_id = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(meta.first[0], isNull);
});
test('prepareSpellingQuestion selects word and creates links at tile load',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-prepare-uid';
await testDb!.seedUser(uid);
final QuestionPipeline pipeline = await _pipeline();
await pipeline.maintainSpellingForUser(uid);
final Result reserved = await testDb!.connection.execute(
Sql.named(
'''
SELECT id, metadata
FROM questions
WHERE assigned_user_id = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
final String questionId = reserved.first[0]!.toString();
final Map<String, dynamic>? prepared = await pipeline.prepareSpellingQuestion(
firebaseUid: uid,
questionId: questionId,
);
expect(prepared, isNotNull);
expect(prepared!['spokenWord'], isNotEmpty);
expect(prepared['audioTitle'], questionId);
expect(prepared['canonicalWord'], isNotEmpty);
final Result link = await testDb!.connection.execute(
Sql.named(
'''
SELECT spelling_item_id
FROM spelling_question_links
WHERE question_id = @qid::uuid
''',
),
parameters: <String, dynamic>{'qid': questionId},
);
expect(link, hasLength(1));
final Result progress = await testDb!.connection.execute(
Sql.named(
'''
SELECT familiarity_score, attempt_count
FROM user_spelling_progress
WHERE firebase_uid = @uid
AND spelling_item_id = @item_id::uuid
''',
),
parameters: <String, dynamic>{
'uid': uid,
'item_id': link.first[0]!.toString(),
},
);
expect(progress, hasLength(1));
expect(progress.first[0], 0);
expect(progress.first[1], 0);
});
test('maintainSpellingForUser skips when spelling source disabled', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-disabled-uid';
await testDb!.seedUser(uid);
await testDb!.questionSourcePreferencesDb.save(
uid,
<Map<String, dynamic>>[
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 50},
<String, dynamic>{'id': 'spelling', 'enabled': false, 'ratio': 50},
],
);
final QuestionPipeline pipeline = await _pipeline();
await pipeline.maintainSpellingForUser(uid);
final Result rows = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM questions
WHERE assigned_user_id = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(rows.first[0], 0);
});
test('onAnswerSubmitted branches to follow-up then another word', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-answer-uid';
await testDb!.seedUser(uid);
final QuestionPipeline pipeline = await _pipeline();
await pipeline.maintainSpellingForUser(uid);
final Result reserved = await testDb!.connection.execute(
Sql.named(
'''
SELECT id
FROM questions
WHERE assigned_user_id = @uid
ORDER BY created_at
LIMIT 1
''',
),
parameters: <String, dynamic>{'uid': uid},
);
final String reservedId = reserved.first[0]!.toString();
await pipeline.prepareSpellingQuestion(
firebaseUid: uid,
questionId: reservedId,
);
final Result first = await testDb!.connection.execute(
Sql.named(
'''
SELECT id, metadata
FROM questions
WHERE assigned_user_id = @uid
ORDER BY created_at
LIMIT 1
''',
),
parameters: <String, dynamic>{'uid': uid},
);
final String questionId = first.first[0]!.toString();
final Object? metadataRaw = first.first[1];
final Map<String, dynamic> metadata = metadataRaw is Map<String, dynamic>
? metadataRaw
: Map<String, dynamic>.from(
jsonDecode(metadataRaw.toString()) as Map<String, dynamic>,
);
final String canonicalWord = metadata['canonical_word']! as String;
final Map<String, dynamic>? answered = await testDb!.questionsDb.submitAnswer(
questionId: questionId,
assignedUserId: uid,
userResponseText: canonicalWord,
);
expect(answered, isNotNull);
await pipeline.onAnswerSubmitted(
firebaseUid: uid,
answeredQuestion: answered!,
userResponse: answered['userResponse'] as num? ?? 0,
);
final Result rows = await testDb!.connection.execute(
Sql.named(
'''
SELECT pipeline_step, source_tag
FROM questions
WHERE assigned_user_id = @uid
ORDER BY created_at
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(rows.length, greaterThanOrEqualTo(2));
expect(rows.last[0], PipelineSteps.spellingFollowUp);
expect(rows.last[1], 'spelling:follow_up');
});
test('resetSpellingProgressForUser clears links and progress then re-seeds',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-reset-uid';
await testDb!.seedUser(uid);
final QuestionDispatcher dispatcher = testDb!.questionDispatcher();
final QuestionPipeline pipeline = testDb!.questionPipeline(testMode: true);
pipeline.attachQuestionDispatcher(dispatcher);
await pipeline.maintainSpellingForUser(uid);
final Result reserved = await testDb!.connection.execute(
Sql.named(
'''
SELECT id
FROM questions
WHERE assigned_user_id = @uid
LIMIT 1
''',
),
parameters: <String, dynamic>{'uid': uid},
);
final String questionId = reserved.first[0]!.toString();
await pipeline.prepareSpellingQuestion(
firebaseUid: uid,
questionId: questionId,
);
final Result progressBefore = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM user_spelling_progress
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
final Result linksBefore = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM spelling_question_links
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(progressBefore.first[0], greaterThan(0));
expect(linksBefore.first[0], greaterThan(0));
final Map<String, dynamic> result =
await pipeline.resetSpellingProgressForUser(uid);
expect(result['unansweredCount'], greaterThanOrEqualTo(1));
final Result progressAfter = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM user_spelling_progress
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
final Result linksAfter = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM spelling_question_links
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(progressAfter.first[0], 0);
expect(linksAfter.first[0], 0);
final Result spellingQuestions = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM questions
WHERE assigned_user_id = @uid
AND pipeline_key = 'spelling'
AND user_response IS NULL
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(spellingQuestions.first[0], 1);
});
}