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 = []; 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 = []; 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('every A0 pattern says when it is used', () { final missing = [ for (final pack in CourseRepository.instance.units) if (pack.level == 'A0') for (final item in pack.coreItems) if (item.type != 'word' && (coreUsage(item.id)?.when ?? '').isEmpty) '${item.id} "${item.en}"', ]; expect( missing, isEmpty, reason: '句型要写清什么时候用,否则学会了也不知道该用哪一句', ); }); test('every confusable points at a real taught item with a note', () { final broken = []; for (final pack in CourseRepository.instance.units) { for (final item in pack.coreItems) { for (final other in item.usage.confuse) { if (!isCoreItem(other.id)) broken.add('${item.id} → ${other.id} 不存在'); if (other.note.isEmpty) broken.add('${item.id} → ${other.id} 没写区别'); if (other.id == item.id) broken.add('${item.id} 指向自己'); } } } expect(broken, isEmpty); }); test('every A0 pattern has something to be told apart from', () { final alone = [ for (final pack in CourseRepository.instance.units) if (pack.level == 'A0') for (final item in pack.coreItems) if (item.type != 'word' && item.usage.confuse.isEmpty) '${item.id} "${item.en}"', ]; expect(alone, isEmpty, reason: '每个句型都要有一句容易混的,才出得了辨析题'); }); test('a pattern can be told apart from something already taught', () { final order = { for (var i = 0; i < CourseRepository.instance.units.length; i++) CourseRepository.instance.units[i].id: i, }; final late = []; for (final pack in CourseRepository.instance.units) { if (pack.level != 'A0') continue; for (final item in pack.coreItems) { if (item.type == 'word' || item.usage.confuse.isEmpty) continue; // The first review of an item comes a day after its own unit, so a // pairing that only names later units has nothing to ask about then. final reachable = item.usage.confuse.any((other) { final unit = coreItemRegistry[other.id]?.unit; return unit != null && (order[unit] ?? 999) <= order[pack.id]!; }); if (!reachable) late.add('${item.id} 的易混句都在后面的单元'); } } expect(late, isEmpty); }); test('a contrast question offers the item and something else to pick', () { final broken = []; for (final id in allCoreItemIds) { if ((coreUsage(id)?.confuse ?? const []).isEmpty) continue; final question = coreContrastQuestion(id); if (question == null) { broken.add('$id has confusables but no question'); continue; } // Two options is a real choice; more than three turns recall into a // reading exercise. if (question.options.length < 2 || question.options.length > 3) { broken.add('$id has ${question.options.length} options'); } // A situation that quotes one of the sentences gives the choice away. for (final option in question.options) { if (question.situation.contains(option)) { broken.add('$id 的情境里写出了选项「$option」'); } } if (!question.options.contains(question.answer)) { broken.add('$id answer is not among the options'); } // Two sentences a learner has to tell apart cannot be the same string. if (question.options.toSet().length != question.options.length) { broken.add('$id repeats an option'); } } expect(broken, isEmpty); }); test('assessment tasks are answerable and replacements point home', () { final ids = {for (final pack in assessmentPacks) pack.id}; final failures = []; 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); }); }