整个课程和数据UI重新设计版本

This commit is contained in:
shenlei
2026-09-18 17:55:44 +09:00
parent febfd30f49
commit 0a4f97d105
154 changed files with 12366 additions and 4784 deletions
@@ -14,24 +14,36 @@ class RealHttpOverrides extends HttpOverrides {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
HttpOverrides.global = RealHttpOverrides();
test('transcribeAudio with valid test audio file returns transcription', () async {
final ai = AiService.instance;
ai.setFallbackApiKey('***REMOVED***');
test(
'transcribeAudio with valid test audio file returns transcription',
() async {
// Never hardcode a key here. Supply one at runtime, e.g.:
// flutter test --dart-define=AI_TEST_KEY=sk-...
const apiKey = String.fromEnvironment('AI_TEST_KEY');
if (apiKey.isEmpty) {
markTestSkipped(
'AI_TEST_KEY not set; skipping live transcription test.',
);
return;
}
final ai = AiService.instance;
ai.setFallbackApiKey(apiKey);
final tempFile = File('/tmp/test_unit.m4a');
if (!await tempFile.exists()) {
final dummyData = List<int>.filled(2048, 0);
await tempFile.writeAsBytes(dummyData);
}
final tempFile = File('/tmp/test_unit.m4a');
if (!await tempFile.exists()) {
final dummyData = List<int>.filled(2048, 0);
await tempFile.writeAsBytes(dummyData);
}
final result = await ai.transcribeAudio(
filePath: tempFile.path,
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1/responses',
model: 'gemini-3.7-flash-high',
);
final result = await ai.transcribeAudio(
filePath: tempFile.path,
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1/responses',
model: 'gemini-3.7-flash-high',
);
print('Transcribe result: $result');
expect(result != null || result == null, isTrue);
});
print('Transcribe result: $result');
expect(result != null || result == null, isTrue);
},
);
}
+49 -17
View File
@@ -13,7 +13,10 @@ void main() {
endpoint: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
expect(
uri.toString(),
equals('https://api.openai.com/v1/chat/completions'),
);
});
test('resolves OpenAI endpoint without /v1 to /v1/chat/completions', () {
@@ -22,7 +25,10 @@ void main() {
endpoint: 'https://api.openai.com',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
expect(
uri.toString(),
equals('https://api.openai.com/v1/chat/completions'),
);
});
test('resolves custom compatible endpoint to /v1/chat/completions', () {
@@ -31,7 +37,10 @@ void main() {
endpoint: 'https://codex.slcydia.fun/v1',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
expect(
uri.toString(),
equals('https://codex.slcydia.fun/v1/chat/completions'),
);
});
test('preserves endpoint already ending in /v1/chat/completions', () {
@@ -40,7 +49,10 @@ void main() {
endpoint: 'https://codex.slcydia.fun/v1/chat/completions',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
expect(
uri.toString(),
equals('https://codex.slcydia.fun/v1/chat/completions'),
);
});
test('preserves /responses endpoint for OpenAI Responses API support', () {
@@ -60,7 +72,9 @@ void main() {
);
expect(
uri.toString(),
equals('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent'),
equals(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent',
),
);
});
});
@@ -84,17 +98,32 @@ void main() {
});
test('handles case-insensitive provider mapping and fallbacks', () {
const openAiJson = '{"provider": "openAi", "endpoint": "https://api.openai.com/v1", "model": "gpt-4o"}';
expect(AiConfigFile.parse(openAiJson).provider, equals(AiProviderType.openAi));
const openAiJson =
'{"provider": "openAi", "endpoint": "https://api.openai.com/v1", "model": "gpt-4o"}';
expect(
AiConfigFile.parse(openAiJson).provider,
equals(AiProviderType.openAi),
);
const geminiJson = '{"provider": "GEMINI", "endpoint": "https://generativelanguage.googleapis.com/v1beta", "model": "gemini-2.5-flash"}';
expect(AiConfigFile.parse(geminiJson).provider, equals(AiProviderType.gemini));
const geminiJson =
'{"provider": "GEMINI", "endpoint": "https://generativelanguage.googleapis.com/v1beta", "model": "gemini-2.5-flash"}';
expect(
AiConfigFile.parse(geminiJson).provider,
equals(AiProviderType.gemini),
);
const mockJson = '{"provider": "mock", "endpoint": "", "model": ""}';
expect(AiConfigFile.parse(mockJson).provider, equals(AiProviderType.mock));
expect(
AiConfigFile.parse(mockJson).provider,
equals(AiProviderType.mock),
);
const unknownJson = '{"provider": "unknown_provider", "endpoint": "", "model": ""}';
expect(AiConfigFile.parse(unknownJson).provider, equals(AiProviderType.compatible));
const unknownJson =
'{"provider": "unknown_provider", "endpoint": "", "model": ""}';
expect(
AiConfigFile.parse(unknownJson).provider,
equals(AiProviderType.compatible),
);
});
test('serializes to JSON correctly', () {
@@ -121,10 +150,13 @@ void main() {
expect(key, equals('explicit-override'));
});
test('falls back to fallbackApiKey when no explicit or secure key is set', () async {
AiService.instance.setFallbackApiKey('config-file-key');
final key = await AiService.instance.resolveApiKey();
expect(key, equals('config-file-key'));
});
test(
'falls back to fallbackApiKey when no explicit or secure key is set',
() async {
AiService.instance.setFallbackApiKey('config-file-key');
final key = await AiService.instance.resolveApiKey();
expect(key, equals('config-file-key'));
},
);
});
}
@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/generated_content.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/core/review_feedback.dart';
import 'package:kouyu_english/features/review/review_page.dart';
const _writing = GeneratedLessonTask(
taskId: 'w',
skill: 'writing',
type: 'writeAnswer',
prompt: '写出你来自哪里。',
stimulus: '你来自哪里?',
answer: 'I am from Hong Kong.',
targetItemIds: ['A0-P12'],
answerSpec: GeneratedAnswerSpec(
requiredAnyPhrases: [
["i'm from", 'i am from'],
['hong kong', 'shanghai'],
],
acceptedAnswers: ['I am from Hong Kong.'],
),
);
void ignoreMissingPlugins(WidgetTester tester) {
final messenger = tester.binding.defaultBinaryMessenger;
for (final name in const [
'com.llfbandit.record/messages',
'xyz.luan/audioplayers',
'xyz.luan/audioplayers.global',
]) {
messenger.setMockMethodCallHandler(MethodChannel(name), (_) async => null);
}
messenger.setMockStreamHandler(
const EventChannel('xyz.luan/audioplayers.global/events'),
MockStreamHandler.inline(onListen: (_, _) {}),
);
final reportError = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exception is! MissingPluginException) {
reportError?.call(details);
}
};
addTearDown(() => FlutterError.onError = reportError);
}
void main() {
group('adaptiveAnswerShortfall', () {
test('is null for a correct answer', () {
expect(adaptiveAnswerShortfall(_writing, "I'm from Shanghai."), isNull);
});
test('names the missing phrase group', () {
final message = adaptiveAnswerShortfall(_writing, 'I am from.');
expect(message, contains('hong kong'));
expect(message, isNot(contains('i am from')));
});
test('only counts what a listening answer lacks', () {
const listening = GeneratedLessonTask(
taskId: 'l',
skill: 'listening',
type: 'listenChoice',
prompt: '听。',
stimulus: 'I am from Hong Kong.',
answer: 'Hong Kong',
targetItemIds: ['A0-P12'],
);
final message = adaptiveAnswerShortfall(listening, 'London');
expect(message, contains('还缺 1 处'));
expect(message, isNot(contains('Hong Kong')));
});
});
test('a failed dictation reports the words it missed', () {
final sentence = coreDictationSentences['A0-W02']!.sentence;
final result = ReviewFeedback.check(
ReviewItem(
id: 'A0-W02',
target: sentence,
prompt: '听写',
hint: '',
dueAt: DateTime(2026),
skill: dictationSkill,
),
'My numbr is one-two-three.',
);
expect(result.complete, isFalse);
expect(result.dictation!.missedWords, ['number']);
expect(result.dictation!.extraWords, ['numbr']);
});
testWidgets('a correct review answer is confirmed with the reference', (
tester,
) async {
ignoreMissingPlugins(tester);
final state = AppState();
state.reviewQueue.add(
ReviewItem(
id: 'A0-P12',
target: "I'm from …",
prompt: '告诉对方你来自哪里。',
hint: "I'm from Shanghai.",
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
skill: '回忆表达',
),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ReviewPage(
state: state,
onFinished: () {},
onOpenAdaptiveLesson: () {},
),
),
),
);
await tester.enterText(find.byType(TextField), 'I am from Hong Kong.');
await tester.pump();
final submit = find.text('我能独立回答');
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.pump();
expect(find.text('答对了!'), findsOneWidget);
expect(find.text('你的回答:I am from Hong Kong.'), findsOneWidget);
expect(find.textContaining("参考说法:I'm from …"), findsOneWidget);
await tester.tap(find.text('完成今天的复习'));
await tester.pump();
expect(find.text('今日复习已完成'), findsOneWidget);
});
testWidgets(
'the adaptive lesson confirms a correct answer before moving on',
(tester) async {
ignoreMissingPlugins(tester);
final state = AppState();
state.cacheApprovedAdaptiveLesson(
const GeneratedLesson(
lessonId: 'ai-a0-p12-1',
revision: 1,
stageVersion: 'A0-1.0',
abilityIds: ['A0-C06'],
prerequisiteIds: ['a0-05'],
targetItemIds: ['A0-P12'],
receptiveChunks: [],
previewItemIds: [],
estimatedMinutes: 10,
tasks: [
_writing,
GeneratedLessonTask(
taskId: 'l',
skill: 'listening',
type: 'listenChoice',
prompt: '听。',
stimulus: 'I am from Hong Kong.',
answer: 'Hong Kong',
targetItemIds: ['A0-P12'],
),
GeneratedLessonTask(
taskId: 's',
skill: 'speaking',
type: 'repeat',
prompt: '说。',
stimulus: 'I am from Hong Kong.',
answer: 'I am from Hong Kong.',
targetItemIds: ['A0-P12'],
),
GeneratedLessonTask(
taskId: 'r',
skill: 'reading',
type: 'readAnswer',
prompt: '读。',
stimulus: 'I am from Hong Kong.',
answer: 'Hong Kong',
targetItemIds: ['A0-P12'],
),
],
),
auditor: 'test',
);
await tester.pumpWidget(
MaterialApp(
home: AdaptiveLessonPage(state: state, onFinished: () {}),
),
);
await tester.enterText(find.byType(TextField), 'I am from.');
await tester.pump();
final check = find.text('检查答案');
await tester.ensureVisible(check);
await tester.tap(check);
await tester.pump();
expect(find.textContaining('还缺:hong kong / shanghai'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'I am from Shanghai.');
await tester.pump();
await tester.ensureVisible(check);
await tester.tap(check);
await tester.pump();
expect(find.text('答对了!'), findsOneWidget);
expect(find.text('AI 补练 · 1/4'), findsOneWidget);
final next = find.text('下一项');
await tester.ensureVisible(next);
await tester.tap(next);
await tester.pump();
expect(find.text('AI 补练 · 2/4'), findsOneWidget);
expect(find.text('答对了!'), findsNothing);
},
);
}
+31 -27
View File
@@ -1,11 +1,9 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/a0_core.dart';
import 'package:kouyu_english/core/assessment_bank.dart';
import 'package:kouyu_english/core/courses/course_repository.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/core/generated_content.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/core/review_feedback.dart';
import 'package:kouyu_english/core/seed_courses.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
@@ -845,11 +843,13 @@ void main() {
test('every A0 seed lesson has complete local activities', () {
expect(a0SeedLessons, hasLength(10));
for (final lesson in a0SeedLessons) {
final activity = activityByLessonId(lesson.id);
expect(lesson.vocabulary, isNotEmpty);
expect(activity.answers, hasLength(3));
expect(activity.answers.first, isNotEmpty);
expect(activity.independentPrompt, isNotEmpty);
for (final segment in lesson.segments) {
final activity = activityBySegmentId(segment.id);
expect(activity.answers, hasLength(3));
expect(activity.answers.first, isNotEmpty);
expect(activity.independentPrompt, isNotEmpty);
}
expect(lesson.schemaVersion, '1.0');
expect(lesson.status, ContentStatus.approved);
expect(lesson.source, ContentSource.builtInOriginal);
@@ -863,7 +863,9 @@ void main() {
test('every A0 seed lesson has a practical grammar reminder', () {
for (final lesson in a0SeedLessons) {
expect(grammarNoteForLesson(lesson.id).trim(), isNotEmpty);
for (final segment in lesson.segments) {
expect(grammarNoteForSegment(segment.id).trim(), isNotEmpty);
}
}
});
@@ -892,18 +894,18 @@ void main() {
test('split lesson segments have their own reviewed activities', () {
for (final lessonId in ['a0-04', 'a0-08']) {
for (final segment in lessonById(lessonId).segments) {
expect(a0SegmentActivities, contains(segment.id));
expect(activityBySegmentId(segment.id, lessonId).listening, isNotEmpty);
expect(segmentActivities, contains(segment.id));
expect(activityBySegmentId(segment.id).listening, isNotEmpty);
}
}
expect(activityBySegmentId('a0-01-a', 'a0-01').writingPrompt, isNotEmpty);
expect(activityBySegmentId('a0-01-a').writingPrompt, isNotEmpty);
});
test('split lesson segments have their own controlled dialogues', () {
for (final lessonId in ['a0-04', 'a0-08']) {
for (final segment in lessonById(lessonId).segments) {
expect(a0SegmentDialogues, contains(segment.id));
expect(dialogueBySegmentId(segment.id, lessonId).prompts, isNotEmpty);
expect(segmentDialogues, contains(segment.id));
expect(dialogueBySegmentId(segment.id).prompts, isNotEmpty);
}
}
});
@@ -911,11 +913,11 @@ void main() {
test('split lesson segments have reviewed vocabulary and grammar notes', () {
for (final lessonId in ['a0-04', 'a0-08']) {
for (final segment in lessonById(lessonId).segments) {
final vocabulary = vocabularyBySegmentId(segment.id, lessonId);
final vocabulary = vocabularyBySegmentId(segment.id);
expect(vocabulary.map((item) => item.id), segment.previewItemIds);
expect(vocabulary.every((item) => item.meaning.isNotEmpty), isTrue);
expect(vocabulary.every((item) => item.example.isNotEmpty), isTrue);
expect(grammarNoteForSegment(segment.id, lessonId).trim(), isNotEmpty);
expect(grammarNoteForSegment(segment.id).trim(), isNotEmpty);
}
}
});
@@ -1010,7 +1012,7 @@ void main() {
test('scenes open with their lessons and rotate the recommendation', () {
final state = AppState();
expect(a0Scenes.where(state.isSceneUnlocked).map((scene) => scene.id), [
expect(allScenes.where(state.isSceneUnlocked).map((scene) => scene.id), [
'a0-meet',
]);
state.completedLessonIds.addAll(['a0-01', 'a0-02', 'a0-03', 'a0-04']);
@@ -1205,6 +1207,8 @@ void main() {
});
test('after the last lesson the learner is sent to unfinished lessons', () {
// Only A0: earlier tests' AppState.load() may have registered A1+ units.
CourseRepository.instance.resetForTest();
final state = AppState();
state.setPlacementStartLesson('a0-02');
for (var index = 1; index < a0SeedLessons.length; index++) {
@@ -1437,23 +1441,23 @@ void main() {
});
test('each lesson practises the core items it targets', () {
for (final entry in a0TargetItemIdsByLesson.entries) {
final segments = lessonById(entry.key).segments;
for (final lesson in a0SeedLessons) {
final segments = lesson.segments;
final text = [
for (final segment in segments) ...[
activityBySegmentId(segment.id, entry.key).listening,
activityBySegmentId(segment.id, entry.key).speaking,
activityBySegmentId(segment.id, entry.key).reading,
activityBySegmentId(segment.id, entry.key).writingExample,
for (final item in vocabularyBySegmentId(segment.id, entry.key))
activityBySegmentId(segment.id).listening,
activityBySegmentId(segment.id).speaking,
activityBySegmentId(segment.id).reading,
activityBySegmentId(segment.id).writingExample,
for (final item in vocabularyBySegmentId(segment.id))
'${item.word}\n${item.example}',
],
].join('\n');
for (final id in entry.value) {
for (final id in lesson.targetItemIds) {
expect(
coreItemUsedIn(id, text, word: a0CoreItems[id]),
coreItemUsedIn(id, text),
isTrue,
reason: '${entry.key} → $id ${a0CoreItems[id]}',
reason: '${lesson.id} → $id ${a0CoreItems[id]}',
);
}
}
+33 -14
View File
@@ -1,29 +1,48 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/a0_core.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import '../tool/courses/course_validator.dart';
void main() {
test('tokenize expands contractions and skips digits', () {
expect(tokenize("I'm Tom. Can't pay 15 dollars at 7 a.m.; let's go"), [
'i', 'am', 'tom', 'can', 'not', 'pay', 'dollars', 'at', 'am', 'let',
'us', 'go',
'i',
'am',
'tom',
'can',
'not',
'pay',
'dollars',
'at',
'am',
'let',
'us',
'go',
]);
});
test('match groups use AND across groups and OR inside a group', () {
expect(satisfiesMatch("Sorry, I don't understand.", [
['do not understand'],
]), isTrue);
expect(satisfiesMatch('twelvee', [
['twelve'],
]), isFalse);
expect(satisfiesMatch('What is this word?', [
['what does', 'what is'],
['mean'],
]), isFalse);
expect(
matchesRule("Sorry, I don't understand.", [
['do not understand'],
]),
isTrue,
);
expect(
matchesRule('twelvee', [
['twelve'],
]),
isFalse,
);
expect(
matchesRule('What is this word?', [
['what does', 'what is'],
['mean'],
]),
isFalse,
);
});
test('course packs pass validation', () {
@@ -32,7 +51,7 @@ void main() {
a0Texts: [
for (final item in a0CoreItems.values)
item.replaceAll(RegExp(r'\[[^\]]*\]'), ''),
for (final sentence in a0DictationSentences.values) sentence.sentence,
for (final sentence in coreDictationSentences.values) sentence.sentence,
],
).validate();
expect(report.errors.map((issue) => '$issue'), isEmpty);
@@ -0,0 +1,132 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/core/courses/course_repository.dart';
List<SeedLesson> get loadedLessons =>
allLessons.where((lesson) => lesson.level != 'A0').toList();
List<DialogueScene> get loadedScenes =>
allScenes.where((scene) => !scene.id.startsWith('a0-')).toList();
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => CourseRepository.instance.resetForTest());
tearDown(() => CourseRepository.instance.resetForTest());
test('loads every unit and adapts it into the runtime registries', () async {
await CourseRepository.instance.load();
final units = CourseRepository.instance.units;
// The course map lists 10 A0 + 21 A1 + 15 A2 + 14 B1 units.
expect(units.length, 60);
expect(units.where((u) => u.level == 'A0').length, 10);
expect(units.where((u) => u.level == 'A1').length, 21);
expect(units.where((u) => u.level == 'A2').length, 15);
expect(units.where((u) => u.level == 'B1').length, 14);
// Unit ids are unique.
expect(units.map((u) => u.id).toSet().length, units.length);
// Adapted lessons are appended after A0 with continuous numbering.
expect(loadedLessons.length, 50);
expect(loadedLessons.first.number, a0SeedLessons.length + 1);
expect(loadedLessons.last.number, a0SeedLessons.length + 50);
expect(allLessons.length, a0SeedLessons.length + 50);
for (final lesson in loadedLessons) {
expect(
lesson.segments,
isNotEmpty,
reason: '${lesson.id} has no segments',
);
expect(lesson.targetItemIds, isNotEmpty);
expect(lesson.vocabulary, isNotEmpty);
// Every adapted lesson resolves through the shared accessors.
expect(lessonById(lesson.id).id, lesson.id);
for (final segment in lesson.segments) {
expect(findLessonSegment(segment.id)?.id, segment.id);
final activity = activityBySegmentId(segment.id);
expect(
activity.listening.isNotEmpty || activity.reading.isNotEmpty,
isTrue,
reason: '${segment.id} has empty listening and reading',
);
}
}
});
test(
'core items register their labels, mastery flag and match rules',
() async {
await CourseRepository.instance.load();
final unit = CourseRepository.instance.unitById('A1-U01')!;
expect(unit.coreItems, isNotEmpty);
for (final item in unit.coreItems) {
expect(isCoreItem(item.id), isTrue);
expect(coreItemLabel(item.id), item.zh);
expect(coreItemEnglish(item.id), item.en);
expect(coreItemMatch[item.id], item.match);
expect(coreReviewTemplates[item.id], isNotNull);
}
},
);
test('each unit main scene becomes a free-practice scene', () async {
await CourseRepository.instance.load();
final expected = CourseRepository.instance.units
.where((u) => u.level != 'A0' && u.mainScene != null)
.length;
expect(loadedScenes.length, expected);
for (final scene in loadedScenes) {
expect(scene.unlockAfterLessonId, isNotNull);
expect(scene.script.prompts, isNotEmpty);
// The scene is reachable through the shared accessor.
expect(sceneById(scene.id).id, scene.id);
}
});
test('listening/reading materials parse with answerable questions', () async {
await CourseRepository.instance.load();
final withMaterials = CourseRepository.instance.units
.where((u) => u.materials.isNotEmpty)
.toList();
expect(withMaterials, isNotEmpty);
for (final unit in withMaterials) {
for (final material in unit.materials) {
expect(material.text, isNotEmpty);
for (final question in material.questions) {
expect(question.options, isNotEmpty);
// The first option is the correct answer by contract.
expect(question.answer, question.options.first);
}
}
}
});
test('load is idempotent', () async {
await CourseRepository.instance.load();
final count = loadedLessons.length;
await CourseRepository.instance.load();
expect(loadedLessons.length, count);
expect(CourseRepository.instance.units.length, 60);
});
test('AI word list covers only the recent units, in English', () async {
await CourseRepository.instance.load();
final b1 = loadedLessons.last;
final words = taughtLanguageUpTo(b1.id);
final window = loadedLessons.sublist(
loadedLessons.length - taughtLanguageUnitWindow,
);
final expected = <String>{
for (final lesson in window)
for (final id in lesson.targetItemIds) coreItemEnglish(id),
};
expect(words, expected.toList());
expect(words.any((w) => RegExp(r'[\u4e00-\u9fff]').hasMatch(w)), isFalse);
// The first A1 unit does not pull in A0.
final firstA1 = taughtLanguageUpTo(loadedLessons.first.id);
expect(firstA1.any(a0CoreItems.values.contains), isFalse);
expect(lessonLevel(b1.id), 'B1');
});
}
@@ -0,0 +1,87 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/courses/course_repository.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/core/models.dart';
/// Every level goes through the same pack structure and the same rules, so
/// the model answers every pack ships have to pass the rules it ships.
void main() {
setUpAll(() => CourseRepository.instance.load());
tearDownAll(() => CourseRepository.instance.resetForTest());
test('each core item and its dictation sentence pass its own rule', () {
final failures = <String>[];
for (final pack in CourseRepository.instance.units) {
for (final item in pack.coreItems) {
if (item.match.isEmpty) continue;
// A slot like [place] is filled by the learner, so only a fixed
// phrase can be checked as written.
if (!item.en.contains('[') && !matchesRule(item.en, item.match)) {
failures.add('${item.id} en "${item.en}"');
}
if (item.dictationSentence.isNotEmpty &&
!matchesRule(item.dictationSentence, item.match)) {
failures.add('${item.id} dictation "${item.dictationSentence}"');
}
}
}
expect(failures, isEmpty);
});
test('each writing example passes its writing rule', () {
final failures = [
for (final MapEntry(key: id, value: activity)
in segmentActivities.entries)
if (activity.writingExample.isNotEmpty &&
activity.writingRequiredTerms.isNotEmpty &&
!matchesRule(
activity.writingExample,
activity.writingRequiredTerms,
))
'$id "${activity.writingExample}"',
];
expect(failures, isEmpty);
});
test('each dialogue model answer passes its turn', () {
final failures = <String>[];
final dialogues = {
...segmentDialogues,
for (final scene in allScenes) 'scene ${scene.id}': scene.script,
};
for (final MapEntry(key: id, value: script) in dialogues.entries) {
for (var stage = 0; stage < script.hints.length; stage++) {
if (!matchesDialogueStage(script, stage, script.hints[stage])) {
failures.add('$id #${stage + 1} "${script.hints[stage]}"');
}
}
}
expect(failures, isEmpty);
});
test('assessment tasks are answerable and replacements point home', () {
final ids = {for (final pack in assessmentPacks) pack.id};
final failures = <String>[];
for (final pack in assessmentPacks) {
if (pack.replaces != null && !ids.contains(pack.replaces)) {
failures.add('${pack.id} replaces missing ${pack.replaces}');
}
for (final task in pack.tasks) {
final open =
task.skill == AssessmentSkill.writing ||
task.skill == AssessmentSkill.speaking;
if (open) {
if (task.accept.isEmpty) failures.add('${task.id} has no accept');
if (task.guide.isEmpty) failures.add('${task.id} has no guide');
} else {
final index = task.answerIndex;
if (index == null || index < 0 || index >= task.choices.length) {
failures.add('${task.id} answerIndex out of range');
}
}
}
}
expect(a0AssessmentPacks, isNotEmpty);
expect(failures, isEmpty);
});
}
+27 -1
View File
@@ -105,6 +105,32 @@ void main() {
expect(jsonDecode(messages![1]['content'] as String), {
'reply': "Hi! What's your name?",
});
expect(messages![2], {'role': 'user', 'content': 'My name is Alex.'});
expect(messages![2]['role'], 'user');
final last = messages![2]['content'] as String;
expect(last, startsWith('My name is Alex.'));
expect(
last,
endsWith('After your line the learner has to: say where they are from.'),
);
expect(messages![0]['content'], isNot(contains('Ask where the learner')));
});
test('dialogue system prompt stays identical across turns', () {
final words = ['Excuse me.', 'Can you help me?'];
final a = AiService.dialogueSystemPrompt(
level: 'A1',
allowedLanguage: words,
);
final b = AiService.dialogueSystemPrompt(
level: 'A1',
allowedLanguage: words,
);
expect(a, b);
expect(a, contains('CEFR A1'));
expect(a, endsWith('Taught language: Excuse me.; Can you help me?'));
expect(
AiService.dialogueSystemPrompt(level: 'A0', allowedLanguage: const []),
contains('A0 English conversation partner'),
);
});
}
@@ -0,0 +1,127 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
void main() {
testWidgets('sending a turn after an AI correction counts as assisted', (
tester,
) async {
// The page wires up recording and playback plugins that tests lack.
final messenger = tester.binding.defaultBinaryMessenger;
for (final name in const [
'com.llfbandit.record/messages',
'xyz.luan/audioplayers',
'xyz.luan/audioplayers.global',
]) {
messenger.setMockMethodCallHandler(
MethodChannel(name),
(_) async => null,
);
}
messenger.setMockStreamHandler(
const EventChannel('xyz.luan/audioplayers.global/events'),
MockStreamHandler.inline(onListen: (_, _) {}),
);
// Each player also opens its own event channel under a random id.
final reportError = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exception is! MissingPluginException) {
reportError?.call(details);
}
};
addTearDown(() => FlutterError.onError = reportError);
AiService.instance.setFallbackApiKey('test-key');
final state = AppState()
..aiProvider = AiProviderType.compatible
..aiEndpoint = 'https://api.deepseek.com'
..aiModel = 'deepseek-flash';
// A free scene only records items the learner has already been taught.
state.mastery['A0-P01'] = MasteryItem(
id: 'A0-P01',
label: '介绍名字',
status: MasteryStatus.recognize,
evidence: const [],
firstTaughtAt: DateTime.now().subtract(const Duration(days: 1)),
);
final check = jsonEncode({
'schemaVersion': 'writing-feedback-1',
'verdict': 'rewrite',
'feedback': '介绍姓名正确,后半句有语法错误。',
'suggestion': 'My name is Alex and I am happy.',
'missing': ['I is 应为 I am'],
'lessonId': 'dialogue-0',
});
final prompts = <String>[];
http.Response reply(String content) => http.Response(
jsonEncode({
'choices': [
{
'message': {'content': content},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
await http.runWithClient(
() async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialoguePage(state: state, onFinished: (_) {}),
),
),
);
await tester.pump();
await tester.enterText(
find.byType(TextField),
'My name is Alex and I is happy',
);
await tester.pump();
final aiButton = find.text('发送前 AI 检查语法和拼写(可选)');
await tester.ensureVisible(aiButton);
await tester.tap(aiButton);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text('· I is 应为 I am'), findsOneWidget);
expect(
find.text('参考改正:My name is Alex and I am happy.'),
findsOneWidget,
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
},
() => MockClient((request) async {
final body = request.body;
prompts.add(body);
// The dialogue reply itself may fall back to the script.
return reply(body.contains('writing-feedback-1') ? check : '{}');
}),
);
expect(prompts.first, contains('I is happy'));
expect(prompts.first, contains('dialogue-0'));
final attempt = state.attemptEvidence.singleWhere(
(entry) => entry.taskId.startsWith('dialogue-scene-'),
);
expect(attempt.itemId, 'A0-P01');
expect(attempt.outcome, EvidenceKind.assisted);
expect(state.sceneDialogueDraft?.usedHelp, isTrue);
});
}
+46 -58
View File
@@ -1,10 +1,13 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/seed_courses.dart';
import 'package:kouyu_english/core/courses/courses.dart';
/// The free "初次见面" scene, which is always open.
LessonDialogue get meet => sceneById('a0-meet').script;
void main() {
group('对话任务校验', () {
test('每个对话都为每一轮声明了任务标签', () {
for (final entry in a0Dialogues.entries) {
for (final entry in segmentDialogues.entries) {
final script = entry.value;
expect(
script.taskLabels.length,
@@ -20,22 +23,12 @@ void main() {
expect(group, isNotEmpty);
}
}
for (final entry in a0SegmentDialogues.entries) {
expect(
entry.value.taskLabels.length,
entry.value.prompts.length,
reason: '${entry.key} 的任务标签数量要和回合数一致',
);
}
expect(a0MeetDialogue.taskLabels.length, a0MeetDialogue.prompts.length);
expect(
a0MeetDialogue.requiredTerms.length,
a0MeetDialogue.prompts.length,
);
expect(meet.taskLabels.length, meet.prompts.length);
expect(meet.requiredTerms.length, meet.prompts.length);
});
test('示范答案能通过对应回合的校验', () {
for (final entry in {...a0Dialogues, 'scene': a0MeetDialogue}.entries) {
for (final entry in {...segmentDialogues, 'scene': meet}.entries) {
final script = entry.value;
for (var stage = 0; stage < script.hints.length; stage++) {
expect(
@@ -50,52 +43,40 @@ void main() {
test('跑题回答不再因为关键词沾边而通过', () {
// 旧实现用 text.contains('it'),下面这些句子全部会被判为完成任务。
expect(
matchesDialogueStage(a0Dialogues['a0-05']!, 0, 'I did it.'),
matchesDialogueStage(segmentDialogues['a0-05-a']!, 0, 'I did it.'),
isFalse,
);
expect(
matchesDialogueStage(a0Dialogues['a0-08']!, 0, 'It is good.'),
matchesDialogueStage(segmentDialogues['a0-08-c']!, 1, 'It is good.'),
isFalse,
);
expect(
matchesDialogueStage(a0Dialogues['a0-09']!, 2, 'I like tea.'),
matchesDialogueStage(segmentDialogues['a0-09-a']!, 2, 'I like tea.'),
isFalse,
);
expect(matchesDialogueStage(a0MeetDialogue, 0, 'Hello.'), isFalse);
expect(matchesDialogueStage(a0MeetDialogue, 1, 'I am good.'), isFalse);
expect(matchesDialogueStage(meet, 0, 'Hello.'), isFalse);
expect(matchesDialogueStage(meet, 1, 'I am good.'), isFalse);
});
test('自由场景接受同样正确的其他说法', () {
// 旧正则只认 good/okay/tired,也不认 my name's。
expect(
matchesDialogueStage(a0MeetDialogue, 0, "My name's Shen."),
isTrue,
);
expect(matchesDialogueStage(a0MeetDialogue, 0, 'I am Shen.'), isTrue);
expect(
matchesDialogueStage(a0MeetDialogue, 2, "I'm fine, thanks."),
isTrue,
);
expect(matchesDialogueStage(a0MeetDialogue, 2, 'I am great!'), isTrue);
expect(
matchesDialogueStage(a0MeetDialogue, 3, 'Where are you from?'),
isTrue,
);
expect(
matchesDialogueStage(a0MeetDialogue, 3, 'Do you like tea?'),
isTrue,
);
expect(matchesDialogueStage(meet, 0, "My name's Shen."), isTrue);
expect(matchesDialogueStage(meet, 0, 'I am Shen.'), isTrue);
expect(matchesDialogueStage(meet, 2, "I'm fine, thanks."), isTrue);
expect(matchesDialogueStage(meet, 2, 'I am great!'), isTrue);
expect(matchesDialogueStage(meet, 3, 'Where are you from?'), isTrue);
expect(matchesDialogueStage(meet, 3, 'Do you like tea?'), isTrue);
});
test('只写半句不算完成任务', () {
expect(matchesDialogueStage(a0MeetDialogue, 0, "I'm"), isFalse);
expect(matchesDialogueStage(meet, 0, "I'm"), isFalse);
expect(
matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is'),
matchesDialogueStage(segmentDialogues['a0-04-c']!, 0, 'My number is'),
isFalse,
);
expect(
matchesDialogueStage(
a0Dialogues['a0-04']!,
segmentDialogues['a0-04-c']!,
0,
'My number is one-three-eight.',
),
@@ -104,11 +85,14 @@ void main() {
});
test('拼读和数字这类结构化要求可以识别', () {
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'S-H-E-N'), isTrue);
expect(
matchesDialogueStage(segmentDialogues['a0-02-a']!, 1, 'S-H-E-N'),
isTrue,
);
});
test('拼读忽略大小写和连字符', () {
final spelling = a0Dialogues['a0-02']!;
final spelling = segmentDialogues['a0-02-a']!;
for (final answer in [
'A-A-A',
'aaa',
@@ -137,36 +121,40 @@ void main() {
);
}
expect(
matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's three o'clock."),
matchesDialogueStage(
segmentDialogues['a0-08-c']!,
1,
"It's three o'clock.",
),
isTrue,
);
expect(
matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's Monday."),
matchesDialogueStage(segmentDialogues['a0-08-c']!, 1, "It's Monday."),
isFalse,
);
});
test('任务标签可用于提示和总结', () {
expect(dialogueTaskLabel(a0MeetDialogue, 0), '介绍姓名');
expect(dialogueTaskLabel(a0MeetDialogue, 9), '完成本轮任务');
expect(dialogueTaskLabel(meet, 0), '介绍姓名');
expect(dialogueTaskLabel(meet, 9), '完成本轮任务');
});
test('输入校验忽略多余空格与大小写', () {
expect(
matchesDialogueStage(meet, 0, " MY NAME IS SHEN "),
isTrue,
);
expect(
matchesDialogueStage(
a0MeetDialogue,
0,
" MY NAME IS SHEN ",
segmentDialogues['a0-02-a']!,
1,
" S H E N ",
),
isTrue,
);
expect(
matchesDialogueStage(a0Dialogues["a0-02"]!, 1, " S H E N "),
isTrue,
);
expect(
matchesDialogueStage(
a0Dialogues["a0-04"]!,
segmentDialogues['a0-04-c']!,
0,
"MY NUMBER IS ONE-THREE-EIGHT",
),
@@ -174,8 +162,8 @@ void main() {
);
expect(
matchesDialogueStage(
a0Dialogues["a0-08"]!,
2,
segmentDialogues['a0-08-c']!,
1,
"IT'S THREE O'CLOCK",
),
isTrue,
@@ -188,7 +176,7 @@ void main() {
expect(first, isNotEmpty);
expect(later.length, greaterThan(first.length));
expect(first.every(later.contains), isTrue);
expect(later.length, lessThanOrEqualTo(allTaughtLanguage.length));
expect(later.length, lessThanOrEqualTo(a0TaughtLanguage.length));
expect(
first.any((word) => word.toLowerCase().contains('coffee')),
isFalse,
@@ -1,79 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/seed_courses.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
void main() {
testWidgets('Dialogue bottom translation chip displays latest partner translation', (
WidgetTester tester,
) async {
final state = AppState();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialoguePage(
state: state,
onFinished: (_) {},
isLessonDialogue: false,
testWidgets(
'Dialogue bottom translation chip displays latest partner translation',
(WidgetTester tester) async {
final state = AppState();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialoguePage(
state: state,
onFinished: (_) {},
isLessonDialogue: false,
),
),
),
),
);
await tester.pumpAndSettle();
);
await tester.pumpAndSettle();
// Initial partner prompt is displayed
expect(find.text('Hi! My name is Mia. What’s your name?'), findsOneWidget);
// Initial partner prompt is displayed
expect(
find.text('Hi! My name is Mia. What’s your name?'),
findsOneWidget,
);
// Click the bottom "翻译" assist chip
final bottomTranslateChip = find.widgetWithText(ActionChip, '翻译');
expect(bottomTranslateChip, findsOneWidget);
await tester.tap(bottomTranslateChip);
await tester.pumpAndSettle();
// Click the bottom "翻译" assist chip
final bottomTranslateChip = find.widgetWithText(ActionChip, '翻译');
expect(bottomTranslateChip, findsOneWidget);
await tester.tap(bottomTranslateChip);
await tester.pumpAndSettle();
// Hint banner displays the latest AI translation
expect(find.text('对方说:嗨!我叫 Mia。你叫什么名字?'), findsOneWidget);
// Hint banner displays the latest AI translation
expect(find.text('对方说:嗨!我叫 Mia。你叫什么名字?'), findsOneWidget);
// Message bubble also shows the inline translation
expect(find.text('嗨!我叫 Mia。你叫什么名字?'), findsOneWidget);
expect(find.text('隐藏翻译'), findsOneWidget);
// Message bubble also shows the inline translation
expect(find.text('嗨!我叫 Mia。你叫什么名字?'), findsOneWidget);
expect(find.text('隐藏翻译'), findsOneWidget);
// Tap "隐藏翻译" on the bubble to hide it
await tester.tap(find.text('隐藏翻译'));
await tester.pumpAndSettle();
// Tap "隐藏翻译" on the bubble to hide it
await tester.tap(find.text('隐藏翻译'));
await tester.pumpAndSettle();
expect(find.text('翻译'), findsWidgets); // Both the bottom chip and bubble button
});
expect(
find.text('翻译'),
findsWidgets,
); // Both the bottom chip and bubble button
},
);
testWidgets('Course lesson dialogue resolves translations correctly for lesson prompts', (
WidgetTester tester,
) async {
final state = AppState()..activeLessonId = 'a0-02';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialoguePage(
state: state,
onFinished: (_) {},
isLessonDialogue: true,
testWidgets(
'Course lesson dialogue resolves translations correctly for lesson prompts',
(WidgetTester tester) async {
final state = AppState()..activeLessonId = 'a0-02';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialoguePage(
state: state,
onFinished: (_) {},
isLessonDialogue: true,
),
),
),
),
);
await tester.pumpAndSettle();
);
await tester.pumpAndSettle();
// Lesson a0-02 initial prompt
final expectedPrompt = a0Dialogues['a0-02']!.prompts.first;
expect(find.text(expectedPrompt), findsOneWidget);
// Lesson a0-02 initial prompt
final expectedPrompt = segmentDialogues['a0-02-a']!.prompts.first;
expect(find.text(expectedPrompt), findsOneWidget);
// Tap bubble's "翻译" button
final bubbleTranslateBtn = find.text('翻译').first;
await tester.tap(bubbleTranslateBtn);
await tester.pumpAndSettle();
// Tap bubble's "翻译" button
final bubbleTranslateBtn = find.text('翻译').first;
await tester.tap(bubbleTranslateBtn);
await tester.pumpAndSettle();
// Check translation is shown
final expectedTranslation = a0Dialogues['a0-02']!.translations.first;
expect(find.text(expectedTranslation), findsOneWidget);
expect(find.text('隐藏翻译'), findsOneWidget);
});
// Check translation is shown
final expectedTranslation =
segmentDialogues['a0-02-a']!.translations.first;
expect(find.text(expectedTranslation), findsOneWidget);
expect(find.text('隐藏翻译'), findsOneWidget);
},
);
}
@@ -0,0 +1,11 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/courses/course_repository.dart';
/// Every test sees the A0 course, just like the app after `main` loads it.
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
TestWidgetsFlutterBinding.ensureInitialized();
await CourseRepository.instance.load(levels: {'A0'});
await testMain();
}
+160
View File
@@ -0,0 +1,160 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/features/lesson/lesson_flow.dart';
void main() {
group('independentShortfall', () {
test('is null when the attempt passes', () {
expect(independentShortfall('a0-04-a', 'zero, one, two'), isNull);
expect(
independentShortfall('a0-01-a', "My name is Alex. Nice to meet you."),
isNull,
);
});
test('names the missing part of a single-segment task', () {
final message = independentShortfall('a0-01-a', 'My name is Alex.');
expect(message, contains('nice to meet you'));
expect(message, isNot(contains('my name is')));
});
test('counts the numbers still needed', () {
expect(
independentShortfall('a0-04-a', 'zero, one'),
contains('目前用上 2 个'),
);
});
test('lists the taught expressions of a split segment', () {
expect(
independentShortfall('a0-08-b', 'It is Monday.'),
contains('friday'),
);
});
});
group('assessmentAnswerGuide', () {
final pack = a0AssessmentPacks.first;
test('shows the correct choice', () {
final task = pack.forSkill(AssessmentSkill.listening).first;
expect(assessmentAnswerGuide(task, '买东西'), '正确答案:自我介绍');
expect(assessmentAnswerGuide(task, '自我介绍'), contains('没有播放音频'));
});
test('explains open speaking and writing tasks', () {
final speaking = pack.forSkill(AssessmentSkill.speaking)[3];
expect(
assessmentAnswerGuide(speaking, 'my mother'),
contains('This is my'),
);
final writing = pack.forSkill(AssessmentSkill.writing).first;
expect(assessmentAnswerGuide(writing, 'abc'), isNotEmpty);
});
});
testWidgets('finishing step 6 after an AI correction counts as assisted', (
tester,
) async {
final messenger = tester.binding.defaultBinaryMessenger;
for (final name in const [
'com.llfbandit.record/messages',
'xyz.luan/audioplayers',
'xyz.luan/audioplayers.global',
]) {
messenger.setMockMethodCallHandler(
MethodChannel(name),
(_) async => null,
);
}
messenger.setMockStreamHandler(
const EventChannel('xyz.luan/audioplayers.global/events'),
MockStreamHandler.inline(onListen: (_, _) {}),
);
final reportError = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exception is! MissingPluginException) {
reportError?.call(details);
}
};
addTearDown(() => FlutterError.onError = reportError);
AiService.instance.setFallbackApiKey('test-key');
final state = AppState()
..aiProvider = AiProviderType.compatible
..aiEndpoint = 'https://api.deepseek.com'
..aiModel = 'deepseek-flash'
..activeLessonId = 'a0-01'
..lessonStep = LessonStep.independent;
final segmentId = lessonById('a0-01').segments.first.id;
final check = jsonEncode({
'schemaVersion': 'writing-feedback-1',
'verdict': 'rewrite',
'feedback': '内容完整,有一个语法错误。',
'suggestion': 'My name is Alex. Nice to meet you. I am happy.',
'missing': ['I is 应为 I am'],
'lessonId': segmentId,
});
final prompts = <String>[];
await http.runWithClient(
() async {
await tester.pumpWidget(
MaterialApp(
home: LessonFlow(
state: state,
onOpenDialogue: () {},
onFinish: () {},
),
),
);
await tester.pump();
expect(find.text('独立完成'), findsOneWidget);
await tester.enterText(
find.byType(TextField),
'My name is Alex. Nice to meet you. I is happy.',
);
await tester.pump();
final aiButton = find.text('提交前 AI 检查语法和拼写(可选)');
await tester.ensureVisible(aiButton);
await tester.tap(aiButton);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text('· I is 应为 I am'), findsOneWidget);
final finish = find.text('带帮助完成');
await tester.ensureVisible(finish);
await tester.tap(finish);
await tester.pump();
},
() => MockClient((request) async {
prompts.add(request.body);
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': check},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
expect(prompts.single, contains('I is happy'));
expect(state.lessonStep, LessonStep.complete);
expect(state.independentAttemptAssisted, isTrue);
});
}
+7 -11
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/seed_courses.dart';
import 'package:kouyu_english/core/courses/courses.dart';
const _digitWords = {
'0': 'zero',
@@ -23,10 +23,7 @@ String _normalize(String text) =>
/// 逐字母拼写(S-H-E-N)先去掉连字符。
bool _appearsInReading(String reading, String option) {
final haystack = _normalize(
reading.replaceAllMapped(
RegExp(r'\d'),
(match) => _digitWords[match[0]]!,
),
reading.replaceAllMapped(RegExp(r'\d'), (match) => _digitWords[match[0]]!),
);
final tokens = option
.replaceAll('-', '')
@@ -38,10 +35,7 @@ bool _appearsInReading(String reading, String option) {
}
void main() {
final activities = <String, LessonActivity>{
...a0Activities,
...a0SegmentActivities,
};
final activities = segmentActivities;
group('阅读找答案题', () {
test('每题三个选项,第一项是标准答案', () {
@@ -92,7 +86,8 @@ void main() {
options[i].contains(options[j]) ||
options[j].contains(options[i]),
isFalse,
reason: '${entry.key}:“${entry.value.readingOptions[i]}”与'
reason:
'${entry.key}:“${entry.value.readingOptions[i]}”与'
'“${entry.value.readingOptions[j]}”互为子串,会被判分逻辑当成同一个答案',
);
}
@@ -130,7 +125,8 @@ void main() {
'${activity.listening}-listening',
);
final listeningSlot = listening.indexOf(activity.answers.first);
listeningSlots[listeningSlot] = (listeningSlots[listeningSlot] ?? 0) + 1;
listeningSlots[listeningSlot] =
(listeningSlots[listeningSlot] ?? 0) + 1;
}
for (final slots in [readingSlots, listeningSlots]) {
expect(slots.keys.toSet(), {0, 1, 2});
+9 -6
View File
@@ -29,12 +29,15 @@ void main() {
expect(findCourseLexicon('teacher'), isNull);
});
test('lookup matches reviewed vocabulary with punctuation and apostrophes', () {
expect(findCourseLexicon('tea'), isNotNull);
expect(findCourseLexicon('tea.'), isNotNull);
expect(findCourseLexicon("What's this?"), isNotNull);
expect(findCourseLexicon('What’s this?'), isNotNull);
});
test(
'lookup matches reviewed vocabulary with punctuation and apostrophes',
() {
expect(findCourseLexicon('tea'), isNotNull);
expect(findCourseLexicon('tea.'), isNotNull);
expect(findCourseLexicon("What's this?"), isNotNull);
expect(findCourseLexicon('What’s this?'), isNotNull);
},
);
testWidgets('cached temporary definition appears when lookup opens', (
tester,
+31 -22
View File
@@ -50,9 +50,7 @@ void main() {
_env('KOUYU_AI_ENDPOINT') ?? 'https://codex.slcydia.fun/v1/responses',
);
final model = _env('KOUYU_AI_MODEL') ?? 'gemini-3.7-flash-high';
final skipReason = apiKey == null
? '未设置 KOUYU_AI_API_KEY,跳过联网测试。'
: null;
final skipReason = apiKey == null ? '未设置 KOUYU_AI_API_KEY,跳过联网测试。' : null;
if (apiKey != null) {
AiService.instance.setFallbackApiKey(apiKey);
}
@@ -73,7 +71,9 @@ void main() {
model: model,
explicitApiKey: apiKey,
);
print('Responses API result: ok=${resResponses.ok}, msg=${resResponses.message}');
print(
'Responses API result: ok=${resResponses.ok}, msg=${resResponses.message}',
);
} catch (e) {
print('Network test skipped: $e');
}
@@ -87,27 +87,36 @@ void main() {
model: model,
explicitApiKey: apiKey,
);
print('Chat Completions API result: ok=${resChat.ok}, msg=${resChat.message}');
print(
'Chat Completions API result: ok=${resChat.ok}, msg=${resChat.message}',
);
} catch (e) {
print('Network test skipped: $e');
}
}, skip: skipReason);
test('Live test: /v1/responses dialogueReply', timeout: const Timeout(Duration(seconds: 60)), () async {
try {
final reply = await AiService.instance.dialogueReply(
provider: AiProviderType.compatible,
endpoint: '$baseUrl/v1/responses',
model: model,
history: [
{'role': 'user', 'content': 'Hello, my name is Alex.'}
],
aiGoal: 'Greet the learner and ask their name',
learnerTask: 'say their own name',
);
print('Dialogue reply from /v1/responses: reply="${reply?.reply}", translation="${reply?.translation}", feedback="${reply?.feedback}"');
} catch (e) {
print('Network test skipped: $e');
}
}, skip: skipReason);
test(
'Live test: /v1/responses dialogueReply',
timeout: const Timeout(Duration(seconds: 60)),
() async {
try {
final reply = await AiService.instance.dialogueReply(
provider: AiProviderType.compatible,
endpoint: '$baseUrl/v1/responses',
model: model,
history: [
{'role': 'user', 'content': 'Hello, my name is Alex.'},
],
aiGoal: 'Greet the learner and ask their name',
learnerTask: 'say their own name',
);
print(
'Dialogue reply from /v1/responses: reply="${reply?.reply}", translation="${reply?.translation}", feedback="${reply?.feedback}"',
);
} catch (e) {
print('Network test skipped: $e');
}
},
skip: skipReason,
);
}
+47
View File
@@ -162,4 +162,51 @@ void main() {
await executor.close();
},
);
test(
'repeated writes with cached analyses still persist later review progress',
() async {
final executor = NativeDatabase.memory();
final store = LocalSnapshotStore.forTesting(executor);
String snapshot(String dueAt) => jsonEncode({
'onboardingComplete': true,
'activeLessonId': 'a0-01',
'attemptEvidence': [],
'mastery': [],
'reviews': [
{
'id': 'hello',
'target': 'hello',
'prompt': 'Say hello',
'hint': '你好',
'dueAt': dueAt,
'skill': '口头回忆',
},
],
'temporaryLexicon': [
{'query': 'Hello', 'definition': '你好'},
{'query': 'hello', 'definition': '你好'},
],
'sentenceAnalyses': [
{
'query': 'Nice to meet you.',
'payload': {'translation': '很高兴认识你。'},
},
],
});
await store.write(snapshot('2026-09-18T08:00:00.000'));
// A completed review moves the card to tomorrow; this write must land.
await store.write(snapshot('2026-09-19T08:00:00.000'));
final restored =
jsonDecode((await store.read())!) as Map<String, dynamic>;
expect(
(restored['reviews'] as List).single['dueAt'],
'2026-09-19T08:00:00.000',
);
expect(restored['sentenceAnalyses'], hasLength(1));
await executor.close();
},
);
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/core/courses/course_pack.dart';
import 'package:kouyu_english/core/courses/course_repository.dart';
import 'package:kouyu_english/features/lesson/lesson_flow.dart';
/// Widget smoke test for the JSON units' 听读材料 (listening/reading
/// comprehension) step: it must be reached only on the last segment of a unit
/// that has materials, render from the real loaded content, and let the learner
/// continue to writing once every question is answered.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await CourseRepository.instance.load();
});
tearDownAll(() => CourseRepository.instance.resetForTest());
/// The first loaded unit whose materials contain at least one question.
CoursePack unitWithQuestions() => CourseRepository.instance.units.firstWhere(
(u) => u.materials.any((m) => m.questions.isNotEmpty),
);
AppState stateOn(String unitId, int segmentIndex) {
final state = AppState();
state.activeLessonId = unitId;
state.activeSegmentIndexes[unitId] = segmentIndex;
return state;
}
testWidgets(
'material step is reached only on the last segment with materials',
(tester) async {
final unit = unitWithQuestions();
final lesson = lessonById(unit.id);
expect(
lesson.segments.length,
greaterThan(1),
reason: 'need a multi-segment unit to prove the gate',
);
// Finishing reading on an earlier segment skips straight to writing.
final early = stateOn(unit.id, 0);
early.lessonStep = LessonStep.reading;
early.completeReading();
expect(early.lessonStep, LessonStep.writing);
// Finishing reading on the final segment opens the comprehension step.
final last = stateOn(unit.id, lesson.segments.length - 1);
last.lessonStep = LessonStep.reading;
last.completeReading();
expect(last.lessonStep, LessonStep.material);
},
);
testWidgets(
'renders the unit materials and continues once every question is answered',
(tester) async {
final unit = unitWithQuestions();
final lesson = lessonById(unit.id);
final state = stateOn(unit.id, lesson.segments.length - 1);
state.lessonStep = LessonStep.material;
await tester.pumpWidget(
MaterialApp(
home: LessonFlow(
state: state,
onOpenDialogue: () {},
onFinish: () {},
),
),
);
await tester.pumpAndSettle();
// The step rendered from the real loaded content.
expect(find.text('读一段真实的小材料,做完理解题。'), findsOneWidget);
// Continue is gated until every question is answered.
expect(find.text('答对所有理解题后继续'), findsOneWidget);
expect(find.text('继续写一写'), findsNothing);
// Answer every question by tapping its correct option (answer == the
// parsed contract's first option; the widget shuffles their display order).
for (final material in unit.materials) {
for (final question in material.questions) {
final option = find.text(question.answer).first;
await tester.ensureVisible(option);
await tester.pumpAndSettle();
await tester.tap(option);
await tester.pumpAndSettle();
}
}
// Now the button is enabled and advances the lesson to writing.
final go = find.text('继续写一写');
expect(go, findsOneWidget);
await tester.ensureVisible(go);
await tester.tap(go);
await tester.pumpAndSettle();
expect(state.lessonStep, LessonStep.writing);
},
);
}
@@ -6,9 +6,10 @@ import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
import 'package:kouyu_english/features/review/review_page.dart';
import 'package:kouyu_english/features/shell/learning_shell.dart';
import 'package:kouyu_english/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'support/app_assets.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
@@ -17,7 +18,7 @@ void main() {
testWidgets('Onboarding PlacementPage back button returns to WelcomePage', (
tester,
) async {
await tester.pumpWidget(const KouyuEnglishApp());
await pumpLoadedApp(tester);
await tester.pumpAndSettle();
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
@@ -33,43 +34,30 @@ void main() {
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
});
testWidgets('SettingsPage back button returns to ProgressPage', (
tester,
) async {
testWidgets('Profile sub-page back button returns to 我的', (tester) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
await tester.pumpAndSettle();
// Navigate to "我的" (progress tab)
await tester.tap(find.text('我的'));
await tester.pumpAndSettle();
final settingsButton = find.text('调整学习与 AI 设置');
await tester.ensureVisible(settingsButton);
final preferences = find.text('学习偏好');
await tester.ensureVisible(preferences);
await tester.tap(preferences);
await tester.pumpAndSettle();
expect(settingsButton, findsOneWidget);
await tester.tap(settingsButton);
expect(find.text('每日学习时间'), findsOneWidget);
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
// In SettingsPage
expect(find.text('学习设置'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Tap back button
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('调整学习与 AI 设置'), findsOneWidget);
expect(find.text('每日学习时间'), findsNothing);
expect(find.text('学习偏好'), findsOneWidget);
});
testWidgets('LessonFlow back button returns to tab', (tester) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
await tester.pumpAndSettle();
expect(find.text('开始今天的学习'), findsOneWidget);
@@ -86,27 +74,15 @@ void main() {
expect(find.text('开始今天的学习'), findsOneWidget);
});
testWidgets('DialogueScenePage from Home has back button and returns to Home', (
tester,
) async {
testWidgets('学习 tab shows today task and the lesson path', (tester) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
await tester.pumpAndSettle();
expect(find.text('开始情境对话'), findsOneWidget);
await tester.tap(find.text('开始情境对话'));
await tester.pumpAndSettle();
// In DialogueScenePage as secondary route
expect(find.text('AI 情境对话'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('开始情境对话'), findsOneWidget);
expect(find.byType(NavigationDestination), findsNWidgets(4));
expect(find.text('今日新课'), findsOneWidget);
expect(find.text('课程路径'), findsOneWidget);
expect(find.textContaining('第 1 课 · '), findsWidgets);
});
testWidgets('DialogueSummaryPage has back button', (tester) async {
@@ -137,7 +113,9 @@ void main() {
expect(backCalled, isTrue);
});
testWidgets('AdaptiveLessonPage has back button in all states', (tester) async {
testWidgets('AdaptiveLessonPage has back button in all states', (
tester,
) async {
final state = AppState()..finishOnboarding();
// 1. Empty state
@@ -200,44 +178,47 @@ void main() {
expect(finished, isTrue);
});
testWidgets('AssessmentPreparationPage and AssessmentPage have back buttons', (
tester,
) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpAndSettle();
testWidgets(
'AssessmentPreparationPage and AssessmentPage have back buttons',
(tester) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
await tester.pumpAndSettle();
// Navigate to "我的"
await tester.tap(find.text('我的'));
await tester.pumpAndSettle();
// Navigate to "我的"
await tester.tap(find.text('我的'));
await tester.pumpAndSettle();
// Open assessment pack A0-E1
final packAButton = find.text('开始 A0-E1');
await tester.ensureVisible(packAButton);
await tester.pumpAndSettle();
final assessments = find.text('四技能评估');
await tester.ensureVisible(assessments);
await tester.tap(assessments);
await tester.pumpAndSettle();
expect(packAButton, findsOneWidget);
await tester.tap(packAButton);
await tester.pumpAndSettle();
// Open assessment pack A0-E1
final packAButton = find.text('开始 A0-E1');
await tester.ensureVisible(packAButton);
await tester.pumpAndSettle();
// In AssessmentPreparationPage
expect(find.text('评估准备'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
expect(packAButton, findsOneWidget);
await tester.tap(packAButton);
await tester.pumpAndSettle();
// Start assessment
await tester.tap(find.text('开始评估'));
await tester.pumpAndSettle();
// In AssessmentPreparationPage
expect(find.text('评估准备'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// In AssessmentPage
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Start assessment
await tester.tap(find.text('开始评估'));
await tester.pumpAndSettle();
// Tap back button in AssessmentPage
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
// In AssessmentPage
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Returns to Progress tab
expect(find.text('A0 四技能评估'), findsOneWidget);
});
// Returns to 我的
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('评估准备'), findsNothing);
expect(find.text('四技能评估'), findsOneWidget);
},
);
}
@@ -0,0 +1,166 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:flutter/material.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/features/review/review_page.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/models.dart';
void main() {
Future<(WritingAiFeedback?, String)> check(Map<String, dynamic> reply) async {
AiService.instance.setFallbackApiKey('test-key');
var prompt = '';
final content = jsonEncode(reply);
final result = await http.runWithClient(
() => AiService.instance.answerFeedback(
provider: AiProviderType.compatible,
endpoint: 'https://api.deepseek.com',
model: 'deepseek-flash',
answerId: 'A0-P12',
target: "I'm from …",
taskPrompt: '告诉对方你来自哪里。',
answer: 'I am from Chna',
),
() => MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
prompt = jsonEncode(body);
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': content},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
return (result, prompt);
}
test('review AI check returns spelling corrections for the item', () async {
final (feedback, prompt) = await check({
'schemaVersion': 'writing-feedback-1',
'verdict': 'rewrite',
'feedback': '句型正确,有一个拼写错误。',
'suggestion': 'I am from China.',
'missing': ['Chna 拼写应为 China'],
'lessonId': 'A0-P12',
});
expect(prompt, contains('spelling'));
expect(prompt, contains('I am from Chna'));
expect(feedback, isNotNull);
expect(feedback!.verdict, 'rewrite');
expect(feedback.suggestion, 'I am from China.');
expect(feedback.missing, ['Chna 拼写应为 China']);
});
test('review AI check rejects feedback for a different item', () async {
final (feedback, _) = await check({
'schemaVersion': 'writing-feedback-1',
'verdict': 'accepted',
'feedback': '很好。',
'suggestion': null,
'missing': <String>[],
'lessonId': 'A0-P01',
});
expect(feedback, isNull);
});
test('review AI check is skipped without a configured provider', () async {
final feedback = await AiService.instance.answerFeedback(
provider: AiProviderType.mock,
endpoint: '',
model: '',
answerId: 'A0-P12',
target: "I'm from …",
taskPrompt: '告诉对方你来自哪里。',
answer: 'I am from China',
);
expect(feedback, isNull);
});
testWidgets('finishing after an AI correction counts as assisted', (
tester,
) async {
AiService.instance.setFallbackApiKey('test-key');
final state = AppState()
..aiProvider = AiProviderType.compatible
..aiEndpoint = 'https://api.deepseek.com'
..aiModel = 'deepseek-flash';
state.reviewQueue.add(
ReviewItem(
id: 'A0-P12',
target: "I'm from …",
prompt: '告诉对方你来自哪里。',
hint: "I'm from China.",
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
skill: '回忆表达',
),
);
final content = jsonEncode({
'schemaVersion': 'writing-feedback-1',
'verdict': 'rewrite',
'feedback': '句型正确,有一个拼写错误。',
'suggestion': 'I am from China.',
'missing': ['Chna 拼写应为 China'],
'lessonId': 'A0-P12',
});
await http.runWithClient(
() async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ReviewPage(
state: state,
onFinished: () {},
onOpenAdaptiveLesson: () {},
),
),
),
);
await tester.enterText(find.byType(TextField), 'I am from Chna');
await tester.pump();
final aiButton = find.text('AI 检查语法和拼写(可选)');
await tester.ensureVisible(aiButton);
await tester.tap(aiButton);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
},
() => MockClient(
(request) async => http.Response(
jsonEncode({
'choices': [
{
'message': {'content': content},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
),
),
);
expect(find.text('· Chna 拼写应为 China'), findsOneWidget);
expect(find.text('参考改正:I am from China.'), findsOneWidget);
final submit = find.text('带提示完成');
expect(submit, findsOneWidget);
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.pump();
expect(state.attemptEvidence.single.outcome, EvidenceKind.assisted);
expect(state.attemptEvidence.single.assisted, isTrue);
});
}
+6 -10
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/a0_core.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/core/review_feedback.dart';
@@ -31,11 +31,11 @@ void main() {
test('checks an A0 word without accepting a different word', () {
expect(
ReviewFeedback.check(review('A0-W08', 'three'), 'four').complete,
ReviewFeedback.check(review('A0-W04', 'three'), 'four').complete,
isFalse,
);
expect(
ReviewFeedback.check(review('A0-W08', 'three'), 'three').complete,
ReviewFeedback.check(review('A0-W04', 'three'), 'three').complete,
isTrue,
);
});
@@ -100,7 +100,7 @@ void main() {
);
// Multi-space on word item
expect(
ReviewFeedback.check(review('A0-W08', 'three'), ' THREE ').complete,
ReviewFeedback.check(review('A0-W04', 'three'), ' THREE ').complete,
isTrue,
);
});
@@ -121,14 +121,10 @@ void main() {
expect(coreReviewVariant('A0-P12', 2).skill, spokenRecallSkill);
expect(coreReviewVariant('A0-P12', 3).skill, '回忆表达');
for (final id in a0CoreItems.keys) {
final sentence = a0DictationSentences[id];
final sentence = coreDictationSentences[id];
expect(sentence, isNotNull, reason: id);
// Each dictation sentence really contains the item it checks.
expect(
coreItemUsedIn(id, sentence!.sentence, word: a0CoreItems[id]),
isTrue,
reason: id,
);
expect(coreItemUsedIn(id, sentence!.sentence), isTrue, reason: id);
}
});
}
+68 -47
View File
@@ -12,11 +12,16 @@ void main() {
expect(isSentenceQuery('check in'), isFalse);
expect(isSentenceQuery('Where is the subway?'), isTrue);
expect(isSentenceQuery("I'd like to check in."), isTrue);
expect(isSentenceQuery('This is a longer sentence with more than three words'), isTrue);
expect(
isSentenceQuery('This is a longer sentence with more than three words'),
isTrue,
);
});
test('extractCourseLexiconPhrases finds embedded course lexicon items', () {
final matches = extractCourseLexiconPhrases("Hello, nice to meet you, where's the taxi?");
final matches = extractCourseLexiconPhrases(
"Hello, nice to meet you, where's the taxi?",
);
expect(matches, isNotEmpty);
final words = matches.map((m) => m.word.toLowerCase()).toList();
expect(words.any((w) => w.contains('nice to meet you')), isTrue);
@@ -49,10 +54,7 @@ void main() {
grammarNote: 'would like 语气委婉客气,适合服务场景。',
pronunciationTips: 'would like 发音轻柔,check in 发生连读。',
phrases: const [
PhraseBreakdownItem(
phrase: 'would like to',
meaning: '想要做某事(委婉)',
),
PhraseBreakdownItem(phrase: 'would like to', meaning: '想要做某事(委婉)'),
PhraseBreakdownItem(
phrase: 'check in',
meaning: '办理入住',
@@ -127,46 +129,62 @@ void main() {
});
group('AppState Sentence Analysis & Review Integration', () {
test('caching and retrieving sentence analyses with query normalization', () {
final state = AppState();
final result = SentenceAnalysisResult(
originalText: ' Where is the bus stop? ',
translation: '请问公交站在哪里?',
sentencePattern: 'Where is + 地点',
createdAt: DateTime.now(),
);
test(
'caching and retrieving sentence analyses with query normalization',
() {
final state = AppState();
final result = SentenceAnalysisResult(
originalText: ' Where is the bus stop? ',
translation: '请问公交站在哪里?',
sentencePattern: 'Where is + 地点',
createdAt: DateTime.now(),
);
state.cacheSentenceAnalysis(result);
state.cacheSentenceAnalysis(result);
expect(state.sentenceAnalysisFor('where is the bus stop?'), isNotNull);
expect(state.sentenceAnalysisFor('WHERE IS THE BUS STOP? '), isNotNull);
expect(state.sentenceAnalysisFor('where is the bus stop?')?.translation, '请问公交站在哪里?');
expect(state.sentenceAnalysisFor('where is the bus stop?'), isNotNull);
expect(
state.sentenceAnalysisFor('WHERE IS THE BUS STOP? '),
isNotNull,
);
expect(
state.sentenceAnalysisFor('where is the bus stop?')?.translation,
'请问公交站在哪里?',
);
state.removeSentenceAnalysis('where is the bus stop?');
expect(state.sentenceAnalysisFor('where is the bus stop?'), isNull);
});
state.removeSentenceAnalysis('where is the bus stop?');
expect(state.sentenceAnalysisFor('where is the bus stop?'), isNull);
},
);
test('addPhraseToReview converts phrase breakdown into vocabulary item in review queue', () {
final state = AppState();
state.addPhraseToReview(
phrase: 'check in',
meaning: '办理登机或入住',
ipa: '/tʃek ɪn/',
usageNote: '酒店机场高频词',
contextSentence: "I'd like to check in please.",
);
test(
'addPhraseToReview converts phrase breakdown into vocabulary item in review queue',
() {
final state = AppState();
state.addPhraseToReview(
phrase: 'check in',
meaning: '办理登机或入住',
ipa: '/tʃek ɪn/',
usageNote: '酒店机场高频词',
contextSentence: "I'd like to check in please.",
);
expect(state.reviewQueue.any((r) => r.target == 'check in'), isTrue);
final saved = state.reviewQueue.firstWhere((r) => r.target == 'check in');
expect(saved.hint, contains('办理登机或入住'));
expect(saved.hint, contains('酒店机场高频词'));
expect(saved.prompt, "I'd like to check in please.");
});
expect(state.reviewQueue.any((r) => r.target == 'check in'), isTrue);
final saved = state.reviewQueue.firstWhere(
(r) => r.target == 'check in',
);
expect(saved.hint, contains('办理登机或入住'));
expect(saved.hint, contains('酒店机场高频词'));
expect(saved.prompt, "I'd like to check in please.");
},
);
});
group('UI Lookup Sheet Sentence Analysis View', () {
testWidgets('renders sentence analysis result cards when cached', (tester) async {
testWidgets('renders sentence analysis result cards when cached', (
tester,
) async {
final state = AppState();
final analysis = SentenceAnalysisResult(
originalText: 'Where is the departure gate?',
@@ -190,18 +208,18 @@ void main() {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showLexiconLookup(
context,
state: state,
initialText: 'Where is the departure gate?',
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showLexiconLookup(
context,
state: state,
initialText: 'Where is the departure gate?',
),
child: const Text('查句'),
),
child: const Text('查句'),
),
),
),
),
);
await tester.tap(find.text('查句'));
@@ -224,7 +242,10 @@ void main() {
await tester.tap(find.text('加复习'));
await tester.pumpAndSettle();
expect(state.reviewQueue.any((r) => r.target == 'departure gate'), isTrue);
expect(
state.reviewQueue.any((r) => r.target == 'departure gate'),
isTrue,
);
expect(find.text('已在复习'), findsOneWidget);
});
});
+24 -15
View File
@@ -41,7 +41,8 @@ void main() {
final recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
const testWave = '/tmp/sherpa_test/sherpa-onnx-zipformer-small-en-2023-06-26/test_wavs/0.wav';
const testWave =
'/tmp/sherpa_test/sherpa-onnx-zipformer-small-en-2023-06-26/test_wavs/0.wav';
if (File(testWave).existsSync()) {
final wave = sherpa_onnx.readWave(testWave);
expect(wave.samples.isNotEmpty, isTrue);
@@ -57,19 +58,27 @@ void main() {
recognizer.free();
});
test('SherpaSttService singleton initializes and transcribes cleanly', () async {
final ready = await SherpaSttService.instance.initialize(nativeLibDir: macosDir);
expect(ready, isTrue);
expect(SherpaSttService.instance.isReady, isTrue);
test(
'SherpaSttService singleton initializes and transcribes cleanly',
() async {
final ready = await SherpaSttService.instance.initialize(
nativeLibDir: macosDir,
);
expect(ready, isTrue);
expect(SherpaSttService.instance.isReady, isTrue);
const testWave = '/tmp/sherpa_test/sherpa-onnx-zipformer-small-en-2023-06-26/test_wavs/0.wav';
if (File(testWave).existsSync()) {
final transcribed = await SherpaSttService.instance.transcribeWav(testWave);
expect(transcribed, isNotNull);
print('SherpaSttService transcribed: $transcribed');
expect(transcribed!.toLowerCase().contains('nightfall'), isTrue);
// SenseVoice tags should be stripped
expect(transcribed.contains('<|'), isFalse);
}
});
const testWave =
'/tmp/sherpa_test/sherpa-onnx-zipformer-small-en-2023-06-26/test_wavs/0.wav';
if (File(testWave).existsSync()) {
final transcribed = await SherpaSttService.instance.transcribeWav(
testWave,
);
expect(transcribed, isNotNull);
print('SherpaSttService transcribed: $transcribed');
expect(transcribed!.toLowerCase().contains('nightfall'), isTrue);
// SenseVoice tags should be stripped
expect(transcribed.contains('<|'), isFalse);
}
},
);
}
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/main.dart';
/// Pumps the whole app and lets `AppState.load` finish. The load reads the
/// course packs and the AI config from the asset bundle, which is real file
/// I/O that fake time never completes, so the test waits for it in real time
/// until the loading spinner is gone.
Future<void> pumpLoadedApp(WidgetTester tester) async {
await tester.pumpWidget(const KouyuEnglishApp());
for (var i = 0; i < 100; i++) {
if (find.byType(CircularProgressIndicator).evaluate().isEmpty) break;
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
await tester.pump();
}
}
+73 -65
View File
@@ -37,7 +37,10 @@ void main() {
expect(recovered.username, 'alice');
expect(recovered.userId, 'u-1');
expect(recovered.isLoggedIn, isTrue);
expect(recovered.lastSyncTime?.toIso8601String(), '2026-09-16T08:00:00.000Z');
expect(
recovered.lastSyncTime?.toIso8601String(),
'2026-09-16T08:00:00.000Z',
);
expect(recovered.autoSyncEnabled, isTrue);
});
@@ -65,7 +68,7 @@ void main() {
'attempts': 3,
'payload': {'label': 'I need coffee'},
'updated_at': '2026-09-16T10:00:00Z',
}
},
],
'profile': {
'onboarding_complete': true,
@@ -75,7 +78,7 @@ void main() {
'show_chinese_hints': true,
'updated_at': '2026-09-16T10:00:00Z',
},
}
},
};
final response = SyncPullResponse.fromJson(pullJson);
@@ -110,63 +113,66 @@ void main() {
expect(item.status, 'recall');
});
test('applyPullResponse merges lessons by union and upgrades mastery by max checkpoint', () {
final state = AppState();
state.completedLessonIds.add('a0-01');
state.completedLessons = 1;
state.mastery['A0-P01'] = const MasteryItem(
id: 'A0-P01',
label: 'I am Shen',
status: MasteryStatus.recognize,
checkpoint: 1,
evidence: [],
);
test(
'applyPullResponse merges lessons by union and upgrades mastery by max checkpoint',
() {
final state = AppState();
state.completedLessonIds.add('a0-01');
state.completedLessons = 1;
state.mastery['A0-P01'] = const MasteryItem(
id: 'A0-P01',
label: 'I am Shen',
status: MasteryStatus.recognize,
checkpoint: 1,
evidence: [],
);
final remotePull = SyncPullResponse(
serverTime: '2026-09-16T10:00:00Z',
progress: const SyncProgressPayload(
activeLessonId: 'a0-03',
completedLessonIds: ['a0-01', 'a0-02'],
completedSegmentIds: ['a0-01-s1', 'a0-02-s1'],
updatedAt: '2026-09-16T10:00:00Z',
),
masteryUpdates: [
const SyncMasteryItemPayload(
itemId: 'A0-P01',
checkpoint: 3,
status: 'use',
dueAt: '2026-09-20T10:00:00Z',
payload: {'label': 'I am Shen'},
final remotePull = SyncPullResponse(
serverTime: '2026-09-16T10:00:00Z',
progress: const SyncProgressPayload(
activeLessonId: 'a0-03',
completedLessonIds: ['a0-01', 'a0-02'],
completedSegmentIds: ['a0-01-s1', 'a0-02-s1'],
updatedAt: '2026-09-16T10:00:00Z',
),
const SyncMasteryItemPayload(
itemId: 'A0-P02',
checkpoint: 1,
status: 'recognize',
dueAt: '2026-09-18T10:00:00Z',
payload: {'label': 'Thank you'},
masteryUpdates: [
const SyncMasteryItemPayload(
itemId: 'A0-P01',
checkpoint: 3,
status: 'use',
dueAt: '2026-09-20T10:00:00Z',
payload: {'label': 'I am Shen'},
updatedAt: '2026-09-16T10:00:00Z',
),
const SyncMasteryItemPayload(
itemId: 'A0-P02',
checkpoint: 1,
status: 'recognize',
dueAt: '2026-09-18T10:00:00Z',
payload: {'label': 'Thank you'},
updatedAt: '2026-09-16T10:00:00Z',
),
],
profile: const SyncProfilePayload(
onboardingComplete: true,
goal: 'dailyLife',
updatedAt: '2026-09-16T10:00:00Z',
),
],
profile: const SyncProfilePayload(
onboardingComplete: true,
goal: 'dailyLife',
updatedAt: '2026-09-16T10:00:00Z',
),
);
);
final changed = SyncMerger.applyPullResponse(state, remotePull);
expect(changed, isTrue);
// Lesson union
expect(state.completedLessonIds, containsAll(['a0-01', 'a0-02']));
expect(state.completedLessons, 2);
// Mastery max checkpoint upgrade
expect(state.mastery['A0-P01']?.checkpoint, 3);
expect(state.mastery['A0-P01']?.status, MasteryStatus.use);
// New item added from remote
expect(state.mastery['A0-P02']?.checkpoint, 1);
expect(state.mastery['A0-P02']?.label, 'Thank you');
});
final changed = SyncMerger.applyPullResponse(state, remotePull);
expect(changed, isTrue);
// Lesson union
expect(state.completedLessonIds, containsAll(['a0-01', 'a0-02']));
expect(state.completedLessons, 2);
// Mastery max checkpoint upgrade
expect(state.mastery['A0-P01']?.checkpoint, 3);
expect(state.mastery['A0-P01']?.status, MasteryStatus.use);
// New item added from remote
expect(state.mastery['A0-P02']?.checkpoint, 1);
expect(state.mastery['A0-P02']?.label, 'Thank you');
},
);
});
group('SyncMerger restores home page position on a fresh device', () {
@@ -238,7 +244,12 @@ void main() {
progress: SyncProgressPayload(
activeLessonId: 'a0-04',
completedLessonIds: const ['a0-01', 'a0-02', 'a0-03'],
completedSegmentIds: const ['a0-01-a', 'a0-02-a', 'a0-03-a', 'a0-04-a'],
completedSegmentIds: const [
'a0-01-a',
'a0-02-a',
'a0-03-a',
'a0-04-a',
],
updatedAt: '2026-09-16T07:54:27Z',
),
);
@@ -255,10 +266,7 @@ void main() {
SyncMerger.applyPullResponse(state, stalePull());
final review = state.reviewQueue.singleWhere((r) => r.id == 'A0-P03');
expect(
review.dueAt.toUtc(),
DateTime.parse('2026-09-17T07:49:45Z'),
);
expect(review.dueAt.toUtc(), DateTime.parse('2026-09-17T07:49:45Z'));
expect(state.mastery['A0-P03']?.status, MasteryStatus.recall);
});
@@ -320,7 +328,7 @@ void main() {
'username': 'shen',
'token': 'jwt_mock_token_abc',
'expires_in': 604800,
}
},
}),
200,
);
@@ -366,7 +374,7 @@ void main() {
},
'mastery_updates': [],
'profile': null,
}
},
}),
200,
);
@@ -376,7 +384,7 @@ void main() {
jsonEncode({
'code': 0,
'message': 'success',
'data': {'server_time': '2026-09-16T12:05:00Z'}
'data': {'server_time': '2026-09-16T12:05:00Z'},
}),
200,
);
@@ -413,7 +421,7 @@ void main() {
'username': 'tester',
'token': 'auth_token_999',
'expires_in': 604800,
}
},
}),
200,
);
@@ -434,7 +442,7 @@ void main() {
},
'mastery_updates': [],
'profile': null,
}
},
}),
200,
);
@@ -443,7 +451,7 @@ void main() {
jsonEncode({
'code': 0,
'message': 'success',
'data': {'server_time': '2026-09-16T15:00:01Z'}
'data': {'server_time': '2026-09-16T15:00:01Z'},
}),
200,
);
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/core/courses/course_repository.dart';
/// The A0 → A1 → A2 → B1 unlock chain. Crossing out of A0 into A1 additionally
/// requires passing the A0 stage assessment; higher levels then chain linearly
/// (finishing the previous unit unlocks the next), with no extra assessment.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async => CourseRepository.instance.load());
tearDownAll(() => CourseRepository.instance.resetForTest());
List<SeedLesson> byLevel(String level) =>
allLessons.where((lesson) => lesson.level == level).toList();
/// Drives [state] into the "A0 passed" state: enough usable/mastered core
/// items to sit the assessment, plus two valid passes 24h+ apart.
void makeA0Passed(AppState state) {
for (final id in a0CoreItems.keys.take(48)) {
state.mastery[id] = MasteryItem(
id: id,
label: a0CoreItems[id]!,
status: MasteryStatus.master,
evidence: const [EvidenceKind.independentSuccess],
);
}
final now = DateTime.now();
final allSkillsPass = {
for (final skill in AssessmentSkill.values) skill: true,
};
state.assessments
..add(
AssessmentRecord(
packId: 'A0-E1',
completedAt: now.subtract(const Duration(days: 2)),
results: allSkillsPass,
),
)
..add(
AssessmentRecord(
packId: 'A0-E2',
completedAt: now.subtract(const Duration(hours: 1)),
results: allSkillsPass,
),
);
}
test('the loaded content actually spans A0 → A1 → A2 → B1', () {
expect(a0SeedLessons, isNotEmpty);
expect(byLevel('A1'), isNotEmpty);
expect(byLevel('A2'), isNotEmpty);
expect(byLevel('B1'), isNotEmpty);
// 48/30 graduation thresholds are only reachable if A0 teaches >=48 items.
expect(a0CoreItems.length, greaterThanOrEqualTo(48));
});
test('finishing A0 does not unlock A1 until the A0 assessment is passed', () {
final state = AppState();
final firstA1 = byLevel('A1').first;
// All A0 lessons done, but the stage assessment not yet passed.
for (final lesson in a0SeedLessons) {
state.completedLessonIds.add(lesson.id);
}
expect(state.a0Passed, isFalse);
expect(
state.isLessonUnlocked(firstA1.id),
isFalse,
reason: 'A1 stays locked until A0 is passed',
);
// Passing the A0 stage assessment opens the first A1 unit.
makeA0Passed(state);
expect(state.a0Passed, isTrue);
expect(state.isLessonUnlocked(firstA1.id), isTrue);
});
test('A1 → A2 → B1 chain linearly by finishing the previous unit', () {
final state = AppState();
makeA0Passed(state);
for (final lesson in a0SeedLessons) {
state.completedLessonIds.add(lesson.id);
}
final a1 = byLevel('A1');
final a2 = byLevel('A2');
final b1 = byLevel('B1');
// A1 opened, but only the first unit; the second waits on the first.
expect(state.isLessonUnlocked(a1.first.id), isTrue);
expect(state.isLessonUnlocked(a1[1].id), isFalse);
// Finishing the first A1 unit unlocks the second — no extra gate.
state.completedLessonIds.add(a1.first.id);
expect(state.isLessonUnlocked(a1[1].id), isTrue);
// A2 stays locked until the last A1 unit is finished.
expect(state.isLessonUnlocked(a2.first.id), isFalse);
for (final lesson in a1) {
state.completedLessonIds.add(lesson.id);
}
expect(
state.isLessonUnlocked(a2.first.id),
isTrue,
reason: 'A1 → A2 chains linearly, no separate assessment',
);
// B1 likewise unlocks only after the last A2 unit.
expect(state.isLessonUnlocked(b1.first.id), isFalse);
for (final lesson in a2) {
state.completedLessonIds.add(lesson.id);
}
expect(
state.isLessonUnlocked(b1.first.id),
isTrue,
reason: 'A2 → B1 chains linearly',
);
});
}
+4 -3
View File
@@ -3,13 +3,14 @@ import 'package:flutter/material.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/features/lesson/lesson_flow.dart';
import 'package:kouyu_english/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'support/app_assets.dart';
void main() {
testWidgets('shows the M1 welcome flow', (tester) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(const KouyuEnglishApp());
await pumpLoadedApp(tester);
await tester.pumpAndSettle();
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
@@ -56,4 +57,4 @@ void main() {
expect(find.text('使用麦克风跟读'), findsOneWidget);
expect(find.text('播放跟读'), findsOneWidget);
});
}
}
+13 -14
View File
@@ -3,16 +3,16 @@ import 'package:kouyu_english/core/writing_feedback.dart';
void main() {
test('accepts a variable name in the first lesson', () {
final result = WritingFeedback.check('a0-01', 'Hello. I am Alex.');
final result = WritingFeedback.check('a0-01-a', 'Hello. I am Alex.');
expect(result.complete, isTrue);
});
test(
'requests the missing sentence frame without rejecting a valid place',
() {
expect(WritingFeedback.check('a0-06', 'Hong Kong.').complete, isFalse);
expect(WritingFeedback.check('a0-06-a', 'Hong Kong.').complete, isFalse);
expect(
WritingFeedback.check('a0-06', "I'm from Shanghai.").complete,
WritingFeedback.check('a0-06-a', "I'm from Shanghai.").complete,
isTrue,
);
},
@@ -20,19 +20,19 @@ void main() {
test('requires the three independent parts in lesson ten', () {
expect(
WritingFeedback.check('a0-10', "I'm Alex. I'm from Beijing.").complete,
WritingFeedback.check('a0-10-a', "I'm Alex. I'm from Beijing.").complete,
isFalse,
);
expect(
WritingFeedback.check(
'a0-10',
'a0-10-a',
"I'm Alex. I'm from Beijing. I like tea.",
).complete,
isFalse,
);
expect(
WritingFeedback.check(
'a0-10',
'a0-10-a',
"I'm Alex. I'm from Beijing. I like water.",
).complete,
isTrue,
@@ -42,23 +42,22 @@ void main() {
test('ignores multiple spaces, mixed casing and curly apostrophes', () {
// a0-01 greeting and name with multiple spaces and uppercase
expect(
WritingFeedback.check('a0-01', ' HELLO I AM ALEX ').complete,
WritingFeedback.check('a0-01-a', ' HELLO I AM ALEX ').complete,
isTrue,
);
// a0-01 with curly quote and multiple spaces
expect(WritingFeedback.check('a0-01', 'HI I’M MIA').complete, isTrue);
expect(
WritingFeedback.check('a0-01-a', 'HI I’M MIA').complete,
isTrue,
);
// a0-07 with multiple spaces in phrase
expect(
WritingFeedback.check('a0-07', 'THIS IS MY MOTHER').complete,
WritingFeedback.check('a0-07-a', 'THIS IS MY MOTHER').complete,
isTrue,
);
// a0-08-c with multiple spaces and curly apostrophe
expect(
WritingFeedback.check(
'a0-08-c',
'IT’S THREE O’CLOCK',
segmentId: 'a0-08-c',
).complete,
WritingFeedback.check('a0-08-c', 'IT’S THREE O’CLOCK').complete,
isTrue,
);
});