import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:qiangbao_accounting/widgets/cute_icon.dart'; /// 图标系统的结构性测试。 /// /// 这类测试的价值在于:图标名是**字符串**,写错一个字母不会编译报错, /// 只会静默退化成兜底图标 —— 那种错在界面上很不显眼,容易一直没人发现。 void main() { test('图标名原样返回', () { expect(resolveIconName('food'), 'food'); expect(resolveIconName('folder-open'), 'folder-open'); }); test('emoji 映射到图标名(迁移前的旧数据要照样能显示)', () { expect(resolveIconName('🍔'), 'food'); expect(resolveIconName('🚗'), 'transport'); expect(resolveIconName('💰'), 'salary'); expect(resolveIconName('📦'), 'other'); }); test('带变体选择符的 emoji 也能认出来', () { // '🛍️' 实际是 U+1F6CD + U+FE0F,不同平台的输入法拼法不一致 expect(resolveIconName('🛍️'), 'shopping'); expect(resolveIconName('🛍'), 'shopping'); }); test('空值和未知值兜底到 other,不返回空图标', () { expect(resolveIconName(null), 'other'); expect(resolveIconName(''), 'other'); expect(resolveIconName(' '), 'other'); expect(resolveIconName('这个不是图标'), 'other'); expect(resolveIconName('🍣未知的寿司'), 'other'); }); test('映射表里的每个目标都是合法图标名(防手滑写错)', () { final bad = {}; cuteEmojiAliases.forEach((emoji, icon) { if (!cuteIconNames.contains(icon)) bad[emoji] = icon; }); expect(bad, isEmpty, reason: '这些映射指向了不存在的图标: $bad'); }); test('每个图标名都有对应的 SVG 文件(防漏生成)', () { final missing = cuteIconNames .where((n) => !File('assets/icons/$n.svg').existsSync()) .toList(); expect(missing, isEmpty, reason: '缺少 SVG: $missing,跑一下 python tools/make_icons.py'); }); test('SVG 目录里没有多余文件(防改名后留下孤儿)', () { final onDisk = Directory('assets/icons') .listSync() .whereType() .map((f) => f.uri.pathSegments.last.replaceAll('.svg', '')) .toSet(); final orphans = onDisk.difference(cuteIconNames); expect(orphans, isEmpty, reason: '这些 SVG 在 cuteIconNames 里没有登记: $orphans'); }); test('没有把图标字段直接丢给 Text 渲染(防漏改渲染点)', () { // 这个测试是真踩出来的:图标从 emoji 换成图标名之后,绝大多数渲染点都 // 改成了 CuteIcon,但分类宫格漏了一处,结果手机上显示成一串 "food" // "transport" 文字,把圆圈挤爆了。 // // 图标值是**字符串**,漏改不会编译报错、analyze 也查不出来,只能靠扫源码。 // // 注意:Text 的首参会带 '$' 做字符串插值(如 '¥${x}'),那种是数字/文本, // 这里只拦「整个参数就是某个 .icon 字段」的情况。 final iconField = RegExp(r'\b\w+\.icon\b|\bcategoryIcon\b|\bmodule\.icon\b'); final textWithIcon = RegExp( r'Text\(\s*(?:const\s+)?[^,()\n]*?(?:\w+\.icon|categoryIcon)[^,()\n]*?(?:,|\))', ); final offenders = []; for (final f in Directory('lib').listSync(recursive: true)) { if (f is! File || !f.path.endsWith('.dart')) continue; final lines = f.readAsLinesSync(); for (var i = 0; i < lines.length; i++) { final line = lines[i]; if (!iconField.hasMatch(line)) continue; if (textWithIcon.hasMatch(line)) { offenders.add('${f.path}:${i + 1} ${line.trim()}'); } } } expect( offenders, isEmpty, reason: '这些地方把图标名当文字渲染了,应该用 CuteIcon:\n${offenders.join('\n')}', ); }); }