diff --git a/lib/widgets/question_source_settings_sheet.dart b/lib/widgets/question_source_settings_sheet.dart index cce1ec3..4c1be53 100644 --- a/lib/widgets/question_source_settings_sheet.dart +++ b/lib/widgets/question_source_settings_sheet.dart @@ -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 source.copyWith()).toList(); } - void _updateSource(int index, QuestionSourcePreference next) { + List _distribute(List targetProportions, int targetSum) { + final List values = targetProportions.map((double p) => p * targetSum).toList(); + final List 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 indices = List.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> otherActiveSources = _draft + .asMap() + .entries + .where((MapEntry entry) => + entry.key != targetIndex && + !entry.value.comingSoon && + entry.value.enabled) + .toList(); + + if (otherActiveSources.isEmpty) { + final List disabledNonComingSoonIndices = _draft + .asMap() + .entries + .where((MapEntry entry) => + entry.key != targetIndex && !entry.value.comingSoon) + .map((MapEntry entry) => entry.key) + .toList(); + + if (disabledNonComingSoonIndices.isNotEmpty) { + final int freedPercentage = 100 - newRatio; + final List proportions = List.filled( + disabledNonComingSoonIndices.length, + 1.0 / disabledNonComingSoonIndices.length, + ); + final List 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( + 0, + (int sum, MapEntry entry) => sum + entry.value.ratio, + ); + + final List otherIndices = otherActiveSources.map((MapEntry entry) => entry.key).toList(); + final List proportions = []; + 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 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 otherActiveIndices = _draft + .asMap() + .entries + .where((MapEntry entry) => + entry.key != targetIndex && + !entry.value.comingSoon && + entry.value.enabled) + .map((MapEntry 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( + 0, + (int sum, int idx) => sum + _draft[idx].ratio, + ); + + final List proportions = []; + 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 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 otherActiveIndices = _draft + .asMap() + .entries + .where((MapEntry entry) => + entry.key != targetIndex && + !entry.value.comingSoon && + entry.value.enabled) + .map((MapEntry entry) => entry.key) + .toList(); + + if (otherActiveIndices.isNotEmpty) { + final int currentOtherSum = otherActiveIndices.fold( + 0, + (int sum, int idx) => sum + _draft[idx].ratio, + ); + + final List proportions = []; + 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 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 - _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 onChanged; + final ValueChanged onRatioChanged; + final ValueChanged 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: [ Flexible( child: Text( - source.label, + widget.source.label, style: Theme.of(context).textTheme.titleLarge, ), ), - if (source.comingSoon) ...[ + if (widget.source.comingSoon) ...[ 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) ...[ + if (widget.source.enabled && !widget.source.comingSoon) ...[ Row( children: [ 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: [ + 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()), ), ], ], diff --git a/server/lib/handlers/questions_handler.dart b/server/lib/handlers/questions_handler.dart index 5576835..d923d11 100644 --- a/server/lib/handlers/questions_handler.dart +++ b/server/lib/handlers/questions_handler.dart @@ -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? score = isMarketAnswer @@ -232,6 +234,7 @@ Handler questionsHandler({ firebaseUid: firebaseUid, deferredQuestionId: id, ); + unawaited(questionDispatcher.topUpQueue(firebaseUid)); return _jsonResponse(200, { 'question': updated, 'unansweredCount': unansweredCount, diff --git a/server/lib/pipeline/question_pipeline.dart b/server/lib/pipeline/question_pipeline.dart index ae9a1e3..d6a1295 100644 --- a/server/lib/pipeline/question_pipeline.dart +++ b/server/lib/pipeline/question_pipeline.dart @@ -496,6 +496,11 @@ class QuestionPipeline { await _startSpellingPipeline(firebaseUid); } + /// Force enqueues a new spelling question (e.g. after a deferral). + Future forceEnqueueSpellingQuestion(String firebaseUid) async { + await _startSpellingPipeline(firebaseUid, force: true); + } + Future> _loadContext( String firebaseUid, String pipelineKey, diff --git a/server/lib/question_dispatcher.dart b/server/lib/question_dispatcher.dart index 69fb181..029aba5 100644 --- a/server/lib/question_dispatcher.dart +++ b/server/lib/question_dispatcher.dart @@ -104,25 +104,12 @@ class QuestionDispatcher { required Map 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 row in rows) { - if (QuestionsDb.resolveSourceId(row) == 'spelling') { - return _toClientPayload(row, rows.length); - } - } - } - final List prefs = await _preferencesDb.getResolved(firebaseUid); final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); @@ -150,17 +137,49 @@ class QuestionDispatcher { required String firebaseUid, required String deferredQuestionId, }) async { + final List prefs = + await _preferencesDb.getResolved(firebaseUid); + final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); + final Map 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> currentRows = + await _listEnabledUnansweredOrdered(firebaseUid); + final bool hasOtherSpelling = currentRows.any((Map row) => + QuestionsDb.resolveSourceId(row) == 'spelling' && + row['id'] != deferredQuestionId); + if (!hasOtherSpelling && QuestionSourcePreferences.isEnabled(prefs, 'spelling')) { + await _questionPipeline.forceEnqueueSpellingQuestion(firebaseUid); + } + } + final List> rows = await _listEnabledUnansweredOrdered(firebaseUid); if (rows.isEmpty) { return dispatchNextQuestion(firebaseUid); } - for (final Map row in rows) { + + final List> ordered = + QuestionSourceMix.orderUnansweredByPreferredSource( + rows, + preferredSourceId: preferred, + ); + + for (final Map 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. diff --git a/server/test/integration/spelling_only_panel_test.dart b/server/test/integration/spelling_only_panel_test.dart index 7738fc0..77bf387 100644 --- a/server/test/integration/spelling_only_panel_test.dart +++ b/server/test/integration/spelling_only_panel_test.dart @@ -1,8 +1,9 @@ @Tags(['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, + >[ + {'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; } diff --git a/test/question_source_settings_test.dart b/test/question_source_settings_test.dart index 2f4c356..a145630 100644 --- a/test/question_source_settings_test.dart +++ b/test/question_source_settings_test.dart @@ -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(marketInput).controller?.text, '50'); + expect(tester.widget(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(marketInput).controller?.text, '80'); + expect(tester.widget(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( + 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( + 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(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(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(marketInput).controller?.text, '50'); + expect(tester.widget(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(marketInput).controller?.text, '100'); + + // Close dialog. + await tester.tap(find.byKey(const Key('question-source-settings-close'))); + await tester.pumpAndSettle(); + }); }