整个课程和数据UI重新设计版本
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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]}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user