cyberhybridhub/server/test/integration/spelling_only_panel_test.dart

299 lines
9.7 KiB
Dart

@Tags(<String>['integration', 'postgres'])
library;
import 'dart:math';
import 'package:cyberhybridhub_server/question_dispatcher.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();
});
test(
'listPanelQuestions excludes market when market_history enabled at ratio 0',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
// The critical scenario: user sets spelling=100%, market stays enabled but
// ratio=0. A stale market question is already in the DB. The dispatcher
// must never serve it.
const String uid = 'spelling-100-market-0-panel-uid';
await testDb!.seedUser(uid);
await testDb!.questionSourcePreferencesDb.save(
uid,
<Map<String, dynamic>>[
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 0},
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 100},
],
);
await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Stale market question',
correctAnswer: 10,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
);
final QuestionDispatcher dispatcher = testDb!.questionDispatcher();
final List<Map<String, dynamic>> panel =
await dispatcher.listPanelQuestions(uid);
expect(panel, isNotEmpty);
for (final Map<String, dynamic> payload in panel) {
expect(payload['sourceId'], isNot('market_history'));
}
expect(panel.first['sourceId'], 'spelling');
expect(panel.first['presentation'], 'spelling_word');
});
test('nextQuestionAfterAnswer never returns market when spelling is 100%',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-100-next-answer-uid';
await testDb!.seedUser(uid);
await testDb!.questionSourcePreferencesDb.save(
uid,
<Map<String, dynamic>>[
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 0},
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 100},
],
);
// Seed a stale market question that should never surface.
await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Old market question',
correctAnswer: 5,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
);
final QuestionDispatcher dispatcher = testDb!.questionDispatcher();
// Simulate the context of "just answered a spelling question".
final Map<String, dynamic> fakeAnswered = <String, dynamic>{
'sourceTag': 'spelling:word_quiz',
'pipelineKey': 'spelling',
'pipelineStep': 'spelling_word_quiz',
};
final Map<String, dynamic>? next = await dispatcher.nextQuestionAfterAnswer(
firebaseUid: uid,
answeredQuestion: fakeAnswered,
);
expect(next, isNotNull);
expect(next!['sourceId'], isNot('market_history'));
expect(next['sourceId'], 'spelling');
});
test('listPanelQuestions excludes market when market_history disabled', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-only-panel-uid';
await testDb!.seedUser(uid);
await testDb!.questionSourcePreferencesDb.save(
uid,
<Map<String, dynamic>>[
<String, dynamic>{'id': 'market_history', 'enabled': false, 'ratio': 0},
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 100},
],
);
await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Stale market question',
correctAnswer: 10,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
);
final QuestionDispatcher dispatcher = testDb!.questionDispatcher();
final List<Map<String, dynamic>> panel =
await dispatcher.listPanelQuestions(uid);
expect(panel, isNotEmpty);
for (final Map<String, dynamic> payload in panel) {
expect(payload['sourceId'], isNot('market_history'));
}
expect(panel.first['sourceId'], 'spelling');
expect(panel.first['presentation'], 'spelling_word');
});
test('nextQuestionAfterAnswer respects random roll for spelling mix when spelling is 50%',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-50-next-answer-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': true, 'ratio': 50},
],
);
// Create a market question and a spelling question.
await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Market question',
correctAnswer: 5,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
);
await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Spelling question',
correctAnswer: 0,
sourceTag: 'spelling:word_quiz',
pipelineKey: 'spelling',
pipelineStep: 'spelling_word_quiz',
);
// Set up a custom mock Random where nextInt(100) returns 80 (which is >= 50, so spelling).
final QuestionDispatcher spellingFavoredDispatcher = testDb!.questionDispatcher(
random: _SequentialRandom(<int>[80]),
);
// Set up a custom mock Random where the first nextInt(100) returns 80 (prefers spelling ordering)
// and the second nextInt(100) returns 10 (< 50, selecting market_history).
final QuestionDispatcher marketFavoredDispatcher = testDb!.questionDispatcher(
random: _SequentialRandom(<int>[80, 10]),
);
final Map<String, dynamic> fakeAnswered = <String, dynamic>{
'sourceTag': 'spelling:word_quiz',
'pipelineKey': 'spelling',
'pipelineStep': 'spelling_word_quiz',
};
// When roll is 10 (spelling favored): next should be spelling question.
final Map<String, dynamic>? nextSpelling = await spellingFavoredDispatcher.nextQuestionAfterAnswer(
firebaseUid: uid,
answeredQuestion: fakeAnswered,
);
expect(nextSpelling, isNotNull);
expect(nextSpelling!['sourceId'], 'spelling');
// When roll is 80 (market/mix favored): next should be market question (since it's at the head).
final Map<String, dynamic>? nextMarket = await marketFavoredDispatcher.nextQuestionAfterAnswer(
firebaseUid: uid,
answeredQuestion: fakeAnswered,
);
expect(nextMarket, isNotNull);
expect(nextMarket!['sourceId'], 'market_history');
});
test('nextQuestionAfterDefer respects mix roll and enqueues new spelling if spelling is rolled',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-50-defer-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': true, 'ratio': 50},
],
);
// Create a market question and a spelling question.
await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Market question',
correctAnswer: 5,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
);
final Map<String, dynamic> spellingQ = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Spelling question',
correctAnswer: 0,
sourceTag: 'spelling:word_quiz',
pipelineKey: 'spelling',
pipelineStep: 'spelling_word_quiz',
);
// spellingFavoredDispatcher rolls 80 (>= 50), which selects spelling.
final QuestionDispatcher spellingFavoredDispatcher = testDb!.questionDispatcher(
random: _SequentialRandom(<int>[80]),
);
// marketFavoredDispatcher rolls 10 (< 50), which selects market_history.
final QuestionDispatcher marketFavoredDispatcher = testDb!.questionDispatcher(
random: _SequentialRandom(<int>[10]),
);
// 1. When spelling is rolled: next should be a NEW spelling question.
final Map<String, dynamic>? nextSpelling = await spellingFavoredDispatcher.nextQuestionAfterDefer(
firebaseUid: uid,
deferredQuestionId: spellingQ['id'] as String,
);
expect(nextSpelling, isNotNull);
expect(nextSpelling!['sourceId'], 'spelling');
expect(nextSpelling['id'], isNot(spellingQ['id'])); // Verifies it is a new question
// 2. When market is rolled: next should be the market question.
final Map<String, dynamic>? nextMarket = await marketFavoredDispatcher.nextQuestionAfterDefer(
firebaseUid: uid,
deferredQuestionId: spellingQ['id'] as String,
);
expect(nextMarket, isNotNull);
expect(nextMarket!['sourceId'], 'market_history');
});
}
class _SequentialRandom implements Random {
_SequentialRandom(this._values);
final List<int> _values;
int _index = 0;
@override
int nextInt(int max) {
final int val = _values[_index % _values.length];
_index++;
return val;
}
@override
bool nextBool() => false;
@override
double nextDouble() => 0.0;
}