balance controled working spelling and market questions.
Some checks failed
Admin portal tests / test (push) Has been cancelled
Some checks failed
Admin portal tests / test (push) Has been cancelled
This commit is contained in:
parent
7f13161320
commit
a521a74214
File diff suppressed because one or more lines are too long
3
.gitignore
vendored
3
.gitignore
vendored
@ -82,3 +82,6 @@ migrate_working_dir/
|
||||
*.env.*
|
||||
!server/.env.example
|
||||
!hosting/hosting.env.example
|
||||
|
||||
# Server Cache
|
||||
server/spelling_audio_cache/
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../config/api_config.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../models/sync_run_event.dart';
|
||||
import '../repositories/sync_run_log_repository.dart';
|
||||
@ -45,6 +47,18 @@ class _MarketHistoryLogScreenState extends State<MarketHistoryLogScreen> {
|
||||
bool _triggerInFlight = false;
|
||||
bool _weekCoverageLoading = 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 =>
|
||||
widget.coverageApi ?? MarketHistoryAdminApi();
|
||||
@ -74,6 +88,7 @@ class _MarketHistoryLogScreenState extends State<MarketHistoryLogScreen> {
|
||||
void dispose() {
|
||||
_autoRefreshTimer?.cancel();
|
||||
_scrollController.dispose();
|
||||
_audioPlayer?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -280,12 +295,624 @@ class _MarketHistoryLogScreenState extends State<MarketHistoryLogScreen> {
|
||||
_triggerInFlight ||
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Market history log'),
|
||||
actions: <Widget>[
|
||||
title: DropdownButtonHideUnderline(
|
||||
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(
|
||||
key: const Key('question-audit-button'),
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -161,6 +161,103 @@ class MarketHistoryAdminApi {
|
||||
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) {
|
||||
if (raw == null) {
|
||||
return <SyncRunEvent>[];
|
||||
|
||||
@ -7,9 +7,11 @@ class QuestionSubmitResult {
|
||||
required this.unansweredCount,
|
||||
required this.score,
|
||||
this.nextQuestion,
|
||||
this.correctValue,
|
||||
});
|
||||
|
||||
final int unansweredCount;
|
||||
final GuessScoreSummary score;
|
||||
final IncomingQuestion? nextQuestion;
|
||||
final num? correctValue;
|
||||
}
|
||||
|
||||
@ -2,10 +2,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../admin/widgets/admin_app_bar_action.dart';
|
||||
import '../models/app_user.dart';
|
||||
import '../models/guess_score_summary.dart';
|
||||
import '../models/incoming_question.dart';
|
||||
import '../models/question_source_preference.dart';
|
||||
import '../utils/guess_slot_format.dart';
|
||||
import '../models/sync_result.dart';
|
||||
import '../models/user_profile.dart';
|
||||
import '../repositories/user_profile_repository.dart';
|
||||
@ -61,7 +59,6 @@ class HomeScreen extends StatelessWidget {
|
||||
QuestionsHubService.instance.hasPendingQuestion,
|
||||
QuestionsHubService.instance.pendingQuestion,
|
||||
QuestionsHubService.instance.pendingQuestionCount,
|
||||
QuestionsHubService.instance.guessScoreSummary,
|
||||
QuestionsHubService.instance.questionPanelOpen,
|
||||
QuestionsHubService.instance.questionQueue,
|
||||
QuestionSourceConfigRepository.instance.preferences,
|
||||
@ -79,29 +76,10 @@ class HomeScreen extends StatelessWidget {
|
||||
final bool showEnvelope = spellingOnly ||
|
||||
marketOnly ||
|
||||
(hasPending && count >= 1);
|
||||
final GuessScoreSummary score =
|
||||
QuestionsHubService.instance.guessScoreSummary.value;
|
||||
final IncomingQuestion? active =
|
||||
QuestionsHubService.instance.currentQuestion;
|
||||
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(
|
||||
if (!showEnvelope) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return HubToolbarIconButton(
|
||||
icon: Icons.mail_outline,
|
||||
iconColor: AppColors.accent,
|
||||
badgeCount: count >= 1 ? count : null,
|
||||
@ -114,9 +92,6 @@ class HomeScreen extends StatelessWidget {
|
||||
: 'Spelling questions',
|
||||
onPressed: () =>
|
||||
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 {
|
||||
const _SyncStatusChip({required this.status});
|
||||
|
||||
@ -128,6 +128,7 @@ class QuestionsApiService {
|
||||
nextQuestion: nextQuestionJson == null
|
||||
? null
|
||||
: IncomingQuestion.fromJson(nextQuestionJson),
|
||||
correctValue: body['correctValue'] as num?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -402,10 +402,10 @@ class QuestionsHubService {
|
||||
}
|
||||
|
||||
/// 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;
|
||||
if (question == null || questionActionBusy.value) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
num effectiveAnswer = answer;
|
||||
@ -413,14 +413,14 @@ class QuestionsHubService {
|
||||
effectiveAnswer = 1;
|
||||
}
|
||||
|
||||
await _submitAnswer(
|
||||
return await _submitAnswer(
|
||||
answeredQuestionId: question.id,
|
||||
question: question,
|
||||
answer: effectiveAnswer,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitAnswer({
|
||||
Future<num?> _submitAnswer({
|
||||
required String answeredQuestionId,
|
||||
required IncomingQuestion question,
|
||||
num? answer,
|
||||
@ -435,7 +435,8 @@ class QuestionsHubService {
|
||||
answerText: answerText,
|
||||
);
|
||||
if (result == null) {
|
||||
return;
|
||||
questionActionBusy.value = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (question.usesSpellingWordTile) {
|
||||
@ -450,6 +451,42 @@ class QuestionsHubService {
|
||||
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 &&
|
||||
_acceptsQuestionSource(result.nextQuestion!)) {
|
||||
IncomingQuestion nextQ = result.nextQuestion!;
|
||||
@ -505,9 +542,6 @@ class QuestionsHubService {
|
||||
unansweredCount: result.unansweredCount,
|
||||
queue: remaining,
|
||||
);
|
||||
} finally {
|
||||
questionActionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes [question] the sole active card (queue head matches [pendingQuestion]).
|
||||
|
||||
@ -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_tts/flutter_tts.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
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 {
|
||||
final FlutterTts _tts = FlutterTts();
|
||||
|
||||
@ -1,11 +1,101 @@
|
||||
import 'dart:async';
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:cyberhybridhub/config/api_config.dart';
|
||||
import 'package:flutter/foundation.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')
|
||||
external SpeechSynthesis get _speechSynthesis;
|
||||
|
||||
@ -17,7 +17,7 @@ class QuestionPresentationRouter extends StatelessWidget {
|
||||
});
|
||||
|
||||
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 ValueChanged<IncomingQuestion>? onPrepared;
|
||||
final Future<void> Function() onDefer;
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models/guess_score_summary.dart';
|
||||
import '../models/question_source_preference.dart';
|
||||
import '../repositories/question_source_config_repository.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import '../services/questions_hub_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/guess_slot_format.dart';
|
||||
|
||||
/// Configure which question API sources are active and their relative mix.
|
||||
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,
|
||||
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(
|
||||
key: Key('question-source-enabled-${widget.source.id}'),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ class SwipeQuestionTile extends StatefulWidget {
|
||||
|
||||
final String questionId;
|
||||
final String? questionText;
|
||||
final Future<void> Function(num answer) onSwipeRight;
|
||||
final Future<num?> Function(num answer) onSwipeRight;
|
||||
final Future<void> Function() onSwipeLeft;
|
||||
final bool busy;
|
||||
|
||||
@ -86,7 +86,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
|
||||
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;
|
||||
|
||||
@ -249,7 +249,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
}
|
||||
|
||||
Future<void> _completeHoldSave() async {
|
||||
if (_acting || widget.busy || _atZero || !mounted) {
|
||||
if (_acting || widget.busy || !mounted) {
|
||||
return;
|
||||
}
|
||||
final double width = MediaQuery.sizeOf(context).width;
|
||||
@ -257,7 +257,15 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
_acting = true;
|
||||
_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) {
|
||||
setState(() {
|
||||
_acting = false;
|
||||
@ -602,7 +610,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
}
|
||||
|
||||
Future<void> _releaseDrag() async {
|
||||
if (_acting || widget.busy || _atZero) {
|
||||
if (_acting || widget.busy) {
|
||||
setState(() => _dragOffset = 0);
|
||||
return;
|
||||
}
|
||||
@ -612,7 +620,15 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
_acting = true;
|
||||
_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) {
|
||||
setState(() {
|
||||
_acting = true;
|
||||
|
||||
@ -6,10 +6,14 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audioplayers_darwin
|
||||
import connectivity_plus
|
||||
import file_picker
|
||||
import firebase_auth
|
||||
@ -16,6 +17,7 @@ import sqlite3_flutter_libs
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
|
||||
64
pubspec.lock
64
pubspec.lock
@ -49,6 +49,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -989,6 +1045,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -49,6 +49,7 @@ dependencies:
|
||||
logging: ^1.3.0
|
||||
file_picker: ^11.0.2
|
||||
flutter_tts: ^4.2.2
|
||||
audioplayers: ^6.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
202
question_system_expantion_guide.md
Normal file
202
question_system_expantion_guide.md
Normal 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,
|
||||
);
|
||||
}
|
||||
```
|
||||
@ -11,11 +11,16 @@ dart test \
|
||||
test/trading/market_history_admin_logic_test.dart \
|
||||
test/trading/market_history_admin_actions_test.dart \
|
||||
test/integration/market_history_admin_handler_test.dart \
|
||||
test/integration/market_history_admin_risk_test.dart \
|
||||
--coverage=coverage
|
||||
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
|
||||
format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
|
||||
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
|
||||
echo "Install coverage package for lcov: dart pub global activate coverage"
|
||||
fi
|
||||
|
||||
@ -67,8 +67,8 @@ fi
|
||||
# build-invariant. Tree-shaking re-subsets MaterialIcons-Regular.otf every build
|
||||
# (different glyphs, same filename), and stale browser caches of that file make
|
||||
# newly-referenced icons (e.g. settings, admin_panel_settings) render blank.
|
||||
flutter build web --release "${_flutter_pub_flag[@]}" #\
|
||||
# --no-tree-shake-icons \
|
||||
flutter build web --release "${_flutter_pub_flag[@]}" \
|
||||
--no-tree-shake-icons #\
|
||||
# --dart-define=API_BASE_URL="$API_BASE_URL" \
|
||||
# --dart-define=AUTH_VERBOSE_LOG=true
|
||||
|
||||
|
||||
@ -38,3 +38,5 @@ MARKET_HISTORY_RETENTION_DAYS=5
|
||||
# Use same origin as the site when HAProxy proxies /v1 and /hubs on :443.
|
||||
API_BASE_URL=https://cyberhybridhub.com
|
||||
QUESTION_DISPATCHER_DEBUG_LOGGING_ENABLED=false
|
||||
MERRIAM_WEBSTER_API_KEY=
|
||||
GOOGLE_CLOUD_API_KEY=
|
||||
|
||||
@ -17,6 +17,7 @@ import '../lib/handlers/question_source_handler.dart';
|
||||
import '../lib/handlers/questions_handler.dart';
|
||||
import '../lib/handlers/questions_hub_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/pipeline/question_pipeline.dart';
|
||||
import '../lib/question_dispatcher.dart';
|
||||
@ -263,6 +264,10 @@ Future<void> main() async {
|
||||
auth: auth,
|
||||
spellingItemsDb: spellingItemsDb,
|
||||
);
|
||||
final Handler spellingAudio = spellingAudioHandler(
|
||||
merriamWebsterApiKey: env.merriamWebsterApiKey,
|
||||
googleCloudApiKey: env.googleCloudApiKey,
|
||||
);
|
||||
final Handler questionsHub = questionsHubHandler(
|
||||
auth: auth,
|
||||
questionService: questionService,
|
||||
@ -321,6 +326,9 @@ Future<void> main() async {
|
||||
if (path.startsWith(spellingWordsBasePath)) {
|
||||
return spellingWords(request);
|
||||
}
|
||||
if (path.startsWith('/v1/spelling/audio/')) {
|
||||
return spellingAudio(request);
|
||||
}
|
||||
return profile(request);
|
||||
});
|
||||
|
||||
|
||||
@ -22,6 +22,8 @@ class ServerEnv {
|
||||
required this.marketHistory,
|
||||
required this.alpaca,
|
||||
required this.questionDispatcherDebugLoggingEnabled,
|
||||
required this.merriamWebsterApiKey,
|
||||
required this.googleCloudApiKey,
|
||||
});
|
||||
|
||||
final String databaseUrl;
|
||||
@ -31,6 +33,8 @@ class ServerEnv {
|
||||
final int questionWorkerIntervalSeconds;
|
||||
final bool questionPipelineTestMode;
|
||||
final bool questionDispatcherDebugLoggingEnabled;
|
||||
final String? merriamWebsterApiKey;
|
||||
final String? googleCloudApiKey;
|
||||
|
||||
/// When true, prospective answers earn closeness bonus points and wrong
|
||||
/// direction scores `-2` instead of `-1`. Default false (`+1`/`-1` only).
|
||||
@ -121,6 +125,8 @@ class ServerEnv {
|
||||
(env['QUESTION_DISPATCHER_DEBUG_LOGGING_ENABLED'] ?? 'false')
|
||||
.toLowerCase() ==
|
||||
'true';
|
||||
final String? merriamWebsterApiKey = env['MERRIAM_WEBSTER_API_KEY'];
|
||||
final String? googleCloudApiKey = env['GOOGLE_CLOUD_API_KEY'];
|
||||
final Map<String, String> stringEnv =
|
||||
Map<String, String>.from(Platform.environment);
|
||||
const List<String> marketHistoryKeys = <String>[
|
||||
@ -165,6 +171,8 @@ class ServerEnv {
|
||||
marketHistory: marketHistory,
|
||||
alpaca: alpaca,
|
||||
questionDispatcherDebugLoggingEnabled: questionDispatcherDebugLoggingEnabled,
|
||||
merriamWebsterApiKey: merriamWebsterApiKey,
|
||||
googleCloudApiKey: googleCloudApiKey,
|
||||
);
|
||||
loaded.assertConsistent();
|
||||
return loaded;
|
||||
|
||||
@ -45,7 +45,10 @@ Handler marketHistoryAdminHandler({
|
||||
required Set<String> adminFirebaseUids,
|
||||
MarketHistoryAdminActions? actions,
|
||||
MarketHistoryAdminPortalConfig? portalConfig,
|
||||
Future<ProcessResult> Function(String, List<String>)? runProcess,
|
||||
}) {
|
||||
final Future<ProcessResult> Function(String, List<String>) runProc =
|
||||
runProcess ?? Process.run;
|
||||
final Router router = Router();
|
||||
|
||||
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 {
|
||||
final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids);
|
||||
if (firebaseUid == null) {
|
||||
|
||||
@ -154,11 +154,21 @@ Handler questionsHandler({
|
||||
final Map<String, dynamic>? score = isMarketAnswer
|
||||
? await questionsDb.getGuessScoreSummary(firebaseUid)
|
||||
: 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>{
|
||||
'question': updated,
|
||||
'unansweredCount': unansweredCount,
|
||||
if (score != null) 'score': score,
|
||||
if (nextQuestion != null) 'nextQuestion': nextQuestion,
|
||||
if (correctValue != null) 'correctValue': correctValue,
|
||||
});
|
||||
} catch (e, st) {
|
||||
stderr.writeln('Answer question error: $e\n$st');
|
||||
|
||||
354
server/lib/handlers/spelling_audio_handler.dart
Normal file
354
server/lib/handlers/spelling_audio_handler.dart
Normal 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'));
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'question_source_preferences_db.dart';
|
||||
import 'questions_db.dart';
|
||||
@ -120,9 +121,13 @@ class QuestionService {
|
||||
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(
|
||||
assignedUserId: firebaseUid,
|
||||
questionText: picked['questionText']! as String,
|
||||
questionText: questionText,
|
||||
correctAnswer: picked['correctAnswer']! as num,
|
||||
sourceTag: 'market_history:prospective',
|
||||
pipelineKey: 'trading',
|
||||
@ -133,6 +138,7 @@ class QuestionService {
|
||||
'older_slot_start': picked['olderSlotStart'],
|
||||
'newer_slot_start': picked['newerSlotStart'],
|
||||
'price_delta_pct': picked['priceDeltaPct'],
|
||||
'is_inverted': isInverted,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import 'trading/guess_score_store.dart';
|
||||
import 'trading/prospective_guess_assignments_db.dart';
|
||||
import 'trading/prospective_guess_selection.dart';
|
||||
import 'trading/user_trading_state_db.dart';
|
||||
import 'trading/market_question_logger.dart';
|
||||
|
||||
/// Postgres access for the questions table.
|
||||
class QuestionsDb {
|
||||
@ -239,6 +240,24 @@ class QuestionsDb {
|
||||
answeredAt: now,
|
||||
);
|
||||
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;
|
||||
}
|
||||
@ -718,7 +737,7 @@ class QuestionsDb {
|
||||
final Map<String, dynamic> metadata = Map<String, dynamic>.from(
|
||||
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 needsPrepare = isSpelling &&
|
||||
(spokenWord == null || spokenWord.isEmpty) &&
|
||||
@ -865,9 +884,11 @@ class QuestionsDb {
|
||||
return;
|
||||
}
|
||||
|
||||
final bool isInverted = metadata['is_inverted'] as bool? ?? false;
|
||||
final ProspectiveAnswerGrade grade = gradeProspectiveAnswer(
|
||||
userResponse: userResponse,
|
||||
correctAnswer: _readNumeric(answeredQuestion['correctAnswer']),
|
||||
isInverted: isInverted,
|
||||
);
|
||||
final String symbol = metadata['symbol'] as String? ?? '';
|
||||
final String? modifiedAtWire = answeredQuestion['modifiedAt'] as String?;
|
||||
@ -905,6 +926,7 @@ class QuestionsDb {
|
||||
final DateTime answeredAt = modifiedAtWire == null
|
||||
? DateTime.now().toUtc()
|
||||
: DateTime.parse(modifiedAtWire).toUtc();
|
||||
final bool isInverted = metadata['is_inverted'] as bool? ?? false;
|
||||
|
||||
await _connection.execute(
|
||||
Sql.named(
|
||||
@ -912,12 +934,14 @@ class QuestionsDb {
|
||||
INSERT INTO market_history_prospective_answers (
|
||||
question_id, assigned_user_id, prospective_question_id,
|
||||
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
|
||||
@question_id::uuid, @assigned_user_id, p.id,
|
||||
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
|
||||
WHERE p.id = @prospective_question_id::uuid
|
||||
ON CONFLICT (question_id) DO UPDATE
|
||||
@ -928,7 +952,8 @@ class QuestionsDb {
|
||||
newer_slot_start = EXCLUDED.newer_slot_start,
|
||||
expected_percent_increase_price = EXCLUDED.expected_percent_increase_price,
|
||||
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>{
|
||||
@ -937,6 +962,7 @@ class QuestionsDb {
|
||||
'prospective_question_id': prospectiveQuestionId,
|
||||
'user_slider_value': userSliderValue,
|
||||
'answered_at': answeredAt,
|
||||
'is_inverted': isInverted,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -104,11 +104,14 @@ abstract final class GuessScoreStore {
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (
|
||||
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 (
|
||||
user_slider_value = 0
|
||||
AND expected_percent_increase_price = 0
|
||||
AND ROUND(expected_percent_increase_price) = 0
|
||||
)
|
||||
)::int AS correct
|
||||
FROM market_history_prospective_answers
|
||||
@ -133,7 +136,7 @@ abstract final class GuessScoreStore {
|
||||
final Result rows = await connection.execute(
|
||||
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
|
||||
WHERE assigned_user_id = @uid
|
||||
ORDER BY answered_at ASC
|
||||
@ -146,6 +149,7 @@ abstract final class GuessScoreStore {
|
||||
final ProspectiveAnswerGrade grade = gradeProspectiveAnswer(
|
||||
userResponse: _readNum(row[0]),
|
||||
correctAnswer: _readNum(row[1]),
|
||||
isInverted: row[2] as bool? ?? false,
|
||||
);
|
||||
total += grade.answerScore;
|
||||
}
|
||||
|
||||
67
server/lib/trading/market_question_logger.dart
Normal file
67
server/lib/trading/market_question_logger.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
@ -38,12 +38,15 @@ const num prospectiveAnswerFullCreditTolerance = 1;
|
||||
ProspectiveAnswerGrade gradeProspectiveAnswer({
|
||||
required num userResponse,
|
||||
required num correctAnswer,
|
||||
bool isInverted = false,
|
||||
}) {
|
||||
final num roundedCorrectAnswer = correctAnswer.round();
|
||||
final num effectiveUserResponse = isInverted ? userResponse * -1 : userResponse;
|
||||
final bool sameDirection = _sameDirection(
|
||||
userResponse: userResponse,
|
||||
correctAnswer: correctAnswer,
|
||||
userResponse: effectiveUserResponse,
|
||||
correctAnswer: roundedCorrectAnswer,
|
||||
);
|
||||
final num absError = (userResponse - correctAnswer).abs();
|
||||
final num absError = (effectiveUserResponse - roundedCorrectAnswer).abs();
|
||||
|
||||
if (!ProspectiveAnswerScoring.closenessExtraPointsEnabled) {
|
||||
final num directionPoint = sameDirection ? 1 : -1;
|
||||
@ -59,8 +62,8 @@ ProspectiveAnswerGrade gradeProspectiveAnswer({
|
||||
final num directionPoint = sameDirection ? 1 : -2;
|
||||
final num closenessPoint = sameDirection
|
||||
? _closenessPoint(
|
||||
userResponse: userResponse,
|
||||
correctAnswer: correctAnswer,
|
||||
userResponse: effectiveUserResponse,
|
||||
correctAnswer: roundedCorrectAnswer,
|
||||
)
|
||||
: 0;
|
||||
return ProspectiveAnswerGrade(
|
||||
|
||||
@ -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;
|
||||
@ -2,6 +2,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cyberhybridhub_server/firebase_auth.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:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
import '../helpers/test_db.dart';
|
||||
|
||||
@ -870,4 +870,461 @@ void main() {
|
||||
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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_selection.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:test/test.dart';
|
||||
|
||||
@ -1335,4 +1337,322 @@ void main() {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
209
server/test/spelling/spelling_audio_handler_test.dart
Normal file
209
server/test/spelling/spelling_audio_handler_test.dart
Normal 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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
125
test/admin/models/admin_models_test.dart
Normal file
125
test/admin/models/admin_models_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -115,14 +115,19 @@ Future<void> _pumpScreen(
|
||||
}
|
||||
|
||||
class _FakeCoverageApi extends MarketHistoryAdminApi {
|
||||
_FakeCoverageApi(this.report)
|
||||
: super(
|
||||
_FakeCoverageApi(
|
||||
this.report, {
|
||||
this.audioFiles,
|
||||
this.audioError = false,
|
||||
}) : super(
|
||||
baseUrl: 'http://localhost:3000',
|
||||
tokenProvider: () async => 'token',
|
||||
client: MockClient((http.Request request) async => http.Response('', 500)),
|
||||
);
|
||||
|
||||
final MarketHistoryWeekCoverageReport report;
|
||||
final List<String>? audioFiles;
|
||||
final bool audioError;
|
||||
|
||||
@override
|
||||
Future<MarketHistoryWeekCoverageReport> fetchWeekCoverage() async => report;
|
||||
@ -138,6 +143,45 @@ class _FakeCoverageApi extends MarketHistoryAdminApi {
|
||||
canStepNewer: false,
|
||||
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() {
|
||||
@ -542,6 +586,228 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
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 {
|
||||
|
||||
@ -255,4 +255,77 @@ void main() {
|
||||
expect(report.assets.single.newerSlot.close, 12);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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.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.pumpAndSettle();
|
||||
|
||||
@ -91,7 +97,12 @@ void main() {
|
||||
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();
|
||||
|
||||
expect(
|
||||
|
||||
@ -102,7 +102,10 @@ void main() {
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
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();
|
||||
|
||||
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');
|
||||
|
||||
// 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();
|
||||
|
||||
// Verify spelling is disabled and market_history automatically becomes 100%.
|
||||
expect(tester.widget<TextField>(marketInput).controller?.text, '100');
|
||||
|
||||
// 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();
|
||||
|
||||
// Verify spelling gets enabled and shares the ratio evenly (50/50).
|
||||
|
||||
112
test/services/dictionary_audio_parsing_test.dart
Normal file
112
test/services/dictionary_audio_parsing_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -33,7 +33,7 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: QuestionPresentationRouter(
|
||||
question: question,
|
||||
onAnswer: (_) async {},
|
||||
onAnswer: (_) async => null,
|
||||
onDefer: () async {},
|
||||
),
|
||||
),
|
||||
@ -62,7 +62,7 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: QuestionPresentationRouter(
|
||||
question: question,
|
||||
onAnswer: (_) async {},
|
||||
onAnswer: (_) async => null,
|
||||
onDefer: () async {},
|
||||
),
|
||||
),
|
||||
@ -93,7 +93,7 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: QuestionPresentationRouter(
|
||||
question: question,
|
||||
onAnswer: (_) async {},
|
||||
onAnswer: (_) async => null,
|
||||
onAnswerText: (_) async {},
|
||||
onDefer: () async {},
|
||||
),
|
||||
|
||||
131
test/widgets/swipe_question_tile_test.dart
Normal file
131
test/widgets/swipe_question_tile_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <firebase_auth/firebase_auth_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>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
connectivity_plus
|
||||
firebase_auth
|
||||
firebase_core
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user