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/app_user.dart';
import '../models/guess_score_summary.dart'; import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart'; import '../models/incoming_question.dart';
import '../models/question_source_preference.dart';
import '../utils/guess_slot_format.dart'; import '../utils/guess_slot_format.dart';
import '../models/sync_result.dart'; import '../models/sync_result.dart';
import '../models/user_profile.dart'; import '../models/user_profile.dart';
@ -190,6 +191,8 @@ class HomeScreen extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[ children: <Widget>[
if (panelOpen)
_QuestionSourceConfigTopBar(sources: sources),
if (showQuestionPanel) if (showQuestionPanel)
Expanded( Expanded(
child: Padding( 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; return;
} }
final IncomingQuestion question = IncomingQuestion.fromJson(json); 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); _applyIncoming(question);
} }
@ -307,8 +317,21 @@ class QuestionsHubService {
if (refreshed.isEmpty) { if (refreshed.isEmpty) {
if (result.nextQuestion != null && if (result.nextQuestion != null &&
_acceptsQuestionSource(result.nextQuestion!)) { _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( _setActiveQuestion(
result.nextQuestion!, nextQ,
unansweredCount: result.unansweredCount, unansweredCount: result.unansweredCount,
); );
} else if (_shouldKeepPanelOpenWithoutQuestion()) { } else if (_shouldKeepPanelOpenWithoutQuestion()) {
@ -334,6 +357,19 @@ class QuestionsHubService {
next = result.nextQuestion!; 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( _setActiveQuestion(
next, next,
unansweredCount: result.unansweredCount, unansweredCount: result.unansweredCount,
@ -372,10 +408,15 @@ class QuestionsHubService {
return; return;
} }
num effectiveAnswer = answer;
if (question.isSpellingFollowUp && answer == 0) {
effectiveAnswer = 1;
}
await _submitAnswer( await _submitAnswer(
answeredQuestionId: question.id, answeredQuestionId: question.id,
question: question, question: question,
answer: answer, answer: effectiveAnswer,
); );
} }
@ -386,6 +427,7 @@ class QuestionsHubService {
String? answerText, String? answerText,
}) async { }) async {
questionActionBusy.value = true; questionActionBusy.value = true;
final DateTime startTime = DateTime.now();
try { try {
final QuestionSubmitResult? result = await _api.submitAnswer( final QuestionSubmitResult? result = await _api.submitAnswer(
questionId: answeredQuestionId, questionId: answeredQuestionId,
@ -396,14 +438,35 @@ class QuestionsHubService {
return; 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) { if (question.isMarketHistory) {
guessScoreSummary.value = result.score; guessScoreSummary.value = result.score;
} }
if (result.nextQuestion != null && if (result.nextQuestion != null &&
_acceptsQuestionSource(result.nextQuestion!)) { _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( _setActiveQuestion(
result.nextQuestion!, nextQ,
unansweredCount: result.unansweredCount, unansweredCount: result.unansweredCount,
); );
return; return;

View File

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

View File

@ -195,12 +195,23 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
_showingFeedback = true; _showingFeedback = true;
}); });
await Future<void>.delayed(const Duration(seconds: 1)); final DateTime startTime = DateTime.now();
if (!mounted) { try {
return; 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;
} }
await widget.onSubmitAnswer(answer);
} }
@override @override

View File

@ -33,12 +33,14 @@ class SwipeQuestionTile extends StatefulWidget {
required this.questionId, required this.questionId,
required this.onSwipeRight, required this.onSwipeRight,
required this.onSwipeLeft, required this.onSwipeLeft,
this.questionText,
this.busy = false, this.busy = false,
this.swipeAxisToleranceDegrees = 25, this.swipeAxisToleranceDegrees = 25,
this.showGuidGlyph = true, this.showGuidGlyph = true,
}); });
final String questionId; final String questionId;
final String? questionText;
final Future<void> Function(num answer) onSwipeRight; final Future<void> Function(num answer) onSwipeRight;
final Future<void> Function() onSwipeLeft; final Future<void> Function() onSwipeLeft;
final bool busy; 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. /// Updated each build from the tile height so ±10 reaches near the track edges.
double _maxVerticalDrag = 120; double _maxVerticalDrag = 120;
bool get _atZero => _snappedSliderValue == 0; bool get _atZero => widget.showGuidGlyph ? _snappedSliderValue == 0 : false;
bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero; bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero;
@ -326,6 +328,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_snappedSliderValue / _sliderMax * _maxVerticalDrag; _snappedSliderValue / _sliderMax * _maxVerticalDrag;
void _applyVerticalInputDelta(double dy) { void _applyVerticalInputDelta(double dy) {
if (!widget.showGuidGlyph) {
return;
}
setState(() { setState(() {
_verticalOffset -= dy; _verticalOffset -= dy;
_verticalOffset = _verticalOffset.clamp( _verticalOffset = _verticalOffset.clamp(
@ -468,7 +473,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
/// Steps the slider by [delta] integers, clamped to [-10, 10]. /// Steps the slider by [delta] integers, clamped to [-10, 10].
void _stepSliderBy(int delta) { void _stepSliderBy(int delta) {
if (widget.busy || _acting || delta == 0) { if (widget.busy || _acting || delta == 0 || !widget.showGuidGlyph) {
return; return;
} }
final int next = (_snappedSliderValue + delta).clamp(_sliderMin, _sliderMax); 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( Positioned(
left: 0, left: 0,

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'pipeline/question_pipeline.dart'; import 'pipeline/question_pipeline.dart';
import 'question_service.dart'; import 'question_service.dart';
import 'question_source_mix.dart'; import 'question_source_mix.dart';
@ -14,15 +16,18 @@ class QuestionDispatcher {
required QuestionSourcePreferencesDb preferencesDb, required QuestionSourcePreferencesDb preferencesDb,
this.debugLoggingEnabled = false, this.debugLoggingEnabled = false,
this.maxQueuedQuestions = 3, this.maxQueuedQuestions = 3,
Random? random,
}) : _questionsDb = questionsDb, }) : _questionsDb = questionsDb,
_questionService = questionService, _questionService = questionService,
_questionPipeline = questionPipeline, _questionPipeline = questionPipeline,
_preferencesDb = preferencesDb; _preferencesDb = preferencesDb,
_random = random ?? Random();
final QuestionsDb _questionsDb; final QuestionsDb _questionsDb;
final QuestionService _questionService; final QuestionService _questionService;
final QuestionPipeline _questionPipeline; final QuestionPipeline _questionPipeline;
final QuestionSourcePreferencesDb _preferencesDb; final QuestionSourcePreferencesDb _preferencesDb;
final Random _random;
final bool debugLoggingEnabled; final bool debugLoggingEnabled;
final int maxQueuedQuestions; final int maxQueuedQuestions;
@ -99,12 +104,25 @@ class QuestionDispatcher {
required Map<String, dynamic> answeredQuestion, required Map<String, dynamic> answeredQuestion,
}) async { }) async {
_log('nextQuestionAfterAnswer($firebaseUid)'); _log('nextQuestionAfterAnswer($firebaseUid)');
final String answeredSourceId = QuestionsDb.resolveSourceId(answeredQuestion);
var rows = await _listEnabledUnansweredOrdered(firebaseUid); var rows = await _listEnabledUnansweredOrdered(firebaseUid);
if (rows.isEmpty) { if (rows.isEmpty) {
_log('Queue empty, falling back to dispatchNextQuestion'); _log('Queue empty, falling back to dispatchNextQuestion');
return dispatchNextQuestion(firebaseUid); return dispatchNextQuestion(firebaseUid);
} }
// Pipeline continuity: If we just answered a spelling question and another
// spelling question is available (e.g. follow-up or next word quiz), return it
// immediately. Pipelines should not be interrupted by mix changes.
if (answeredSourceId == 'spelling') {
for (final Map<String, dynamic> row in rows) {
if (QuestionsDb.resolveSourceId(row) == 'spelling') {
return _toClientPayload(row, rows.length);
}
}
}
final List<ResolvedQuestionSourcePreference> prefs = final List<ResolvedQuestionSourcePreference> prefs =
await _preferencesDb.getResolved(firebaseUid); await _preferencesDb.getResolved(firebaseUid);
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs); final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
@ -118,9 +136,8 @@ class QuestionDispatcher {
firebaseUid, firebaseUid,
effectiveMixPercent: mix, effectiveMixPercent: mix,
deliveredCounts: delivered, deliveredCounts: delivered,
afterAnsweringSourceId:
QuestionsDb.resolveSourceId(answeredQuestion),
allowedSourceIds: allowed, allowedSourceIds: allowed,
random: _random,
); );
if (row == null) { if (row == null) {
return dispatchNextQuestion(firebaseUid); return dispatchNextQuestion(firebaseUid);
@ -187,6 +204,7 @@ class QuestionDispatcher {
final String primary = QuestionSourceMix.pickNextSourceId( final String primary = QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: mix, effectiveMixPercent: mix,
deliveredCounts: delivered, deliveredCounts: delivered,
random: _random,
); );
_log('_enqueueOneFromMix: picked primary=$primary (mix=$mix, delivered=$delivered)'); _log('_enqueueOneFromMix: picked primary=$primary (mix=$mix, delivered=$delivered)');
final List<String> order = _sourceTryOrderForEnqueue( final List<String> order = _sourceTryOrderForEnqueue(
@ -229,6 +247,7 @@ class QuestionDispatcher {
firebaseUid, firebaseUid,
effectiveMixPercent: mix, effectiveMixPercent: mix,
allowedSourceIds: allowed, allowedSourceIds: allowed,
random: _random,
); );
} }

View File

@ -1,44 +1,42 @@
import 'dart:math';
import 'question_source_preferences.dart'; import 'question_source_preferences.dart';
import 'questions_db.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 { 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({ static String pickNextSourceId({
required Map<String, int> effectiveMixPercent, required Map<String, int> effectiveMixPercent,
required Map<String, int> deliveredCounts, required Map<String, int> deliveredCounts,
Random? random,
}) { }) {
if (effectiveMixPercent.isEmpty) { if (effectiveMixPercent.isEmpty) {
return 'market_history'; return 'market_history';
} }
final int totalDelivered = final int totalWeight =
deliveredCounts.values.fold<int>(0, (int a, int b) => a + b); effectiveMixPercent.values.fold<int>(0, (int a, int b) => a + b);
if (totalDelivered == 0) { if (totalWeight <= 0) {
return effectiveMixPercent.entries return effectiveMixPercent.keys.first;
.reduce(
(
MapEntry<String, int> a,
MapEntry<String, int> b,
) =>
a.value >= b.value ? a : b,
)
.key;
} }
String? pick; // If one source has 100% of the active weight, bypass the random roll completely.
var maxDeficit = double.negativeInfinity;
for (final MapEntry<String, int> entry in effectiveMixPercent.entries) { for (final MapEntry<String, int> entry in effectiveMixPercent.entries) {
final double target = entry.value / 100.0; if (entry.value == totalWeight) {
final double actual = return entry.key;
(deliveredCounts[entry.key] ?? 0) / totalDelivered;
final double deficit = target - actual;
if (deficit > maxDeficit) {
maxDeficit = deficit;
pick = 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( static Map<String, int> effectiveMixFromResolved(

View File

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

View File

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

View File

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

View File

@ -1,53 +1,48 @@
import 'dart:math';
import 'package:cyberhybridhub_server/question_source_mix.dart'; import 'package:cyberhybridhub_server/question_source_mix.dart';
import 'package:cyberhybridhub_server/questions_db.dart'; import 'package:cyberhybridhub_server/questions_db.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {
group('QuestionSourceMix.pickNextSourceId', () { group('QuestionSourceMix.pickNextSourceId', () {
test('picks highest share when nothing delivered yet', () { 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( expect(
QuestionSourceMix.pickNextSourceId( QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: <String, int>{ effectiveMixPercent: <String, int>{'spelling': 100},
'market_history': 70,
'spelling': 30,
},
deliveredCounts: <String, int>{}, 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', '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', () { group('QuestionSourceMix.orderUnansweredByPreferredSource', () {

View File

@ -140,4 +140,52 @@ void main() {
); );
expect(find.byKey(const Key('question-source-settings-dialog')), findsNothing); 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(find.byType(SwipeQuestionTile), findsOneWidget);
expect(
tester.widget<SwipeQuestionTile>(find.byType(SwipeQuestionTile)).showGuidGlyph,
true,
);
expect(find.byType(SpellingQuestionTile), findsNothing); expect(find.byType(SpellingQuestionTile), findsNothing);
}); });
@ -66,6 +70,10 @@ void main() {
); );
expect(find.byType(SwipeQuestionTile), findsOneWidget); expect(find.byType(SwipeQuestionTile), findsOneWidget);
expect(
tester.widget<SwipeQuestionTile>(find.byType(SwipeQuestionTile)).showGuidGlyph,
false,
);
expect(find.byType(SpellingQuestionTile), findsNothing); 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.tap(find.byKey(const Key('spelling-submit-answer')));
await tester.pump(); await tester.pump();
expect(submitCount, 0); expect(submitCount, 1);
final Text feedback = tester.widget<Text>( final Text feedback = tester.widget<Text>(
find.byKey(const Key('spelling-feedback-word')), find.byKey(const Key('spelling-feedback-word')),
); );