working percentage
This commit is contained in:
parent
59b039a3c0
commit
78e5db6775
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models/question_source_preference.dart';
|
||||
import '../repositories/question_source_config_repository.dart';
|
||||
@ -53,9 +54,191 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
||||
).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(() {
|
||||
_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;
|
||||
});
|
||||
}
|
||||
@ -197,8 +380,8 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
||||
key: Key('question-source-row-${_draft[index].id}'),
|
||||
source: _draft[index],
|
||||
effectivePercent: mix[_draft[index].id],
|
||||
onChanged: (QuestionSourcePreference next) =>
|
||||
_updateSource(index, next),
|
||||
onRatioChanged: (int ratio) => _updateRatio(index, ratio),
|
||||
onEnabledChanged: (bool enabled) => _toggleSourceEnabled(index, enabled),
|
||||
onEditSpelling: _draft[index].id == 'spelling'
|
||||
? () => _openSpellingWordList(context)
|
||||
: null,
|
||||
@ -281,12 +464,13 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
||||
}
|
||||
}
|
||||
|
||||
class _QuestionSourceRow extends StatelessWidget {
|
||||
class _QuestionSourceRow extends StatefulWidget {
|
||||
const _QuestionSourceRow({
|
||||
super.key,
|
||||
required this.source,
|
||||
required this.effectivePercent,
|
||||
required this.onChanged,
|
||||
required this.onRatioChanged,
|
||||
required this.onEnabledChanged,
|
||||
this.onEditSpelling,
|
||||
this.onResetSpelling,
|
||||
this.resetSpellingBusy = false,
|
||||
@ -294,11 +478,73 @@ class _QuestionSourceRow extends StatelessWidget {
|
||||
|
||||
final QuestionSourcePreference source;
|
||||
final int? effectivePercent;
|
||||
final ValueChanged<QuestionSourcePreference> onChanged;
|
||||
final ValueChanged<int> onRatioChanged;
|
||||
final ValueChanged<bool> onEnabledChanged;
|
||||
final VoidCallback? onEditSpelling;
|
||||
final VoidCallback? onResetSpelling;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
@ -324,11 +570,11 @@ class _QuestionSourceRow extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
source.label,
|
||||
widget.source.label,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
if (source.comingSoon) ...<Widget>[
|
||||
if (widget.source.comingSoon) ...<Widget>[
|
||||
const SizedBox(width: 8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
@ -358,61 +604,116 @@ class _QuestionSourceRow extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
source.description,
|
||||
widget.source.description,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onEditSpelling != null)
|
||||
if (widget.onEditSpelling != null)
|
||||
IconButton(
|
||||
key: const Key('question-source-edit-spelling'),
|
||||
tooltip: 'Edit spelling word list',
|
||||
onPressed: resetSpellingBusy ? null : onEditSpelling,
|
||||
onPressed: widget.resetSpellingBusy ? null : widget.onEditSpelling,
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
),
|
||||
if (onResetSpelling != null)
|
||||
if (widget.onResetSpelling != null)
|
||||
IconButton(
|
||||
key: const Key('question-source-reset-spelling'),
|
||||
tooltip: 'Reset spelling progress',
|
||||
onPressed: resetSpellingBusy ? null : onResetSpelling,
|
||||
onPressed: widget.resetSpellingBusy ? null : widget.onResetSpelling,
|
||||
icon: const Icon(Icons.restart_alt, size: 20),
|
||||
),
|
||||
Switch(
|
||||
key: Key('question-source-enabled-${source.id}'),
|
||||
value: source.enabled,
|
||||
onChanged: source.comingSoon
|
||||
key: Key('question-source-enabled-${widget.source.id}'),
|
||||
value: widget.source.enabled,
|
||||
onChanged: widget.source.comingSoon
|
||||
? null
|
||||
: (bool enabled) =>
|
||||
onChanged(source.copyWith(enabled: enabled)),
|
||||
: (bool enabled) => widget.onEnabledChanged(enabled),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (source.enabled && !source.comingSoon) ...<Widget>[
|
||||
if (widget.source.enabled && !widget.source.comingSoon) ...<Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Text('Weight'),
|
||||
const Spacer(),
|
||||
Text(
|
||||
effectivePercent == null
|
||||
? '${source.ratio}'
|
||||
: '${source.ratio} (${effectivePercent}%)',
|
||||
SizedBox(
|
||||
width: 70,
|
||||
height: 36,
|
||||
child: TextField(
|
||||
key: Key('question-source-input-${widget.source.id}'),
|
||||
controller: _controller,
|
||||
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(
|
||||
key: Key('question-source-ratio-${source.id}'),
|
||||
value: source.ratio.toDouble(),
|
||||
key: Key('question-source-ratio-${widget.source.id}'),
|
||||
value: widget.source.ratio.toDouble(),
|
||||
min: 0,
|
||||
max: 100,
|
||||
divisions: 20,
|
||||
label: '${source.ratio}',
|
||||
divisions: 100,
|
||||
label: '${widget.source.ratio}',
|
||||
onChanged: (double value) =>
|
||||
onChanged(source.copyWith(ratio: value.round())),
|
||||
widget.onRatioChanged(value.round()),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@ -147,6 +148,7 @@ Handler questionsHandler({
|
||||
(nextQuestion['unansweredCount'] as num?)?.toInt() ??
|
||||
unansweredCount;
|
||||
}
|
||||
unawaited(questionDispatcher.topUpQueue(firebaseUid));
|
||||
final bool isMarketAnswer =
|
||||
QuestionsDb.resolveSourceId(updated) == 'market_history';
|
||||
final Map<String, dynamic>? score = isMarketAnswer
|
||||
@ -232,6 +234,7 @@ Handler questionsHandler({
|
||||
firebaseUid: firebaseUid,
|
||||
deferredQuestionId: id,
|
||||
);
|
||||
unawaited(questionDispatcher.topUpQueue(firebaseUid));
|
||||
return _jsonResponse(200, <String, dynamic>{
|
||||
'question': updated,
|
||||
'unansweredCount': unansweredCount,
|
||||
|
||||
@ -496,6 +496,11 @@ class QuestionPipeline {
|
||||
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(
|
||||
String firebaseUid,
|
||||
String pipelineKey,
|
||||
|
||||
@ -104,25 +104,12 @@ class QuestionDispatcher {
|
||||
required Map<String, dynamic> answeredQuestion,
|
||||
}) async {
|
||||
_log('nextQuestionAfterAnswer($firebaseUid)');
|
||||
final String answeredSourceId = QuestionsDb.resolveSourceId(answeredQuestion);
|
||||
|
||||
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||
if (rows.isEmpty) {
|
||||
_log('Queue empty, falling back to dispatchNextQuestion');
|
||||
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 =
|
||||
await _preferencesDb.getResolved(firebaseUid);
|
||||
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
||||
@ -150,17 +137,49 @@ class QuestionDispatcher {
|
||||
required String firebaseUid,
|
||||
required String deferredQuestionId,
|
||||
}) 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 =
|
||||
await _listEnabledUnansweredOrdered(firebaseUid);
|
||||
if (rows.isEmpty) {
|
||||
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) {
|
||||
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.
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
@Tags(<String>['integration', 'postgres'])
|
||||
library;
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cyberhybridhub_server/question_dispatcher.dart';
|
||||
import 'package:cyberhybridhub_server/questions_db.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../helpers/test_db.dart';
|
||||
@ -145,4 +146,153 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -188,4 +188,88 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user