This commit is contained in:
nathanAndrinoid 2026-06-06 19:23:18 +00:00
parent 46cc822586
commit 00840a80e4
17 changed files with 376 additions and 91 deletions

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,7 @@ import '../admin/widgets/admin_app_bar_action.dart';
import '../models/app_user.dart';
import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart';
import '../models/question_source_preference.dart';
import '../utils/guess_slot_format.dart';
import '../models/sync_result.dart';
import '../models/user_profile.dart';
@ -190,6 +191,8 @@ class HomeScreen extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (panelOpen)
_QuestionSourceConfigTopBar(sources: sources),
if (showQuestionPanel)
Expanded(
child: Padding(
@ -625,3 +628,103 @@ class _SyncStatusChip extends StatelessWidget {
);
}
}
class _QuestionSourceConfigTopBar extends StatelessWidget {
const _QuestionSourceConfigTopBar({
required this.sources,
});
final List<QuestionSourcePreference> sources;
@override
Widget build(BuildContext context) {
final Map<String, int> mix =
QuestionSourceConfigRepository.effectiveMix(sources);
final String mixText = mix.isEmpty
? 'No sources enabled'
: mix.entries.map((MapEntry<String, int> entry) {
final QuestionSourcePreference source = sources.firstWhere(
(QuestionSourcePreference s) => s.id == entry.key,
);
return '${source.label}: ${entry.value}%';
}).join(', ');
return Container(
key: const Key('question-source-config-top-bar'),
decoration: BoxDecoration(
color: AppColors.surfaceElevated,
border: Border(
bottom: BorderSide(
color: AppColors.accent.withValues(alpha: 0.15),
width: 1,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: <Widget>[
const Icon(
Icons.tune_outlined,
color: AppColors.accent,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Text(
'Question source configuration',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
mixText,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 11,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
TextButton.icon(
key: const Key('question-source-config-top-bar-button'),
onPressed: () => QuestionSourceSettingsSheet.show(context),
icon: const Icon(
Icons.settings_outlined,
size: 14,
color: AppColors.accent,
),
label: const Text(
'Configure',
style: TextStyle(
color: AppColors.accent,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: AppColors.accent.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(
color: AppColors.accent.withValues(alpha: 0.2),
),
),
),
),
],
),
);
}
}

View File

@ -126,6 +126,16 @@ class QuestionsHubService {
return;
}
final IncomingQuestion question = IncomingQuestion.fromJson(json);
if (questionActionBusy.value) {
debugPrint('ReceiveQuestion: ignored push because action is busy');
return;
}
if (pendingQuestion.value != null && pendingQuestion.value!.id != question.id) {
debugPrint('ReceiveQuestion: ignored push because another question is active');
return;
}
_applyIncoming(question);
}
@ -307,8 +317,21 @@ class QuestionsHubService {
if (refreshed.isEmpty) {
if (result.nextQuestion != null &&
_acceptsQuestionSource(result.nextQuestion!)) {
IncomingQuestion nextQ = result.nextQuestion!;
if (nextQ.needsPrepare) {
try {
final IncomingQuestion? prepared = await _api.prepareSpellingQuestion(
questionId: nextQ.id,
);
if (prepared != null) {
nextQ = prepared;
}
} catch (e) {
debugPrint('Failed to prepare next question in defer: $e');
}
}
_setActiveQuestion(
result.nextQuestion!,
nextQ,
unansweredCount: result.unansweredCount,
);
} else if (_shouldKeepPanelOpenWithoutQuestion()) {
@ -334,6 +357,19 @@ class QuestionsHubService {
next = result.nextQuestion!;
}
if (next.needsPrepare) {
try {
final IncomingQuestion? prepared = await _api.prepareSpellingQuestion(
questionId: next.id,
);
if (prepared != null) {
next = prepared;
}
} catch (e) {
debugPrint('Failed to prepare next question in defer: $e');
}
}
_setActiveQuestion(
next,
unansweredCount: result.unansweredCount,
@ -372,10 +408,15 @@ class QuestionsHubService {
return;
}
num effectiveAnswer = answer;
if (question.isSpellingFollowUp && answer == 0) {
effectiveAnswer = 1;
}
await _submitAnswer(
answeredQuestionId: question.id,
question: question,
answer: answer,
answer: effectiveAnswer,
);
}
@ -386,6 +427,7 @@ class QuestionsHubService {
String? answerText,
}) async {
questionActionBusy.value = true;
final DateTime startTime = DateTime.now();
try {
final QuestionSubmitResult? result = await _api.submitAnswer(
questionId: answeredQuestionId,
@ -396,14 +438,35 @@ class QuestionsHubService {
return;
}
if (question.usesSpellingWordTile) {
final int elapsedMs = DateTime.now().difference(startTime).inMilliseconds;
final int remainingMs = 1000 - elapsedMs;
if (remainingMs > 0) {
await Future<void>.delayed(Duration(milliseconds: remainingMs));
}
}
if (question.isMarketHistory) {
guessScoreSummary.value = result.score;
}
if (result.nextQuestion != null &&
_acceptsQuestionSource(result.nextQuestion!)) {
IncomingQuestion nextQ = result.nextQuestion!;
if (nextQ.needsPrepare) {
try {
final IncomingQuestion? prepared = await _api.prepareSpellingQuestion(
questionId: nextQ.id,
);
if (prepared != null) {
nextQ = prepared;
}
} catch (e) {
debugPrint('Failed to prepare next question in submit: $e');
}
}
_setActiveQuestion(
result.nextQuestion!,
nextQ,
unansweredCount: result.unansweredCount,
);
return;

View File

@ -44,9 +44,11 @@ class QuestionPresentationRouter extends StatelessWidget {
return SwipeQuestionTile(
key: ValueKey<String>(question.id),
questionId: question.id,
questionText: question.text,
busy: busy,
onSwipeRight: onAnswer,
onSwipeLeft: onDefer,
showGuidGlyph: !question.isSpelling,
);
}
}

View File

@ -195,12 +195,23 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
_showingFeedback = true;
});
await Future<void>.delayed(const Duration(seconds: 1));
if (!mounted) {
return;
}
final DateTime startTime = DateTime.now();
try {
await widget.onSubmitAnswer(answer);
} catch (_) {
final int elapsedMs = DateTime.now().difference(startTime).inMilliseconds;
final int remainingMs = 1000 - elapsedMs;
if (remainingMs > 0) {
await Future<void>.delayed(Duration(milliseconds: remainingMs));
}
if (mounted) {
setState(() {
_showingFeedback = false;
_answerCorrect = null;
});
}
rethrow;
}
}
@override

View File

@ -33,12 +33,14 @@ class SwipeQuestionTile extends StatefulWidget {
required this.questionId,
required this.onSwipeRight,
required this.onSwipeLeft,
this.questionText,
this.busy = false,
this.swipeAxisToleranceDegrees = 25,
this.showGuidGlyph = true,
});
final String questionId;
final String? questionText;
final Future<void> Function(num answer) onSwipeRight;
final Future<void> Function() onSwipeLeft;
final bool busy;
@ -82,7 +84,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
/// Updated each build from the tile height so ±10 reaches near the track edges.
double _maxVerticalDrag = 120;
bool get _atZero => _snappedSliderValue == 0;
bool get _atZero => widget.showGuidGlyph ? _snappedSliderValue == 0 : false;
bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero;
@ -326,6 +328,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_snappedSliderValue / _sliderMax * _maxVerticalDrag;
void _applyVerticalInputDelta(double dy) {
if (!widget.showGuidGlyph) {
return;
}
setState(() {
_verticalOffset -= dy;
_verticalOffset = _verticalOffset.clamp(
@ -468,7 +473,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
/// Steps the slider by [delta] integers, clamped to [-10, 10].
void _stepSliderBy(int delta) {
if (widget.busy || _acting || delta == 0) {
if (widget.busy || _acting || delta == 0 || !widget.showGuidGlyph) {
return;
}
final int next = (_snappedSliderValue + delta).clamp(_sliderMin, _sliderMax);
@ -743,6 +748,26 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
),
),
),
)
else if (widget.questionText != null)
Positioned(
top: 8,
bottom: 8,
left: _centerSideInset,
right: _centerSideInset,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
widget.questionText!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
),
),
),
),
Positioned(
left: 0,

View File

@ -101,6 +101,7 @@ Future<void> main() async {
marketDataDb: marketDataDb,
tradingConfigDb: tradingConfigDb,
tradingStateDb: tradingStateDb,
questionSourcePreferencesDb: questionSourcePreferencesDb,
marketHistoryQuery: MarketHistoryQuery(connection: db.connection),
marketHistoryEnv: mh,
guardrails: Guardrails(allowLive: env.alpaca.allowLive),

View File

@ -198,10 +198,11 @@ class QuestionPipeline {
}
final List<ResolvedQuestionSourcePreference> prefs =
await preferencesDb.getResolved(firebaseUid);
final Map<String, int> mix = QuestionSourcePreferences.effectiveMix(prefs);
final Set<String> allowed = prefs
.where(
(ResolvedQuestionSourcePreference s) =>
s.enabled && !s.comingSoon,
s.enabled && !s.comingSoon && (mix[s.id] ?? 0) > 0,
)
.map((ResolvedQuestionSourcePreference s) => s.id)
.toSet();
@ -229,7 +230,7 @@ class QuestionPipeline {
);
}
if (!await _canEnqueue(firebaseUid)) {
if (pipelineKey != PipelineKeys.spelling && !await _canEnqueue(firebaseUid)) {
return;
}
@ -730,8 +731,8 @@ class QuestionPipeline {
/// question is immediately dispatchable and the client never needs a separate
/// prepare call. Falls back to a [spelling_pending] placeholder only when no
/// catalog words are available.
Future<void> _startSpellingPipeline(String firebaseUid) async {
if (!await _canEnqueue(firebaseUid)) {
Future<void> _startSpellingPipeline(String firebaseUid, {bool force = false}) async {
if (!force && !await _canEnqueue(firebaseUid)) {
return;
}
if (!await _isSpellingEnabled(firebaseUid)) {
@ -843,7 +844,7 @@ class QuestionPipeline {
);
case PipelineSteps.spellingFollowUp:
if (userResponse > 0) {
await _startSpellingPipeline(firebaseUid);
await _startSpellingPipeline(firebaseUid, force: true);
} else {
await _setIdle(firebaseUid, PipelineKeys.spelling);
}

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'pipeline/question_pipeline.dart';
import 'question_service.dart';
import 'question_source_mix.dart';
@ -14,15 +16,18 @@ class QuestionDispatcher {
required QuestionSourcePreferencesDb preferencesDb,
this.debugLoggingEnabled = false,
this.maxQueuedQuestions = 3,
Random? random,
}) : _questionsDb = questionsDb,
_questionService = questionService,
_questionPipeline = questionPipeline,
_preferencesDb = preferencesDb;
_preferencesDb = preferencesDb,
_random = random ?? Random();
final QuestionsDb _questionsDb;
final QuestionService _questionService;
final QuestionPipeline _questionPipeline;
final QuestionSourcePreferencesDb _preferencesDb;
final Random _random;
final bool debugLoggingEnabled;
final int maxQueuedQuestions;
@ -99,12 +104,25 @@ 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);
@ -118,9 +136,8 @@ class QuestionDispatcher {
firebaseUid,
effectiveMixPercent: mix,
deliveredCounts: delivered,
afterAnsweringSourceId:
QuestionsDb.resolveSourceId(answeredQuestion),
allowedSourceIds: allowed,
random: _random,
);
if (row == null) {
return dispatchNextQuestion(firebaseUid);
@ -187,6 +204,7 @@ class QuestionDispatcher {
final String primary = QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: mix,
deliveredCounts: delivered,
random: _random,
);
_log('_enqueueOneFromMix: picked primary=$primary (mix=$mix, delivered=$delivered)');
final List<String> order = _sourceTryOrderForEnqueue(
@ -229,6 +247,7 @@ class QuestionDispatcher {
firebaseUid,
effectiveMixPercent: mix,
allowedSourceIds: allowed,
random: _random,
);
}

View File

@ -1,44 +1,42 @@
import 'dart:math';
import 'question_source_preferences.dart';
import 'questions_db.dart';
/// Picks the next question source to match configured mix percentages over time.
/// Picks the next question source randomly matching configured mix percentages.
abstract final class QuestionSourceMix {
/// Source id with the largest deficit vs configured effective mix (sequential balance).
/// Source id selected using weighted random selection based on configured mix ratios.
static String pickNextSourceId({
required Map<String, int> effectiveMixPercent,
required Map<String, int> deliveredCounts,
Random? random,
}) {
if (effectiveMixPercent.isEmpty) {
return 'market_history';
}
final int totalDelivered =
deliveredCounts.values.fold<int>(0, (int a, int b) => a + b);
if (totalDelivered == 0) {
return effectiveMixPercent.entries
.reduce(
(
MapEntry<String, int> a,
MapEntry<String, int> b,
) =>
a.value >= b.value ? a : b,
)
.key;
final int totalWeight =
effectiveMixPercent.values.fold<int>(0, (int a, int b) => a + b);
if (totalWeight <= 0) {
return effectiveMixPercent.keys.first;
}
String? pick;
var maxDeficit = double.negativeInfinity;
// If one source has 100% of the active weight, bypass the random roll completely.
for (final MapEntry<String, int> entry in effectiveMixPercent.entries) {
final double target = entry.value / 100.0;
final double actual =
(deliveredCounts[entry.key] ?? 0) / totalDelivered;
final double deficit = target - actual;
if (deficit > maxDeficit) {
maxDeficit = deficit;
pick = entry.key;
if (entry.value == totalWeight) {
return entry.key;
}
}
return pick ?? effectiveMixPercent.keys.first;
final Random rng = random ?? Random();
var roll = rng.nextInt(totalWeight);
for (final MapEntry<String, int> entry in effectiveMixPercent.entries) {
roll -= entry.value;
if (roll < 0) {
return entry.key;
}
}
return effectiveMixPercent.keys.first;
}
static Map<String, int> effectiveMixFromResolved(

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math';
import 'package:postgres/postgres.dart';
import 'package:uuid/uuid.dart';
@ -273,8 +274,8 @@ class QuestionsDb {
String assignedUserId, {
required Map<String, int> effectiveMixPercent,
Map<String, int>? deliveredCounts,
String? afterAnsweringSourceId,
Set<String>? allowedSourceIds,
Random? random,
}) async {
List<Map<String, dynamic>> rows =
await listUnansweredQuestions(assignedUserId);
@ -285,19 +286,12 @@ class QuestionsDb {
return null;
}
if (afterAnsweringSourceId == 'spelling') {
for (final Map<String, dynamic> row in rows) {
if (resolveSourceId(row) == 'spelling') {
return row;
}
}
}
final Map<String, int> delivered =
deliveredCounts ?? await countDeliveredBySourceId(assignedUserId);
final String preferred = QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: effectiveMixPercent,
deliveredCounts: delivered,
random: random,
);
final List<Map<String, dynamic>> ordered =
QuestionSourceMix.orderUnansweredByPreferredSource(
@ -312,6 +306,7 @@ class QuestionsDb {
String assignedUserId, {
required Map<String, int> effectiveMixPercent,
Set<String>? allowedSourceIds,
Random? random,
}) async {
List<Map<String, dynamic>> rows =
await listUnansweredQuestions(assignedUserId);
@ -326,6 +321,7 @@ class QuestionsDb {
final String preferred = QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: effectiveMixPercent,
deliveredCounts: delivered,
random: random,
);
return QuestionSourceMix.orderUnansweredByPreferredSource(
rows,

View File

@ -15,6 +15,7 @@ import 'symbol_obfuscator.dart';
import 'trading_config.dart';
import 'trading_config_db.dart';
import 'user_trading_state_db.dart';
import '../question_source_preferences_db.dart';
/// Result of one [TradingPipeline.evaluate] cycle for a single user.
class TradingEvaluationResult {
@ -39,6 +40,7 @@ class TradingPipeline {
required MarketDataDb marketDataDb,
required TradingConfigDb tradingConfigDb,
required UserTradingStateDb tradingStateDb,
QuestionSourcePreferencesDb? questionSourcePreferencesDb,
RuleEngine? ruleEngine,
MarketHistoryQuery? marketHistoryQuery,
MarketHistoryEnv? marketHistoryEnv,
@ -51,6 +53,7 @@ class TradingPipeline {
_marketDataDb = marketDataDb,
_tradingConfigDb = tradingConfigDb,
_tradingStateDb = tradingStateDb,
_questionSourcePreferencesDb = questionSourcePreferencesDb,
_ruleEngine = ruleEngine ?? RuleEngine(clock: clock),
_marketHistoryQuery = marketHistoryQuery,
_marketHistoryEnv = marketHistoryEnv ?? MarketHistoryEnv.fromMap(<String, String>{}),
@ -64,6 +67,7 @@ class TradingPipeline {
final MarketDataDb _marketDataDb;
final TradingConfigDb _tradingConfigDb;
final UserTradingStateDb _tradingStateDb;
final QuestionSourcePreferencesDb? _questionSourcePreferencesDb;
final RuleEngine _ruleEngine;
final MarketHistoryQuery? _marketHistoryQuery;
final MarketHistoryEnv _marketHistoryEnv;
@ -85,6 +89,15 @@ class TradingPipeline {
final List<String> fired = <String>[];
final List<String> skipped = <String>[];
if (_questionSourcePreferencesDb != null &&
!await _questionSourcePreferencesDb!.isSourceEnabled(firebaseUid, 'market_history')) {
return TradingEvaluationResult(
questionsCreated: 0,
rulesFired: fired,
rulesSkipped: skipped,
);
}
final EffectiveTradingConfig? config =
await _tradingConfigDb.resolveEffectiveConfig(firebaseUid);
if (config == null || !config.enabled) {

View File

@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:math';
import 'package:cyberhybridhub_server/db.dart';
import 'package:cyberhybridhub_server/question_source_preferences_db.dart';
@ -138,13 +139,14 @@ class TestDb {
testMode: testMode,
);
QuestionDispatcher questionDispatcher({bool testMode = true}) {
QuestionDispatcher questionDispatcher({bool testMode = true, Random? random}) {
final QuestionPipeline pipeline = questionPipeline(testMode: testMode);
final QuestionDispatcher dispatcher = QuestionDispatcher(
questionsDb: questionsDb,
questionService: questionService(),
questionPipeline: pipeline,
preferencesDb: questionSourcePreferencesDb,
random: random ?? Random(1),
);
pipeline.attachQuestionDispatcher(dispatcher);
return dispatcher;

View File

@ -1,53 +1,48 @@
import 'dart:math';
import 'package:cyberhybridhub_server/question_source_mix.dart';
import 'package:cyberhybridhub_server/questions_db.dart';
import 'package:test/test.dart';
void main() {
group('QuestionSourceMix.pickNextSourceId', () {
test('picks highest share when nothing delivered yet', () {
expect(
QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: <String, int>{
test('weighted random selection works deterministically with seeded random', () {
final Map<String, int> mix = <String, int>{
'market_history': 70,
'spelling': 30,
},
};
var marketCount = 0;
var spellingCount = 0;
final Random random = Random(42);
for (var i = 0; i < 200; i++) {
final String pick = QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: mix,
deliveredCounts: <String, int>{},
random: random,
);
if (pick == 'market_history') {
marketCount++;
} else if (pick == 'spelling') {
spellingCount++;
}
}
expect(marketCount, greaterThan(120));
expect(spellingCount, greaterThan(40));
expect(marketCount + spellingCount, 200);
});
test('always picks the only active source', () {
expect(
QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: <String, int>{'spelling': 100},
deliveredCounts: <String, int>{},
),
'market_history',
);
});
test('picks spelling when market is ahead of target mix', () {
expect(
QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: <String, int>{
'market_history': 50,
'spelling': 50,
},
deliveredCounts: <String, int>{
'market_history': 4,
'spelling': 0,
},
),
'spelling',
);
});
test('picks market when spelling is ahead of target mix', () {
expect(
QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: <String, int>{
'market_history': 70,
'spelling': 30,
},
deliveredCounts: <String, int>{
'market_history': 1,
'spelling': 3,
},
),
'market_history',
);
});
});
group('QuestionSourceMix.orderUnansweredByPreferredSource', () {

View File

@ -140,4 +140,52 @@ void main() {
);
expect(find.byKey(const Key('question-source-settings-dialog')), findsNothing);
});
testWidgets('Question Source config top bar appears when panel is open and configures source preferences', (
WidgetTester tester,
) async {
final QuestionsHubService hub = QuestionsHubService.instance;
hub.hasPendingQuestion.value = true;
hub.pendingQuestionCount.value = 1;
hub.questionPanelOpen.value = true;
addTearDown(() {
hub.hasPendingQuestion.value = false;
hub.pendingQuestionCount.value = 0;
hub.questionPanelOpen.value = false;
QuestionSourceConfigRepository.instance.resetToDefaults();
});
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: const HomeScreen(
user: AppUser(uid: 'u1', displayName: 'Test User'),
),
),
);
// Verify config top bar is displayed when panel is open.
expect(find.byKey(const Key('question-source-config-top-bar')), findsOneWidget);
expect(find.text('Question source configuration'), findsOneWidget);
expect(find.text('Market question: 50%, Spelling: 50%'), findsOneWidget);
// Click on the Configure button to open settings dialog.
expect(find.byKey(const Key('question-source-config-top-bar-button')), findsOneWidget);
await tester.tap(find.byKey(const Key('question-source-config-top-bar-button')));
await tester.pumpAndSettle();
// Verify settings dialog is open.
expect(find.byKey(const Key('question-source-settings-dialog')), findsOneWidget);
// Close settings dialog.
await tester.tap(find.byKey(const Key('question-source-settings-close')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('question-source-settings-dialog')), findsNothing);
// Close panel and verify top bar is gone.
hub.closeQuestionPanel();
await tester.pumpAndSettle();
expect(find.byKey(const Key('question-source-config-top-bar')), findsNothing);
});
}

View File

@ -41,6 +41,10 @@ void main() {
);
expect(find.byType(SwipeQuestionTile), findsOneWidget);
expect(
tester.widget<SwipeQuestionTile>(find.byType(SwipeQuestionTile)).showGuidGlyph,
true,
);
expect(find.byType(SpellingQuestionTile), findsNothing);
});
@ -66,6 +70,10 @@ void main() {
);
expect(find.byType(SwipeQuestionTile), findsOneWidget);
expect(
tester.widget<SwipeQuestionTile>(find.byType(SwipeQuestionTile)).showGuidGlyph,
false,
);
expect(find.byType(SpellingQuestionTile), findsNothing);
});

View File

@ -29,7 +29,7 @@ void main() {
await tester.tap(find.byKey(const Key('spelling-submit-answer')));
await tester.pump();
expect(submitCount, 0);
expect(submitCount, 1);
final Text feedback = tester.widget<Text>(
find.byKey(const Key('spelling-feedback-word')),
);