## 独立词库 理解词原先只能跟着课程单元走,学完 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>
256 lines
9.2 KiB
Dart
256 lines
9.2 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.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/widgets/lexicon_lookup.dart';
|
||
|
||
void main() {
|
||
group('Sentence and Phrase Parsing Logic', () {
|
||
test('isSentenceQuery correctly detects sentences vs words', () {
|
||
expect(isSentenceQuery('apple'), isFalse);
|
||
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,
|
||
);
|
||
});
|
||
|
||
test('extractCourseLexiconPhrases finds embedded course lexicon items', () {
|
||
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);
|
||
});
|
||
});
|
||
|
||
group('Models Serialization', () {
|
||
test('PhraseBreakdownItem serialization round-trip', () {
|
||
const item = PhraseBreakdownItem(
|
||
phrase: 'check in',
|
||
meaning: '办理入住/值机',
|
||
ipa: '/tʃek ɪn/',
|
||
usageNote: '连读发音,酒店或机场常用',
|
||
);
|
||
|
||
final json = item.toJson();
|
||
final parsed = PhraseBreakdownItem.fromJson(json);
|
||
|
||
expect(parsed.phrase, 'check in');
|
||
expect(parsed.meaning, '办理入住/值机');
|
||
expect(parsed.ipa, '/tʃek ɪn/');
|
||
expect(parsed.usageNote, '连读发音,酒店或机场常用');
|
||
});
|
||
|
||
test('SentenceAnalysisResult serialization round-trip', () {
|
||
final result = SentenceAnalysisResult(
|
||
originalText: "I would like to check in, please.",
|
||
translation: '我想办理入住手续,谢谢。',
|
||
sentencePattern: "I would like to + 动词原形 (礼貌表达需求)",
|
||
grammarNote: 'would like 语气委婉客气,适合服务场景。',
|
||
pronunciationTips: 'would like 发音轻柔,check in 发生连读。',
|
||
phrases: const [
|
||
PhraseBreakdownItem(phrase: 'would like to', meaning: '想要做某事(委婉)'),
|
||
PhraseBreakdownItem(
|
||
phrase: 'check in',
|
||
meaning: '办理入住',
|
||
ipa: '/tʃek ɪn/',
|
||
),
|
||
],
|
||
provider: 'mock',
|
||
model: 'test-model',
|
||
createdAt: DateTime.utc(2026, 9, 16, 10, 0, 0),
|
||
);
|
||
|
||
final json = result.toJson();
|
||
final parsed = SentenceAnalysisResult.fromJson(json);
|
||
|
||
expect(parsed.originalText, result.originalText);
|
||
expect(parsed.translation, result.translation);
|
||
expect(parsed.sentencePattern, result.sentencePattern);
|
||
expect(parsed.grammarNote, result.grammarNote);
|
||
expect(parsed.pronunciationTips, result.pronunciationTips);
|
||
expect(parsed.phrases.length, 2);
|
||
expect(parsed.phrases[0].phrase, 'would like to');
|
||
expect(parsed.phrases[1].ipa, '/tʃek ɪn/');
|
||
expect(parsed.provider, 'mock');
|
||
expect(parsed.model, 'test-model');
|
||
});
|
||
});
|
||
|
||
group('AiService Mock Sentence Analysis', () {
|
||
test('returns structured breakdown for would like', () async {
|
||
final analysis = await AiService.instance.analyzeSentence(
|
||
provider: AiProviderType.mock,
|
||
endpoint: '',
|
||
model: 'mock',
|
||
text: 'I would like a cup of tea.',
|
||
);
|
||
|
||
expect(analysis, isNotNull);
|
||
expect(analysis!.translation, contains('想要'));
|
||
expect(analysis.sentencePattern, contains('would like'));
|
||
expect(analysis.pronunciationTips, isNotNull);
|
||
expect(analysis.phrases, isNotEmpty);
|
||
expect(analysis.phrases.any((p) => p.phrase == 'would like'), isTrue);
|
||
});
|
||
|
||
test('returns structured breakdown for where is', () async {
|
||
final analysis = await AiService.instance.analyzeSentence(
|
||
provider: AiProviderType.mock,
|
||
endpoint: '',
|
||
model: 'mock',
|
||
text: 'Where is the gate?',
|
||
);
|
||
|
||
expect(analysis, isNotNull);
|
||
expect(analysis!.translation, contains('在哪里'));
|
||
expect(analysis.sentencePattern, contains('Where is'));
|
||
expect(analysis.phrases.any((p) => p.phrase == 'where is'), isTrue);
|
||
});
|
||
|
||
test('returns structured fallback for generic sentence', () async {
|
||
final analysis = await AiService.instance.analyzeSentence(
|
||
provider: AiProviderType.mock,
|
||
endpoint: '',
|
||
model: 'mock',
|
||
text: 'The weather is very sunny today.',
|
||
);
|
||
|
||
expect(analysis, isNotNull);
|
||
expect(analysis!.translation, isNotEmpty);
|
||
expect(analysis.sentencePattern, isNotEmpty);
|
||
expect(analysis.grammarNote, isNotEmpty);
|
||
});
|
||
});
|
||
|
||
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(),
|
||
);
|
||
|
||
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,
|
||
'请问公交站在哪里?',
|
||
);
|
||
|
||
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.",
|
||
);
|
||
|
||
// Learning engine 3.5: a saved phrase is an extension word. It is
|
||
// recognition-only, so it goes to the word list, not the checkpoint
|
||
// queue where it would be asked to be produced.
|
||
expect(state.reviewQueue, isEmpty);
|
||
final id = state.savedWords.keys.singleWhere(
|
||
(key) => state.savedWords[key]!.en == 'check in',
|
||
);
|
||
expect(state.savedWords[id]!.zh, contains('办理登机或入住'));
|
||
expect(state.savedWords[id]!.zh, contains('酒店机场高频词'));
|
||
expect(state.wordStatus(id), WordStatus.newWord);
|
||
},
|
||
);
|
||
});
|
||
|
||
group('UI Lookup Sheet Sentence Analysis View', () {
|
||
testWidgets('renders sentence analysis result cards when cached', (
|
||
tester,
|
||
) async {
|
||
final state = AppState();
|
||
final analysis = SentenceAnalysisResult(
|
||
originalText: 'Where is the departure gate?',
|
||
translation: '请问登机口在哪里?',
|
||
sentencePattern: 'Where is + 目的地',
|
||
grammarNote: 'where 引导的疑问句,注意语调。',
|
||
pronunciationTips: 'Where 与 is 自然连读。',
|
||
phrases: const [
|
||
PhraseBreakdownItem(
|
||
phrase: 'departure gate',
|
||
ipa: '/dɪˈpɑːrtʃər ɡeɪt/',
|
||
meaning: '登机口',
|
||
usageNote: '机场核心词汇',
|
||
),
|
||
],
|
||
provider: 'mock',
|
||
createdAt: DateTime.now(),
|
||
);
|
||
state.cacheSentenceAnalysis(analysis);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: Scaffold(
|
||
body: Builder(
|
||
builder: (context) => ElevatedButton(
|
||
onPressed: () => showLexiconLookup(
|
||
context,
|
||
state: state,
|
||
initialText: 'Where is the departure gate?',
|
||
),
|
||
child: const Text('查句'),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.text('查句'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('查词与整句深度解析'), findsOneWidget);
|
||
expect(find.text('中文整句翻译'), findsOneWidget);
|
||
expect(find.text('请问登机口在哪里?'), findsOneWidget);
|
||
expect(find.text('核心句型'), findsOneWidget);
|
||
expect(find.text('Where is + 目的地'), findsOneWidget);
|
||
expect(find.text('口语连读与发音'), findsOneWidget);
|
||
expect(find.text('重点短语与搭配 (1)'), findsOneWidget);
|
||
expect(find.text('departure gate'), findsOneWidget);
|
||
expect(find.text('登机口'), findsOneWidget);
|
||
expect(find.text('加到单词'), findsOneWidget);
|
||
|
||
// Scroll to '加到单词' and tap
|
||
await tester.ensureVisible(find.text('加到单词'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('加到单词'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
state.savedWords.values.any((word) => word.en == 'departure gate'),
|
||
isTrue,
|
||
);
|
||
expect(state.reviewQueue, isEmpty);
|
||
expect(find.text('已在单词'), findsOneWidget);
|
||
});
|
||
});
|
||
}
|