@Tags(['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, >[ {'id': 'market_history', 'enabled': true, 'ratio': 0}, {'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> panel = await dispatcher.listPanelQuestions(uid); expect(panel, isNotEmpty); for (final Map 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, >[ {'id': 'market_history', 'enabled': true, 'ratio': 0}, {'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 fakeAnswered = { 'sourceTag': 'spelling:word_quiz', 'pipelineKey': 'spelling', 'pipelineStep': 'spelling_word_quiz', }; final Map? 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, >[ {'id': 'market_history', 'enabled': false, 'ratio': 0}, {'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> panel = await dispatcher.listPanelQuestions(uid); expect(panel, isNotEmpty); for (final Map 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, >[ {'id': 'market_history', 'enabled': true, 'ratio': 50}, {'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([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([80, 10]), ); final Map fakeAnswered = { 'sourceTag': 'spelling:word_quiz', 'pipelineKey': 'spelling', 'pipelineStep': 'spelling_word_quiz', }; // When roll is 10 (spelling favored): next should be spelling question. final Map? 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? 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, >[ {'id': 'market_history', 'enabled': true, 'ratio': 50}, {'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 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([80]), ); // marketFavoredDispatcher rolls 10 (< 50), which selects market_history. final QuestionDispatcher marketFavoredDispatcher = testDb!.questionDispatcher( random: _SequentialRandom([10]), ); // 1. When spelling is rolled: next should be a NEW spelling question. final Map? 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? 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 _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; }