better spelling

This commit is contained in:
nathanAndrinoid 2026-06-05 15:10:03 +00:00
parent 4459852f83
commit be57c17c15
19 changed files with 588 additions and 47 deletions

View File

@ -9,6 +9,7 @@ class IncomingQuestion {
this.presentation = defaultPresentation,
this.spokenWord,
this.audioTitle,
this.canonicalWord,
this.needsPrepare = false,
});
@ -34,6 +35,9 @@ class IncomingQuestion {
/// Question id referenced as the audio utterance title.
final String? audioTitle;
/// Proper spelling used to grade the typed answer.
final String? canonicalWord;
/// True when the server still needs tile-load word selection.
final bool needsPrepare;
@ -77,6 +81,7 @@ class IncomingQuestion {
presentation: json['presentation'] as String? ?? defaultPresentation,
spokenWord: json['spokenWord'] as String?,
audioTitle: json['audioTitle'] as String?,
canonicalWord: json['canonicalWord'] as String?,
needsPrepare: json['needsPrepare'] as bool? ?? false,
);
}
@ -85,6 +90,7 @@ class IncomingQuestion {
String? text,
String? spokenWord,
String? audioTitle,
String? canonicalWord,
int? unansweredCount,
bool? needsPrepare,
}) {
@ -97,6 +103,7 @@ class IncomingQuestion {
presentation: presentation,
spokenWord: spokenWord ?? this.spokenWord,
audioTitle: audioTitle ?? this.audioTitle,
canonicalWord: canonicalWord ?? this.canonicalWord,
needsPrepare: needsPrepare ?? this.needsPrepare,
);
}

View File

@ -0,0 +1,12 @@
import 'incoming_question.dart';
/// Outcome of resetting per-user spelling links and familiarity progress.
class SpellingProgressResetResult {
const SpellingProgressResetResult({
required this.unansweredCount,
this.question,
});
final int unansweredCount;
final IncomingQuestion? question;
}

View File

@ -386,13 +386,21 @@ class HomeScreen extends StatelessWidget {
),
),
ListenableBuilder(
listenable: QuestionsHubService.instance.scoreResetBusy,
listenable: Listenable.merge(<Listenable>[
QuestionsHubService.instance.scoreResetBusy,
QuestionsHubService.instance.spellingResetBusy,
]),
builder: (BuildContext context, Widget? child) {
if (!QuestionsHubService.instance.scoreResetBusy.value) {
final bool blocking =
QuestionsHubService.instance.scoreResetBusy.value ||
QuestionsHubService.instance.spellingResetBusy.value;
if (!blocking) {
return const SizedBox.shrink();
}
return Stack(
key: const Key('guess-score-reset-blocking-overlay'),
key: QuestionsHubService.instance.spellingResetBusy.value
? const Key('spelling-progress-reset-blocking-overlay')
: const Key('guess-score-reset-blocking-overlay'),
children: <Widget>[
ModalBarrier(
dismissible: false,

View File

@ -7,6 +7,7 @@ import '../config/api_config.dart';
import '../models/guess_score_reset_result.dart';
import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart';
import '../models/spelling_progress_reset_result.dart';
import '../models/question_defer_result.dart';
import '../models/question_submit_result.dart';
import 'auth_service.dart';
@ -160,6 +161,36 @@ class QuestionsApiService {
return IncomingQuestion.fromJson(questionJson);
}
/// Clears spelling links and familiarity progress, then re-seeds when enabled.
Future<SpellingProgressResetResult?> resetSpellingProgress() async {
final String? token = await AuthService.instance.getIdToken();
if (token == null) {
return null;
}
final http.Response response = await _client.post(
Uri.parse('$apiBaseUrl/v1/me/questions/spelling/reset'),
headers: _authHeaders(token),
);
if (response.statusCode != 200) {
debugPrint(
'resetSpellingProgress failed: ${response.statusCode} ${response.body}',
);
return null;
}
final Map<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>;
final Map<String, dynamic>? questionJson =
body['question'] as Map<String, dynamic>?;
return SpellingProgressResetResult(
unansweredCount: (body['unansweredCount'] as num?)?.toInt() ?? 0,
question: questionJson == null
? null
: IncomingQuestion.fromJson(questionJson),
);
}
Future<QuestionDeferResult?> deferQuestion({required String questionId}) async {
final String? token = await AuthService.instance.getIdToken();
if (token == null) {

View File

@ -31,6 +31,7 @@ class QuestionsHubService {
ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]);
final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false);
final ValueNotifier<bool> scoreResetBusy = ValueNotifier<bool>(false);
final ValueNotifier<bool> spellingResetBusy = ValueNotifier<bool>(false);
final ValueNotifier<GuessScoreSummary> guessScoreSummary =
ValueNotifier<GuessScoreSummary>(GuessScoreSummary.empty);
@ -418,6 +419,36 @@ class QuestionsHubService {
guessScoreSummary.value = score;
}
/// Clears spelling links, familiarity progress, and spelling questions on the server.
Future<bool> resetSpellingProgress() async {
spellingResetBusy.value = true;
try {
final result = await _api.resetSpellingProgress();
if (result == null) {
return false;
}
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered();
final List<IncomingQuestion> active =
refreshed.where(_acceptsQuestionSource).toList(growable: false);
if (active.isNotEmpty) {
pendingQuestion.value = active.first;
pendingQuestionCount.value = active.length;
hasPendingQuestion.value = true;
if (questionPanelOpen.value) {
questionQueue.value = active;
}
} else {
pendingQuestion.value = null;
questionQueue.value = <IncomingQuestion>[];
hasPendingQuestion.value = _shouldKeepPanelOpenWithoutQuestion();
}
return true;
} finally {
spellingResetBusy.value = false;
}
}
/// Clears cumulative guess score and answer statistics on the server.
Future<bool> resetGuessScoreSummary() async {
scoreResetBusy.value = true;

View File

@ -9,6 +9,7 @@ abstract final class AppColors {
static const Color textPrimary = Color(0xFFF8FAFC);
static const Color textSecondary = Color(0xFF94A3B8);
static const Color success = Color(0xFF34D399);
static const Color error = Color(0xFFF87171);
}
ThemeData buildAppTheme() {

View File

@ -0,0 +1,6 @@
/// Grading rules for typed spelling answers (mirrors server policy).
abstract final class SpellingAnswerPolicy {
static bool matches(String typed, String canonical) {
return typed.trim().toLowerCase() == canonical.trim().toLowerCase();
}
}

View File

@ -32,6 +32,7 @@ class QuestionPresentationRouter extends StatelessWidget {
questionText: question.text,
spokenWord: question.spokenWord,
audioTitle: question.audioTitle,
canonicalWord: question.canonicalWord,
needsPrepare: question.needsPrepare,
busy: busy,
onSubmitAnswer: onAnswerText ?? (_) async {},

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../models/question_source_preference.dart';
import '../repositories/question_source_config_repository.dart';
import '../routes/app_routes.dart';
import '../screens/spelling_list_screen.dart';
import '../services/questions_hub_service.dart';
import '../theme/app_theme.dart';
/// Configure which question API sources are active and their relative mix.
@ -43,6 +43,7 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
late List<QuestionSourcePreference> _draft;
String? _validationMessage;
bool _saving = false;
bool _resettingSpelling = false;
@override
void initState() {
@ -63,6 +64,53 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
Navigator.of(context).pushNamed(AppRoutes.spellingWordList);
}
Future<void> _confirmResetSpellingProgress() async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
key: const Key('spelling-progress-reset-confirm-dialog'),
title: const Text('Reset spelling progress?'),
content: const Text(
'This deletes your spelling question links and familiarity scores '
'and returns spelling to its initial state. You cannot undo this.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
TextButton(
key: const Key('spelling-progress-reset-confirm-button'),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Reset'),
),
],
);
},
);
if (confirmed != true || !mounted) {
return;
}
setState(() {
_resettingSpelling = true;
_validationMessage = null;
});
final bool ok =
await QuestionsHubService.instance.resetSpellingProgress();
if (!mounted) {
return;
}
setState(() {
_resettingSpelling = false;
if (!ok) {
_validationMessage = 'Could not reset spelling progress.';
}
});
}
Future<void> _save() async {
final bool hasEnabled =
_draft.any((QuestionSourcePreference source) => source.enabled);
@ -103,7 +151,9 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
final int enabledCount =
_draft.where((QuestionSourcePreference s) => s.enabled).length;
return Padding(
return Stack(
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
@ -126,7 +176,9 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
IconButton(
key: const Key('question-source-settings-close'),
tooltip: 'Close',
onPressed: () => Navigator.of(context).pop(),
onPressed: _resettingSpelling || _saving
? null
: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
@ -150,6 +202,10 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
onEditSpelling: _draft[index].id == 'spelling'
? () => _openSpellingWordList(context)
: null,
onResetSpelling: _draft[index].id == 'spelling'
? _confirmResetSpellingProgress
: null,
resetSpellingBusy: _resettingSpelling,
),
],
),
@ -182,13 +238,15 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: _resettingSpelling || _saving
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
key: const Key('question-source-settings-save'),
onPressed: _saving ? null : _save,
onPressed: _saving || _resettingSpelling ? null : _save,
child: _saving
? const SizedBox(
width: 18,
@ -201,6 +259,24 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
),
],
),
),
if (_resettingSpelling)
Positioned.fill(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.45),
child: const Center(
child: SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.accent,
),
),
),
),
),
],
);
}
}
@ -212,12 +288,16 @@ class _QuestionSourceRow extends StatelessWidget {
required this.effectivePercent,
required this.onChanged,
this.onEditSpelling,
this.onResetSpelling,
this.resetSpellingBusy = false,
});
final QuestionSourcePreference source;
final int? effectivePercent;
final ValueChanged<QuestionSourcePreference> onChanged;
final VoidCallback? onEditSpelling;
final VoidCallback? onResetSpelling;
final bool resetSpellingBusy;
@override
Widget build(BuildContext context) {
@ -288,9 +368,16 @@ class _QuestionSourceRow extends StatelessWidget {
IconButton(
key: const Key('question-source-edit-spelling'),
tooltip: 'Edit spelling word list',
onPressed: onEditSpelling,
onPressed: resetSpellingBusy ? null : onEditSpelling,
icon: const Icon(Icons.edit, size: 20),
),
if (onResetSpelling != null)
IconButton(
key: const Key('question-source-reset-spelling'),
tooltip: 'Reset spelling progress',
onPressed: resetSpellingBusy ? null : onResetSpelling,
icon: const Icon(Icons.restart_alt, size: 20),
),
Switch(
key: Key('question-source-enabled-${source.id}'),
value: source.enabled,

View File

@ -6,6 +6,7 @@ import '../models/incoming_question.dart';
import '../services/questions_api_service.dart';
import '../services/spelling_text_to_speech.dart';
import '../theme/app_theme.dart';
import '../utils/spelling_answer_policy.dart';
import '../utils/spelling_spoken_word.dart';
/// Spelling quiz: hear the word, type your spelling, and submit.
@ -19,6 +20,7 @@ class SpellingQuestionTile extends StatefulWidget {
this.busy = false,
this.spokenWord,
this.audioTitle,
this.canonicalWord,
this.needsPrepare = false,
this.onPrepared,
this.prepareQuestion,
@ -36,6 +38,9 @@ class SpellingQuestionTile extends StatefulWidget {
/// Question id used as the TTS utterance title.
final String? audioTitle;
/// Proper spelling shown after submit for green/red feedback.
final String? canonicalWord;
/// When true, calls the prepare API to select a word at tile load.
final bool needsPrepare;
@ -53,16 +58,21 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
final SpellingTextToSpeech _speech = SpellingTextToSpeech();
final QuestionsApiService _api = QuestionsApiService();
final TextEditingController _answerController = TextEditingController();
final FocusNode _answerFocusNode = FocusNode();
String? _spokenWord;
String? _audioTitle;
String? _canonicalWord;
bool _preparing = false;
bool _showingFeedback = false;
bool? _answerCorrect;
@override
void initState() {
super.initState();
_spokenWord = widget.spokenWord;
_audioTitle = widget.audioTitle ?? widget.questionId;
_canonicalWord = widget.canonicalWord;
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
}
@ -73,6 +83,9 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
_answerController.clear();
_spokenWord = widget.spokenWord;
_audioTitle = widget.audioTitle ?? widget.questionId;
_canonicalWord = widget.canonicalWord;
_showingFeedback = false;
_answerCorrect = null;
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
}
}
@ -80,10 +93,27 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
@override
void dispose() {
_answerController.dispose();
_answerFocusNode.dispose();
unawaited(_speech.stop());
super.dispose();
}
void _requestAnswerFieldFocus() {
if (!mounted ||
!_wordReady ||
widget.busy ||
_preparing ||
_showingFeedback) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_answerFocusNode.requestFocus();
});
}
Future<void> _prepareAndSpeak() async {
if (_preparing) {
return;
@ -91,6 +121,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
if (!widget.needsPrepare) {
await _speakWord();
_requestAnswerFieldFocus();
return;
}
@ -107,6 +138,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
setState(() {
_spokenWord = prepared.spokenWord ?? _spokenWord;
_audioTitle = prepared.audioTitle ?? prepared.id;
_canonicalWord = prepared.canonicalWord ?? _canonicalWord;
});
widget.onPrepared?.call(prepared);
}
@ -114,6 +146,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
} finally {
if (mounted) {
setState(() => _preparing = false);
_requestAnswerFieldFocus();
}
}
}
@ -137,18 +170,48 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
);
}
String? get _canonicalForGrading =>
_canonicalWord ?? widget.canonicalWord;
Future<void> _submitAnswer() async {
final String answer = _answerController.text.trim();
if (answer.isEmpty || widget.busy || _preparing || !_wordReady) {
if (answer.isEmpty ||
widget.busy ||
_preparing ||
_showingFeedback ||
!_wordReady) {
return;
}
final String? canonical = _canonicalForGrading;
if (canonical == null || canonical.isEmpty) {
await widget.onSubmitAnswer(answer);
return;
}
final bool correct = SpellingAnswerPolicy.matches(answer, canonical);
setState(() {
_answerCorrect = correct;
_showingFeedback = true;
});
await Future<void>.delayed(const Duration(seconds: 1));
if (!mounted) {
return;
}
await widget.onSubmitAnswer(answer);
}
@override
Widget build(BuildContext context) {
final bool canInteract = _wordReady && !widget.busy && !_preparing;
final bool canSpeak = _wordReady;
final bool canInteract =
_wordReady && !widget.busy && !_preparing && !_showingFeedback;
final bool canSpeak = _wordReady && !_showingFeedback;
final String? feedbackWord = _showingFeedback ? _canonicalForGrading : null;
final Color? feedbackColor = _answerCorrect == null
? null
: (_answerCorrect! ? AppColors.success : AppColors.error);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@ -185,9 +248,25 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
),
),
const SizedBox(height: 28),
if (feedbackWord != null && feedbackColor != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
feedbackWord,
key: const Key('spelling-feedback-word'),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: feedbackColor,
fontWeight: FontWeight.w700,
),
),
)
else ...<Widget>[
TextField(
key: const Key('spelling-answer-field'),
controller: _answerController,
focusNode: _answerFocusNode,
autofocus: canInteract,
enabled: canInteract,
textCapitalization: TextCapitalization.none,
autocorrect: false,
@ -209,7 +288,8 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
vertical: 14,
),
),
onSubmitted: canInteract ? (_) => unawaited(_submitAnswer()) : null,
onSubmitted:
canInteract ? (_) => unawaited(_submitAnswer()) : null,
),
const SizedBox(height: 16),
FilledButton(
@ -224,6 +304,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
child: const Text('Skip for now'),
),
],
],
),
);
}

View File

@ -165,6 +165,24 @@ Handler questionsHandler({
},
);
router.post('$questionsBasePath/spelling/reset', (Request request) async {
final String? firebaseUid = await _verify(auth, request);
if (firebaseUid == null) {
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
}
if (questionPipeline == null) {
return _jsonResponse(503, <String, dynamic>{'error': 'Unavailable'});
}
try {
final Map<String, dynamic> result =
await questionPipeline.resetSpellingProgressForUser(firebaseUid);
return _jsonResponse(200, result);
} catch (e, st) {
stderr.writeln('Reset spelling progress error: $e\n$st');
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
}
});
router.post(
'$questionsBasePath/<id>/spelling/prepare',
(Request request, String id) async {

View File

@ -459,6 +459,26 @@ class QuestionPipeline {
);
}
/// Clears spelling links, progress, questions, and pipeline state; re-seeds when enabled.
Future<Map<String, dynamic>> resetSpellingProgressForUser(
String firebaseUid,
) async {
await _spellingQuestionLinksDb.deleteAllForUser(firebaseUid);
await _spellingProgressDb.deleteAllForUser(firebaseUid);
await _questionsDb.deleteAllSpellingQuestionsForUser(firebaseUid);
await _questionsDb.clearSpellingPipelineState(firebaseUid);
await maintainSpellingForUser(firebaseUid);
final Map<String, dynamic>? question =
await _questionDispatcher?.dispatchNextQuestion(firebaseUid);
final int unansweredCount =
await _questionsDb.countUnansweredQuestions(firebaseUid);
return <String, dynamic>{
'unansweredCount': unansweredCount,
if (question != null) 'question': question,
};
}
/// Ensures an unanswered spelling word quiz exists when the source is enabled.
Future<void> maintainSpellingForUser(String firebaseUid) async {
if (!await _isSpellingEnabled(firebaseUid)) {

View File

@ -106,6 +106,37 @@ class QuestionsDb {
return rows.first;
}
/// Deletes every spelling pipeline question row for [firebaseUid].
Future<void> deleteAllSpellingQuestionsForUser(String firebaseUid) async {
await _connection.execute(
Sql.named(
'''
DELETE FROM questions
WHERE assigned_user_id = @uid
AND (
pipeline_key = 'spelling'
OR source_tag LIKE 'spelling:%'
)
''',
),
parameters: <String, dynamic>{'uid': firebaseUid},
);
}
/// Clears spelling pipeline state when [assignedUserId] is on that branch.
Future<void> clearSpellingPipelineState(String assignedUserId) async {
await _connection.execute(
Sql.named(
'''
DELETE FROM user_pipeline_state
WHERE assigned_user_id = @uid
AND pipeline_key = 'spelling'
''',
),
parameters: <String, dynamic>{'uid': assignedUserId},
);
}
/// Deletes every guess-game question row for [firebaseUid].
///
/// Assignments and answer snapshots referencing these rows cascade away.
@ -712,6 +743,8 @@ class QuestionsDb {
if (isPreparedSpelling) ...<String, dynamic>{
'spokenWord': spokenWord,
'audioTitle': question['id'],
if (metadata['canonical_word'] is String)
'canonicalWord': metadata['canonical_word'],
},
};
}

View File

@ -31,6 +31,19 @@ class SpellingQuestionLinksDb {
);
}
/// Removes every quiz-to-item link for [firebaseUid].
Future<void> deleteAllForUser(String firebaseUid) async {
await _connection.execute(
Sql.named(
'''
DELETE FROM spelling_question_links
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': firebaseUid},
);
}
Future<bool> hasLink(String questionId) async {
final Result result = await _connection.execute(
Sql.named(

View File

@ -89,6 +89,19 @@ class UserSpellingProgressDb {
);
}
/// Removes all per-user spelling familiarity rows (initial progress state).
Future<void> deleteAllForUser(String firebaseUid) async {
await _connection.execute(
Sql.named(
'''
DELETE FROM user_spelling_progress
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': firebaseUid},
);
}
Future<int?> familiarityScore({
required String firebaseUid,
required String spellingItemId,

View File

@ -4,6 +4,7 @@ library;
import 'dart:convert';
import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
import 'package:cyberhybridhub_server/question_dispatcher.dart';
import 'package:postgres/postgres.dart';
import 'package:test/test.dart';
@ -107,6 +108,7 @@ void main() {
expect(prepared, isNotNull);
expect(prepared!['spokenWord'], isNotEmpty);
expect(prepared['audioTitle'], questionId);
expect(prepared['canonicalWord'], isNotEmpty);
final Result link = await testDb!.connection.execute(
Sql.named(
@ -250,4 +252,101 @@ void main() {
expect(rows.last[0], PipelineSteps.spellingFollowUp);
expect(rows.last[1], 'spelling:follow_up');
});
test('resetSpellingProgressForUser clears links and progress then re-seeds',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'spelling-reset-uid';
await testDb!.seedUser(uid);
final QuestionDispatcher dispatcher = testDb!.questionDispatcher();
final QuestionPipeline pipeline = testDb!.questionPipeline(testMode: true);
pipeline.attachQuestionDispatcher(dispatcher);
await pipeline.maintainSpellingForUser(uid);
final Result reserved = await testDb!.connection.execute(
Sql.named(
'''
SELECT id
FROM questions
WHERE assigned_user_id = @uid
LIMIT 1
''',
),
parameters: <String, dynamic>{'uid': uid},
);
final String questionId = reserved.first[0]!.toString();
await pipeline.prepareSpellingQuestion(
firebaseUid: uid,
questionId: questionId,
);
final Result progressBefore = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM user_spelling_progress
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
final Result linksBefore = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM spelling_question_links
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(progressBefore.first[0], greaterThan(0));
expect(linksBefore.first[0], greaterThan(0));
final Map<String, dynamic> result =
await pipeline.resetSpellingProgressForUser(uid);
expect(result['unansweredCount'], greaterThanOrEqualTo(1));
final Result progressAfter = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM user_spelling_progress
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
final Result linksAfter = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM spelling_question_links
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(progressAfter.first[0], 0);
expect(linksAfter.first[0], 0);
final Result spellingQuestions = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM questions
WHERE assigned_user_id = @uid
AND pipeline_key = 'spelling'
AND user_response IS NULL
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect(spellingQuestions.first[0], 1);
});
}

View File

@ -62,6 +62,10 @@ void main() {
find.byKey(const Key('question-source-edit-spelling')),
findsOneWidget,
);
expect(
find.byKey(const Key('question-source-reset-spelling')),
findsOneWidget,
);
});
testWidgets('spelling edit opens spelling word list screen', (

View File

@ -14,6 +14,7 @@ Future<IncomingQuestion?> _mockPrepare(String questionId) async {
presentation: 'spelling_word',
spokenWord: 'colour',
audioTitle: questionId,
canonicalWord: 'colour',
);
}

View File

@ -0,0 +1,75 @@
import 'package:cyberhybridhub/theme/app_theme.dart';
import 'package:cyberhybridhub/widgets/spelling_question_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('shows green canonical word then submits after one second', (tester) async {
var submitCount = 0;
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: SpellingQuestionTile(
questionId: 'spell-1',
questionText: 'Spell the word you hear.',
spokenWord: 'colour',
canonicalWord: 'colour',
onSubmitAnswer: (_) async {
submitCount++;
},
onDefer: () async {},
),
),
),
);
await tester.enterText(find.byKey(const Key('spelling-answer-field')), 'colour');
await tester.tap(find.byKey(const Key('spelling-submit-answer')));
await tester.pump();
expect(submitCount, 0);
final Text feedback = tester.widget<Text>(
find.byKey(const Key('spelling-feedback-word')),
);
expect(feedback.data, 'colour');
expect(feedback.style?.color, AppColors.success);
await tester.pump(const Duration(seconds: 1));
await tester.pump();
expect(submitCount, 1);
});
testWidgets('shows red canonical word when answer is wrong', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: SpellingQuestionTile(
questionId: 'spell-2',
questionText: 'Spell the word you hear.',
spokenWord: 'colour',
canonicalWord: 'colour',
onSubmitAnswer: (_) async {},
onDefer: () async {},
),
),
),
);
await tester.enterText(find.byKey(const Key('spelling-answer-field')), 'color');
await tester.tap(find.byKey(const Key('spelling-submit-answer')));
await tester.pump();
final Text feedback = tester.widget<Text>(
find.byKey(const Key('spelling-feedback-word')),
);
expect(feedback.data, 'colour');
expect(feedback.style?.color, AppColors.error);
await tester.pump(const Duration(seconds: 1));
await tester.pump();
});
}