390 lines
13 KiB
Dart
390 lines
13 KiB
Dart
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 submit = find.text('提交独立尝试');
|
|
await tester.ensureVisible(submit);
|
|
await tester.tap(submit);
|
|
await tester.runAsync(
|
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('· I is 应为 I am'), findsOneWidget);
|
|
expect(state.lessonStep, LessonStep.independent);
|
|
await tester.enterText(
|
|
find.byType(TextField),
|
|
'My name is Alex. Nice to meet you. I am happy.',
|
|
);
|
|
await tester.pump();
|
|
await tester.ensureVisible(submit);
|
|
await tester.tap(submit);
|
|
await tester.runAsync(
|
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
|
);
|
|
await tester.pump();
|
|
},
|
|
() => MockClient((request) async {
|
|
prompts.add(request.body);
|
|
final accepted = jsonEncode({
|
|
'schemaVersion': 'writing-feedback-1',
|
|
'verdict': 'accepted',
|
|
'feedback': '表达正确。',
|
|
'suggestion': null,
|
|
'missing': <String>[],
|
|
'lessonId': segmentId,
|
|
});
|
|
return http.Response(
|
|
jsonEncode({
|
|
'choices': [
|
|
{
|
|
'message': {'content': prompts.length == 1 ? check : accepted},
|
|
},
|
|
],
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
);
|
|
}),
|
|
);
|
|
|
|
expect(prompts, hasLength(2));
|
|
expect(prompts.first, contains('I is happy'));
|
|
expect(state.lessonStep, LessonStep.complete);
|
|
expect(state.independentAttemptAssisted, isTrue);
|
|
});
|
|
|
|
testWidgets('writing submission runs AI check without a separate button', (
|
|
tester,
|
|
) async {
|
|
AiService.instance.setFallbackApiKey('test-key');
|
|
final state = AppState()
|
|
..aiProvider = AiProviderType.compatible
|
|
..aiEndpoint = 'https://api.deepseek.com'
|
|
..aiModel = 'deepseek-flash'
|
|
..lessonStep = LessonStep.writing;
|
|
var requests = 0;
|
|
await http.runWithClient(
|
|
() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: LessonFlow(
|
|
state: state,
|
|
onOpenDialogue: () {},
|
|
onFinish: () {},
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.text('收起示例,自己试一次'));
|
|
await tester.pump();
|
|
await tester.enterText(find.byType(TextField), 'Hello. I am Alex.');
|
|
await tester.pump();
|
|
expect(find.text('获取 AI 写作建议(可选)'), findsNothing);
|
|
final submit = find.text('提交并继续');
|
|
await tester.ensureVisible(submit);
|
|
await tester.tap(submit);
|
|
await tester.runAsync(
|
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
|
);
|
|
await tester.pump();
|
|
},
|
|
() => MockClient((request) async {
|
|
requests++;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'choices': [
|
|
{
|
|
'message': {
|
|
'content': jsonEncode({
|
|
'schemaVersion': 'writing-feedback-1',
|
|
'verdict': 'accepted',
|
|
'feedback': '表达正确。',
|
|
'suggestion': null,
|
|
'missing': <String>[],
|
|
'lessonId': 'a0-01',
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
);
|
|
}),
|
|
);
|
|
expect(requests, 1);
|
|
expect(state.lessonStep, LessonStep.dialogue);
|
|
expect(
|
|
state.attemptEvidence
|
|
.where((entry) => entry.taskId == 'lesson-a0-01-a-writing')
|
|
.every((entry) => entry.outcome == EvidenceKind.assisted),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
testWidgets('a locally matching bad sentence waits for AI correction', (
|
|
tester,
|
|
) async {
|
|
AiService.instance.setFallbackApiKey('test-key');
|
|
final state = AppState()
|
|
..aiProvider = AiProviderType.compatible
|
|
..aiEndpoint = 'https://api.deepseek.com'
|
|
..aiModel = 'deepseek-flash'
|
|
..lessonStep = LessonStep.writing;
|
|
await http.runWithClient(
|
|
() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: LessonFlow(
|
|
state: state,
|
|
onOpenDialogue: () {},
|
|
onFinish: () {},
|
|
),
|
|
),
|
|
);
|
|
await tester.enterText(find.byType(TextField), 'Hello. I am is Alex.');
|
|
await tester.pump();
|
|
final submit = find.text('提交并继续');
|
|
await tester.ensureVisible(submit);
|
|
await tester.tap(submit);
|
|
await tester.runAsync(
|
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
|
);
|
|
await tester.pump();
|
|
},
|
|
() => MockClient(
|
|
(_) async => http.Response(
|
|
jsonEncode({
|
|
'choices': [
|
|
{
|
|
'message': {
|
|
'content': jsonEncode({
|
|
'schemaVersion': 'writing-feedback-1',
|
|
'verdict': 'rewrite',
|
|
'feedback': '这里多了 is。',
|
|
'suggestion': 'Hello. I am Alex.',
|
|
'missing': ['删去多余的 is'],
|
|
'lessonId': 'a0-01',
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
),
|
|
),
|
|
);
|
|
expect(state.lessonStep, LessonStep.writing);
|
|
expect(find.text('AI 建议:这里多了 is。'), findsOneWidget);
|
|
expect(find.text('请按自己的意思修改,再提交检查。看过改正后,本次只算辅助练习。'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('independent submission without AI stays unverified', (
|
|
tester,
|
|
) async {
|
|
final state = AppState()..lessonStep = LessonStep.independent;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: LessonFlow(state: state, onOpenDialogue: () {}, onFinish: () {}),
|
|
),
|
|
);
|
|
await tester.enterText(
|
|
find.byType(TextField),
|
|
'My name is Alex. Nice to meet you.',
|
|
);
|
|
await tester.pump();
|
|
final submit = find.text('提交独立尝试');
|
|
await tester.ensureVisible(submit);
|
|
await tester.tap(submit);
|
|
await tester.pump();
|
|
expect(state.lessonStep, LessonStep.complete);
|
|
expect(state.independentAttemptPending, isTrue);
|
|
expect(
|
|
state.attemptEvidence
|
|
.where((entry) => entry.taskId == 'lesson-a0-01-a-independent')
|
|
.every((entry) => entry.outcome == EvidenceKind.pending),
|
|
isTrue,
|
|
);
|
|
expect(find.textContaining('未经 AI 核验'), findsOneWidget);
|
|
});
|
|
|
|
test('unverified completion survives a local snapshot', () {
|
|
final first = AppState()
|
|
..completeIndependentAttempt(
|
|
assisted: false,
|
|
verified: false,
|
|
rawAnswer: 'My name is Alex. Nice to meet you.',
|
|
);
|
|
final resumed = AppState()..restoreForTest(first.snapshotForTest());
|
|
expect(resumed.independentAttemptPending, isTrue);
|
|
expect(resumed.independentAttemptComplete, isTrue);
|
|
expect(
|
|
resumed.attemptEvidence
|
|
.where((entry) => entry.taskId == 'lesson-a0-01-a-independent')
|
|
.every((entry) => entry.outcome == EvidenceKind.pending),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
testWidgets('AI outage offers an unverified continuation', (tester) async {
|
|
AiService.instance.setFallbackApiKey('test-key');
|
|
final state = AppState()
|
|
..aiProvider = AiProviderType.compatible
|
|
..aiEndpoint = 'https://api.deepseek.com'
|
|
..aiModel = 'deepseek-flash'
|
|
..lessonStep = LessonStep.independent;
|
|
await http.runWithClient(() async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: LessonFlow(
|
|
state: state,
|
|
onOpenDialogue: () {},
|
|
onFinish: () {},
|
|
),
|
|
),
|
|
);
|
|
await tester.enterText(
|
|
find.byType(TextField),
|
|
'My name is Alex. Nice to meet you.',
|
|
);
|
|
await tester.pump();
|
|
final submit = find.text('提交独立尝试');
|
|
await tester.ensureVisible(submit);
|
|
await tester.tap(submit);
|
|
await tester.runAsync(
|
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
|
);
|
|
await tester.pump();
|
|
expect(state.lessonStep, LessonStep.independent);
|
|
final fallback = find.text('未核验,保存练习并继续');
|
|
await tester.ensureVisible(fallback);
|
|
await tester.tap(fallback);
|
|
await tester.pump();
|
|
}, () => MockClient((_) async => http.Response('{}', 503)));
|
|
expect(state.lessonStep, LessonStep.complete);
|
|
expect(state.independentAttemptPending, isTrue);
|
|
});
|
|
}
|