## 独立词库 理解词原先只能跟着课程单元走,学完 A0 十课词汇量只增加约 22 个实词, 不足以解决"记不住单词"。新增一份独立词库 assets/words/wordbank.json (2748 词,A1–B1),挂进 receptiveWordRegistry 的合成单元 bank-A1/A2/B1, 完全复用理解词已有的状态机,不依赖课程进度,第一天就能用。 数据来源、许可与合成规则记在 tool/words/DATA-NOTE.md:CEFR-J 定等级、 公开词书提供音标、AI 重写全部释义并生成例句、OpenSubtitles 提供口语词频。 词书部分为 CC BY-NC-SA 4.0 且上游权利不明,仅供个人非商用; 若要分发或上架,须替换音标那一列。 ## 背单词机制 - 间隔阶梯 1/3/7/15/30/60/120 天,连续答对上一级,答错回第一级。 原先首次答对后要等 7 天才复习,正是"第二天就忘"的成因。 - 每日新词上限(10 分钟 8 个 / 20 分钟 15 个 / 30 分钟 20 个)。 阶梯第一级是次日,今天引入的新词就是明天的工作量。 - 新词按口语频率发放,不再按字母序 —— A1 从 a.m./ability 变成 no/not/know/just。 - 三个方向按层级轮转:看词(英→中)→ 听词(音→中)→ 想词(中→英)。 想词题仍是选择题,不要求产出,理解词定位不变,不进升级分母。 - 单词页独立成 tab,首页今日任务卡下方给一张认词入口卡。 ## 用法对照 课程 JSON 增加 usage 字段(when/reply/swap/confuse):一个句型用在什么场合、 对方通常怎么答、还能怎么说、跟哪个学过的句型容易混。 知道 How are you? 的意思,不等于知道它不是用来问名字的。 ## 复习流 - 当日快闪(recap)独立成队列,不占复习预算,也不计入积压。 - 只发放当日预算内的量,其余保持到期状态等下次,不悄悄丢弃或改期。 - 答错的项隔几题后回来,而不是立刻重问。 测试 296 通过,flutter analyze 干净。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
10 KiB
Dart
306 lines
10 KiB
Dart
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/generated_content.dart';
|
|
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:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'package:kouyu_english/core/courses/course_repository.dart';
|
|
import 'support/app_assets.dart';
|
|
|
|
void main() {
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
});
|
|
|
|
testWidgets('Onboarding PlacementPage back button returns to WelcomePage', (
|
|
tester,
|
|
) async {
|
|
await pumpLoadedApp(tester);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
|
|
await tester.tap(find.text('继续'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('从哪里开始?'), findsOneWidget);
|
|
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
|
|
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Profile sub-page back button returns to 我的', (tester) async {
|
|
final state = AppState()..finishOnboarding();
|
|
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('我的'));
|
|
await tester.pumpAndSettle();
|
|
|
|
final preferences = find.text('学习偏好');
|
|
await tester.ensureVisible(preferences);
|
|
await tester.tap(preferences);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('每日学习时间'), findsOneWidget);
|
|
await tester.tap(find.byType(BackButton));
|
|
await tester.pumpAndSettle();
|
|
|
|
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.pumpAndSettle();
|
|
|
|
expect(find.text('开始今天的学习'), findsOneWidget);
|
|
await tester.tap(find.text('开始今天的学习'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// In LessonFlow
|
|
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
|
|
|
// Tap back button
|
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('开始今天的学习'), findsOneWidget);
|
|
});
|
|
|
|
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.pumpAndSettle();
|
|
|
|
expect(find.byType(NavigationDestination), findsNWidgets(5));
|
|
expect(find.text('单词'), findsOneWidget);
|
|
expect(find.text('今日新课'), findsOneWidget);
|
|
expect(find.text('课程路径'), findsOneWidget);
|
|
expect(find.textContaining('第 1 课 · '), findsWidgets);
|
|
});
|
|
|
|
testWidgets('DialogueSummaryPage has back button', (tester) async {
|
|
var backCalled = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: DialogueSummaryPage(
|
|
summary: const DialogueSummaryData(
|
|
completedTasks: ['介绍姓名'],
|
|
personalSentence: 'My name is Shen.',
|
|
usedHelp: false,
|
|
),
|
|
onHome: () {},
|
|
onLesson: () {},
|
|
onRetry: () {},
|
|
onBack: () => backCalled = true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('对话完成'), findsNWidgets(2)); // AppBar title and Eyebrow
|
|
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
|
|
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(backCalled, isTrue);
|
|
});
|
|
|
|
testWidgets('AdaptiveLessonPage has back button in all states', (
|
|
tester,
|
|
) async {
|
|
final state = AppState()..finishOnboarding();
|
|
|
|
// 1. Empty state
|
|
var finished = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: AdaptiveLessonPage(
|
|
state: state,
|
|
onFinished: () => finished = true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('AI 四技能补练'), findsNWidgets(2));
|
|
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
|
expect(finished, isTrue);
|
|
|
|
// 2. Active lesson state
|
|
state.cacheApprovedAdaptiveLesson(
|
|
const GeneratedLesson(
|
|
lessonId: 'adapt-1',
|
|
revision: 1,
|
|
stageVersion: 'A0',
|
|
abilityIds: ['greeting'],
|
|
prerequisiteIds: [],
|
|
targetItemIds: ['name'],
|
|
receptiveChunks: ['My name is Mia.'],
|
|
previewItemIds: ['name'],
|
|
estimatedMinutes: 5,
|
|
tasks: [
|
|
GeneratedLessonTask(
|
|
taskId: 't1',
|
|
skill: 'listening',
|
|
type: 'listen',
|
|
prompt: '听并写出名字',
|
|
stimulus: 'My name is Mia.',
|
|
answer: 'Mia',
|
|
targetItemIds: ['name'],
|
|
),
|
|
],
|
|
),
|
|
auditor: 'test',
|
|
);
|
|
|
|
finished = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: AdaptiveLessonPage(
|
|
state: state,
|
|
onFinished: () => finished = true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
|
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();
|
|
|
|
// Navigate to "我的"
|
|
await tester.tap(find.text('我的'));
|
|
await tester.pumpAndSettle();
|
|
|
|
final assessments = find.text('四技能评估');
|
|
await tester.ensureVisible(assessments);
|
|
await tester.tap(assessments);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Open assessment pack A0-E1
|
|
final packAButton = find.text('开始 A0-E1');
|
|
await tester.ensureVisible(packAButton);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(packAButton, findsOneWidget);
|
|
await tester.tap(packAButton);
|
|
await tester.pumpAndSettle();
|
|
|
|
// In AssessmentPreparationPage
|
|
expect(find.text('评估准备'), findsOneWidget);
|
|
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
|
|
|
// Start assessment
|
|
await tester.tap(find.text('开始评估'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// In AssessmentPage
|
|
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
|
|
|
// Returns to 我的
|
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('评估准备'), findsNothing);
|
|
expect(find.text('四技能评估'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'LessonFlow on complete step: back button finalizes segment and advances lesson',
|
|
(tester) async {
|
|
await tester.runAsync(
|
|
() => CourseRepository.instance.load(levels: {'A0'}),
|
|
);
|
|
final state = AppState()..finishOnboarding();
|
|
expect(state.activeLessonId, 'a0-01');
|
|
|
|
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Tap '开始今天的学习' to enter LessonFlow
|
|
await tester.tap(find.text('开始今天的学习'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Complete all steps of a0-01
|
|
state.completePreview();
|
|
state.completeListening(recognized: true);
|
|
state.completeSpeaking();
|
|
state.completeReading();
|
|
state.completeWriting(assisted: false, rawAnswer: 'hello');
|
|
state.completeLessonDialogue();
|
|
state.completeIndependentAttempt(assisted: false, rawAnswer: 'hello');
|
|
expect(state.lessonStep, LessonStep.complete);
|
|
await tester.pumpAndSettle();
|
|
|
|
// We should be in lesson flow complete screen
|
|
expect(find.text('本段已保存'), findsOneWidget);
|
|
expect(find.text('回到首页'), findsOneWidget);
|
|
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
|
|
|
// Tap the top-left AppBar back button instead of '回到首页'
|
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Should be back on the 学习 tab, and lesson must have advanced to a0-02
|
|
expect(state.completedLessonIds, contains('a0-01'));
|
|
expect(state.activeLessonId, 'a0-02');
|
|
expect(find.textContaining('第 2 课 · '), findsWidgets);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'TodayTaskCard reflects multi-segment progress and completed fallback',
|
|
(tester) async {
|
|
await tester.runAsync(
|
|
() => CourseRepository.instance.load(levels: {'A0'}),
|
|
);
|
|
final state = AppState()..finishOnboarding();
|
|
state.activeLessonId = 'a0-04'; // a0-04 has 3 segments
|
|
|
|
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Segment 1 (initial)
|
|
expect(find.text('今日新课'), findsOneWidget);
|
|
expect(find.text('开始今天的学习'), findsOneWidget);
|
|
|
|
// Now complete segment 1
|
|
state.completeSegment('a0-04', 0);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Segment 2 (resuming)
|
|
expect(find.text('继续课程'), findsOneWidget);
|
|
expect(find.text('继续学习第 2 段'), findsOneWidget);
|
|
expect(find.textContaining('第 2/3 段'), findsWidgets);
|
|
|
|
// If activeLessonId is marked as completed, card targets next incomplete
|
|
state.completedLessonIds.add('a0-04');
|
|
state.notifyListeners();
|
|
await tester.pumpAndSettle();
|
|
|
|
// Should target a0-01 or next incomplete, not show completed lesson
|
|
expect(find.text('继续课程'), findsNothing);
|
|
expect(find.textContaining('第 1 课 · '), findsWidgets);
|
|
},
|
|
);
|
|
}
|