working percentage

This commit is contained in:
nathanAndrinoid 2026-06-08 00:22:16 +00:00
parent 59b039a3c0
commit 78e5db6775
6 changed files with 610 additions and 48 deletions

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/question_source_preference.dart'; import '../models/question_source_preference.dart';
import '../repositories/question_source_config_repository.dart'; import '../repositories/question_source_config_repository.dart';
@ -53,9 +54,191 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
).map((QuestionSourcePreference source) => source.copyWith()).toList(); ).map((QuestionSourcePreference source) => source.copyWith()).toList();
} }
void _updateSource(int index, QuestionSourcePreference next) { List<int> _distribute(List<double> targetProportions, int targetSum) {
final List<double> values = targetProportions.map((double p) => p * targetSum).toList();
final List<int> floorValues = values.map((double v) => v.floor()).toList();
final int currentSum = floorValues.fold(0, (int sum, int val) => sum + val);
final int remainder = targetSum - currentSum;
if (remainder > 0) {
final List<int> indices = List<int>.generate(values.length, (int i) => i);
indices.sort((int a, int b) {
final double fracA = values[a] - floorValues[a];
final double fracB = values[b] - floorValues[b];
return fracB.compareTo(fracA);
});
for (int i = 0; i < remainder; i++) {
floorValues[indices[i % indices.length]] += 1;
}
}
return floorValues;
}
void _updateRatio(int targetIndex, int newRatio) {
setState(() { setState(() {
_draft[index] = next; newRatio = newRatio.clamp(0, 100);
if (newRatio == 100) {
for (int i = 0; i < _draft.length; i++) {
if (i == targetIndex) {
_draft[i] = _draft[i].copyWith(enabled: true, ratio: 100);
} else {
_draft[i] = _draft[i].copyWith(enabled: false, ratio: 0);
}
}
_validationMessage = null;
return;
}
final List<MapEntry<int, QuestionSourcePreference>> otherActiveSources = _draft
.asMap()
.entries
.where((MapEntry<int, QuestionSourcePreference> entry) =>
entry.key != targetIndex &&
!entry.value.comingSoon &&
entry.value.enabled)
.toList();
if (otherActiveSources.isEmpty) {
final List<int> disabledNonComingSoonIndices = _draft
.asMap()
.entries
.where((MapEntry<int, QuestionSourcePreference> entry) =>
entry.key != targetIndex && !entry.value.comingSoon)
.map((MapEntry<int, QuestionSourcePreference> entry) => entry.key)
.toList();
if (disabledNonComingSoonIndices.isNotEmpty) {
final int freedPercentage = 100 - newRatio;
final List<double> proportions = List<double>.filled(
disabledNonComingSoonIndices.length,
1.0 / disabledNonComingSoonIndices.length,
);
final List<int> distributed = _distribute(proportions, freedPercentage);
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: newRatio);
for (int i = 0; i < disabledNonComingSoonIndices.length; i++) {
final int idx = disabledNonComingSoonIndices[i];
_draft[idx] = _draft[idx].copyWith(enabled: true, ratio: distributed[i]);
}
} else {
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: 100);
}
_validationMessage = null;
return;
}
final int targetOtherSum = 100 - newRatio;
final int currentOtherSum = otherActiveSources.fold<int>(
0,
(int sum, MapEntry<int, QuestionSourcePreference> entry) => sum + entry.value.ratio,
);
final List<int> otherIndices = otherActiveSources.map((MapEntry<int, QuestionSourcePreference> entry) => entry.key).toList();
final List<double> proportions = <double>[];
if (currentOtherSum > 0) {
for (final int idx in otherIndices) {
proportions.add(_draft[idx].ratio / currentOtherSum);
}
} else {
for (int i = 0; i < otherIndices.length; i++) {
proportions.add(1.0 / otherIndices.length);
}
}
final List<int> distributed = _distribute(proportions, targetOtherSum);
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: newRatio);
for (int i = 0; i < otherIndices.length; i++) {
final int idx = otherIndices[i];
_draft[idx] = _draft[idx].copyWith(ratio: distributed[i]);
}
_validationMessage = null;
});
}
void _toggleSourceEnabled(int targetIndex, bool enabled) {
setState(() {
if (enabled) {
final List<int> otherActiveIndices = _draft
.asMap()
.entries
.where((MapEntry<int, QuestionSourcePreference> entry) =>
entry.key != targetIndex &&
!entry.value.comingSoon &&
entry.value.enabled)
.map((MapEntry<int, QuestionSourcePreference> entry) => entry.key)
.toList();
if (otherActiveIndices.isEmpty) {
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: 100);
} else {
final int newCount = otherActiveIndices.length + 1;
final int targetRatio = (100 / newCount).round();
final int remainingRatio = 100 - targetRatio;
final int currentOtherSum = otherActiveIndices.fold<int>(
0,
(int sum, int idx) => sum + _draft[idx].ratio,
);
final List<double> proportions = <double>[];
if (currentOtherSum > 0) {
for (final int idx in otherActiveIndices) {
proportions.add(_draft[idx].ratio / currentOtherSum);
}
} else {
for (int i = 0; i < otherActiveIndices.length; i++) {
proportions.add(1.0 / otherActiveIndices.length);
}
}
final List<int> distributed = _distribute(proportions, remainingRatio);
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: targetRatio);
for (int i = 0; i < otherActiveIndices.length; i++) {
final int idx = otherActiveIndices[i];
_draft[idx] = _draft[idx].copyWith(ratio: distributed[i]);
}
}
} else {
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: false, ratio: 0);
final List<int> otherActiveIndices = _draft
.asMap()
.entries
.where((MapEntry<int, QuestionSourcePreference> entry) =>
entry.key != targetIndex &&
!entry.value.comingSoon &&
entry.value.enabled)
.map((MapEntry<int, QuestionSourcePreference> entry) => entry.key)
.toList();
if (otherActiveIndices.isNotEmpty) {
final int currentOtherSum = otherActiveIndices.fold<int>(
0,
(int sum, int idx) => sum + _draft[idx].ratio,
);
final List<double> proportions = <double>[];
if (currentOtherSum > 0) {
for (final int idx in otherActiveIndices) {
proportions.add(_draft[idx].ratio / currentOtherSum);
}
} else {
for (int i = 0; i < otherActiveIndices.length; i++) {
proportions.add(1.0 / otherActiveIndices.length);
}
}
final List<int> distributed = _distribute(proportions, 100);
for (int i = 0; i < otherActiveIndices.length; i++) {
final int idx = otherActiveIndices[i];
_draft[idx] = _draft[idx].copyWith(ratio: distributed[i]);
}
}
}
_validationMessage = null; _validationMessage = null;
}); });
} }
@ -197,8 +380,8 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
key: Key('question-source-row-${_draft[index].id}'), key: Key('question-source-row-${_draft[index].id}'),
source: _draft[index], source: _draft[index],
effectivePercent: mix[_draft[index].id], effectivePercent: mix[_draft[index].id],
onChanged: (QuestionSourcePreference next) => onRatioChanged: (int ratio) => _updateRatio(index, ratio),
_updateSource(index, next), onEnabledChanged: (bool enabled) => _toggleSourceEnabled(index, enabled),
onEditSpelling: _draft[index].id == 'spelling' onEditSpelling: _draft[index].id == 'spelling'
? () => _openSpellingWordList(context) ? () => _openSpellingWordList(context)
: null, : null,
@ -281,12 +464,13 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
} }
} }
class _QuestionSourceRow extends StatelessWidget { class _QuestionSourceRow extends StatefulWidget {
const _QuestionSourceRow({ const _QuestionSourceRow({
super.key, super.key,
required this.source, required this.source,
required this.effectivePercent, required this.effectivePercent,
required this.onChanged, required this.onRatioChanged,
required this.onEnabledChanged,
this.onEditSpelling, this.onEditSpelling,
this.onResetSpelling, this.onResetSpelling,
this.resetSpellingBusy = false, this.resetSpellingBusy = false,
@ -294,11 +478,73 @@ class _QuestionSourceRow extends StatelessWidget {
final QuestionSourcePreference source; final QuestionSourcePreference source;
final int? effectivePercent; final int? effectivePercent;
final ValueChanged<QuestionSourcePreference> onChanged; final ValueChanged<int> onRatioChanged;
final ValueChanged<bool> onEnabledChanged;
final VoidCallback? onEditSpelling; final VoidCallback? onEditSpelling;
final VoidCallback? onResetSpelling; final VoidCallback? onResetSpelling;
final bool resetSpellingBusy; final bool resetSpellingBusy;
@override
State<_QuestionSourceRow> createState() => _QuestionSourceRowState();
}
class _QuestionSourceRowState extends State<_QuestionSourceRow> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: '${widget.source.ratio}');
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
@override
void didUpdateWidget(covariant _QuestionSourceRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.source.ratio != widget.source.ratio) {
final int? parsed = int.tryParse(_controller.text);
if (parsed != widget.source.ratio) {
if (_controller.text.isEmpty && widget.source.ratio == 0) {
return;
}
_controller.text = '${widget.source.ratio}';
}
}
}
@override
void dispose() {
_controller.dispose();
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
void _onFocusChange() {
if (!_focusNode.hasFocus) {
if (_controller.text.isEmpty) {
_controller.text = '0';
widget.onRatioChanged(0);
}
}
}
void _onTextChange(String text) {
if (text.isEmpty) {
widget.onRatioChanged(0);
return;
}
final int? value = int.tryParse(text);
if (value != null) {
final int clamped = value.clamp(0, 100);
if (clamped != widget.source.ratio) {
widget.onRatioChanged(clamped);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
@ -324,11 +570,11 @@ class _QuestionSourceRow extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Flexible( Flexible(
child: Text( child: Text(
source.label, widget.source.label,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
), ),
if (source.comingSoon) ...<Widget>[ if (widget.source.comingSoon) ...<Widget>[
const SizedBox(width: 8), const SizedBox(width: 8),
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -358,61 +604,116 @@ class _QuestionSourceRow extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
source.description, widget.source.description,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
], ],
), ),
), ),
if (onEditSpelling != null) if (widget.onEditSpelling != null)
IconButton( IconButton(
key: const Key('question-source-edit-spelling'), key: const Key('question-source-edit-spelling'),
tooltip: 'Edit spelling word list', tooltip: 'Edit spelling word list',
onPressed: resetSpellingBusy ? null : onEditSpelling, onPressed: widget.resetSpellingBusy ? null : widget.onEditSpelling,
icon: const Icon(Icons.edit, size: 20), icon: const Icon(Icons.edit, size: 20),
), ),
if (onResetSpelling != null) if (widget.onResetSpelling != null)
IconButton( IconButton(
key: const Key('question-source-reset-spelling'), key: const Key('question-source-reset-spelling'),
tooltip: 'Reset spelling progress', tooltip: 'Reset spelling progress',
onPressed: resetSpellingBusy ? null : onResetSpelling, onPressed: widget.resetSpellingBusy ? null : widget.onResetSpelling,
icon: const Icon(Icons.restart_alt, size: 20), icon: const Icon(Icons.restart_alt, size: 20),
), ),
Switch( Switch(
key: Key('question-source-enabled-${source.id}'), key: Key('question-source-enabled-${widget.source.id}'),
value: source.enabled, value: widget.source.enabled,
onChanged: source.comingSoon onChanged: widget.source.comingSoon
? null ? null
: (bool enabled) => : (bool enabled) => widget.onEnabledChanged(enabled),
onChanged(source.copyWith(enabled: enabled)),
), ),
], ],
), ),
if (source.enabled && !source.comingSoon) ...<Widget>[ if (widget.source.enabled && !widget.source.comingSoon) ...<Widget>[
Row( Row(
children: <Widget>[ children: <Widget>[
const Text('Weight'), const Text('Weight'),
const Spacer(), const Spacer(),
Text( SizedBox(
effectivePercent == null width: 70,
? '${source.ratio}' height: 36,
: '${source.ratio} (${effectivePercent}%)', child: TextField(
style: const TextStyle( key: Key('question-source-input-${widget.source.id}'),
color: AppColors.accent, controller: _controller,
fontWeight: FontWeight.w600, focusNode: _focusNode,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.accent,
fontWeight: FontWeight.w600,
fontSize: 14,
),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 8,
),
suffixText: '%',
suffixStyle: const TextStyle(
color: AppColors.accent,
fontWeight: FontWeight.w600,
fontSize: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(
color: AppColors.accent,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: AppColors.accent.withValues(alpha: 0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(
color: AppColors.accent,
),
),
),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
onChanged: (String text) {
final int? val = int.tryParse(text);
if (val != null) {
if (val > 100) {
_controller.text = '100';
_controller.selection = TextSelection.fromPosition(
const TextPosition(offset: 3),
);
widget.onRatioChanged(100);
} else {
_onTextChange(text);
}
} else {
_onTextChange(text);
}
},
), ),
), ),
], ],
), ),
Slider( Slider(
key: Key('question-source-ratio-${source.id}'), key: Key('question-source-ratio-${widget.source.id}'),
value: source.ratio.toDouble(), value: widget.source.ratio.toDouble(),
min: 0, min: 0,
max: 100, max: 100,
divisions: 20, divisions: 100,
label: '${source.ratio}', label: '${widget.source.ratio}',
onChanged: (double value) => onChanged: (double value) =>
onChanged(source.copyWith(ratio: value.round())), widget.onRatioChanged(value.round()),
), ),
], ],
], ],

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@ -147,6 +148,7 @@ Handler questionsHandler({
(nextQuestion['unansweredCount'] as num?)?.toInt() ?? (nextQuestion['unansweredCount'] as num?)?.toInt() ??
unansweredCount; unansweredCount;
} }
unawaited(questionDispatcher.topUpQueue(firebaseUid));
final bool isMarketAnswer = final bool isMarketAnswer =
QuestionsDb.resolveSourceId(updated) == 'market_history'; QuestionsDb.resolveSourceId(updated) == 'market_history';
final Map<String, dynamic>? score = isMarketAnswer final Map<String, dynamic>? score = isMarketAnswer
@ -232,6 +234,7 @@ Handler questionsHandler({
firebaseUid: firebaseUid, firebaseUid: firebaseUid,
deferredQuestionId: id, deferredQuestionId: id,
); );
unawaited(questionDispatcher.topUpQueue(firebaseUid));
return _jsonResponse(200, <String, dynamic>{ return _jsonResponse(200, <String, dynamic>{
'question': updated, 'question': updated,
'unansweredCount': unansweredCount, 'unansweredCount': unansweredCount,

View File

@ -496,6 +496,11 @@ class QuestionPipeline {
await _startSpellingPipeline(firebaseUid); await _startSpellingPipeline(firebaseUid);
} }
/// Force enqueues a new spelling question (e.g. after a deferral).
Future<void> forceEnqueueSpellingQuestion(String firebaseUid) async {
await _startSpellingPipeline(firebaseUid, force: true);
}
Future<Map<String, dynamic>> _loadContext( Future<Map<String, dynamic>> _loadContext(
String firebaseUid, String firebaseUid,
String pipelineKey, String pipelineKey,

View File

@ -104,25 +104,12 @@ class QuestionDispatcher {
required Map<String, dynamic> answeredQuestion, required Map<String, dynamic> answeredQuestion,
}) async { }) async {
_log('nextQuestionAfterAnswer($firebaseUid)'); _log('nextQuestionAfterAnswer($firebaseUid)');
final String answeredSourceId = QuestionsDb.resolveSourceId(answeredQuestion);
var rows = await _listEnabledUnansweredOrdered(firebaseUid); var rows = await _listEnabledUnansweredOrdered(firebaseUid);
if (rows.isEmpty) { if (rows.isEmpty) {
_log('Queue empty, falling back to dispatchNextQuestion'); _log('Queue empty, falling back to dispatchNextQuestion');
return dispatchNextQuestion(firebaseUid); return dispatchNextQuestion(firebaseUid);
} }
// Pipeline continuity: If we just answered a spelling question and another
// spelling question is available (e.g. follow-up or next word quiz), return it
// immediately. Pipelines should not be interrupted by mix changes.
if (answeredSourceId == 'spelling') {
for (final Map<String, dynamic> row in rows) {
if (QuestionsDb.resolveSourceId(row) == 'spelling') {
return _toClientPayload(row, rows.length);
}
}
}
final List<ResolvedQuestionSourcePreference> prefs = final List<ResolvedQuestionSourcePreference> prefs =
await _preferencesDb.getResolved(firebaseUid); await _preferencesDb.getResolved(firebaseUid);
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs); final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
@ -150,17 +137,49 @@ class QuestionDispatcher {
required String firebaseUid, required String firebaseUid,
required String deferredQuestionId, required String deferredQuestionId,
}) async { }) async {
final List<ResolvedQuestionSourcePreference> prefs =
await _preferencesDb.getResolved(firebaseUid);
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
final Map<String, int> delivered =
await _questionsDb.countDeliveredBySourceId(firebaseUid);
final String preferred = QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: mix,
deliveredCounts: delivered,
random: _random,
);
// If spelling was rolled/preferred, but the only spelling question is the deferred one,
// force enqueue a new spelling question (so the user gets a different word).
if (preferred == 'spelling') {
final List<Map<String, dynamic>> currentRows =
await _listEnabledUnansweredOrdered(firebaseUid);
final bool hasOtherSpelling = currentRows.any((Map<String, dynamic> row) =>
QuestionsDb.resolveSourceId(row) == 'spelling' &&
row['id'] != deferredQuestionId);
if (!hasOtherSpelling && QuestionSourcePreferences.isEnabled(prefs, 'spelling')) {
await _questionPipeline.forceEnqueueSpellingQuestion(firebaseUid);
}
}
final List<Map<String, dynamic>> rows = final List<Map<String, dynamic>> rows =
await _listEnabledUnansweredOrdered(firebaseUid); await _listEnabledUnansweredOrdered(firebaseUid);
if (rows.isEmpty) { if (rows.isEmpty) {
return dispatchNextQuestion(firebaseUid); return dispatchNextQuestion(firebaseUid);
} }
for (final Map<String, dynamic> row in rows) {
final List<Map<String, dynamic>> ordered =
QuestionSourceMix.orderUnansweredByPreferredSource(
rows,
preferredSourceId: preferred,
);
for (final Map<String, dynamic> row in ordered) {
if (row['id'] != deferredQuestionId) { if (row['id'] != deferredQuestionId) {
return _toClientPayload(row, rows.length); return _toClientPayload(row, rows.length);
} }
} }
return _toClientPayload(rows.first, rows.length); return _toClientPayload(ordered.first, rows.length);
} }
/// Fills the queue up to [maxQueuedQuestions] using mix-sequential source picks. /// Fills the queue up to [maxQueuedQuestions] using mix-sequential source picks.

View File

@ -1,8 +1,9 @@
@Tags(<String>['integration', 'postgres']) @Tags(<String>['integration', 'postgres'])
library; library;
import 'dart:math';
import 'package:cyberhybridhub_server/question_dispatcher.dart'; import 'package:cyberhybridhub_server/question_dispatcher.dart';
import 'package:cyberhybridhub_server/questions_db.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import '../helpers/test_db.dart'; import '../helpers/test_db.dart';
@ -145,4 +146,153 @@ void main() {
expect(panel.first['sourceId'], 'spelling'); expect(panel.first['sourceId'], 'spelling');
expect(panel.first['presentation'], 'spelling_word'); 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;
} }

View File

@ -188,4 +188,88 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('question-source-config-top-bar')), findsNothing); expect(find.byKey(const Key('question-source-config-top-bar')), findsNothing);
}); });
testWidgets('proportional slider balancing and manual text inputs', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: const HomeScreen(
user: AppUser(uid: 'u1', displayName: 'Test User'),
),
),
);
// Open configuration dialog.
await tester.tap(find.byKey(const Key('question-source-settings-button')));
await tester.pumpAndSettle();
// Verify initial state: both sources enabled and ratio is 50.
final Finder marketInput = find.byKey(const Key('question-source-input-market_history'));
final Finder spellingInput = find.byKey(const Key('question-source-input-spelling'));
expect(marketInput, findsOneWidget);
expect(spellingInput, findsOneWidget);
expect(tester.widget<TextField>(marketInput).controller?.text, '50');
expect(tester.widget<TextField>(spellingInput).controller?.text, '50');
// 1. Enter 80 in market_history input.
await tester.enterText(marketInput, '80');
await tester.pumpAndSettle();
// Verify spelling proportionally reduced to 20.
expect(tester.widget<TextField>(marketInput).controller?.text, '80');
expect(tester.widget<TextField>(spellingInput).controller?.text, '20');
// 2. Put market_history to 100.
await tester.enterText(marketInput, '100');
await tester.pumpAndSettle();
// Verify spelling is auto-disabled.
final Switch spellingSwitch = tester.widget<Switch>(
find.byKey(const Key('question-source-enabled-spelling')),
);
expect(spellingSwitch.value, isFalse);
expect(find.byKey(const Key('question-source-input-spelling')), findsNothing);
// 3. Reduce market_history to 70.
await tester.enterText(marketInput, '70');
await tester.pumpAndSettle();
// Verify spelling auto-enabled and gets 30%.
final Switch spellingSwitchAfter = tester.widget<Switch>(
find.byKey(const Key('question-source-enabled-spelling')),
);
expect(spellingSwitchAfter.value, isTrue);
expect(find.byKey(const Key('question-source-input-spelling')), findsOneWidget);
expect(tester.widget<TextField>(find.byKey(const Key('question-source-input-spelling'))).controller?.text, '30');
// 4. Manually disable spelling.
await tester.tap(find.byKey(const Key('question-source-enabled-spelling')));
await tester.pumpAndSettle();
// Verify spelling is disabled and market_history automatically becomes 100%.
expect(tester.widget<TextField>(marketInput).controller?.text, '100');
// 5. Manually enable spelling.
await tester.tap(find.byKey(const Key('question-source-enabled-spelling')));
await tester.pumpAndSettle();
// Verify spelling gets enabled and shares the ratio evenly (50/50).
expect(tester.widget<TextField>(marketInput).controller?.text, '50');
expect(tester.widget<TextField>(find.byKey(const Key('question-source-input-spelling'))).controller?.text, '50');
// 6. Enter a value > 100 (e.g. 150) in market_history input.
await tester.enterText(marketInput, '150');
await tester.pumpAndSettle();
// Verify it clamps to 100.
expect(tester.widget<TextField>(marketInput).controller?.text, '100');
// Close dialog.
await tester.tap(find.byKey(const Key('question-source-settings-close')));
await tester.pumpAndSettle();
});
} }