整个课程和数据UI重新设计版本
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
/// 单元内容包(assets/courses)的离线校验,规则见 Doc/COURSE-PACK-JSON.md
|
||||
/// 与《学习引擎规格》5.4–5.6。只依赖 dart:core,供命令行工具和测试共用。
|
||||
/// 与《学习引擎规格》5.4–5.6。供命令行工具和测试共用;match 的判定直接用
|
||||
/// App 的 answer_rules.dart,校验结果和运行时判分一致。
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:kouyu_english/core/courses/answer_rules.dart' show matchesRule;
|
||||
|
||||
const levelOrder = ['A0', 'A1', 'A2', 'B1', 'B2'];
|
||||
|
||||
int levelRank(String level) {
|
||||
@@ -241,21 +244,16 @@ String normalizeForMatch(String text) {
|
||||
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
}
|
||||
|
||||
bool containsTerm(String normalizedText, String term) {
|
||||
final normalizedTerm = normalizeForMatch(term);
|
||||
if (normalizedTerm.isEmpty) return false;
|
||||
/// 规范化后的 [normalizedText] 是否以完整词语的形式含有 [phrase],用于
|
||||
/// 查找选项在原文里是否出现;match 规则的判定用 answer_rules.dart。
|
||||
bool containsPhrase(String normalizedText, String phrase) {
|
||||
final normalizedPhrase = normalizeForMatch(phrase);
|
||||
if (normalizedPhrase.isEmpty) return false;
|
||||
return RegExp(
|
||||
'(^| )${RegExp.escape(normalizedTerm)}(\$| )',
|
||||
'(^| )${RegExp.escape(normalizedPhrase)}(\$| )',
|
||||
).hasMatch(normalizedText);
|
||||
}
|
||||
|
||||
bool satisfiesMatch(String text, List<List<String>> groups) {
|
||||
final normalized = normalizeForMatch(text);
|
||||
return groups.every(
|
||||
(group) => group.any((term) => containsTerm(normalized, term)),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 词表
|
||||
|
||||
const functionPos = {
|
||||
@@ -451,6 +449,9 @@ class CourseValidator {
|
||||
|
||||
for (final level in (map['levels'] as List).cast<Map<String, dynamic>>()) {
|
||||
final levelId = level['id'] as String;
|
||||
// A0 is the frozen baseline: its words are passed in as `a0Texts`, and
|
||||
// it has no word pool or unit contract of the later levels.
|
||||
if (levelId == 'A0') continue;
|
||||
final functionWords = <String>{...lexicon.functionWordsUpTo(levelId)};
|
||||
var ready = true;
|
||||
for (final entry in (level['units'] as List).cast<Map<String, dynamic>>()) {
|
||||
@@ -723,7 +724,7 @@ class _UnitContext {
|
||||
_addWords(unitTaught, en);
|
||||
_addWords(unitCovered, en);
|
||||
final match = _match(item, id);
|
||||
if (en.isNotEmpty && match.isNotEmpty && !satisfiesMatch(en, match)) {
|
||||
if (en.isNotEmpty && match.isNotEmpty && !matchesRule(en, match)) {
|
||||
error(id, 'en 本身不满足 match');
|
||||
}
|
||||
final example = item['example'];
|
||||
@@ -732,7 +733,7 @@ class _UnitContext {
|
||||
} else {
|
||||
final exampleEn = _string(example, 'en', '$id.example');
|
||||
_string(example, 'zh', '$id.example');
|
||||
if (match.isNotEmpty && !satisfiesMatch(exampleEn, match)) {
|
||||
if (match.isNotEmpty && !matchesRule(exampleEn, match)) {
|
||||
error(id, 'example.en 不满足 match');
|
||||
}
|
||||
}
|
||||
@@ -907,9 +908,9 @@ class _UnitContext {
|
||||
}
|
||||
if (sourceText != null) {
|
||||
final text = normalizeForMatch(sourceText);
|
||||
final present = normalized.where((option) => containsTerm(text, option)).length;
|
||||
final present = normalized.where((option) => containsPhrase(text, option)).length;
|
||||
if (present < 2) error(where, '英文选项至少两个要在原文里出现');
|
||||
if (!containsTerm(text, normalized.first)) error(where, '正确选项没有在原文中出现');
|
||||
if (!containsPhrase(text, normalized.first)) error(where, '正确选项没有在原文中出现');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -969,7 +970,7 @@ class _UnitContext {
|
||||
_checkText('$id.writing.example', example);
|
||||
for (final ref in _itemRefs(writing, '$id.writing')) {
|
||||
final item = core[ref];
|
||||
if (item != null && !satisfiesMatch(example, _match(item, ref))) {
|
||||
if (item != null && !matchesRule(example, _match(item, ref))) {
|
||||
error('$id.writing', '示范没有用到 $ref');
|
||||
}
|
||||
}
|
||||
@@ -989,7 +990,7 @@ class _UnitContext {
|
||||
for (final itemId in itemIds) {
|
||||
final item = core[itemId];
|
||||
if (item == null) continue;
|
||||
if (practice.any((text) => satisfiesMatch(text, _match(item, itemId)))) {
|
||||
if (practice.any((text) => matchesRule(text, _match(item, itemId)))) {
|
||||
usage[itemId]?.add('practice');
|
||||
}
|
||||
}
|
||||
@@ -1029,7 +1030,7 @@ class _UnitContext {
|
||||
for (final ref in _itemRefs(turn, where)) {
|
||||
final item = core[ref];
|
||||
if (item == null) continue;
|
||||
if (satisfiesMatch(model, _match(item, ref))) {
|
||||
if (matchesRule(model, _match(item, ref))) {
|
||||
usage[ref]?.add('scene');
|
||||
} else {
|
||||
error(where, 'model 没有用到 $ref');
|
||||
|
||||
@@ -3,20 +3,39 @@
|
||||
// dart run tool/courses/validate.dart # 报告错误和覆盖率
|
||||
// dart run tool/courses/validate.dart --uncovered A1 # 列出未覆盖的词
|
||||
// dart run tool/courses/validate.dart --levels A1-U01 # 打印条目的计算等级
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:kouyu_english/core/a0_core.dart';
|
||||
|
||||
import 'course_validator.dart';
|
||||
|
||||
void main(List<String> args) {
|
||||
// A0 核心条目与听写句来自 A0 单元包,脚本在 Flutter 之外运行,直接读文件。
|
||||
final map = jsonDecode(
|
||||
File('assets/courses/course-map.json').readAsStringSync(),
|
||||
);
|
||||
final a0Level = (map['levels'] as List).firstWhere(
|
||||
(level) => level['id'] == 'A0',
|
||||
);
|
||||
final a0Items = [
|
||||
for (final unit in a0Level['units'] as List)
|
||||
for (final item
|
||||
in jsonDecode(
|
||||
File('assets/courses/${unit['file']}').readAsStringSync(),
|
||||
)['coreItems']
|
||||
as List)
|
||||
item as Map<String, dynamic>,
|
||||
];
|
||||
final validator = CourseValidator(
|
||||
projectRoot: Directory.current,
|
||||
a0Texts: [
|
||||
// 去掉 [name] 这类槽位占位符,它们不是教过的词。
|
||||
for (final item in a0CoreItems.values)
|
||||
item.replaceAll(RegExp(r'\[[^\]]*\]'), ''),
|
||||
for (final sentence in a0DictationSentences.values) sentence.sentence,
|
||||
for (final item in a0Items)
|
||||
(item['en'] as String).replaceAll(RegExp(r'\[[^\]]*\]'), ''),
|
||||
for (final item in a0Items)
|
||||
if (item['review'] case {
|
||||
'dictation': {'sentence': final String sentence},
|
||||
})
|
||||
sentence,
|
||||
],
|
||||
);
|
||||
final report = validator.validate();
|
||||
@@ -25,13 +44,17 @@ void main(List<String> args) {
|
||||
stdout.writeln('错误 $issue');
|
||||
}
|
||||
if (report.missingUnits.isNotEmpty) {
|
||||
stdout.writeln('待编写 ${report.missingUnits.length} 个单元:'
|
||||
'${report.missingUnits.first} … ${report.missingUnits.last}');
|
||||
stdout.writeln(
|
||||
'待编写 ${report.missingUnits.length} 个单元:'
|
||||
'${report.missingUnits.first} … ${report.missingUnits.last}',
|
||||
);
|
||||
}
|
||||
for (final coverage in report.coverage.values) {
|
||||
stdout.writeln('${coverage.level} 词池覆盖 ${coverage.covered}/${coverage.poolSize} '
|
||||
'(${(coverage.ratio * 100).toStringAsFixed(1)}%)'
|
||||
'${coverage.unitsReady ? '' : ',单元未写完'}');
|
||||
stdout.writeln(
|
||||
'${coverage.level} 词池覆盖 ${coverage.covered}/${coverage.poolSize} '
|
||||
'(${(coverage.ratio * 100).toStringAsFixed(1)}%)'
|
||||
'${coverage.unitsReady ? '' : ',单元未写完'}',
|
||||
);
|
||||
}
|
||||
|
||||
final uncoveredIndex = args.indexOf('--uncovered');
|
||||
@@ -39,7 +62,8 @@ void main(List<String> args) {
|
||||
final coverage = report.coverage[args[uncoveredIndex + 1]];
|
||||
final byTopic = <String, List<String>>{};
|
||||
for (final word in coverage?.uncovered ?? const <PoolWord>[]) {
|
||||
byTopic.putIfAbsent(word.topic.isEmpty ? '(无主题)' : word.topic, () => [])
|
||||
byTopic
|
||||
.putIfAbsent(word.topic.isEmpty ? '(无主题)' : word.topic, () => [])
|
||||
.add(word.headword);
|
||||
}
|
||||
final topics = byTopic.keys.toList()
|
||||
|
||||
Reference in New Issue
Block a user