balance controled working spelling and market questions.
Some checks failed
Admin portal tests / test (push) Has been cancelled

This commit is contained in:
nathanAndrinoid 2026-06-17 04:20:45 +00:00
parent 7f13161320
commit a521a74214
46 changed files with 3952 additions and 334 deletions

File diff suppressed because one or more lines are too long

3
.gitignore vendored
View File

@ -82,3 +82,6 @@ migrate_working_dir/
*.env.* *.env.*
!server/.env.example !server/.env.example
!hosting/hosting.env.example !hosting/hosting.env.example
# Server Cache
server/spelling_audio_cache/

View File

@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../config/api_config.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../models/sync_run_event.dart'; import '../models/sync_run_event.dart';
import '../repositories/sync_run_log_repository.dart'; import '../repositories/sync_run_log_repository.dart';
@ -45,6 +47,18 @@ class _MarketHistoryLogScreenState extends State<MarketHistoryLogScreen> {
bool _triggerInFlight = false; bool _triggerInFlight = false;
bool _weekCoverageLoading = false; bool _weekCoverageLoading = false;
bool _questionAuditLoading = false; bool _questionAuditLoading = false;
String _selectedAdminOption = 'market_history_log';
bool _questionLogLoading = false;
List<String> _questionLogEntries = <String>[];
String? _questionLogError;
bool _audioCacheLoading = false;
List<String> _audioCacheFiles = <String>[];
String? _audioCacheError;
AudioPlayer? _audioPlayer;
String? _playingFile;
final Map<String, double> _volumes = <String, double>{};
final Map<String, bool> _normalizeFlags = <String, bool>{};
final Map<String, bool> _savingFlags = <String, bool>{};
MarketHistoryAdminApi get _coverageApi => MarketHistoryAdminApi get _coverageApi =>
widget.coverageApi ?? MarketHistoryAdminApi(); widget.coverageApi ?? MarketHistoryAdminApi();
@ -74,6 +88,7 @@ class _MarketHistoryLogScreenState extends State<MarketHistoryLogScreen> {
void dispose() { void dispose() {
_autoRefreshTimer?.cancel(); _autoRefreshTimer?.cancel();
_scrollController.dispose(); _scrollController.dispose();
_audioPlayer?.dispose();
super.dispose(); super.dispose();
} }
@ -280,12 +295,624 @@ class _MarketHistoryLogScreenState extends State<MarketHistoryLogScreen> {
_triggerInFlight || _triggerInFlight ||
_state.hasInProgressRun; _state.hasInProgressRun;
Future<void> _loadQuestionLog() async {
setState(() {
_questionLogLoading = true;
_questionLogError = null;
});
try {
final List<String> entries = await _coverageApi.fetchMarketQuestionLog(asOf: widget.now);
if (!mounted) {
return;
}
setState(() {
_questionLogEntries = entries;
_questionLogLoading = false;
});
} catch (e) {
if (!mounted) {
return;
}
setState(() {
_questionLogError = e.toString();
_questionLogLoading = false;
});
}
}
final RegExp _logRegex = RegExp(
r'^\[([^\]]+)\] Question ID: ([^,]+), User ID: ([^,]+), Symbol: ([^,]+), Response: ([^,]+), Correct: ([^,]+), Inverted: (true|false)',
);
Widget _buildQuestionLogTile(String line) {
final Match? match = _logRegex.firstMatch(line);
if (match == null) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
line,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppColors.textSecondary,
),
),
);
}
final String timestamp = match.group(1)!;
final String questionId = match.group(2)!.trim();
final String userId = match.group(3)!.trim();
final String symbol = match.group(4)!.trim();
final String response = match.group(5)!.trim();
final String correct = match.group(6)!.trim();
final String inverted = match.group(7)!.trim();
String displayTime = timestamp;
try {
final DateTime parsedTime = DateTime.parse(timestamp).toLocal();
final String hour = parsedTime.hour.toString().padLeft(2, '0');
final String minute = parsedTime.minute.toString().padLeft(2, '0');
final String second = parsedTime.second.toString().padLeft(2, '0');
displayTime = '$hour:$minute:$second';
} catch (_) {}
final bool isInverted = inverted == 'true';
final double? respVal = double.tryParse(response);
final double? corrVal = double.tryParse(correct);
bool isCorrect = false;
if (respVal != null && corrVal != null) {
final double effectiveCorrect = isInverted ? corrVal * -1 : corrVal;
isCorrect = respVal.compareTo(0) == effectiveCorrect.compareTo(0);
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceElevated,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: (isCorrect ? AppColors.success : AppColors.error).withValues(alpha: 0.15),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
displayTime,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(4),
),
child: Text(
symbol,
style: const TextStyle(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
color: AppColors.accent,
fontSize: 12,
),
),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'User: $userId • Q: $questionId',
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Text(
'Response: $response',
style: TextStyle(
color: isCorrect ? AppColors.success : AppColors.textSecondary,
fontSize: 12,
),
),
Text(
'Correct: $correct',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
Text(
'Inverted: $inverted',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
Text(
'Points: ${isCorrect ? "+1" : "-1"}',
style: TextStyle(
color: isCorrect ? AppColors.success : AppColors.error,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
if (isInverted)
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(3),
),
child: const Text(
'INVERTED',
style: TextStyle(
color: Colors.amber,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
Icon(
isCorrect ? Icons.check_circle_outline : Icons.highlight_off,
color: isCorrect ? AppColors.success : AppColors.error,
size: 20,
),
],
),
);
}
Widget _buildQuestionLogBody() {
if (_questionLogLoading) {
return const Center(
key: Key('question-log-loading-indicator'),
child: CircularProgressIndicator(color: AppColors.accent),
);
}
if (_questionLogError != null) {
return Center(
key: const Key('question-log-error-state'),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(Icons.error_outline, color: Colors.redAccent, size: 40),
const SizedBox(height: 12),
Text(
_questionLogError!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _loadQuestionLog,
child: const Text('Retry'),
),
],
),
),
);
}
if (_questionLogEntries.isEmpty) {
return const Center(
key: Key('question-log-empty-state'),
child: Text(
'No questions answered today yet.',
style: TextStyle(color: AppColors.textSecondary),
),
);
}
return RefreshIndicator(
key: const Key('question-log-refresh-indicator'),
onRefresh: _loadQuestionLog,
color: AppColors.accent,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _questionLogEntries.length,
itemBuilder: (BuildContext context, int index) {
return _buildQuestionLogTile(_questionLogEntries[index]);
},
),
);
}
AudioPlayer get _player {
_audioPlayer ??= AudioPlayer();
return _audioPlayer!;
}
Future<void> _loadSpellingAudioCache() async {
setState(() {
_audioCacheLoading = true;
_audioCacheError = null;
});
try {
final List<String> files = await _coverageApi.fetchSpellingAudioCache();
if (!mounted) {
return;
}
setState(() {
_audioCacheFiles = files;
_audioCacheLoading = false;
for (final String file in files) {
_volumes.putIfAbsent(file, () => 1.0);
_normalizeFlags.putIfAbsent(file, () => false);
_savingFlags.putIfAbsent(file, () => false);
}
});
} catch (e) {
if (!mounted) {
return;
}
setState(() {
_audioCacheError = e.toString();
_audioCacheLoading = false;
});
}
}
Future<void> _playAudio(String filename) async {
final String url = '$apiBaseUrl/v1/spelling/audio/$filename?cb=${DateTime.now().millisecondsSinceEpoch}';
try {
if (_playingFile == filename) {
await _player.stop();
setState(() {
_playingFile = null;
});
} else {
await _player.stop();
setState(() {
_playingFile = filename;
});
_player.onPlayerComplete.first.then((_) {
if (mounted && _playingFile == filename) {
setState(() {
_playingFile = null;
});
}
});
await _player.setVolume((_volumes[filename] ?? 1.0).clamp(0.0, 1.0));
await _player.play(UrlSource(url));
}
} catch (e) {
debugPrint('Error playing audio: $e');
if (mounted) {
setState(() {
_playingFile = null;
});
}
}
}
Future<void> _saveAudioAdjustments(String filename) async {
setState(() {
_savingFlags[filename] = true;
});
try {
if (_playingFile == filename) {
await _player.stop();
setState(() {
_playingFile = null;
});
}
final double vol = _volumes[filename] ?? 1.0;
final bool norm = _normalizeFlags[filename] ?? false;
await _coverageApi.adjustSpellingAudio(
filename: filename,
volume: vol,
normalize: norm,
);
if (!mounted) return;
_showSnack('Audio levels updated successfully for $filename');
setState(() {
_savingFlags[filename] = false;
_volumes[filename] = 1.0;
_normalizeFlags[filename] = false;
});
_loadSpellingAudioCache();
} catch (e) {
if (!mounted) return;
_showSnack('Error saving audio levels: $e');
setState(() {
_savingFlags[filename] = false;
});
}
}
Widget _buildSpellingAudioCacheBody() {
if (_audioCacheLoading) {
return const Center(
key: Key('spelling-audio-cache-loading-indicator'),
child: CircularProgressIndicator(color: AppColors.accent),
);
}
if (_audioCacheError != null) {
return Center(
key: const Key('spelling-audio-cache-error-state'),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(Icons.error_outline, color: Colors.redAccent, size: 40),
const SizedBox(height: 12),
Text(
_audioCacheError!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _loadSpellingAudioCache,
child: const Text('Retry'),
),
],
),
),
);
}
if (_audioCacheFiles.isEmpty) {
return const Center(
key: Key('spelling-audio-cache-empty-state'),
child: Text(
'No cached spelling audio files.',
style: TextStyle(color: AppColors.textSecondary),
),
);
}
return RefreshIndicator(
key: const Key('spelling-audio-cache-refresh-indicator'),
onRefresh: _loadSpellingAudioCache,
color: AppColors.accent,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _audioCacheFiles.length,
itemBuilder: (BuildContext context, int index) {
final String filename = _audioCacheFiles[index];
final bool isPlaying = _playingFile == filename;
final double vol = _volumes[filename] ?? 1.0;
final bool norm = _normalizeFlags[filename] ?? false;
final bool saving = _savingFlags[filename] ?? false;
return Container(
key: Key('audio-file-$filename'),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surfaceElevated,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: (isPlaying ? AppColors.accent : AppColors.surfaceElevated)
.withValues(alpha: 0.15),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
IconButton(
key: Key('play-button-$filename'),
tooltip: isPlaying ? 'Stop' : 'Listen',
icon: Icon(
isPlaying ? Icons.stop : Icons.volume_up,
color: isPlaying ? AppColors.accent : AppColors.textSecondary,
),
onPressed: () => _playAudio(filename),
),
const SizedBox(width: 8),
Expanded(
child: Text(
filename,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: AppColors.textPrimary,
),
),
),
],
),
const Divider(height: 16),
Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Volume Level: ${vol.toStringAsFixed(1)}x',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
Row(
children: <Widget>[
const Icon(Icons.volume_down, color: AppColors.textSecondary, size: 16),
Expanded(
child: Slider(
key: Key('volume-slider-$filename'),
value: vol,
min: 0.1,
max: 2.0,
divisions: 19,
activeColor: AppColors.accent,
inactiveColor: AppColors.surface,
onChanged: saving ? null : (double val) {
setState(() {
_volumes[filename] = val;
});
},
),
),
const Icon(Icons.volume_up, color: AppColors.textSecondary, size: 16),
],
),
const SizedBox(height: 8),
Row(
children: <Widget>[
SizedBox(
height: 24,
width: 24,
child: Checkbox(
key: Key('normalize-checkbox-$filename'),
value: norm,
activeColor: AppColors.accent,
onChanged: saving ? null : (bool? val) {
setState(() {
_normalizeFlags[filename] = val ?? false;
});
},
),
),
const SizedBox(width: 8),
const Text(
'Loudness Normalization',
style: TextStyle(
fontSize: 13,
color: AppColors.textPrimary,
),
),
const Spacer(),
if (saving)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.accent,
),
)
else
FilledButton(
key: Key('save-adjust-$filename'),
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
onPressed: () => _saveAudioAdjustments(filename),
child: const Text('Save to Server'),
),
],
),
],
),
),
],
),
);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Market history log'), title: DropdownButtonHideUnderline(
actions: <Widget>[ child: DropdownButton<String>(
key: const Key('admin-portal-dropdown'),
value: _selectedAdminOption,
isExpanded: true,
dropdownColor: AppColors.surfaceElevated,
icon: const Icon(Icons.arrow_drop_down, color: AppColors.textSecondary),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
items: const <DropdownMenuItem<String>>[
DropdownMenuItem<String>(
value: 'market_history_log',
child: Text(
'Market history log',
overflow: TextOverflow.ellipsis,
),
),
DropdownMenuItem<String>(
value: 'market_question_log',
child: Text(
'Market Question Log',
overflow: TextOverflow.ellipsis,
),
),
DropdownMenuItem<String>(
value: 'spelling_audio_cache',
child: Text(
'Spelling Audio Cache',
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (String? value) {
if (value != null) {
setState(() {
_selectedAdminOption = value;
});
if (value == 'market_question_log') {
_loadQuestionLog();
} else if (value == 'spelling_audio_cache') {
_loadSpellingAudioCache();
}
}
},
),
),
actions: _selectedAdminOption == 'market_question_log'
? <Widget>[
IconButton(
key: const Key('question-log-refresh-button'),
tooltip: 'Refresh',
onPressed: _questionLogLoading ? null : _loadQuestionLog,
icon: const Icon(Icons.refresh),
),
]
: _selectedAdminOption == 'spelling_audio_cache'
? <Widget>[
IconButton(
key: const Key('audio-cache-refresh-button'),
tooltip: 'Refresh',
onPressed: _audioCacheLoading ? null : _loadSpellingAudioCache,
icon: const Icon(Icons.refresh),
),
]
: <Widget>[
IconButton( IconButton(
key: const Key('question-audit-button'), key: const Key('question-audit-button'),
tooltip: 'Audit prospective question data', tooltip: 'Audit prospective question data',
@ -330,7 +957,11 @@ class _MarketHistoryLogScreenState extends State<MarketHistoryLogScreen> {
), ),
], ],
), ),
body: _buildBody(), body: _selectedAdminOption == 'market_question_log'
? _buildQuestionLogBody()
: _selectedAdminOption == 'spelling_audio_cache'
? _buildSpellingAudioCacheBody()
: _buildBody(),
); );
} }

View File

@ -161,6 +161,103 @@ class MarketHistoryAdminApi {
return MarketHistoryWeekCoverageReport.fromJson(body); return MarketHistoryWeekCoverageReport.fromJson(body);
} }
Future<List<String>> fetchMarketQuestionLog({DateTime? asOf}) async {
final String? token = await _tokenProvider();
if (token == null || token.isEmpty) {
throw MarketHistoryAdminApiException('Missing auth token');
}
final Map<String, String> query = <String, String>{};
if (asOf != null) {
query['asOf'] = asOf.toUtc().toIso8601String();
}
final Uri uri = Uri.parse('$_baseUrl/v1/admin/market-history/question-log')
.replace(queryParameters: query.isEmpty ? null : query);
final http.Response response = await _client.get(
uri,
headers: <String, String>{
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
if (response.statusCode != 200) {
throw MarketHistoryAdminApiException(
response.body,
statusCode: response.statusCode,
);
}
final Map<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>;
final List<dynamic> raw = body['entries'] as List<dynamic>? ?? <dynamic>[];
return raw.cast<String>().toList();
}
Future<List<String>> fetchSpellingAudioCache() async {
final String? token = await _tokenProvider();
if (token == null || token.isEmpty) {
throw MarketHistoryAdminApiException('Missing auth token');
}
final Uri uri = Uri.parse('$_baseUrl/v1/admin/market-history/spelling-audio-cache');
final http.Response response = await _client.get(
uri,
headers: <String, String>{
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
if (response.statusCode != 200) {
throw MarketHistoryAdminApiException(
response.body,
statusCode: response.statusCode,
);
}
final Map<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>;
final List<dynamic> raw = body['files'] as List<dynamic>? ?? <dynamic>[];
return raw.cast<String>().toList();
}
Future<void> adjustSpellingAudio({
required String filename,
double? volume,
bool? normalize,
}) async {
final String? token = await _tokenProvider();
if (token == null || token.isEmpty) {
throw MarketHistoryAdminApiException('Missing auth token');
}
final Uri uri = Uri.parse('$_baseUrl/v1/admin/market-history/spelling-audio-cache/adjust');
final http.Response response = await _client.post(
uri,
headers: <String, String>{
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'filename': filename,
if (volume != null) 'volume': volume,
if (normalize != null) 'normalize': normalize,
}),
);
if (response.statusCode != 200) {
throw MarketHistoryAdminApiException(
response.body,
statusCode: response.statusCode,
);
}
}
static List<SyncRunEvent> _parseEventList(dynamic raw, String field) { static List<SyncRunEvent> _parseEventList(dynamic raw, String field) {
if (raw == null) { if (raw == null) {
return <SyncRunEvent>[]; return <SyncRunEvent>[];

View File

@ -7,9 +7,11 @@ class QuestionSubmitResult {
required this.unansweredCount, required this.unansweredCount,
required this.score, required this.score,
this.nextQuestion, this.nextQuestion,
this.correctValue,
}); });
final int unansweredCount; final int unansweredCount;
final GuessScoreSummary score; final GuessScoreSummary score;
final IncomingQuestion? nextQuestion; final IncomingQuestion? nextQuestion;
final num? correctValue;
} }

View File

@ -2,10 +2,8 @@ import 'package:flutter/material.dart';
import '../admin/widgets/admin_app_bar_action.dart'; 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/incoming_question.dart'; import '../models/incoming_question.dart';
import '../models/question_source_preference.dart'; import '../models/question_source_preference.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';
import '../repositories/user_profile_repository.dart'; import '../repositories/user_profile_repository.dart';
@ -61,7 +59,6 @@ class HomeScreen extends StatelessWidget {
QuestionsHubService.instance.hasPendingQuestion, QuestionsHubService.instance.hasPendingQuestion,
QuestionsHubService.instance.pendingQuestion, QuestionsHubService.instance.pendingQuestion,
QuestionsHubService.instance.pendingQuestionCount, QuestionsHubService.instance.pendingQuestionCount,
QuestionsHubService.instance.guessScoreSummary,
QuestionsHubService.instance.questionPanelOpen, QuestionsHubService.instance.questionPanelOpen,
QuestionsHubService.instance.questionQueue, QuestionsHubService.instance.questionQueue,
QuestionSourceConfigRepository.instance.preferences, QuestionSourceConfigRepository.instance.preferences,
@ -79,29 +76,10 @@ class HomeScreen extends StatelessWidget {
final bool showEnvelope = spellingOnly || final bool showEnvelope = spellingOnly ||
marketOnly || marketOnly ||
(hasPending && count >= 1); (hasPending && count >= 1);
final GuessScoreSummary score = if (!showEnvelope) {
QuestionsHubService.instance.guessScoreSummary.value; return const SizedBox.shrink();
final IncomingQuestion? active = }
QuestionsHubService.instance.currentQuestion; return HubToolbarIconButton(
final bool panelOpen =
QuestionsHubService.instance.questionPanelOpen.value;
final bool showMarketScore = !panelOpen ||
active == null ||
active.isMarketHistory;
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (showMarketScore)
_CumulativeScoreChip(
summary: score,
onPressed: () =>
_showGuessScoreStatsDialog(context, score),
),
if (showMarketScore && showEnvelope)
const SizedBox(width: 8),
if (showEnvelope) ...<Widget>[
if (!showMarketScore) const SizedBox(width: 8),
HubToolbarIconButton(
icon: Icons.mail_outline, icon: Icons.mail_outline,
iconColor: AppColors.accent, iconColor: AppColors.accent,
badgeCount: count >= 1 ? count : null, badgeCount: count >= 1 ? count : null,
@ -114,9 +92,6 @@ class HomeScreen extends StatelessWidget {
: 'Spelling questions', : 'Spelling questions',
onPressed: () => onPressed: () =>
QuestionsHubService.instance.openQuestionPanel(), QuestionsHubService.instance.openQuestionPanel(),
),
],
],
); );
}, },
), ),
@ -425,166 +400,6 @@ class HomeScreen extends StatelessWidget {
} }
} }
void _showGuessScoreStatsDialog(BuildContext context, GuessScoreSummary summary) {
final String totalText = _formatScoreNumber(summary.total);
final String percentText = _formatScoreNumber(summary.percentCorrect);
final String? slotText = summary.slotStart == null
? null
: formatGuessSlotRange(
slotStart: summary.slotStart!,
newerSlotStart: summary.newerSlotStart,
);
showDialog<void>(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
key: const Key('guess-score-stats-dialog'),
title: const Text('Score statistics'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (slotText != null)
_ScoreStatRow(
key: const Key('guess-score-slot-row'),
label: 'Time slot',
value: slotText,
),
_ScoreStatRow(label: 'Total score', value: totalText),
_ScoreStatRow(
label: 'Questions answered',
value: '${summary.answersTotal}',
),
_ScoreStatRow(label: 'Percent correct', value: '$percentText%'),
],
),
actions: <Widget>[
TextButton(
key: const Key('guess-score-reset-button'),
onPressed: () => _confirmResetGuessScore(dialogContext),
child: const Text('Reset'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Close'),
),
],
);
},
);
}
String _formatScoreNumber(num value) {
return value == value.roundToDouble()
? value.toStringAsFixed(0)
: value.toStringAsFixed(2);
}
Future<void> _confirmResetGuessScore(BuildContext context) async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
key: const Key('guess-score-reset-confirm-dialog'),
title: const Text('Reset score?'),
content: const Text(
'This clears your total score and answer statistics. '
'You cannot undo this.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
key: const Key('guess-score-reset-confirm-button'),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Reset'),
),
],
);
},
);
if (confirmed != true || !context.mounted) {
return;
}
Navigator.of(context).pop();
await QuestionsHubService.instance.resetGuessScoreSummary();
}
class _ScoreStatRow extends StatelessWidget {
const _ScoreStatRow({
super.key,
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(label, style: Theme.of(context).textTheme.bodyLarge),
Text(
value,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.accent,
),
),
],
),
);
}
}
class _CumulativeScoreChip extends StatelessWidget {
const _CumulativeScoreChip({
required this.summary,
required this.onPressed,
});
final GuessScoreSummary summary;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final String scoreText = summary.total == summary.total.roundToDouble()
? summary.total.toStringAsFixed(0)
: summary.total.toStringAsFixed(2);
return Material(
color: Colors.transparent,
child: InkWell(
key: const Key('topbar-cumulative-score'),
onTap: onPressed,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: AppColors.accent.withValues(alpha: 0.35)),
),
child: Text(
'Score: $scoreText',
style: const TextStyle(
color: AppColors.accent,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
),
),
);
}
}
class _SyncStatusChip extends StatelessWidget { class _SyncStatusChip extends StatelessWidget {
const _SyncStatusChip({required this.status}); const _SyncStatusChip({required this.status});

View File

@ -128,6 +128,7 @@ class QuestionsApiService {
nextQuestion: nextQuestionJson == null nextQuestion: nextQuestionJson == null
? null ? null
: IncomingQuestion.fromJson(nextQuestionJson), : IncomingQuestion.fromJson(nextQuestionJson),
correctValue: body['correctValue'] as num?,
); );
} }

View File

@ -402,10 +402,10 @@ class QuestionsHubService {
} }
/// Swipe right: submit slider value as the answer. /// Swipe right: submit slider value as the answer.
Future<void> submitCurrentAnswer({num answer = 0}) async { Future<num?> submitCurrentAnswer({num answer = 0}) async {
final IncomingQuestion? question = currentQuestion; final IncomingQuestion? question = currentQuestion;
if (question == null || questionActionBusy.value) { if (question == null || questionActionBusy.value) {
return; return null;
} }
num effectiveAnswer = answer; num effectiveAnswer = answer;
@ -413,14 +413,14 @@ class QuestionsHubService {
effectiveAnswer = 1; effectiveAnswer = 1;
} }
await _submitAnswer( return await _submitAnswer(
answeredQuestionId: question.id, answeredQuestionId: question.id,
question: question, question: question,
answer: effectiveAnswer, answer: effectiveAnswer,
); );
} }
Future<void> _submitAnswer({ Future<num?> _submitAnswer({
required String answeredQuestionId, required String answeredQuestionId,
required IncomingQuestion question, required IncomingQuestion question,
num? answer, num? answer,
@ -435,7 +435,8 @@ class QuestionsHubService {
answerText: answerText, answerText: answerText,
); );
if (result == null) { if (result == null) {
return; questionActionBusy.value = false;
return null;
} }
if (question.usesSpellingWordTile) { if (question.usesSpellingWordTile) {
@ -450,6 +451,42 @@ class QuestionsHubService {
guessScoreSummary.value = result.score; guessScoreSummary.value = result.score;
} }
if (question.isMarketHistory && result.correctValue != null) {
unawaited(Future<void>.delayed(const Duration(seconds: 2)).then((_) async {
try {
await _advanceQueue(
result: result,
question: question,
answeredQuestionId: answeredQuestionId,
);
} finally {
questionActionBusy.value = false;
}
}));
return result.correctValue;
} else {
try {
await _advanceQueue(
result: result,
question: question,
answeredQuestionId: answeredQuestionId,
);
} finally {
questionActionBusy.value = false;
}
return null;
}
} catch (e) {
questionActionBusy.value = false;
rethrow;
}
}
Future<void> _advanceQueue({
required QuestionSubmitResult result,
required IncomingQuestion question,
required String answeredQuestionId,
}) async {
if (result.nextQuestion != null && if (result.nextQuestion != null &&
_acceptsQuestionSource(result.nextQuestion!)) { _acceptsQuestionSource(result.nextQuestion!)) {
IncomingQuestion nextQ = result.nextQuestion!; IncomingQuestion nextQ = result.nextQuestion!;
@ -505,9 +542,6 @@ class QuestionsHubService {
unansweredCount: result.unansweredCount, unansweredCount: result.unansweredCount,
queue: remaining, queue: remaining,
); );
} finally {
questionActionBusy.value = false;
}
} }
/// Makes [question] the sole active card (queue head matches [pendingQuestion]). /// Makes [question] the sole active card (queue head matches [pendingQuestion]).

View File

@ -1,9 +1,101 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:cyberhybridhub/config/api_config.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_tts/flutter_tts.dart'; import 'package:flutter_tts/flutter_tts.dart';
import 'package:http/http.dart' as http;
import 'spelling_tts_backend.dart'; import 'spelling_tts_backend.dart';
SpellingTtsBackend createSpellingTtsBackend() => _FlutterTtsBackend(); SpellingTtsBackend createSpellingTtsBackend() =>
_FlutterDictionaryFallbackBackend(_FlutterTtsBackend());
class _FlutterDictionaryFallbackBackend implements SpellingTtsBackend {
_FlutterDictionaryFallbackBackend(this._fallback);
final SpellingTtsBackend _fallback;
final AudioPlayer _player = AudioPlayer();
Completer<void>? _completer;
StreamSubscription<void>? _onCompleteSubscription;
Future<Uint8List?> _fetchAudioBytes(String word) async {
final String query = word.trim().toLowerCase();
if (query.isEmpty) return null;
try {
final String encoded = Uri.encodeComponent(query);
final String url = '$apiBaseUrl/v1/spelling/audio/$encoded.mp3';
final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 8));
if (response.statusCode == 200) {
return response.bodyBytes;
}
} catch (e) {
debugPrint('Client audio fetch failed: $e');
}
return null;
}
@override
Future<void> speak(String text, {String? title}) async {
await stop();
final String trimmed = text.trim();
if (trimmed.isEmpty) return;
final Uint8List? bytes = await _fetchAudioBytes(trimmed);
if (bytes != null) {
try {
final Completer<void> completer = Completer<void>();
_completer = completer;
_onCompleteSubscription = _player.onPlayerComplete.listen(
(_) {
if (!completer.isCompleted) completer.complete();
},
onError: (Object err) {
if (!completer.isCompleted) completer.completeError(err);
},
onDone: () {
if (!completer.isCompleted) completer.complete();
},
);
await _player.play(BytesSource(bytes));
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () => debugPrint('Native audio playback timed out'),
);
} catch (e) {
debugPrint('Native audio play failed, falling back to TTS: $e');
await _fallback.speak(trimmed, title: title);
} finally {
await _onCompleteSubscription?.cancel();
_onCompleteSubscription = null;
_completer = null;
}
} else {
await _fallback.speak(trimmed, title: title);
}
}
@override
Future<void> stop() async {
final Completer<void>? completer = _completer;
_completer = null;
await _onCompleteSubscription?.cancel();
_onCompleteSubscription = null;
try {
await _player.stop();
} catch (e) {
debugPrint('Failed to stop audio player: $e');
}
if (completer != null && !completer.isCompleted) {
completer.complete();
}
await _fallback.stop();
}
}
class _FlutterTtsBackend implements SpellingTtsBackend { class _FlutterTtsBackend implements SpellingTtsBackend {
final FlutterTts _tts = FlutterTts(); final FlutterTts _tts = FlutterTts();

View File

@ -1,11 +1,101 @@
import 'dart:async'; import 'dart:async';
import 'dart:js_interop'; import 'dart:js_interop';
import 'package:cyberhybridhub/config/api_config.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'spelling_tts_backend.dart'; import 'spelling_tts_backend.dart';
SpellingTtsBackend createSpellingTtsBackend() => _WebSpeechBackend(); SpellingTtsBackend createSpellingTtsBackend() =>
_WebDictionaryFallbackBackend(_WebSpeechBackend());
@JS('Audio')
extension type JSAudio._(JSObject _) implements JSObject {
external JSAudio([String? src]);
external JSPromise play();
external void pause();
external void addEventListener(String type, JSFunction listener);
external void removeEventListener(String type, JSFunction listener);
}
class _WebDictionaryFallbackBackend implements SpellingTtsBackend {
_WebDictionaryFallbackBackend(this._fallback);
final SpellingTtsBackend _fallback;
JSAudio? _currentAudio;
Completer<void>? _audioPlayCompleter;
@override
Future<void> speak(String text, {String? title}) async {
await stop();
final String trimmed = text.trim();
if (trimmed.isEmpty) return;
final String query = trimmed.toLowerCase();
final String encoded = Uri.encodeComponent(query);
final String audioUrl = '$apiBaseUrl/v1/spelling/audio/$encoded.mp3';
try {
final JSAudio audio = JSAudio(audioUrl);
_currentAudio = audio;
final Completer<void> completer = Completer<void>();
_audioPlayCompleter = completer;
bool hasError = false;
final JSFunction onEnded = (() {
if (!completer.isCompleted) completer.complete();
}).toJS;
final JSFunction onError = ((JSAny event) {
hasError = true;
if (!completer.isCompleted) completer.complete();
}).toJS;
audio.addEventListener('ended', onEnded);
audio.addEventListener('error', onError);
try {
await audio.play().toDart;
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () => debugPrint('Web audio playback timed out'),
);
if (hasError) {
throw Exception('HTMLAudio element error event fired');
}
} catch (e) {
debugPrint('Web HTMLAudio play failed, falling back to TTS: $e');
await _fallback.speak(trimmed, title: title);
} finally {
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('error', onError);
}
} catch (e, st) {
debugPrint('Web audio playback setup error: $e\n$st');
await _fallback.speak(trimmed, title: title);
}
}
@override
Future<void> stop() async {
final Completer<void>? completer = _audioPlayCompleter;
_audioPlayCompleter = null;
final JSAudio? audio = _currentAudio;
_currentAudio = null;
if (audio != null) {
try {
audio.pause();
} catch (e) {
debugPrint('Failed to pause web audio: $e');
}
}
if (completer != null && !completer.isCompleted) {
completer.complete();
}
await _fallback.stop();
}
}
@JS('speechSynthesis') @JS('speechSynthesis')
external SpeechSynthesis get _speechSynthesis; external SpeechSynthesis get _speechSynthesis;

View File

@ -17,7 +17,7 @@ class QuestionPresentationRouter extends StatelessWidget {
}); });
final IncomingQuestion question; final IncomingQuestion question;
final Future<void> Function(num answer) onAnswer; final Future<num?> Function(num answer) onAnswer;
final Future<void> Function(String answerText)? onAnswerText; final Future<void> Function(String answerText)? onAnswerText;
final ValueChanged<IncomingQuestion>? onPrepared; final ValueChanged<IncomingQuestion>? onPrepared;
final Future<void> Function() onDefer; final Future<void> Function() onDefer;

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../models/guess_score_summary.dart';
import '../models/question_source_preference.dart'; import '../models/question_source_preference.dart';
import '../repositories/question_source_config_repository.dart'; import '../repositories/question_source_config_repository.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../services/questions_hub_service.dart'; import '../services/questions_hub_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../utils/guess_slot_format.dart';
/// Configure which question API sources are active and their relative mix. /// Configure which question API sources are active and their relative mix.
class QuestionSourceSettingsSheet extends StatefulWidget { class QuestionSourceSettingsSheet extends StatefulWidget {
@ -459,6 +461,23 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
), ),
), ),
), ),
ListenableBuilder(
listenable: QuestionsHubService.instance.scoreResetBusy,
builder: (BuildContext context, Widget? child) {
if (!QuestionsHubService.instance.scoreResetBusy.value) {
return const SizedBox.shrink();
}
return Positioned.fill(
key: const Key('guess-score-reset-blocking-overlay'),
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.45),
child: const Center(
child: CircularProgressIndicator(color: AppColors.accent),
),
),
);
},
),
], ],
); );
} }
@ -624,6 +643,19 @@ class _QuestionSourceRowState extends State<_QuestionSourceRow> {
onPressed: widget.resetSpellingBusy ? null : widget.onResetSpelling, onPressed: widget.resetSpellingBusy ? null : widget.onResetSpelling,
icon: const Icon(Icons.restart_alt, size: 20), icon: const Icon(Icons.restart_alt, size: 20),
), ),
if (widget.source.id == 'market_history')
ValueListenableBuilder<GuessScoreSummary>(
valueListenable: QuestionsHubService.instance.guessScoreSummary,
builder: (BuildContext context, GuessScoreSummary score, Widget? child) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _CumulativeScoreChip(
summary: score,
onPressed: () => _showGuessScoreStatsDialog(context, score),
),
);
},
),
Switch( Switch(
key: Key('question-source-enabled-${widget.source.id}'), key: Key('question-source-enabled-${widget.source.id}'),
value: widget.source.enabled, value: widget.source.enabled,
@ -723,3 +755,164 @@ class _QuestionSourceRowState extends State<_QuestionSourceRow> {
); );
} }
} }
void _showGuessScoreStatsDialog(BuildContext context, GuessScoreSummary summary) {
final String totalText = _formatScoreNumber(summary.total);
final String percentText = _formatScoreNumber(summary.percentCorrect);
final String? slotText = summary.slotStart == null
? null
: formatGuessSlotRange(
slotStart: summary.slotStart!,
newerSlotStart: summary.newerSlotStart,
);
showDialog<void>(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
key: const Key('guess-score-stats-dialog'),
title: const Text('Score statistics'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (slotText != null)
_ScoreStatRow(
key: const Key('guess-score-slot-row'),
label: 'Time slot',
value: slotText,
),
_ScoreStatRow(label: 'Total score', value: totalText),
_ScoreStatRow(
label: 'Questions answered',
value: '${summary.answersTotal}',
),
_ScoreStatRow(label: 'Percent correct', value: '$percentText%'),
],
),
actions: <Widget>[
TextButton(
key: const Key('guess-score-reset-button'),
onPressed: () => _confirmResetGuessScore(dialogContext),
child: const Text('Reset'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Close'),
),
],
);
},
);
}
String _formatScoreNumber(num value) {
return value == value.roundToDouble()
? value.toStringAsFixed(0)
: value.toStringAsFixed(2);
}
Future<void> _confirmResetGuessScore(BuildContext context) async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
key: const Key('guess-score-reset-confirm-dialog'),
title: const Text('Reset score?'),
content: const Text(
'This clears your total score and answer statistics. '
'You cannot undo this.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
key: const Key('guess-score-reset-confirm-button'),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Reset'),
),
],
);
},
);
if (confirmed != true || !context.mounted) {
return;
}
Navigator.of(context).pop();
await QuestionsHubService.instance.resetGuessScoreSummary();
}
class _ScoreStatRow extends StatelessWidget {
const _ScoreStatRow({
super.key,
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(label, style: Theme.of(context).textTheme.bodyLarge),
Text(
value,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.accent,
),
),
],
),
);
}
}
class _CumulativeScoreChip extends StatelessWidget {
const _CumulativeScoreChip({
required this.summary,
required this.onPressed,
});
final GuessScoreSummary summary;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final String scoreText = summary.total == summary.total.roundToDouble()
? summary.total.toStringAsFixed(0)
: summary.total.toStringAsFixed(2);
return Material(
color: Colors.transparent,
child: InkWell(
key: const Key('topbar-cumulative-score'),
onTap: onPressed,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: AppColors.accent.withValues(alpha: 0.35)),
),
child: Text(
'Score: $scoreText',
style: const TextStyle(
color: AppColors.accent,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
),
),
);
}
}

View File

@ -41,7 +41,7 @@ class SwipeQuestionTile extends StatefulWidget {
final String questionId; final String questionId;
final String? questionText; final String? questionText;
final Future<void> Function(num answer) onSwipeRight; final Future<num?> Function(num answer) onSwipeRight;
final Future<void> Function() onSwipeLeft; final Future<void> Function() onSwipeLeft;
final bool busy; final bool busy;
@ -86,7 +86,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
bool get _atZero => widget.showGuidGlyph ? _snappedSliderValue == 0 : false; bool get _atZero => widget.showGuidGlyph ? _snappedSliderValue == 0 : false;
bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero; bool get _holdSaveEnabled => !widget.busy && !_acting;
bool get _holdDeferEnabled => !widget.busy && !_acting; bool get _holdDeferEnabled => !widget.busy && !_acting;
@ -249,7 +249,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
} }
Future<void> _completeHoldSave() async { Future<void> _completeHoldSave() async {
if (_acting || widget.busy || _atZero || !mounted) { if (_acting || widget.busy || !mounted) {
return; return;
} }
final double width = MediaQuery.sizeOf(context).width; final double width = MediaQuery.sizeOf(context).width;
@ -257,7 +257,15 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_acting = true; _acting = true;
_dragOffset = width; _dragOffset = width;
}); });
await widget.onSwipeRight(_snappedSliderValue); final num? correctValue = await widget.onSwipeRight(_snappedSliderValue);
if (correctValue != null && mounted) {
setState(() {
_dragOffset = 0;
final int snappedCorrect = correctValue.round().clamp(_sliderMin, _sliderMax);
_verticalOffset = snappedCorrect / _sliderMax * _maxVerticalDrag;
});
await Future<void>.delayed(const Duration(seconds: 2));
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_acting = false; _acting = false;
@ -602,7 +610,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
} }
Future<void> _releaseDrag() async { Future<void> _releaseDrag() async {
if (_acting || widget.busy || _atZero) { if (_acting || widget.busy) {
setState(() => _dragOffset = 0); setState(() => _dragOffset = 0);
return; return;
} }
@ -612,7 +620,15 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_acting = true; _acting = true;
_dragOffset = MediaQuery.sizeOf(context).width; _dragOffset = MediaQuery.sizeOf(context).width;
}); });
await widget.onSwipeRight(_snappedSliderValue); final num? correctValue = await widget.onSwipeRight(_snappedSliderValue);
if (correctValue != null && mounted) {
setState(() {
_dragOffset = 0;
final int snappedCorrect = correctValue.round().clamp(_sliderMin, _sliderMax);
_verticalOffset = snappedCorrect / _sliderMax * _maxVerticalDrag;
});
await Future<void>.delayed(const Duration(seconds: 2));
}
} else if (_dragOffset < -_swipeThreshold) { } else if (_dragOffset < -_swipeThreshold) {
setState(() { setState(() {
_acting = true; _acting = true;

View File

@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h> #include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);

View File

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
sqlite3_flutter_libs sqlite3_flutter_libs
url_launcher_linux url_launcher_linux
) )

View File

@ -5,6 +5,7 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import audioplayers_darwin
import connectivity_plus import connectivity_plus
import file_picker import file_picker
import firebase_auth import firebase_auth
@ -16,6 +17,7 @@ import sqlite3_flutter_libs
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))

View File

@ -49,6 +49,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" version: "2.13.1"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: f16640453cc47487b7de72a2b28d37c7df1ac97999849f4a46d92b1d2b0f093d
url: "https://pub.dev"
source: hosted
version: "6.7.1"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
url: "https://pub.dev"
source: hosted
version: "6.4.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: "24a6f258062bd7da8cb2157e83fccb9816a08dd306cbaaa24f9813d071470545"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: "95f875a96c88c3dbbcb608d4f8288e300b0113d256a81d0b3197fcc18f0dc91a"
url: "https://pub.dev"
source: hosted
version: "4.3.1"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -989,6 +1045,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:

View File

@ -49,6 +49,7 @@ dependencies:
logging: ^1.3.0 logging: ^1.3.0
file_picker: ^11.0.2 file_picker: ^11.0.2
flutter_tts: ^4.2.2 flutter_tts: ^4.2.2
audioplayers: ^6.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -0,0 +1,202 @@
# Question System Expansion Guide
This document explains the architecture, databases, and control flow of the Question System in CyberHybridHub. It serves as a design reference and checklist for developers who wish to introduce new types of questions.
---
## 1. System Overview & Architecture
The question system serves active, unanswered questions to users based on weighted random selection ratios. Currently, it supports:
- **Market History Questions**: Prospective price-movement guessing games where users swipe to guess price directions.
- **Spelling Questions**: Audio-driven quiz items where users listen to a word read aloud via Text-to-Speech (TTS) and type the correct spelling.
### System Diagram
```mermaid
graph TD
Client[Flutter Client] <-->|SignalR / HTTP| API[Shelf API Router]
API <--> Dispatcher[QuestionDispatcher]
Dispatcher <--> |Weighted Mix Roll| Mix[QuestionSourceMix]
Dispatcher <--> QService[QuestionService]
Dispatcher <--> Pipeline[QuestionPipeline]
QService <--> |Create & Fetch| DB[(PostgreSQL)]
Pipeline <--> |Evaluate & Transition| DB
subgraph Client-Side Presentation
Router[QuestionPresentationRouter] -->|usesSpellingWordTile| SpellingTile[SpellingQuestionTile]
Router -->|isMarket / Swipe| SwipeTile[SwipeQuestionTile]
end
Client --> Router
```
---
## 2. Core Database Schema
The questions and their pipeline states are stored in the following PostgreSQL tables:
### The `questions` Table
Tracks individual questions generated for, queued for, or answered by a user.
- `id` (`UUID PRIMARY KEY`): Unique question identifier.
- `assigned_user_id` (`TEXT`): References the user's Firebase UID.
- `question_text` (`TEXT`): The text prompt displayed to the user.
- `user_response` (`NUMERIC`): Stores numeric answers (e.g., slider inputs or swipe values like `10` / `-10`).
- `user_response_text` (`TEXT`): Stores string-based typed answers (e.g., spelling inputs).
- `correct_answer` (`NUMERIC`): The correct answer/benchmark to grade against.
- `created_at` / `modified_at` (`TIMESTAMPTZ`): Auditing and ordering timestamps.
- `source_tag` (`TEXT`): Category tag (e.g., `market_history:prospective`, `spelling:word_quiz`).
- `pipeline_key` (`TEXT`): The active pipeline context (e.g., `trading`, `spelling`, `weather`).
- `pipeline_step` (`TEXT`): Active step within the pipeline (e.g., `spelling_word_quiz`).
- `metadata` (`JSONB`): Key-value payload for question-specific properties (e.g., symbol tickers, audio URLs, canonical word answers).
### The `user_pipeline_state` Table
Maintains multi-step session progress for branching or sequential question chains.
- `assigned_user_id` (`TEXT PRIMARY KEY`): User's Firebase UID.
- `pipeline_key` (`TEXT`): The active branching pipeline identifier.
- `step` (`TEXT`): The current step index or name (e.g., `temperature_quiz`, `idle`).
- `context` (`JSONB`): Arbitrary session context (e.g., weather facts, selected words, target numbers).
- `updated_at` (`TIMESTAMPTZ`): Last state transition timestamp.
---
## 3. Configuration & Mix Ratios
The ratio of different questions served to a user is governed by **Question Source Preferences**.
### Definition & Mix Allocation
Source definitions are registered statically in [question_source_preferences.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_source_preferences.dart):
```dart
static const List<QuestionSourcePreferenceDefinition> definitions = [
QuestionSourcePreferenceDefinition(
id: 'market_history',
label: 'Market question',
description: 'Prospective price-move guessing game',
),
QuestionSourcePreferenceDefinition(
id: 'spelling',
label: 'Spelling',
description: 'Spell-check quiz from curated word lists',
),
];
```
1. **Preference Selection**: Users can enable/disable individual question sources and configure their relative weights (ratios) from 0 to 100 via the settings panel.
2. **Effective Mix**: The server resolves configuration details and computes the normalized relative percentages using `QuestionSourcePreferences.effectiveMix()`.
3. **Dispatcher Selection**: When dispatching or queueing questions, the system calls `QuestionSourceMix.pickNextSourceId()` to perform a weighted random roll matching the configured percentages.
4. **Zero-Ratio Suppression**: If a source is enabled but configured with a ratio of `0`, it is suppressed and will not yield questions.
---
## 4. Question Lifecycle & Serving Flow
### 1. Queue Maintenance & Seeding
- The server maintains a small cache of unanswered questions for each active user (typically limited to `maxQueuedQuestions = 3`).
- The periodic cron or a login event invokes `QuestionDispatcher.topUpQueue()` in [question_dispatcher.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_dispatcher.dart).
- If the current count of unanswered questions is below the threshold, the dispatcher rolls for a preferred source and attempts to produce one question using `_produceFromSource()`. If it fails or is capped, it rolls over to other enabled sources sorted by descending ratio weight.
### 2. Retrieval & Dispatch
- The client initiates a request via `GET /v1/me/questions`.
- The [questions_handler.dart](file:///home/chh/cyberhybridhub.com/server/lib/handlers/questions_handler.dart) delegates to `QuestionDispatcher.listPanelQuestions()`.
- The dispatcher retrieves unanswered questions and orders them so that the currently rolled source preference resides at the head of the list (using `QuestionSourceMix.orderUnansweredByPreferredSource()`).
### 3. Client Routing & Presentation
- On the Flutter client, the [question_presentation_router.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/question_presentation_router.dart) examines incoming properties:
- If `IncomingQuestion.usesSpellingWordTile` is true, it renders a [spelling_question_tile.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/spelling_question_tile.dart).
- Otherwise, it defaults to rendering a [swipe_question_tile.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/swipe_question_tile.dart) (binary choice swipe interface).
### 4. Deferral & Skip Actions
- When a user chooses to skip or defer a question (e.g., swiping left or clicking skip):
- A `POST /v1/me/questions/<id>/defer` request is sent.
- The database updates the prioritization parameters (moving the question to the back of the queue by editing `view_order_at` or `created_at`).
- **Special Spelling Behavior**: In [question_dispatcher.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_dispatcher.dart#L154-L163), if spelling was the rolled preference but the user just deferred the only spelling question in their queue, the system forcefully enqueues a *new* spelling question immediately so the user is not served the exact same word again.
### 5. Deferred Preparation (Tile Load)
- For performance and synchronization, some questions defer part of their configuration until they are actively displayed.
- For example, spelling questions are created with a placeholder title and `needsPrepare = true`.
- When [spelling_question_tile.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/spelling_question_tile.dart) is mounted, it sends a `POST /v1/me/questions/<id>/spelling/prepare` request.
- The pipeline selects a word, saves it in the database and pipeline context, links spelling progress trackers, and replies with the updated client payload (containing the spoken word and audio properties).
### 6. Submission, Grading, & Progression
- When a user submits an answer via `POST /v1/me/questions/<id>/answer`:
- [questions_db.dart](file:///home/chh/cyberhybridhub.com/server/lib/questions_db.dart) logs and writes the answer to PostgreSQL.
- The dispatcher runs grading logic:
- **Spelling**: String comparison matching `userResponseText` against the `canonicalWord` metadata field via [spelling_answer_policy.dart](file:///home/chh/cyberhybridhub.com/server/lib/spelling/spelling_answer_policy.dart).
- **Market History**: Numeric evaluation using `gradeProspectiveAnswer()`.
- The pipeline runner (`QuestionPipeline.onAnswerSubmitted()`) processes the answer. Based on the matching/scoring outcome, it transitions the `user_pipeline_state` and optionally enqueues a branched follow-up question.
- Finally, the server schedules an asynchronous `topUpQueue()` execution to replenish the unanswered queue.
---
## 5. Guide: Adding a New Question Type
To introduce a new type of question (e.g., a "Math Trivia" question), follow this systematic implementation checklist:
### Step 1: Define Database Fields & Custom Tables (Server)
Determine if your question type requires dedicated tracking tables (like spelling word catalogs or trade order details).
- Create a SQL migration file in `server/migrations/` to define any new tables or constraints.
- Ensure any additional metadata needed to present or grade the question can be stored inside the JSONB `metadata` column of the `questions` table.
### Step 2: Register the Question Source
1. Open [question_source_preferences.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_source_preferences.dart).
2. Append a new `QuestionSourcePreferenceDefinition` to the static `definitions` list:
```dart
QuestionSourcePreferenceDefinition(
id: 'math_trivia',
label: 'Math Puzzle',
description: 'Solve arithmetic equations and logic puzzles',
defaultRatio: 30,
)
```
### Step 3: Implement Database Resolution (Server)
Modify the static resolution helpers in [questions_db.dart](file:///home/chh/cyberhybridhub.com/server/lib/questions_db.dart):
1. Locate `resolveSourceId(Map<String, dynamic> question)`. Add a case to return your new source identifier:
```dart
if (question['pipelineKey'] == 'math_trivia' || (sourceTag != null && sourceTag.startsWith('math:'))) {
return 'math_trivia';
}
```
2. Locate `resolvePresentation(Map<String, dynamic> question)`. Return the correct frontend layout identifier:
```dart
if (resolveSourceId(question) == 'math_trivia') {
return 'math_quiz_keyboard'; // Target layout format
}
```
### Step 4: Add Queue Maintenance Production (Server)
Integrate your question generator into the top-up flow:
1. Open [question_dispatcher.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_dispatcher.dart).
2. Inside `_produceFromSource()`, add a case for your source ID that invokes your generator:
```dart
case 'math_trivia':
await _questionService.tryEnqueueMathQuestions(firebaseUid);
```
### Step 5: Build the Generation & Pipeline Engine (Server)
Create a service class (or extend `QuestionPipeline`) to handle generation and state progression:
1. **Generation**: Implement a method that creates a database row using `questionsDb.createQuestion()`. Specify your custom `sourceTag`, `pipelineKey`, `pipelineStep`, and grade variables. Ensure it respects batch parameters to avoid flooding the user's queue.
2. **State Progression**: In `QuestionPipeline.onAnswerSubmitted()`, add a switch case for your new `pipelineKey`. Read the answer, evaluate correctness, update any progress records, and transition the user's pipeline state as required.
### Step 6: Parse the Client Model (Frontend)
1. Open [incoming_question.dart](file:///home/chh/cyberhybridhub.com/lib/models/incoming_question.dart).
2. Add a getter to identify the new question type:
```dart
bool get isMathTrivia => sourceId == 'math_trivia';
```
3. Update key mapping properties like `usesSpellingWordTile` or custom tile configuration parameters if you need to bypass default layouts.
### Step 7: Create the UI Component & Router (Frontend)
1. If your question type requires a custom layout (e.g. keyboard input, multi-choice grids), create a new widget in `lib/widgets/` (e.g., `math_question_tile.dart`).
2. Open [question_presentation_router.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/question_presentation_router.dart).
3. Update the `build` method to intercept your presentation format and route to the new widget:
```dart
if (question.presentation == 'math_quiz_keyboard') {
return MathQuestionTile(
key: ValueKey<String>(question.id),
question: question,
onSubmit: onAnswerText,
onDefer: onDefer,
);
}
```

View File

@ -11,11 +11,16 @@ dart test \
test/trading/market_history_admin_logic_test.dart \ test/trading/market_history_admin_logic_test.dart \
test/trading/market_history_admin_actions_test.dart \ test/trading/market_history_admin_actions_test.dart \
test/integration/market_history_admin_handler_test.dart \ test/integration/market_history_admin_handler_test.dart \
test/integration/market_history_admin_risk_test.dart \
--coverage=coverage --coverage=coverage
dart pub global activate coverage 2>/dev/null || true dart pub global activate coverage 2>/dev/null || true
export PATH="$PATH:$HOME/.pub-cache/bin"
if command -v format_coverage >/dev/null 2>&1; then if command -v format_coverage >/dev/null 2>&1; then
format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
echo "Server lcov: server/coverage/lcov.info" echo "Server lcov: server/coverage/lcov.info"
elif dart pub global run coverage:format_coverage --help >/dev/null 2>&1; then
dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
echo "Server lcov: server/coverage/lcov.info"
else else
echo "Install coverage package for lcov: dart pub global activate coverage" echo "Install coverage package for lcov: dart pub global activate coverage"
fi fi

View File

@ -67,8 +67,8 @@ fi
# build-invariant. Tree-shaking re-subsets MaterialIcons-Regular.otf every build # build-invariant. Tree-shaking re-subsets MaterialIcons-Regular.otf every build
# (different glyphs, same filename), and stale browser caches of that file make # (different glyphs, same filename), and stale browser caches of that file make
# newly-referenced icons (e.g. settings, admin_panel_settings) render blank. # newly-referenced icons (e.g. settings, admin_panel_settings) render blank.
flutter build web --release "${_flutter_pub_flag[@]}" #\ flutter build web --release "${_flutter_pub_flag[@]}" \
# --no-tree-shake-icons \ --no-tree-shake-icons #\
# --dart-define=API_BASE_URL="$API_BASE_URL" \ # --dart-define=API_BASE_URL="$API_BASE_URL" \
# --dart-define=AUTH_VERBOSE_LOG=true # --dart-define=AUTH_VERBOSE_LOG=true

View File

@ -38,3 +38,5 @@ MARKET_HISTORY_RETENTION_DAYS=5
# Use same origin as the site when HAProxy proxies /v1 and /hubs on :443. # Use same origin as the site when HAProxy proxies /v1 and /hubs on :443.
API_BASE_URL=https://cyberhybridhub.com API_BASE_URL=https://cyberhybridhub.com
QUESTION_DISPATCHER_DEBUG_LOGGING_ENABLED=false QUESTION_DISPATCHER_DEBUG_LOGGING_ENABLED=false
MERRIAM_WEBSTER_API_KEY=
GOOGLE_CLOUD_API_KEY=

View File

@ -17,6 +17,7 @@ import '../lib/handlers/question_source_handler.dart';
import '../lib/handlers/questions_handler.dart'; import '../lib/handlers/questions_handler.dart';
import '../lib/handlers/questions_hub_handler.dart'; import '../lib/handlers/questions_hub_handler.dart';
import '../lib/handlers/spelling_words_handler.dart'; import '../lib/handlers/spelling_words_handler.dart';
import '../lib/handlers/spelling_audio_handler.dart';
import '../lib/handlers/trading_dev_handler.dart'; import '../lib/handlers/trading_dev_handler.dart';
import '../lib/pipeline/question_pipeline.dart'; import '../lib/pipeline/question_pipeline.dart';
import '../lib/question_dispatcher.dart'; import '../lib/question_dispatcher.dart';
@ -263,6 +264,10 @@ Future<void> main() async {
auth: auth, auth: auth,
spellingItemsDb: spellingItemsDb, spellingItemsDb: spellingItemsDb,
); );
final Handler spellingAudio = spellingAudioHandler(
merriamWebsterApiKey: env.merriamWebsterApiKey,
googleCloudApiKey: env.googleCloudApiKey,
);
final Handler questionsHub = questionsHubHandler( final Handler questionsHub = questionsHubHandler(
auth: auth, auth: auth,
questionService: questionService, questionService: questionService,
@ -321,6 +326,9 @@ Future<void> main() async {
if (path.startsWith(spellingWordsBasePath)) { if (path.startsWith(spellingWordsBasePath)) {
return spellingWords(request); return spellingWords(request);
} }
if (path.startsWith('/v1/spelling/audio/')) {
return spellingAudio(request);
}
return profile(request); return profile(request);
}); });

View File

@ -22,6 +22,8 @@ class ServerEnv {
required this.marketHistory, required this.marketHistory,
required this.alpaca, required this.alpaca,
required this.questionDispatcherDebugLoggingEnabled, required this.questionDispatcherDebugLoggingEnabled,
required this.merriamWebsterApiKey,
required this.googleCloudApiKey,
}); });
final String databaseUrl; final String databaseUrl;
@ -31,6 +33,8 @@ class ServerEnv {
final int questionWorkerIntervalSeconds; final int questionWorkerIntervalSeconds;
final bool questionPipelineTestMode; final bool questionPipelineTestMode;
final bool questionDispatcherDebugLoggingEnabled; final bool questionDispatcherDebugLoggingEnabled;
final String? merriamWebsterApiKey;
final String? googleCloudApiKey;
/// When true, prospective answers earn closeness bonus points and wrong /// When true, prospective answers earn closeness bonus points and wrong
/// direction scores `-2` instead of `-1`. Default false (`+1`/`-1` only). /// direction scores `-2` instead of `-1`. Default false (`+1`/`-1` only).
@ -121,6 +125,8 @@ class ServerEnv {
(env['QUESTION_DISPATCHER_DEBUG_LOGGING_ENABLED'] ?? 'false') (env['QUESTION_DISPATCHER_DEBUG_LOGGING_ENABLED'] ?? 'false')
.toLowerCase() == .toLowerCase() ==
'true'; 'true';
final String? merriamWebsterApiKey = env['MERRIAM_WEBSTER_API_KEY'];
final String? googleCloudApiKey = env['GOOGLE_CLOUD_API_KEY'];
final Map<String, String> stringEnv = final Map<String, String> stringEnv =
Map<String, String>.from(Platform.environment); Map<String, String>.from(Platform.environment);
const List<String> marketHistoryKeys = <String>[ const List<String> marketHistoryKeys = <String>[
@ -165,6 +171,8 @@ class ServerEnv {
marketHistory: marketHistory, marketHistory: marketHistory,
alpaca: alpaca, alpaca: alpaca,
questionDispatcherDebugLoggingEnabled: questionDispatcherDebugLoggingEnabled, questionDispatcherDebugLoggingEnabled: questionDispatcherDebugLoggingEnabled,
merriamWebsterApiKey: merriamWebsterApiKey,
googleCloudApiKey: googleCloudApiKey,
); );
loaded.assertConsistent(); loaded.assertConsistent();
return loaded; return loaded;

View File

@ -45,7 +45,10 @@ Handler marketHistoryAdminHandler({
required Set<String> adminFirebaseUids, required Set<String> adminFirebaseUids,
MarketHistoryAdminActions? actions, MarketHistoryAdminActions? actions,
MarketHistoryAdminPortalConfig? portalConfig, MarketHistoryAdminPortalConfig? portalConfig,
Future<ProcessResult> Function(String, List<String>)? runProcess,
}) { }) {
final Future<ProcessResult> Function(String, List<String>) runProc =
runProcess ?? Process.run;
final Router router = Router(); final Router router = Router();
router.get('$marketHistoryAdminBasePath/sync-runs', (Request request) async { router.get('$marketHistoryAdminBasePath/sync-runs', (Request request) async {
@ -159,6 +162,160 @@ Handler marketHistoryAdminHandler({
} }
}); });
router.get('$marketHistoryAdminBasePath/question-log', (Request request) async {
final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids);
if (firebaseUid == null) {
return _authFailure(auth, request, adminFirebaseUids);
}
try {
final DateTime now =
_parseBefore(request.requestedUri.queryParameters['asOf']) ??
DateTime.now().toUtc();
final String y = now.year.toString().padLeft(4, '0');
final String m = now.month.toString().padLeft(2, '0');
final String d = now.day.toString().padLeft(2, '0');
final String dateString = '$y-$m-$d';
final String filename = 'market_questions_$dateString.log';
final File file = File(filename);
final List<String> entries = <String>[];
if (await file.exists()) {
final List<String> lines = await file.readAsLines();
for (final String line in lines) {
if (line.trim().isNotEmpty) {
entries.add(line);
}
}
}
final List<String> reversedEntries = entries.reversed.toList();
return _jsonResponse(200, <String, dynamic>{
'entries': reversedEntries,
});
} catch (e, st) {
stderr.writeln('market history admin question-log failed: $e\n$st');
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
}
});
router.get('$marketHistoryAdminBasePath/spelling-audio-cache', (Request request) async {
final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids);
if (firebaseUid == null) {
return _authFailure(auth, request, adminFirebaseUids);
}
try {
final Directory cacheDir = Directory('spelling_audio_cache');
final List<String> files = <String>[];
if (await cacheDir.exists()) {
final List<FileSystemEntity> entities = cacheDir.listSync();
for (final FileSystemEntity entity in entities) {
if (entity is File) {
final String name = entity.uri.pathSegments.last;
if (name.endsWith('.mp3')) {
files.add(name);
}
}
}
}
files.sort();
return _jsonResponse(200, <String, dynamic>{
'files': files,
});
} catch (e, st) {
stderr.writeln('market history admin spelling-audio-cache failed: $e\n$st');
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
}
});
router.post('$marketHistoryAdminBasePath/spelling-audio-cache/adjust', (Request request) async {
final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids);
if (firebaseUid == null) {
return _authFailure(auth, request, adminFirebaseUids);
}
try {
final String bodyString = await request.readAsString();
final Map<String, dynamic> body = jsonDecode(bodyString) as Map<String, dynamic>;
final String? filename = body['filename'] as String?;
if (filename == null || filename.isEmpty) {
return _jsonResponse(400, <String, dynamic>{'error': 'Missing filename'});
}
if (filename.contains('..') || filename.contains('/') || filename.contains('\\') || !filename.endsWith('.mp3')) {
return _jsonResponse(400, <String, dynamic>{'error': 'Invalid filename'});
}
final double? volume = (body['volume'] as num?)?.toDouble();
final bool? normalize = body['normalize'] as bool?;
if (volume == null && normalize == null) {
return _jsonResponse(400, <String, dynamic>{'error': 'No adjustments specified.'});
}
if (volume != null && (volume < 0.1 || volume > 2.0)) {
return _jsonResponse(400, <String, dynamic>{'error': 'Volume must be between 0.1 and 2.0'});
}
final File originalFile = File('spelling_audio_cache/$filename');
if (!await originalFile.exists()) {
return _jsonResponse(404, <String, dynamic>{'error': 'File not found'});
}
// Check if ffmpeg is installed
try {
final ProcessResult versionCheck = await runProc('ffmpeg', <String>['-version']);
if (versionCheck.exitCode != 0) {
return _jsonResponse(503, <String, dynamic>{'error': 'ffmpeg is not installed on the server.'});
}
} catch (_) {
return _jsonResponse(503, <String, dynamic>{'error': 'ffmpeg is not installed on the server.'});
}
final String tempFilename = 'temp_${DateTime.now().microsecondsSinceEpoch}_$filename';
final File tempFile = File('spelling_audio_cache/$tempFilename');
final List<String> filters = <String>[];
if (volume != null) {
filters.add('volume=$volume');
}
if (normalize == true) {
filters.add('loudnorm');
}
final List<String> args = <String>[
'-y',
'-i',
originalFile.path,
'-filter:a',
filters.join(','),
tempFile.path,
];
final ProcessResult result = await runProc('ffmpeg', args);
if (result.exitCode != 0) {
if (await tempFile.exists()) {
await tempFile.delete();
}
return _jsonResponse(500, <String, dynamic>{
'error': 'ffmpeg execution failed: ${result.stderr}',
});
}
if (await tempFile.exists()) {
await tempFile.copy(originalFile.path);
await tempFile.delete();
}
return _jsonResponse(200, <String, dynamic>{'success': true});
} catch (e, st) {
stderr.writeln('market history admin spelling-audio-cache adjust failed: $e\n$st');
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
}
});
router.post('$marketHistoryAdminDataBasePath/resync', (Request request) async { router.post('$marketHistoryAdminDataBasePath/resync', (Request request) async {
final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids); final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids);
if (firebaseUid == null) { if (firebaseUid == null) {

View File

@ -154,11 +154,21 @@ Handler questionsHandler({
final Map<String, dynamic>? score = isMarketAnswer final Map<String, dynamic>? score = isMarketAnswer
? await questionsDb.getGuessScoreSummary(firebaseUid) ? await questionsDb.getGuessScoreSummary(firebaseUid)
: null; : null;
num? correctValue;
if (isMarketAnswer) {
final Map<String, dynamic> metadata = Map<String, dynamic>.from(
updated['metadata'] as Map<String, dynamic>? ?? <String, dynamic>{},
);
final bool isInverted = metadata['is_inverted'] as bool? ?? false;
final num rawCorrect = updated['correctAnswer'] as num? ?? 0;
correctValue = isInverted ? -rawCorrect : rawCorrect;
}
return _jsonResponse(200, <String, dynamic>{ return _jsonResponse(200, <String, dynamic>{
'question': updated, 'question': updated,
'unansweredCount': unansweredCount, 'unansweredCount': unansweredCount,
if (score != null) 'score': score, if (score != null) 'score': score,
if (nextQuestion != null) 'nextQuestion': nextQuestion, if (nextQuestion != null) 'nextQuestion': nextQuestion,
if (correctValue != null) 'correctValue': correctValue,
}); });
} catch (e, st) { } catch (e, st) {
stderr.writeln('Answer question error: $e\n$st'); stderr.writeln('Answer question error: $e\n$st');

View File

@ -0,0 +1,354 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import '../cors_headers.dart';
Handler spellingAudioHandler({String? merriamWebsterApiKey, String? googleCloudApiKey, http.Client? httpClient}) {
final http.Client client = httpClient ?? http.Client();
final Directory cacheDir = Directory('spelling_audio_cache');
if (!cacheDir.existsSync()) {
cacheDir.createSync(recursive: true);
}
final File logFile = File('spelling_audio_errors.log');
Future<void> logFailure({
required String word,
required String apiName,
required String url,
int? statusCode,
String? responseBody,
String? errorDetails,
}) async {
final String timestamp = DateTime.now().toUtc().toIso8601String();
final StringBuffer buffer = StringBuffer()
..writeln('=== Audio Fetch Failure ===')
..writeln('Timestamp: $timestamp')
..writeln('Word: $word')
..writeln('API: $apiName')
..writeln('URL: $url');
if (statusCode != null) {
buffer.writeln('Status Code: $statusCode');
}
if (responseBody != null) {
buffer.writeln('Response Body: $responseBody');
}
if (errorDetails != null) {
buffer.writeln('Error Details: $errorDetails');
}
buffer.writeln('===========================');
try {
await logFile.writeAsString(
buffer.toString(),
mode: FileMode.append,
flush: true,
);
} catch (e) {
stderr.writeln('Failed to write spelling audio log: $e');
}
}
Future<String?> fetchAudioUrl(String word) async {
final String query = word.trim().toLowerCase();
if (query.isEmpty) return null;
final String url = 'https://api.dictionaryapi.dev/api/v2/entries/en/$query';
try {
final response = await client.get(Uri.parse(url)).timeout(const Duration(seconds: 4));
if (response.statusCode == 200) {
final List<dynamic> entries = jsonDecode(response.body) as List<dynamic>;
for (final entry in entries) {
if (entry is Map<String, dynamic>) {
final phonetics = entry['phonetics'];
if (phonetics is List<dynamic>) {
for (final phonetic in phonetics) {
if (phonetic is Map<String, dynamic>) {
final String? audio = phonetic['audio'] as String?;
if (audio != null && audio.isNotEmpty) {
if (audio.startsWith('//')) {
return 'https:$audio';
}
return audio;
}
}
}
}
}
}
await logFailure(
word: word,
apiName: 'dictionaryapi.dev',
url: url,
statusCode: 200,
responseBody: response.body,
errorDetails: 'No pronunciation audio URL found in response structure.',
);
} else {
await logFailure(
word: word,
apiName: 'dictionaryapi.dev',
url: url,
statusCode: response.statusCode,
responseBody: response.body,
);
}
} catch (e) {
stderr.writeln('Server fetchAudioUrl failed for word $word: $e');
await logFailure(
word: word,
apiName: 'dictionaryapi.dev',
url: url,
errorDetails: e.toString(),
);
}
return null;
}
Future<String?> fetchMerriamWebsterAudioUrl(String word) async {
if (merriamWebsterApiKey == null || merriamWebsterApiKey.isEmpty) {
return null;
}
final String cleanWord = word.trim().toLowerCase();
final String url = 'https://www.dictionaryapi.com/api/v3/references/collegiate/json/$cleanWord?key=$merriamWebsterApiKey';
try {
final response = await client.get(Uri.parse(url)).timeout(const Duration(seconds: 4));
if (response.statusCode != 200) {
await logFailure(
word: word,
apiName: 'Merriam-Webster',
url: url,
statusCode: response.statusCode,
responseBody: response.body,
);
return null;
}
final List<dynamic> json = jsonDecode(response.body) as List<dynamic>;
if (json.isEmpty || json[0] is String) {
await logFailure(
word: word,
apiName: 'Merriam-Webster',
url: url,
statusCode: 200,
responseBody: response.body,
errorDetails: 'Empty response or suggestions returned instead of entry details.',
);
return null;
}
final entry = json[0] as Map<String, dynamic>;
final hwi = entry['hwi'] as Map<String, dynamic>?;
final prs = hwi?['prs'] as List<dynamic>?;
if (prs == null || prs.isEmpty) {
await logFailure(
word: word,
apiName: 'Merriam-Webster',
url: url,
statusCode: 200,
responseBody: response.body,
errorDetails: 'No pronunciation (prs) array found in entry.',
);
return null;
}
final sound = prs[0]['sound'] as Map<String, dynamic>?;
final String? audioFileName = sound?['audio'];
if (audioFileName == null || audioFileName.isEmpty) {
await logFailure(
word: word,
apiName: 'Merriam-Webster',
url: url,
statusCode: 200,
responseBody: response.body,
errorDetails: 'No audio filename found in sound block.',
);
return null;
}
String subdirectory = 'number';
if (RegExp(r'^[a-zA-Z]').hasMatch(audioFileName)) {
if (audioFileName.startsWith('bix')) {
subdirectory = 'bix';
} else if (audioFileName.startsWith('gg')) {
subdirectory = 'gg';
} else {
subdirectory = audioFileName[0];
}
}
return 'https://media.merriam-webster.com/audio/prons/en/us/mp3/$subdirectory/$audioFileName.mp3';
} catch (e) {
stderr.writeln('Server Merriam-Webster fetchAudioUrl failed for word $word: $e');
await logFailure(
word: word,
apiName: 'Merriam-Webster',
url: url,
errorDetails: e.toString(),
);
}
return null;
}
Future<Uint8List?> synthesizeGoogleCloudTts(String word) async {
if (googleCloudApiKey == null || googleCloudApiKey.isEmpty) {
return null;
}
final String url = 'https://texttospeech.googleapis.com/v1/text:synthesize?key=$googleCloudApiKey';
try {
final response = await client.post(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'input': {'text': word},
'voice': {
'languageCode': 'en-US',
'name': 'en-US-Neural2-F',
},
'audioConfig': {
'audioEncoding': 'MP3',
'speakingRate': 0.85,
}
}),
).timeout(const Duration(seconds: 8));
if (response.statusCode == 200) {
final Map<String, dynamic> data = jsonDecode(response.body) as Map<String, dynamic>;
final String? audioContent = data['audioContent'] as String?;
if (audioContent != null && audioContent.isNotEmpty) {
return base64Decode(audioContent);
}
await logFailure(
word: word,
apiName: 'Google Cloud TTS',
url: url,
statusCode: 200,
responseBody: response.body,
errorDetails: 'Empty audioContent in response payload.',
);
} else {
await logFailure(
word: word,
apiName: 'Google Cloud TTS',
url: url,
statusCode: response.statusCode,
responseBody: response.body,
);
}
} catch (e) {
stderr.writeln('Server Google Cloud TTS synthesis failed for word $word: $e');
await logFailure(
word: word,
apiName: 'Google Cloud TTS',
url: url,
errorDetails: e.toString(),
);
}
return null;
}
return (Request request) async {
if (request.method == 'OPTIONS') {
return Response.ok('', headers: apiCorsHeaders(methods: 'GET, OPTIONS'));
}
if (request.method != 'GET') {
return Response(405, body: 'Method not allowed', headers: apiCorsHeaders(methods: 'GET, OPTIONS'));
}
final String path = request.requestedUri.path;
// Expected path: /v1/spelling/audio/<word> or /v1/spelling/audio/<word>.mp3
final String prefix = '/v1/spelling/audio/';
if (!path.startsWith(prefix)) {
return Response.notFound('Not found', headers: apiCorsHeaders(methods: 'GET, OPTIONS'));
}
String wordPart = path.substring(prefix.length);
if (wordPart.endsWith('.mp3')) {
wordPart = wordPart.substring(0, wordPart.length - 4);
}
final String word = Uri.decodeComponent(wordPart).trim().toLowerCase();
if (word.isEmpty || word.contains('/') || word.contains('\\') || word.contains('..')) {
return Response.badRequest(body: 'Invalid word', headers: apiCorsHeaders(methods: 'GET, OPTIONS'));
}
final File localFile = File('${cacheDir.path}/$word.mp3');
try {
if (localFile.existsSync()) {
final Stream<List<int>> fileStream = localFile.openRead();
return Response.ok(
fileStream,
headers: <String, String>{
...apiCorsHeaders(methods: 'GET, OPTIONS'),
'Content-Type': 'audio/mpeg',
'Content-Length': localFile.lengthSync().toString(),
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
);
}
// Fetch from dictionaryapi.dev
String? audioUrl = await fetchAudioUrl(word);
if (audioUrl == null) {
// Fallback to Merriam-Webster Collegiate API
audioUrl = await fetchMerriamWebsterAudioUrl(word);
}
Uint8List? audioBytes;
if (audioUrl != null) {
final http.Response audioResponse = await client.get(Uri.parse(audioUrl)).timeout(const Duration(seconds: 10));
if (audioResponse.statusCode == 200) {
audioBytes = audioResponse.bodyBytes;
} else {
await logFailure(
word: word,
apiName: 'MP3 Download',
url: audioUrl,
statusCode: audioResponse.statusCode,
responseBody: audioResponse.body,
);
}
}
if (audioBytes == null) {
// Fallback to Google Cloud TTS
audioBytes = await synthesizeGoogleCloudTts(word);
}
if (audioBytes == null) {
return Response.notFound('Audio not found', headers: apiCorsHeaders(methods: 'GET, OPTIONS'));
}
// Cache locally
await localFile.writeAsBytes(audioBytes);
return Response.ok(
audioBytes,
headers: <String, String>{
...apiCorsHeaders(methods: 'GET, OPTIONS'),
'Content-Type': 'audio/mpeg',
'Content-Length': audioBytes.length.toString(),
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
);
} catch (e, st) {
stderr.writeln('Error serving spelling audio for word $word: $e\n$st');
await logFailure(
word: word,
apiName: 'Serving Process',
url: 'N/A',
errorDetails: '$e\n$st',
);
return Response.internalServerError(body: 'Internal error', headers: apiCorsHeaders(methods: 'GET, OPTIONS'));
}
};
}

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'question_source_preferences_db.dart'; import 'question_source_preferences_db.dart';
import 'questions_db.dart'; import 'questions_db.dart';
@ -120,9 +121,13 @@ class QuestionService {
newerSlotStart: batch.newerSlotStart, newerSlotStart: batch.newerSlotStart,
); );
final bool isInverted = Random().nextBool();
final String text = picked['questionText']! as String;
final String questionText = isInverted ? '$text [Inverted]' : text;
final Map<String, dynamic> question = await _questionsDb.createQuestion( final Map<String, dynamic> question = await _questionsDb.createQuestion(
assignedUserId: firebaseUid, assignedUserId: firebaseUid,
questionText: picked['questionText']! as String, questionText: questionText,
correctAnswer: picked['correctAnswer']! as num, correctAnswer: picked['correctAnswer']! as num,
sourceTag: 'market_history:prospective', sourceTag: 'market_history:prospective',
pipelineKey: 'trading', pipelineKey: 'trading',
@ -133,6 +138,7 @@ class QuestionService {
'older_slot_start': picked['olderSlotStart'], 'older_slot_start': picked['olderSlotStart'],
'newer_slot_start': picked['newerSlotStart'], 'newer_slot_start': picked['newerSlotStart'],
'price_delta_pct': picked['priceDeltaPct'], 'price_delta_pct': picked['priceDeltaPct'],
'is_inverted': isInverted,
}, },
); );

View File

@ -11,6 +11,7 @@ import 'trading/guess_score_store.dart';
import 'trading/prospective_guess_assignments_db.dart'; import 'trading/prospective_guess_assignments_db.dart';
import 'trading/prospective_guess_selection.dart'; import 'trading/prospective_guess_selection.dart';
import 'trading/user_trading_state_db.dart'; import 'trading/user_trading_state_db.dart';
import 'trading/market_question_logger.dart';
/// Postgres access for the questions table. /// Postgres access for the questions table.
class QuestionsDb { class QuestionsDb {
@ -239,6 +240,24 @@ class QuestionsDb {
answeredAt: now, answeredAt: now,
); );
await _gradeProspectiveAnswerIfNeeded(updated); await _gradeProspectiveAnswerIfNeeded(updated);
final Map<String, dynamic> metadata = Map<String, dynamic>.from(
updated['metadata'] as Map<String, dynamic>? ?? <String, dynamic>{},
);
final String symbol = metadata['symbol'] as String? ?? '';
final bool isInverted = metadata['is_inverted'] as bool? ?? false;
final num? userResponse = _readOptionalNumeric(updated['userResponse']);
final num correctAnswer = _readNumeric(updated['correctAnswer']);
await MarketQuestionLogger.logAnswer(
questionId: questionId,
userId: assignedUserId,
symbol: symbol,
response: userResponse ?? 0,
correctAnswer: correctAnswer,
isInverted: isInverted,
now: now,
);
} }
return updated; return updated;
} }
@ -718,7 +737,7 @@ class QuestionsDb {
final Map<String, dynamic> metadata = Map<String, dynamic>.from( final Map<String, dynamic> metadata = Map<String, dynamic>.from(
question['metadata'] as Map<String, dynamic>? ?? <String, dynamic>{}, question['metadata'] as Map<String, dynamic>? ?? <String, dynamic>{},
); );
final String? spokenWord = metadata['displayed_word'] as String?; final String? spokenWord = metadata['canonical_word'] as String?;
final bool isSpelling = resolveSourceId(question) == 'spelling'; final bool isSpelling = resolveSourceId(question) == 'spelling';
final bool needsPrepare = isSpelling && final bool needsPrepare = isSpelling &&
(spokenWord == null || spokenWord.isEmpty) && (spokenWord == null || spokenWord.isEmpty) &&
@ -865,9 +884,11 @@ class QuestionsDb {
return; return;
} }
final bool isInverted = metadata['is_inverted'] as bool? ?? false;
final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( final ProspectiveAnswerGrade grade = gradeProspectiveAnswer(
userResponse: userResponse, userResponse: userResponse,
correctAnswer: _readNumeric(answeredQuestion['correctAnswer']), correctAnswer: _readNumeric(answeredQuestion['correctAnswer']),
isInverted: isInverted,
); );
final String symbol = metadata['symbol'] as String? ?? ''; final String symbol = metadata['symbol'] as String? ?? '';
final String? modifiedAtWire = answeredQuestion['modifiedAt'] as String?; final String? modifiedAtWire = answeredQuestion['modifiedAt'] as String?;
@ -905,6 +926,7 @@ class QuestionsDb {
final DateTime answeredAt = modifiedAtWire == null final DateTime answeredAt = modifiedAtWire == null
? DateTime.now().toUtc() ? DateTime.now().toUtc()
: DateTime.parse(modifiedAtWire).toUtc(); : DateTime.parse(modifiedAtWire).toUtc();
final bool isInverted = metadata['is_inverted'] as bool? ?? false;
await _connection.execute( await _connection.execute(
Sql.named( Sql.named(
@ -912,12 +934,14 @@ class QuestionsDb {
INSERT INTO market_history_prospective_answers ( INSERT INTO market_history_prospective_answers (
question_id, assigned_user_id, prospective_question_id, question_id, assigned_user_id, prospective_question_id,
symbol, older_slot_start, newer_slot_start, symbol, older_slot_start, newer_slot_start,
expected_percent_increase_price, user_slider_value, answered_at expected_percent_increase_price, user_slider_value, answered_at,
is_inverted
) )
SELECT SELECT
@question_id::uuid, @assigned_user_id, p.id, @question_id::uuid, @assigned_user_id, p.id,
p.symbol, p.older_slot_start, p.newer_slot_start, p.symbol, p.older_slot_start, p.newer_slot_start,
p.price_delta_pct, @user_slider_value, @answered_at p.price_delta_pct, @user_slider_value, @answered_at,
@is_inverted
FROM market_history_prospective_questions p FROM market_history_prospective_questions p
WHERE p.id = @prospective_question_id::uuid WHERE p.id = @prospective_question_id::uuid
ON CONFLICT (question_id) DO UPDATE ON CONFLICT (question_id) DO UPDATE
@ -928,7 +952,8 @@ class QuestionsDb {
newer_slot_start = EXCLUDED.newer_slot_start, newer_slot_start = EXCLUDED.newer_slot_start,
expected_percent_increase_price = EXCLUDED.expected_percent_increase_price, expected_percent_increase_price = EXCLUDED.expected_percent_increase_price,
user_slider_value = EXCLUDED.user_slider_value, user_slider_value = EXCLUDED.user_slider_value,
answered_at = EXCLUDED.answered_at answered_at = EXCLUDED.answered_at,
is_inverted = EXCLUDED.is_inverted
''', ''',
), ),
parameters: <String, dynamic>{ parameters: <String, dynamic>{
@ -937,6 +962,7 @@ class QuestionsDb {
'prospective_question_id': prospectiveQuestionId, 'prospective_question_id': prospectiveQuestionId,
'user_slider_value': userSliderValue, 'user_slider_value': userSliderValue,
'answered_at': answeredAt, 'answered_at': answeredAt,
'is_inverted': isInverted,
}, },
); );
} }

View File

@ -104,11 +104,14 @@ abstract final class GuessScoreStore {
COUNT(*)::int AS total, COUNT(*)::int AS total,
COUNT(*) FILTER ( COUNT(*) FILTER (
WHERE ( WHERE (
sign(user_slider_value) = sign(expected_percent_increase_price) CASE
WHEN is_inverted THEN sign(user_slider_value) = -sign(ROUND(expected_percent_increase_price))
ELSE sign(user_slider_value) = sign(ROUND(expected_percent_increase_price))
END
) )
OR ( OR (
user_slider_value = 0 user_slider_value = 0
AND expected_percent_increase_price = 0 AND ROUND(expected_percent_increase_price) = 0
) )
)::int AS correct )::int AS correct
FROM market_history_prospective_answers FROM market_history_prospective_answers
@ -133,7 +136,7 @@ abstract final class GuessScoreStore {
final Result rows = await connection.execute( final Result rows = await connection.execute(
Sql.named( Sql.named(
''' '''
SELECT user_slider_value, expected_percent_increase_price SELECT user_slider_value, expected_percent_increase_price, is_inverted
FROM market_history_prospective_answers FROM market_history_prospective_answers
WHERE assigned_user_id = @uid WHERE assigned_user_id = @uid
ORDER BY answered_at ASC ORDER BY answered_at ASC
@ -146,6 +149,7 @@ abstract final class GuessScoreStore {
final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( final ProspectiveAnswerGrade grade = gradeProspectiveAnswer(
userResponse: _readNum(row[0]), userResponse: _readNum(row[0]),
correctAnswer: _readNum(row[1]), correctAnswer: _readNum(row[1]),
isInverted: row[2] as bool? ?? false,
); );
total += grade.answerScore; total += grade.answerScore;
} }

View File

@ -0,0 +1,67 @@
import 'dart:io';
class MarketQuestionLogger {
MarketQuestionLogger._();
static const String _logFilePrefix = 'market_questions_';
static const String _logFileSuffix = '.log';
/// Log an answered market question to a date-stamped file with a 2-day rotation.
static Future<void> logAnswer({
required String questionId,
required String userId,
required String symbol,
required num response,
required num correctAnswer,
required bool isInverted,
DateTime? now,
}) async {
final DateTime time = now ?? DateTime.now().toUtc();
final String dateString = _formatDate(time);
// Construct today's and yesterday's file names
final String todayFileName = '$_logFilePrefix$dateString$_logFileSuffix';
final String yesterdayFileName = '$_logFilePrefix${_formatDate(time.subtract(const Duration(days: 1)))}$_logFileSuffix';
// 1. Write the log entry
final String logLine = '[${time.toIso8601String()}] '
'Question ID: $questionId, '
'User ID: $userId, '
'Symbol: $symbol, '
'Response: $response, '
'Correct: $correctAnswer, '
'Inverted: $isInverted\n';
final File logFile = File(todayFileName);
try {
await logFile.writeAsString(logLine, mode: FileMode.append, flush: true);
} catch (e) {
stderr.writeln('Failed to write market question log: $e');
}
// 2. Perform rotation (delete any log files that are not today's or yesterday's)
try {
final Directory currentDir = Directory.current;
final List<FileSystemEntity> entities = currentDir.listSync();
for (final FileSystemEntity entity in entities) {
if (entity is File) {
final String name = entity.uri.pathSegments.last;
if (name.startsWith(_logFilePrefix) && name.endsWith(_logFileSuffix)) {
if (name != todayFileName && name != yesterdayFileName) {
await entity.delete();
}
}
}
}
} catch (e) {
stderr.writeln('Failed to rotate market question logs: $e');
}
}
static String _formatDate(DateTime dt) {
final String y = dt.year.toString().padLeft(4, '0');
final String m = dt.month.toString().padLeft(2, '0');
final String d = dt.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
}

View File

@ -38,12 +38,15 @@ const num prospectiveAnswerFullCreditTolerance = 1;
ProspectiveAnswerGrade gradeProspectiveAnswer({ ProspectiveAnswerGrade gradeProspectiveAnswer({
required num userResponse, required num userResponse,
required num correctAnswer, required num correctAnswer,
bool isInverted = false,
}) { }) {
final num roundedCorrectAnswer = correctAnswer.round();
final num effectiveUserResponse = isInverted ? userResponse * -1 : userResponse;
final bool sameDirection = _sameDirection( final bool sameDirection = _sameDirection(
userResponse: userResponse, userResponse: effectiveUserResponse,
correctAnswer: correctAnswer, correctAnswer: roundedCorrectAnswer,
); );
final num absError = (userResponse - correctAnswer).abs(); final num absError = (effectiveUserResponse - roundedCorrectAnswer).abs();
if (!ProspectiveAnswerScoring.closenessExtraPointsEnabled) { if (!ProspectiveAnswerScoring.closenessExtraPointsEnabled) {
final num directionPoint = sameDirection ? 1 : -1; final num directionPoint = sameDirection ? 1 : -1;
@ -59,8 +62,8 @@ ProspectiveAnswerGrade gradeProspectiveAnswer({
final num directionPoint = sameDirection ? 1 : -2; final num directionPoint = sameDirection ? 1 : -2;
final num closenessPoint = sameDirection final num closenessPoint = sameDirection
? _closenessPoint( ? _closenessPoint(
userResponse: userResponse, userResponse: effectiveUserResponse,
correctAnswer: correctAnswer, correctAnswer: roundedCorrectAnswer,
) )
: 0; : 0;
return ProspectiveAnswerGrade( return ProspectiveAnswerGrade(

View File

@ -0,0 +1,4 @@
-- Track if the prospective market answer was inverted.
ALTER TABLE market_history_prospective_answers
ADD COLUMN IF NOT EXISTS is_inverted BOOLEAN NOT NULL DEFAULT FALSE;

View File

@ -2,6 +2,7 @@
library; library;
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:cyberhybridhub_server/firebase_auth.dart'; import 'package:cyberhybridhub_server/firebase_auth.dart';
import 'package:cyberhybridhub_server/handlers/market_history_admin_handler.dart'; import 'package:cyberhybridhub_server/handlers/market_history_admin_handler.dart';
@ -14,7 +15,6 @@ import 'package:cyberhybridhub_server/trading/tradable_assets_sync.dart';
import 'package:postgres/postgres.dart'; import 'package:postgres/postgres.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'package:timezone/timezone.dart' as tz;
import '../helpers/test_db.dart'; import '../helpers/test_db.dart';
@ -870,4 +870,461 @@ void main() {
jsonDecode(await response.readAsString()) as Map<String, dynamic>; jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['error'], contains('MARKET_HISTORY_SYNC_ENABLED=false')); expect(body['error'], contains('MARKET_HISTORY_SYNC_ENABLED=false'));
}); });
test('handler parsing edge cases and OPTIONS requests', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
} }
final Handler handler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(),
connection: testDb!.connection,
adminFirebaseUids: <String>{'admin-uid'},
actions: null,
portalConfig: null,
);
// 1. OPTIONS request
final Response optionsResponse = await handler(
Request(
'OPTIONS',
Uri.parse('http://localhost/v1/admin/market-history/sync-runs'),
),
);
expect(optionsResponse.statusCode, 200);
// 2. Limit parsing boundary checks (< 1 and > 200)
final Response limitLessResponse = await _get(
handler,
path: '/v1/admin/market-history/sync-runs?limit=0',
bearer: 'admin-uid',
);
expect(limitLessResponse.statusCode, 200);
final Response limitMoreResponse = await _get(
handler,
path: '/v1/admin/market-history/sync-runs?limit=300',
bearer: 'admin-uid',
);
expect(limitMoreResponse.statusCode, 200);
// 3. Kind parsing with invalid kind
final Response invalidKindResponse = await _get(
handler,
path: '/v1/admin/market-history/sync-runs?kind=invalid',
bearer: 'admin-uid',
);
expect(invalidKindResponse.statusCode, 200);
// 4. Invalid token verification returning 401
final Response unauthResponse = await _get(
handler,
path: '/v1/admin/market-history/sync-runs',
bearer: '', // will fail verification
);
expect(unauthResponse.statusCode, 401);
// 5. POST cleanup with null actions
final Response cleanupDisabledResponse = await _post(
handler,
path: '/v1/admin/market-data/cleanup?archive=invalid&windowDays=-5',
bearer: 'admin-uid',
);
expect(cleanupDisabledResponse.statusCode, 503);
// 6. POST resync with null actions
final Response resyncDisabledResponse = await _post(
handler,
path: '/v1/admin/market-data/resync',
bearer: 'admin-uid',
);
expect(resyncDisabledResponse.statusCode, 503);
// 7. GET week-coverage without asOf query parameter
final Response weekCoverageDefaultResponse = await _get(
handler,
path: '/v1/admin/market-history/week-coverage',
bearer: 'admin-uid',
);
expect(weekCoverageDefaultResponse.statusCode, 200);
// 8. GET sync-runs with valid token but non-admin UID (403)
final Response nonAdminRunsResponse = await _get(
handler,
path: '/v1/admin/market-history/sync-runs',
bearer: 'non-admin',
);
expect(nonAdminRunsResponse.statusCode, 403);
});
test('GET /v1/admin/market-history/question-log returns reversed log entries', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
final File tempLog = File('market_questions_2026-06-13.log');
if (tempLog.existsSync()) {
tempLog.deleteSync();
}
tempLog.writeAsStringSync('line 1\nline 2\n\nline 3\n');
final Handler handler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(),
connection: testDb!.connection,
adminFirebaseUids: <String>{'admin-uid'},
);
try {
final Response response = await _get(
handler,
path: '/v1/admin/market-history/question-log?asOf=2026-06-13T12:00:00Z',
bearer: 'admin-uid',
);
expect(response.statusCode, 200);
final Map<String, dynamic> body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final List<dynamic> entries = body['entries'] as List<dynamic>;
expect(entries, <String>['line 3', 'line 2', 'line 1']);
} finally {
if (tempLog.existsSync()) {
tempLog.deleteSync();
}
}
});
test('GET /v1/admin/market-history/question-log returns empty list when file does not exist', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
final File tempLog = File('market_questions_2026-06-14.log');
if (tempLog.existsSync()) {
tempLog.deleteSync();
}
final Handler handler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(),
connection: testDb!.connection,
adminFirebaseUids: <String>{'admin-uid'},
);
final Response response = await _get(
handler,
path: '/v1/admin/market-history/question-log?asOf=2026-06-14T12:00:00Z',
bearer: 'admin-uid',
);
expect(response.statusCode, 200);
final Map<String, dynamic> body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final List<dynamic> entries = body['entries'] as List<dynamic>;
expect(entries, isEmpty);
});
test('GET /v1/admin/market-history/spelling-audio-cache returns sorted mp3 files list', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
final Directory testDir = Directory('spelling_audio_cache');
if (!testDir.existsSync()) {
testDir.createSync();
}
final File fileB = File('spelling_audio_cache/banana.mp3');
final File fileA = File('spelling_audio_cache/apple.mp3');
final File fileTxt = File('spelling_audio_cache/test.txt');
fileB.writeAsStringSync('banana');
fileA.writeAsStringSync('apple');
fileTxt.writeAsStringSync('txt');
final Handler handler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(),
connection: testDb!.connection,
adminFirebaseUids: <String>{'admin-uid'},
);
try {
final Response response = await _get(
handler,
path: '/v1/admin/market-history/spelling-audio-cache',
bearer: 'admin-uid',
);
expect(response.statusCode, 200);
final Map<String, dynamic> body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final List<dynamic> files = body['files'] as List<dynamic>;
expect(files, containsAll(<String>['apple.mp3', 'banana.mp3']));
expect(files, isNot(contains('test.txt')));
} finally {
if (fileA.existsSync()) fileA.deleteSync();
if (fileB.existsSync()) fileB.deleteSync();
if (fileTxt.existsSync()) fileTxt.deleteSync();
}
});
test('GET /v1/admin/market-history/spelling-audio-cache returns empty list when cache directory does not exist', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
final Directory originalDir = Directory('spelling_audio_cache');
final Directory tempDir = Directory('spelling_audio_cache_temp_backup');
final bool originallyExists = originalDir.existsSync();
if (originallyExists) {
originalDir.renameSync(tempDir.path);
}
final Handler handler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(),
connection: testDb!.connection,
adminFirebaseUids: <String>{'admin-uid'},
);
try {
final Response response = await _get(
handler,
path: '/v1/admin/market-history/spelling-audio-cache',
bearer: 'admin-uid',
);
expect(response.statusCode, 200);
final Map<String, dynamic> body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final List<dynamic> files = body['files'] as List<dynamic>;
expect(files, isEmpty);
} finally {
if (originallyExists && tempDir.existsSync()) {
tempDir.renameSync(originalDir.path);
}
}
});
test('POST /v1/admin/market-history/spelling-audio-cache/adjust validation and ffmpeg check', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
final Handler handler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(),
connection: testDb!.connection,
adminFirebaseUids: <String>{'admin-uid'},
);
Future<Response> _postJson(Map<String, dynamic> body) async {
return await handler(
Request(
'POST',
Uri.parse('http://localhost/v1/admin/market-history/spelling-audio-cache/adjust'),
headers: <String, String>{
'Authorization': 'Bearer admin-uid',
'Content-Type': 'application/json',
},
body: jsonEncode(body),
),
);
}
// 1. Missing filename
{
final Response res = await _postJson(<String, dynamic>{'volume': 1.0});
expect(res.statusCode, 400);
final Map<String, dynamic> body = jsonDecode(await res.readAsString()) as Map<String, dynamic>;
expect(body['error'], 'Missing filename');
}
// 2. Invalid filename (traversal)
{
final Response res = await _postJson(<String, dynamic>{'filename': '../test.mp3', 'volume': 1.0});
expect(res.statusCode, 400);
final Map<String, dynamic> body = jsonDecode(await res.readAsString()) as Map<String, dynamic>;
expect(body['error'], 'Invalid filename');
}
// 3. No adjustments specified
{
final Response res = await _postJson(<String, dynamic>{'filename': 'apple.mp3'});
expect(res.statusCode, 400);
final Map<String, dynamic> body = jsonDecode(await res.readAsString()) as Map<String, dynamic>;
expect(body['error'], 'No adjustments specified.');
}
// 4. Invalid volume range
{
final Response res = await _postJson(<String, dynamic>{'filename': 'apple.mp3', 'volume': 2.5});
expect(res.statusCode, 400);
final Map<String, dynamic> body = jsonDecode(await res.readAsString()) as Map<String, dynamic>;
expect(body['error'], 'Volume must be between 0.1 and 2.0');
}
// 5. File not found
{
final Response res = await _postJson(<String, dynamic>{'filename': 'nonexistent.mp3', 'volume': 1.0});
expect(res.statusCode, 404);
final Map<String, dynamic> body = jsonDecode(await res.readAsString()) as Map<String, dynamic>;
expect(body['error'], 'Not found');
}
// 6. Check ffmpeg check
{
// Create temporary file so it bypasses file not found check
final Directory testDir = Directory('spelling_audio_cache');
if (!testDir.existsSync()) {
testDir.createSync();
}
final File tempFile = File('spelling_audio_cache/temp_test_adjust.mp3');
tempFile.writeAsStringSync('mp3 dummy data');
try {
final Response res = await _postJson(<String, dynamic>{
'filename': 'temp_test_adjust.mp3',
'volume': 1.2,
'normalize': true,
});
expect(res.statusCode, anyOf(200, 503, 500));
final Map<String, dynamic> body = jsonDecode(await res.readAsString()) as Map<String, dynamic>;
if (res.statusCode == 503) {
expect(body['error'], contains('ffmpeg is not installed on the server.'));
}
} finally {
if (tempFile.existsSync()) {
tempFile.deleteSync();
}
}
}
});
test('POST /v1/admin/market-history/spelling-audio-cache/adjust mock success/failure runs', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
final Directory testDir = Directory('spelling_audio_cache');
if (!testDir.existsSync()) {
testDir.createSync();
}
final File originalFile = File('spelling_audio_cache/test_original.mp3');
originalFile.writeAsStringSync('original audio');
try {
// 1. Success mock
int runProcessCalls = 0;
final Handler successHandler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(),
connection: testDb!.connection,
adminFirebaseUids: <String>{'admin-uid'},
runProcess: (String command, List<String> args) async {
runProcessCalls++;
if (args.contains('-version')) {
return ProcessResult(0, 0, 'ffmpeg version 4.4', '');
}
final String outputPath = args.last;
final File tempFile = File(outputPath);
tempFile.writeAsStringSync('processed audio');
return ProcessResult(0, 0, 'success', '');
},
);
final Response res1 = await successHandler(
Request(
'POST',
Uri.parse('http://localhost/v1/admin/market-history/spelling-audio-cache/adjust'),
headers: <String, String>{
'Authorization': 'Bearer admin-uid',
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'filename': 'test_original.mp3',
'volume': 1.5,
'normalize': true,
}),
),
);
expect(res1.statusCode, 200);
expect(runProcessCalls, 2);
expect(originalFile.readAsStringSync(), 'processed audio');
// Reset file content
originalFile.writeAsStringSync('original audio');
// 2. FFMPEG version check fails mock
final Handler noFfmpegHandler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(),
connection: testDb!.connection,
adminFirebaseUids: <String>{'admin-uid'},
runProcess: (String command, List<String> args) async {
if (args.contains('-version')) {
return ProcessResult(0, 127, '', 'not found');
}
return ProcessResult(0, 0, '', '');
},
);
final Response res2 = await noFfmpegHandler(
Request(
'POST',
Uri.parse('http://localhost/v1/admin/market-history/spelling-audio-cache/adjust'),
headers: <String, String>{
'Authorization': 'Bearer admin-uid',
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'filename': 'test_original.mp3',
'volume': 1.5,
}),
),
);
expect(res2.statusCode, 503);
final Map<String, dynamic> body2 = jsonDecode(await res2.readAsString()) as Map<String, dynamic>;
expect(body2['error'], contains('ffmpeg is not installed on the server.'));
// 3. FFMPEG execution fails mock
final Handler failHandler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(),
connection: testDb!.connection,
adminFirebaseUids: <String>{'admin-uid'},
runProcess: (String command, List<String> args) async {
if (args.contains('-version')) {
return ProcessResult(0, 0, 'ffmpeg version 4.4', '');
}
return ProcessResult(0, 1, '', 'ffmpeg error message');
},
);
final Response res3 = await failHandler(
Request(
'POST',
Uri.parse('http://localhost/v1/admin/market-history/spelling-audio-cache/adjust'),
headers: <String, String>{
'Authorization': 'Bearer admin-uid',
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'filename': 'test_original.mp3',
'volume': 1.5,
}),
),
);
expect(res3.statusCode, 500);
final Map<String, dynamic> body3 = jsonDecode(await res3.readAsString()) as Map<String, dynamic>;
expect(body3['error'], contains('ffmpeg error message'));
} finally {
if (originalFile.existsSync()) {
originalFile.deleteSync();
}
}
});
}

View File

@ -11,6 +11,8 @@ import 'package:cyberhybridhub_server/trading/prospective_answer_scoring.dart';
import 'package:cyberhybridhub_server/trading/prospective_guess_assignments_db.dart'; import 'package:cyberhybridhub_server/trading/prospective_guess_assignments_db.dart';
import 'package:cyberhybridhub_server/trading/prospective_guess_selection.dart'; import 'package:cyberhybridhub_server/trading/prospective_guess_selection.dart';
import 'package:cyberhybridhub_server/trading/market_history_session_slot.dart'; import 'package:cyberhybridhub_server/trading/market_history_session_slot.dart';
import 'package:cyberhybridhub_server/trading/market_question_logger.dart';
import 'dart:io';
import 'package:postgres/postgres.dart'; import 'package:postgres/postgres.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -1335,4 +1337,322 @@ void main() {
expect(queue.last['id'], second.question['id']); expect(queue.last['id'], second.question['id']);
}, },
); );
test('submitAnswer handles inverted prospective answer correct and incorrect grading', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
ProspectiveAnswerScoring.closenessExtraPointsEnabled = true;
addTearDown(() {
ProspectiveAnswerScoring.closenessExtraPointsEnabled = false;
});
const String uid = 'inverted-answer-grading-uid';
final DateTime now = DateTime.now().toUtc();
final DateTime newerSlot =
MarketHistorySessionSlot.lastCompletedSlotStart(now);
final DateTime olderSlot =
MarketHistorySessionSlot.previousSlotStart(newerSlot)!;
await testDb!.seedUser(uid);
final Result insertedProspective = await testDb!.connection.execute(
Sql.named(
'''
INSERT INTO market_history_prospective_questions (
compare_until, newer_slot_start, older_slot_start,
symbol, question_text, correct_answer,
price_delta_pct, volume_delta_pct, avg_volume_usd,
older_slot, newer_slot
) VALUES (
@compare_until, @newer_slot_start, @older_slot_start,
'AAPL', 'What was AAPL move?', 2.0,
2.0, 2.0, 123456,
'{}'::jsonb, '{}'::jsonb
)
RETURNING id
''',
),
parameters: <String, dynamic>{
'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot),
'newer_slot_start': newerSlot,
'older_slot_start': olderSlot,
},
);
final String prospectiveId = insertedProspective.first[0].toString();
final Result insertedProspective2 = await testDb!.connection.execute(
Sql.named(
'''
INSERT INTO market_history_prospective_questions (
compare_until, newer_slot_start, older_slot_start,
symbol, question_text, correct_answer,
price_delta_pct, volume_delta_pct, avg_volume_usd,
older_slot, newer_slot
) VALUES (
@compare_until, @newer_slot_start, @older_slot_start,
'MSFT', 'What was MSFT move?', 2.0,
2.0, 2.0, 123456,
'{}'::jsonb, '{}'::jsonb
)
RETURNING id
''',
),
parameters: <String, dynamic>{
'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot),
'newer_slot_start': newerSlot,
'older_slot_start': olderSlot,
},
);
final String prospectiveId2 = insertedProspective2.first[0].toString();
// 1. Correct Answer: since price_delta_pct is 2.0 (positive) and is_inverted is true,
// the user needs to answer negative. Let's answer -1.5 (negative, which is correct direction).
final Map<String, dynamic> q1 = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Guess AAPL move [Inverted]',
correctAnswer: 2.0,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
pipelineStep: 'guess_weekly_move:await_answer',
metadata: <String, dynamic>{
'prospective_question_id': prospectiveId,
'symbol': 'AAPL',
'is_inverted': true,
},
);
await testDb!.questionsDb.submitAnswer(
questionId: q1['id']! as String,
assignedUserId: uid,
userResponse: -1.5,
);
final Map<String, dynamic> score1 =
await testDb!.questionsDb.getGuessScoreSummary(uid);
// User answered -1.5. Since isInverted is true, effectiveUserResponse is 1.5.
// Direction: sign(1.5) == sign(2.0) -> correct direction (+1 point)
// Closeness: (1.5 - 2.0).abs() = 0.5 <= 1.0 (full credit tolerance) -> +1 point
// Total expected score: 2
expect(score1['answersTotal'], 1);
expect(score1['answersCorrect'], 1);
expect(score1['percentCorrect'], 100);
expect(score1['total'], 2);
// 2. Incorrect Answer: since price_delta_pct is 2.0 (positive) and is_inverted is true,
// user answers positive (+2.5). Effective answer is -2.5.
// Direction: sign(-2.5) != sign(2.0) -> incorrect direction (-2 points)
// Closeness: 0
// Total expected score delta: -2. Cumulative score: 2 - 2 = 0
final Map<String, dynamic> q2 = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Guess MSFT move [Inverted]',
correctAnswer: 2.0,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
pipelineStep: 'guess_weekly_move:await_answer',
metadata: <String, dynamic>{
'prospective_question_id': prospectiveId2,
'symbol': 'MSFT',
'is_inverted': true,
},
);
await testDb!.questionsDb.submitAnswer(
questionId: q2['id']! as String,
assignedUserId: uid,
userResponse: 2.5,
);
final Map<String, dynamic> score2 =
await testDb!.questionsDb.getGuessScoreSummary(uid);
expect(score2['answersTotal'], 2);
expect(score2['answersCorrect'], 1);
expect(score2['percentCorrect'], 50);
expect(score2['total'], 0);
});
test('MarketQuestionLogger logs answers and rotates logs correctly', () async {
final DateTime now = DateTime.utc(2026, 6, 13, 12, 0, 0);
final String dateToday = '2026-06-13';
final String dateYesterday = '2026-06-12';
final String dateBeforeYesterday = '2026-06-11';
final String dateOlder = '2026-06-10';
final File fileToday = File('market_questions_$dateToday.log');
final File fileYesterday = File('market_questions_$dateYesterday.log');
final File fileBeforeYesterday = File('market_questions_$dateBeforeYesterday.log');
final File fileOlder = File('market_questions_$dateOlder.log');
// Clean up any files that might exist beforehand
if (fileToday.existsSync()) fileToday.deleteSync();
if (fileYesterday.existsSync()) fileYesterday.deleteSync();
if (fileBeforeYesterday.existsSync()) fileBeforeYesterday.deleteSync();
if (fileOlder.existsSync()) fileOlder.deleteSync();
addTearDown(() {
if (fileToday.existsSync()) fileToday.deleteSync();
if (fileYesterday.existsSync()) fileYesterday.deleteSync();
if (fileBeforeYesterday.existsSync()) fileBeforeYesterday.deleteSync();
if (fileOlder.existsSync()) fileOlder.deleteSync();
});
// Create dummy files for yesterday, day before yesterday, and older
fileYesterday.writeAsStringSync('yesterday content\n');
fileBeforeYesterday.writeAsStringSync('before yesterday content\n');
fileOlder.writeAsStringSync('older content\n');
// Call logger to log a new answer for today
await MarketQuestionLogger.logAnswer(
questionId: 'q-123',
userId: 'user-456',
symbol: 'TSLA',
response: 3.5,
correctAnswer: 4.0,
isInverted: false,
now: now,
);
// Verify today's file was created and contains correct logs
expect(fileToday.existsSync(), isTrue);
final String todayContent = fileToday.readAsStringSync();
expect(todayContent, contains('Question ID: q-123'));
expect(todayContent, contains('User ID: user-456'));
expect(todayContent, contains('Symbol: TSLA'));
expect(todayContent, contains('Response: 3.5'));
expect(todayContent, contains('Correct: 4.0'));
expect(todayContent, contains('Inverted: false'));
// Verify yesterday's file still exists
expect(fileYesterday.existsSync(), isTrue);
// Verify day before yesterday's file and older files were deleted
expect(fileBeforeYesterday.existsSync(), isFalse);
expect(fileOlder.existsSync(), isFalse);
});
test('submitAnswer rounds correct answer to nearest integer and handles strict (-0.5, 0.5) interval', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
ProspectiveAnswerScoring.closenessExtraPointsEnabled = true;
addTearDown(() {
ProspectiveAnswerScoring.closenessExtraPointsEnabled = false;
});
const String uid = 'rounding-grading-uid';
final DateTime now = DateTime.now().toUtc();
final DateTime newerSlot =
MarketHistorySessionSlot.lastCompletedSlotStart(now);
final DateTime olderSlot =
MarketHistorySessionSlot.previousSlotStart(newerSlot)!;
await testDb!.seedUser(uid);
Future<String> seedProspective(String symbol, num delta) async {
final Result inserted = await testDb!.connection.execute(
Sql.named(
'''
INSERT INTO market_history_prospective_questions (
compare_until, newer_slot_start, older_slot_start,
symbol, question_text, correct_answer,
price_delta_pct, volume_delta_pct, avg_volume_usd,
older_slot, newer_slot
) VALUES (
@compare_until, @newer_slot_start, @older_slot_start,
@symbol, 'test text', @correct,
@correct, 1.0, 100,
'{}'::jsonb, '{}'::jsonb
)
RETURNING id
''',
),
parameters: <String, dynamic>{
'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot),
'newer_slot_start': newerSlot,
'older_slot_start': olderSlot,
'symbol': symbol,
'correct': delta,
},
);
return inserted.first[0].toString();
}
// AAPL: correct answer 0.3 (should round to 0). User response 0.
// Result should be correct.
final String p1 = await seedProspective('AAPL', 0.3);
final Map<String, dynamic> q1 = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Guess AAPL',
correctAnswer: 0.3,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
pipelineStep: 'guess_weekly_move:await_answer',
metadata: <String, dynamic>{
'prospective_question_id': p1,
'symbol': 'AAPL',
},
);
await testDb!.questionsDb.submitAnswer(
questionId: q1['id']! as String,
assignedUserId: uid,
userResponse: 0,
);
// MSFT: correct answer -0.3 (should round to 0). User response 0.
// Result should be correct.
final String p2 = await seedProspective('MSFT', -0.3);
final Map<String, dynamic> q2 = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Guess MSFT',
correctAnswer: -0.3,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
pipelineStep: 'guess_weekly_move:await_answer',
metadata: <String, dynamic>{
'prospective_question_id': p2,
'symbol': 'MSFT',
},
);
await testDb!.questionsDb.submitAnswer(
questionId: q2['id']! as String,
assignedUserId: uid,
userResponse: 0,
);
// GOOG: correct answer 0.5 (should round to 1). User response 0.
// Result should be incorrect.
final String p3 = await seedProspective('GOOG', 0.5);
final Map<String, dynamic> q3 = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Guess GOOG',
correctAnswer: 0.5,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
pipelineStep: 'guess_weekly_move:await_answer',
metadata: <String, dynamic>{
'prospective_question_id': p3,
'symbol': 'GOOG',
},
);
await testDb!.questionsDb.submitAnswer(
questionId: q3['id']! as String,
assignedUserId: uid,
userResponse: 0,
);
final Map<String, dynamic> score =
await testDb!.questionsDb.getGuessScoreSummary(uid);
// AAPL (correct: 0.3 -> 0, user: 0) -> correct
// MSFT (correct: -0.3 -> 0, user: 0) -> correct
// GOOG (correct: 0.5 -> 1, user: 0) -> incorrect (sameDirection false, score delta -2)
// Score expected: 2 + 2 - 2 = 2
expect(score['answersTotal'], 3);
expect(score['answersCorrect'], 2);
expect(score['total'], 2);
});
} }

View File

@ -0,0 +1,209 @@
import 'dart:io';
import 'package:cyberhybridhub_server/handlers/spelling_audio_handler.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shelf/shelf.dart';
import 'package:test/test.dart';
void main() {
group('SpellingAudioHandler tests', () {
late Directory tempCacheDir;
late File logFile;
setUp(() {
tempCacheDir = Directory('spelling_audio_cache');
if (tempCacheDir.existsSync()) {
try {
tempCacheDir.deleteSync(recursive: true);
} catch (_) {}
}
logFile = File('spelling_audio_errors.log');
if (logFile.existsSync()) {
try {
logFile.deleteSync();
} catch (_) {}
}
});
tearDown(() {
if (tempCacheDir.existsSync()) {
try {
tempCacheDir.deleteSync(recursive: true);
} catch (_) {}
}
if (logFile.existsSync()) {
try {
logFile.deleteSync();
} catch (_) {}
}
});
test('serves cached file if exists', () async {
tempCacheDir.createSync(recursive: true);
final File localFile = File('${tempCacheDir.path}/hello.mp3');
localFile.writeAsBytesSync([1, 2, 3, 4]);
final handler = spellingAudioHandler();
final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/hello.mp3'));
final response = await handler(request);
expect(response.statusCode, 200);
expect(response.headers['Content-Type'], 'audio/mpeg');
expect(await response.read().first, [1, 2, 3, 4]);
});
test('downloads, caches and serves file on-demand if not cached', () async {
final mockClient = MockClient((request) async {
if (request.url.path.contains('/entries/en/hello')) {
const jsonResponse = '''
[
{
"word": "hello",
"phonetics": [
{
"audio": "https://example.com/hello.mp3"
}
]
}
]
''';
return http.Response(jsonResponse, 200);
}
if (request.url.toString() == 'https://example.com/hello.mp3') {
return http.Response.bytes([5, 6, 7, 8], 200);
}
return http.Response('Not found', 404);
});
final handler = spellingAudioHandler(httpClient: mockClient);
final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/hello.mp3'));
final response = await handler(request);
expect(response.statusCode, 200);
expect(response.headers['Content-Type'], 'audio/mpeg');
expect(await response.read().first, [5, 6, 7, 8]);
// Check if file is saved locally
final File localFile = File('${tempCacheDir.path}/hello.mp3');
expect(localFile.existsSync(), isTrue);
expect(localFile.readAsBytesSync(), [5, 6, 7, 8]);
});
test('returns 404 if dictionary api fails or has no audio', () async {
final mockClient = MockClient((request) async {
return http.Response('[]', 200);
});
final handler = spellingAudioHandler(httpClient: mockClient);
final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/nonexistent.mp3'));
final response = await handler(request);
expect(response.statusCode, 404);
});
test('falls back to Merriam-Webster Collegiate API if dictionaryapi.dev returns empty/fails', () async {
final mockClient = MockClient((request) async {
if (request.url.path.contains('/entries/en/apple')) {
return http.Response('[]', 200); // dictionaryapi.dev returns no entries
}
if (request.url.path.contains('/references/collegiate/json/apple')) {
expect(request.url.queryParameters['key'], 'TEST_KEY');
const mwResponse = '''
[
{
"hwi": {
"prs": [
{
"sound": {
"audio": "apple"
}
}
]
}
}
]
''';
return http.Response(mwResponse, 200);
}
if (request.url.toString() == 'https://media.merriam-webster.com/audio/prons/en/us/mp3/a/apple.mp3') {
return http.Response.bytes([9, 10, 11, 12], 200);
}
return http.Response('Not found', 404);
});
final handler = spellingAudioHandler(merriamWebsterApiKey: 'TEST_KEY', httpClient: mockClient);
final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/apple.mp3'));
final response = await handler(request);
expect(response.statusCode, 200);
expect(response.headers['Content-Type'], 'audio/mpeg');
expect(await response.read().first, [9, 10, 11, 12]);
final File localFile = File('${tempCacheDir.path}/apple.mp3');
expect(localFile.existsSync(), isTrue);
expect(localFile.readAsBytesSync(), [9, 10, 11, 12]);
});
test('logs failures to spelling_audio_errors.log when APIs return non-200 or empty data', () async {
final mockClient = MockClient((request) async {
if (request.url.path.contains('/entries/en/failedword')) {
return http.Response('Server Error', 500);
}
return http.Response('Not found', 404);
});
final handler = spellingAudioHandler(httpClient: mockClient);
final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/failedword.mp3'));
final response = await handler(request);
expect(response.statusCode, 404);
expect(logFile.existsSync(), isTrue);
final String logContents = logFile.readAsStringSync();
expect(logContents, contains('=== Audio Fetch Failure ==='));
expect(logContents, contains('Word: failedword'));
expect(logContents, contains('API: dictionaryapi.dev'));
expect(logContents, contains('Status Code: 500'));
expect(logContents, contains('Response Body: Server Error'));
});
test('falls back to Google Cloud TTS if both dictionaries return empty/fails', () async {
final mockClient = MockClient((request) async {
if (request.url.path.contains('/entries/en/orange')) {
return http.Response('[]', 200);
}
if (request.url.path.contains('/references/collegiate/json/orange')) {
return http.Response('[]', 200);
}
if (request.url.path.contains('/text:synthesize')) {
expect(request.url.queryParameters['key'], 'GOOGLE_KEY');
expect(request.body, contains('"text":"orange"'));
const gcResponse = '''
{
"audioContent": "ZGVtb2J5dGVz"
}
''';
return http.Response(gcResponse, 200);
}
return http.Response('Not found', 404);
});
final handler = spellingAudioHandler(
merriamWebsterApiKey: 'TEST_KEY',
googleCloudApiKey: 'GOOGLE_KEY',
httpClient: mockClient,
);
final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/orange.mp3'));
final response = await handler(request);
expect(response.statusCode, 200);
expect(response.headers['Content-Type'], 'audio/mpeg');
// base64Decode("ZGVtb2J5dGVz") -> "demobytes" -> [100, 101, 109, 111, 98, 121, 116, 101, 115]
expect(await response.read().first, [100, 101, 109, 111, 98, 121, 116, 101, 115]);
final File localFile = File('${tempCacheDir.path}/orange.mp3');
expect(localFile.existsSync(), isTrue);
expect(localFile.readAsBytesSync(), [100, 101, 109, 111, 98, 121, 116, 101, 115]);
});
});
}

View File

@ -0,0 +1,125 @@
import 'package:cyberhybridhub/admin/models/market_history_admin_config.dart';
import 'package:cyberhybridhub/admin/models/market_history_week_coverage.dart';
import 'package:cyberhybridhub/admin/models/question_audit_asset.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('MarketHistoryAdminConfig', () {
test('parses from null JSON', () {
final config = MarketHistoryAdminConfig.fromJson(null);
expect(config.archiveEnabled, isFalse);
expect(config.windowDays, 5);
expect(config.retentionDays, 5);
expect(config.syncEnabled, isFalse);
});
test('parses from valid JSON', () {
final config = MarketHistoryAdminConfig.fromJson(<String, dynamic>{
'archiveEnabled': true,
'windowDays': 10,
'retentionDays': 30,
'syncEnabled': true,
});
expect(config.archiveEnabled, isTrue);
expect(config.windowDays, 10);
expect(config.retentionDays, 30);
expect(config.syncEnabled, isTrue);
});
});
group('QuestionAuditReport', () {
test('parses from JSON with custom slot starts', () {
final report = QuestionAuditReport.fromJson(<String, dynamic>{
'compareUntil': '2026-05-30T16:00:00Z',
'newerSlotStart': '2026-05-30T12:45:00Z',
'olderSlotStart': '2026-05-30T09:30:00Z',
'windowDays': 5,
'canStepOlder': true,
'canStepNewer': false,
'stepOlderCompareUntil': '2026-05-25T16:00:00Z',
'assets': [
{
'symbol': 'AAPL',
'priceDelta': 1.25,
'volumeDelta': 5000,
'olderSlot': {
'asOf': '2026-05-30T09:30:00Z',
'open': 150.0,
'high': 152.0,
'low': 149.0,
'close': 151.0,
'avgPrice': 150.5,
'volume': 10000,
'volumeUsd': 1505000,
},
'newerSlot': {
'asOf': '2026-05-30T12:45:00Z',
'avgPrice': 152.0,
'volume': 12000,
}
}
]
});
expect(report.compareUntil, DateTime.parse('2026-05-30T16:00:00Z').toUtc());
expect(report.newerSlotStart, DateTime.parse('2026-05-30T12:45:00Z').toUtc());
expect(report.olderSlotStart, DateTime.parse('2026-05-30T09:30:00Z').toUtc());
expect(report.canStepOlder, isTrue);
expect(report.canStepNewer, isFalse);
expect(report.stepOlderCompareUntil, DateTime.parse('2026-05-25T16:00:00Z').toUtc());
expect(report.stepNewerCompareUntil, isNull);
expect(report.assets.length, 1);
final asset = report.assets.first;
expect(asset.symbol, 'AAPL');
expect(asset.priceDelta, 1.25);
expect(asset.olderSlot.open, 150.0);
expect(asset.olderSlot.volumeUsd, 1505000);
expect(asset.newerSlot.volumeUsd, 12000 * 152.0); // fallback calculation
});
test('parses from JSON with default/missing slot starts', () {
final report = QuestionAuditReport.fromJson(<String, dynamic>{
'asOf': '2026-05-30T16:00:00Z',
'windowDays': 5,
});
expect(report.compareUntil, DateTime.parse('2026-05-30T16:00:00Z').toUtc());
expect(report.newerSlotStart, report.compareUntil.subtract(const Duration(hours: 3, minutes: 15)));
expect(report.olderSlotStart, report.compareUntil.subtract(const Duration(hours: 6, minutes: 30)));
});
});
group('MarketHistoryWeekCoverageReport', () {
test('parses from valid JSON', () {
final report = MarketHistoryWeekCoverageReport.fromJson(<String, dynamic>{
'asOf': '2026-05-30T15:00:00Z',
'windowDays': 5,
'slotsPerDay': 6,
'symbolCount': 10,
'isConsistent': true,
'days': [
{
'date': '2026-05-30',
'slotsPerDay': 6,
'completedSlots': 6,
'fullySyncedSlots': 6,
'slots': [
{
'slotStart': '2026-05-30T00:00:00Z',
'completed': true,
'fullySynced': true,
'syncedSymbolCount': 10,
'expectedSymbolCount': 10,
}
]
}
]
});
expect(report.asOf, DateTime.parse('2026-05-30T15:00:00Z').toUtc());
expect(report.days.length, 1);
expect(report.days.first.date, DateTime.parse('2026-05-30T00:00:00Z').toUtc());
expect(report.days.first.slots.first.completed, isTrue);
});
});
}

View File

@ -115,14 +115,19 @@ Future<void> _pumpScreen(
} }
class _FakeCoverageApi extends MarketHistoryAdminApi { class _FakeCoverageApi extends MarketHistoryAdminApi {
_FakeCoverageApi(this.report) _FakeCoverageApi(
: super( this.report, {
this.audioFiles,
this.audioError = false,
}) : super(
baseUrl: 'http://localhost:3000', baseUrl: 'http://localhost:3000',
tokenProvider: () async => 'token', tokenProvider: () async => 'token',
client: MockClient((http.Request request) async => http.Response('', 500)), client: MockClient((http.Request request) async => http.Response('', 500)),
); );
final MarketHistoryWeekCoverageReport report; final MarketHistoryWeekCoverageReport report;
final List<String>? audioFiles;
final bool audioError;
@override @override
Future<MarketHistoryWeekCoverageReport> fetchWeekCoverage() async => report; Future<MarketHistoryWeekCoverageReport> fetchWeekCoverage() async => report;
@ -138,6 +143,45 @@ class _FakeCoverageApi extends MarketHistoryAdminApi {
canStepNewer: false, canStepNewer: false,
assets: const <QuestionAuditAsset>[], assets: const <QuestionAuditAsset>[],
); );
@override
Future<List<String>> fetchMarketQuestionLog({DateTime? asOf}) async {
return <String>[
'[2026-05-27T10:00:00.000Z] Question ID: q100, User ID: u1, Symbol: AAPL, Response: 100.0, Correct: 100.0, Inverted: false',
'[2026-05-27T09:00:00.000Z] Question ID: q99, User ID: u2, Symbol: TSLA, Response: 150.0, Correct: 200.0, Inverted: true',
'[2026-05-27T08:00:00.000Z] Question ID: q98, User ID: u3, Symbol: MSFT, Response: -50.0, Correct: 100.0, Inverted: false',
'[2026-05-27T07:00:00.000Z] Question ID: q97, User ID: u4, Symbol: GOOG, Response: 50.0, Correct: -100.0, Inverted: true',
'Raw unparseable console log line fallback check',
];
}
@override
Future<List<String>> fetchSpellingAudioCache() async {
if (audioError) {
throw Exception('Server error loading audio cache');
}
return audioFiles ?? <String>[
'apple.mp3',
'banana.mp3',
];
}
bool adjustSpellingAudioCalled = false;
String? adjustFilename;
double? adjustVolume;
bool? adjustNormalize;
@override
Future<void> adjustSpellingAudio({
required String filename,
double? volume,
bool? normalize,
}) async {
adjustSpellingAudioCalled = true;
adjustFilename = filename;
adjustVolume = volume;
adjustNormalize = normalize;
}
} }
MarketHistoryWeekCoverageReport _sampleWeekReport() { MarketHistoryWeekCoverageReport _sampleWeekReport() {
@ -542,6 +586,228 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('week-coverage-dialog')), findsNothing); expect(find.byKey(const Key('week-coverage-dialog')), findsNothing);
}); });
testWidgets('renders dropdown with market history log selected by default and updates on change', (
WidgetTester tester,
) async {
final _FakeController controller = _FakeController(<SyncRunLogRepositoryState>[
const SyncRunLogRepositoryState(
pinned: <SyncRunEvent>[],
history: <SyncRunEvent>[],
nextBefore: null,
isLoading: false,
errorMessage: null,
),
]);
await _pumpScreen(tester, controller);
final Finder dropdownFinder = find.byKey(const Key('admin-portal-dropdown'));
expect(dropdownFinder, findsOneWidget);
final DropdownButton<String> dropdown = tester.widget<DropdownButton<String>>(dropdownFinder);
expect(dropdown.value, 'market_history_log');
});
testWidgets('selects Market Question Log option and displays log entries', (
WidgetTester tester,
) async {
final _FakeController controller = _FakeController(<SyncRunLogRepositoryState>[
const SyncRunLogRepositoryState(
pinned: <SyncRunEvent>[],
history: <SyncRunEvent>[],
nextBefore: null,
isLoading: false,
errorMessage: null,
),
]);
await _pumpScreen(
tester,
controller,
coverageApi: _FakeCoverageApi(_sampleWeekReport()),
);
expect(find.byKey(const Key('admin-portal-dropdown')), findsOneWidget);
await tester.tap(find.byKey(const Key('admin-portal-dropdown')));
await tester.pumpAndSettle();
await tester.tap(find.text('Market Question Log').last);
await tester.pumpAndSettle();
final DropdownButton<String> dropdown = tester.widget<DropdownButton<String>>(
find.byKey(const Key('admin-portal-dropdown')),
);
expect(dropdown.value, 'market_question_log');
expect(find.text('User: u1 • Q: q100'), findsOneWidget);
expect(find.text('User: u2 • Q: q99'), findsOneWidget);
expect(find.text('User: u3 • Q: q98'), findsOneWidget);
expect(find.text('User: u4 • Q: q97'), findsOneWidget);
expect(find.text('Raw unparseable console log line fallback check'), findsOneWidget);
expect(find.text('AAPL'), findsOneWidget);
expect(find.text('TSLA'), findsOneWidget);
expect(find.text('MSFT'), findsOneWidget);
expect(find.text('GOOG'), findsOneWidget);
expect(find.text('INVERTED'), findsNWidgets(2));
expect(find.text('Inverted: false'), findsNWidgets(2));
expect(find.text('Inverted: true'), findsNWidgets(2));
expect(find.text('Points: +1'), findsNWidgets(2));
expect(find.text('Points: -1'), findsNWidgets(2));
await tester.tap(find.byKey(const Key('question-log-refresh-button')));
await tester.pumpAndSettle();
});
testWidgets('selects Spelling Audio Cache option, lists files and triggers play', (
WidgetTester tester,
) async {
final _FakeController controller = _FakeController(<SyncRunLogRepositoryState>[
const SyncRunLogRepositoryState(
pinned: <SyncRunEvent>[],
history: <SyncRunEvent>[],
nextBefore: null,
isLoading: false,
errorMessage: null,
),
]);
await _pumpScreen(
tester,
controller,
coverageApi: _FakeCoverageApi(_sampleWeekReport()),
);
await tester.tap(find.byKey(const Key('admin-portal-dropdown')));
await tester.pumpAndSettle();
await tester.tap(find.text('Spelling Audio Cache').last);
await tester.pumpAndSettle();
final DropdownButton<String> dropdown = tester.widget<DropdownButton<String>>(
find.byKey(const Key('admin-portal-dropdown')),
);
expect(dropdown.value, 'spelling_audio_cache');
expect(find.text('apple.mp3'), findsOneWidget);
expect(find.text('banana.mp3'), findsOneWidget);
final Finder playButton = find.byKey(const Key('play-button-apple.mp3'));
expect(playButton, findsOneWidget);
await tester.tap(playButton);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('audio-cache-refresh-button')));
await tester.pumpAndSettle();
});
testWidgets('selects Spelling Audio Cache option and handles empty state', (
WidgetTester tester,
) async {
final _FakeController controller = _FakeController(<SyncRunLogRepositoryState>[
const SyncRunLogRepositoryState(
pinned: <SyncRunEvent>[],
history: <SyncRunEvent>[],
nextBefore: null,
isLoading: false,
errorMessage: null,
),
]);
await _pumpScreen(
tester,
controller,
coverageApi: _FakeCoverageApi(
_sampleWeekReport(),
audioFiles: <String>[],
),
);
await tester.tap(find.byKey(const Key('admin-portal-dropdown')));
await tester.pumpAndSettle();
await tester.tap(find.text('Spelling Audio Cache').last);
await tester.pumpAndSettle();
expect(find.byKey(const Key('spelling-audio-cache-empty-state')), findsOneWidget);
});
testWidgets('selects Spelling Audio Cache option and handles error state', (
WidgetTester tester,
) async {
final _FakeController controller = _FakeController(<SyncRunLogRepositoryState>[
const SyncRunLogRepositoryState(
pinned: <SyncRunEvent>[],
history: <SyncRunEvent>[],
nextBefore: null,
isLoading: false,
errorMessage: null,
),
]);
await _pumpScreen(
tester,
controller,
coverageApi: _FakeCoverageApi(
_sampleWeekReport(),
audioError: true,
),
);
await tester.tap(find.byKey(const Key('admin-portal-dropdown')));
await tester.pumpAndSettle();
await tester.tap(find.text('Spelling Audio Cache').last);
await tester.pumpAndSettle();
expect(find.byKey(const Key('spelling-audio-cache-error-state')), findsOneWidget);
});
testWidgets('selects Spelling Audio Cache option and adjusts levels of a file', (
WidgetTester tester,
) async {
final _FakeController controller = _FakeController(<SyncRunLogRepositoryState>[
const SyncRunLogRepositoryState(
pinned: <SyncRunEvent>[],
history: <SyncRunEvent>[],
nextBefore: null,
isLoading: false,
errorMessage: null,
),
]);
final _FakeCoverageApi api = _FakeCoverageApi(_sampleWeekReport());
await _pumpScreen(
tester,
controller,
coverageApi: api,
);
await tester.tap(find.byKey(const Key('admin-portal-dropdown')));
await tester.pumpAndSettle();
await tester.tap(find.text('Spelling Audio Cache').last);
await tester.pumpAndSettle();
expect(find.byKey(const Key('volume-slider-apple.mp3')), findsOneWidget);
expect(find.byKey(const Key('normalize-checkbox-apple.mp3')), findsOneWidget);
expect(find.byKey(const Key('save-adjust-apple.mp3')), findsOneWidget);
await tester.tap(find.byKey(const Key('normalize-checkbox-apple.mp3')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('save-adjust-apple.mp3')));
await tester.pumpAndSettle();
expect(api.adjustSpellingAudioCalled, isTrue);
expect(api.adjustFilename, 'apple.mp3');
expect(api.adjustNormalize, isTrue);
expect(api.adjustVolume, 1.0);
});
} }
class _PendingController implements SyncRunLogController { class _PendingController implements SyncRunLogController {

View File

@ -255,4 +255,77 @@ void main() {
expect(report.assets.single.newerSlot.close, 12); expect(report.assets.single.newerSlot.close, 12);
expect(report.assets.single.newerSlot.volumeUsd, 1875); expect(report.assets.single.newerSlot.volumeUsd, 1875);
}); });
test('fetchMarketQuestionLog parses log entries', () async {
final MarketHistoryAdminApi api = MarketHistoryAdminApi(
baseUrl: 'http://localhost:3000',
tokenProvider: () async => 'test-token',
client: MockClient((http.Request request) async {
expect(request.url.path, '/v1/admin/market-history/question-log');
expect(request.url.queryParameters['asOf'], '2026-05-30T12:00:00.000Z');
return http.Response(
jsonEncode(<String, dynamic>{
'entries': <String>['line 2', 'line 1'],
}),
200,
headers: <String, String>{'content-type': 'application/json'},
);
}),
);
final List<String> entries = await api.fetchMarketQuestionLog(
asOf: DateTime.utc(2026, 5, 30, 12),
);
expect(entries, <String>['line 2', 'line 1']);
});
test('fetchSpellingAudioCache parses files list', () async {
final MarketHistoryAdminApi api = MarketHistoryAdminApi(
baseUrl: 'http://localhost:3000',
tokenProvider: () async => 'test-token',
client: MockClient((http.Request request) async {
expect(request.url.path, '/v1/admin/market-history/spelling-audio-cache');
return http.Response(
jsonEncode(<String, dynamic>{
'files': <String>['apple.mp3', 'banana.mp3'],
}),
200,
headers: <String, String>{'content-type': 'application/json'},
);
}),
);
final List<String> files = await api.fetchSpellingAudioCache();
expect(files, <String>['apple.mp3', 'banana.mp3']);
});
test('adjustSpellingAudio posts correct payload and handles success', () async {
late Uri seenUri;
late String seenBody;
final MarketHistoryAdminApi api = MarketHistoryAdminApi(
baseUrl: 'http://localhost:3000',
tokenProvider: () async => 'test-token',
client: MockClient((http.Request request) async {
seenUri = request.url;
seenBody = request.body;
return http.Response(
jsonEncode(<String, dynamic>{'success': true}),
200,
headers: <String, String>{'content-type': 'application/json'},
);
}),
);
await api.adjustSpellingAudio(
filename: 'apple.mp3',
volume: 1.5,
normalize: true,
);
expect(seenUri.path, '/v1/admin/market-history/spelling-audio-cache/adjust');
final Map<String, dynamic> decoded = jsonDecode(seenBody) as Map<String, dynamic>;
expect(decoded['filename'], 'apple.mp3');
expect(decoded['volume'], 1.5);
expect(decoded['normalize'], true);
});
} }

View File

@ -38,6 +38,9 @@ void main() {
), ),
); );
await tester.tap(find.byKey(const Key('question-source-settings-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('topbar-cumulative-score')), findsOneWidget); expect(find.byKey(const Key('topbar-cumulative-score')), findsOneWidget);
expect(find.text('Score: 3.50'), findsOneWidget); expect(find.text('Score: 3.50'), findsOneWidget);
@ -80,6 +83,9 @@ void main() {
), ),
); );
await tester.tap(find.byKey(const Key('question-source-settings-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('topbar-cumulative-score'))); await tester.tap(find.byKey(const Key('topbar-cumulative-score')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -91,7 +97,12 @@ void main() {
findsOneWidget, findsOneWidget,
); );
await tester.tap(find.text('Cancel')); await tester.tap(
find.descendant(
of: find.byKey(const Key('guess-score-reset-confirm-dialog')),
matching: find.text('Cancel'),
),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(

View File

@ -102,7 +102,10 @@ void main() {
await tester.tap(find.text('Open')); await tester.tap(find.text('Open'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('question-source-edit-spelling'))); final Finder editSpelling = find.byKey(const Key('question-source-edit-spelling'));
await tester.ensureVisible(editSpelling);
await tester.pumpAndSettle();
await tester.tap(editSpelling);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Spelling words page'), findsOneWidget); expect(find.text('Spelling words page'), findsOneWidget);
@ -247,14 +250,19 @@ void main() {
expect(tester.widget<TextField>(find.byKey(const Key('question-source-input-spelling'))).controller?.text, '30'); expect(tester.widget<TextField>(find.byKey(const Key('question-source-input-spelling'))).controller?.text, '30');
// 4. Manually disable spelling. // 4. Manually disable spelling.
await tester.tap(find.byKey(const Key('question-source-enabled-spelling'))); final Finder enabledSpelling = find.byKey(const Key('question-source-enabled-spelling'));
await tester.ensureVisible(enabledSpelling);
await tester.pumpAndSettle();
await tester.tap(enabledSpelling);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify spelling is disabled and market_history automatically becomes 100%. // Verify spelling is disabled and market_history automatically becomes 100%.
expect(tester.widget<TextField>(marketInput).controller?.text, '100'); expect(tester.widget<TextField>(marketInput).controller?.text, '100');
// 5. Manually enable spelling. // 5. Manually enable spelling.
await tester.tap(find.byKey(const Key('question-source-enabled-spelling'))); await tester.ensureVisible(enabledSpelling);
await tester.pumpAndSettle();
await tester.tap(enabledSpelling);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify spelling gets enabled and shares the ratio evenly (50/50). // Verify spelling gets enabled and shares the ratio evenly (50/50).

View File

@ -0,0 +1,112 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
String? extractAudioUrl(String responseBody) {
try {
final List<dynamic> entries = jsonDecode(responseBody) as List<dynamic>;
for (final entry in entries) {
if (entry is Map<String, dynamic>) {
final phonetics = entry['phonetics'];
if (phonetics is List<dynamic>) {
for (final phonetic in phonetics) {
if (phonetic is Map<String, dynamic>) {
final String? audio = phonetic['audio'] as String?;
if (audio != null && audio.isNotEmpty) {
if (audio.startsWith('//')) {
return 'https:$audio';
}
return audio;
}
}
}
}
}
}
} catch (_) {}
return null;
}
void main() {
group('Dictionary Audio Parser Tests', () {
test('extracts standard audio URL', () {
const jsonStr = '''
[
{
"word": "hello",
"phonetics": [
{
"text": "/həˈləʊ/",
"audio": "https://api.dictionaryapi.dev/media/pronunciations/en/hello-uk.mp3"
}
]
}
]
''';
expect(
extractAudioUrl(jsonStr),
'https://api.dictionaryapi.dev/media/pronunciations/en/hello-uk.mp3',
);
});
test('handles protocol-relative URL starting with //', () {
const jsonStr = '''
[
{
"word": "test",
"phonetics": [
{
"audio": "//ssl.gstatic.com/dictionary/static/sounds/20201201/test--us.mp3"
}
]
}
]
''';
expect(
extractAudioUrl(jsonStr),
'https://ssl.gstatic.com/dictionary/static/sounds/20201201/test--us.mp3',
);
});
test('skips empty audio fields and selects first valid audio URL', () {
const jsonStr = '''
[
{
"word": "multiple",
"phonetics": [
{
"text": "/mʌl.tɪ.pl̩/",
"audio": ""
},
{
"audio": "https://example.com/valid.mp3"
}
]
}
]
''';
expect(
extractAudioUrl(jsonStr),
'https://example.com/valid.mp3',
);
});
test('returns null for empty json or missing audio', () {
expect(extractAudioUrl('[]'), isNull);
expect(extractAudioUrl('invalid json'), isNull);
const missingAudioJson = '''
[
{
"word": "missing",
"phonetics": [
{
"text": "/mɪs.ɪŋ/"
}
]
}
]
''';
expect(extractAudioUrl(missingAudioJson), isNull);
});
});
}

View File

@ -33,7 +33,7 @@ void main() {
home: Scaffold( home: Scaffold(
body: QuestionPresentationRouter( body: QuestionPresentationRouter(
question: question, question: question,
onAnswer: (_) async {}, onAnswer: (_) async => null,
onDefer: () async {}, onDefer: () async {},
), ),
), ),
@ -62,7 +62,7 @@ void main() {
home: Scaffold( home: Scaffold(
body: QuestionPresentationRouter( body: QuestionPresentationRouter(
question: question, question: question,
onAnswer: (_) async {}, onAnswer: (_) async => null,
onDefer: () async {}, onDefer: () async {},
), ),
), ),
@ -93,7 +93,7 @@ void main() {
home: Scaffold( home: Scaffold(
body: QuestionPresentationRouter( body: QuestionPresentationRouter(
question: question, question: question,
onAnswer: (_) async {}, onAnswer: (_) async => null,
onAnswerText: (_) async {}, onAnswerText: (_) async {},
onDefer: () async {}, onDefer: () async {},
), ),

View File

@ -0,0 +1,131 @@
import 'package:cyberhybridhub/services/questions_hub_service.dart';
import 'package:cyberhybridhub/widgets/swipe_question_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
setUp(() {
// Ensure the panel is marked open so the tile captures keyboard events.
QuestionsHubService.instance.questionPanelOpen.value = true;
});
tearDown(() {
QuestionsHubService.instance.questionPanelOpen.value = false;
});
testWidgets('allows swiping right at zero to submit answer', (tester) async {
num? submittedValue;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SwipeQuestionTile(
questionId: 'test-q-1',
onSwipeRight: (val) async {
submittedValue = val;
return null;
},
onSwipeLeft: () async {},
),
),
),
);
// Swipe right starting from the center of the widget.
// Swipe distance needs to exceed _swipeThreshold (96).
final Finder tileFinder = find.byType(SwipeQuestionTile);
final Offset start = tester.getCenter(tileFinder);
// Drag right by 200 pixels.
await tester.dragFrom(start, const Offset(200, 0));
await tester.pumpAndSettle();
expect(submittedValue, 0);
});
testWidgets('allows swiping left at zero to defer question', (tester) async {
bool deferred = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SwipeQuestionTile(
questionId: 'test-q-2',
onSwipeRight: (_) async => null,
onSwipeLeft: () async {
deferred = true;
},
),
),
),
);
// Swipe left starting from the center of the widget.
final Finder tileFinder = find.byType(SwipeQuestionTile);
final Offset start = tester.getCenter(tileFinder);
// Drag left by 200 pixels.
await tester.dragFrom(start, const Offset(-200, 0));
await tester.pumpAndSettle();
expect(deferred, true);
});
testWidgets('allows right arrow key holding/press at zero to submit answer', (tester) async {
num? submittedValue;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SwipeQuestionTile(
questionId: 'test-q-3',
onSwipeRight: (val) async {
submittedValue = val;
return null;
},
onSwipeLeft: () async {},
),
),
),
);
await tester.pump();
// Simulate holding right arrow key.
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.pump(const Duration(seconds: 2)); // Wait past hold edge duration (1s)
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
expect(submittedValue, 0);
});
testWidgets('allows left arrow key holding/press at zero to defer question', (tester) async {
bool deferred = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SwipeQuestionTile(
questionId: 'test-q-4',
onSwipeRight: (_) async => null,
onSwipeLeft: () async {
deferred = true;
},
),
),
),
);
await tester.pump();
// Simulate holding left arrow key.
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
await tester.pump(const Duration(seconds: 2));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft);
await tester.pumpAndSettle();
expect(deferred, true);
});
}

View File

@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h> #include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
@ -14,6 +15,8 @@
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar( ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FirebaseAuthPluginCApiRegisterWithRegistrar( FirebaseAuthPluginCApiRegisterWithRegistrar(

View File

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
connectivity_plus connectivity_plus
firebase_auth firebase_auth
firebase_core firebase_core