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/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}%)',
style: const TextStyle(
color: AppColors.accent,
fontWeight: FontWeight.w600,
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()),
),
],
],

View File

@ -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,

View File

@ -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,

View File

@ -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.

View File

@ -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;
}

View File

@ -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();
});
}