buttons
This commit is contained in:
parent
46cc822586
commit
00840a80e4
File diff suppressed because one or more lines are too long
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
await widget.onSubmitAnswer(answer);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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', () {
|
||||
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>{
|
||||
'market_history': 70,
|
||||
'spelling': 30,
|
||||
},
|
||||
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', () {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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')),
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user