// 校验 assets/courses 下的单元内容包并报告词池覆盖。 // // 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 'course_validator.dart'; void main(List 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, ]; final validator = CourseValidator( projectRoot: Directory.current, a0Texts: [ // 去掉 [name] 这类槽位占位符,它们不是教过的词。 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(); for (final issue in report.errors) { stdout.writeln('错误 $issue'); } if (report.missingUnits.isNotEmpty) { 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 ? '' : ',单元未写完'}', ); } final uncoveredIndex = args.indexOf('--uncovered'); if (uncoveredIndex >= 0 && uncoveredIndex + 1 < args.length) { final coverage = report.coverage[args[uncoveredIndex + 1]]; final byTopic = >{}; for (final word in coverage?.uncovered ?? const []) { byTopic .putIfAbsent(word.topic.isEmpty ? '(无主题)' : word.topic, () => []) .add(word.headword); } final topics = byTopic.keys.toList() ..sort((a, b) => byTopic[b]!.length - byTopic[a]!.length); for (final topic in topics) { stdout.writeln('\n[$topic] ${byTopic[topic]!.length}'); stdout.writeln(byTopic[topic]!.join(', ')); } } final levelsIndex = args.indexOf('--levels'); if (levelsIndex >= 0 && levelsIndex + 1 < args.length) { final levels = report.itemLevels[args[levelsIndex + 1]] ?? const {}; for (final MapEntry(:key, :value) in levels.entries) { stdout.writeln('$key\t$value'); } } exitCode = report.errors.isEmpty ? 0 : 1; }