feat(app): 内容安全词库 + 过滤服务 + 分享前检查 — 28 个测试全覆盖
新增文件: - sensitive_words.dart — 8 分类 ~200 条敏感词 + 谐音/形近/数字变体映射 - content_filter_service.dart — 精确匹配 + 变体匹配 + 文本预处理(去零宽/空格/符号) - content_filter_service_test.dart — 28 个测试(8分类精确/安全内容/预处理/变体/边界/词库完整性) 修改: - share_bottom_sheet.dart — 分享到班级前调用 ContentFilterService, 有敏感词时弹出警告对话框(返回修改/仍然分享),新增 contentText 参数
This commit is contained in:
221
app/test/data/services/content_filter_service_test.dart
Normal file
221
app/test/data/services/content_filter_service_test.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
// ContentFilterService 单元测试
|
||||
//
|
||||
// 覆盖:精确匹配、谐音变体匹配、文本预处理、各分类检测、边界条件
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuanji_app/data/services/content_filter_service.dart';
|
||||
import 'package:nuanji_app/data/services/sensitive_words.dart';
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// 精确匹配 — 各分类
|
||||
// ============================================================
|
||||
group('精确匹配', () {
|
||||
test('暴力类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('我要打死你');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.violence), isTrue);
|
||||
expect(matches.any((m) => m.word == '打死你'), isTrue);
|
||||
});
|
||||
|
||||
test('色情类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('这个视频很色情');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.sexual), isTrue);
|
||||
});
|
||||
|
||||
test('欺凌类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('你是个废物');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.bullying), isTrue);
|
||||
expect(matches.any((m) => m.word == '废物'), isTrue);
|
||||
});
|
||||
|
||||
test('毒品类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('他在吸毒');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.drugs), isTrue);
|
||||
});
|
||||
|
||||
test('赌博类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('我们去赌钱吧');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.gambling), isTrue);
|
||||
});
|
||||
|
||||
test('粗口类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('卧槽太厉害了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.profanity), isTrue);
|
||||
});
|
||||
|
||||
test('诈骗类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('恭喜中奖了,点击链接领奖');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.fraud), isTrue);
|
||||
});
|
||||
|
||||
test('政治敏感类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('要造反了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.politics), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 安全内容
|
||||
// ============================================================
|
||||
group('安全内容', () {
|
||||
test('正常日记文本不触发', () {
|
||||
final text = '今天天气很好,我和小明一起去公园玩,非常开心。'
|
||||
'我们玩了滑梯、秋千,还吃了冰淇淋。';
|
||||
final matches = ContentFilterService.checkText(text);
|
||||
expect(matches, isEmpty);
|
||||
});
|
||||
|
||||
test('学习相关文本不触发', () {
|
||||
final text = '今天数学课学了乘法,我觉得很有趣。'
|
||||
'老师表扬了我,说我进步很大。';
|
||||
final matches = ContentFilterService.checkText(text);
|
||||
expect(matches, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 文本预处理 — 绕过手法
|
||||
// ============================================================
|
||||
group('文本预处理', () {
|
||||
test('空格分隔不影响检测', () {
|
||||
final matches = ContentFilterService.checkText('我 要 打 死 你');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '打死你'), isTrue);
|
||||
});
|
||||
|
||||
test('特殊符号插入不影响检测', () {
|
||||
final matches = ContentFilterService.checkText('废.物.垃.圾');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '废物'), isTrue);
|
||||
});
|
||||
|
||||
test('零宽字符不影响检测', () {
|
||||
// U+200B 零宽空格
|
||||
final matches = ContentFilterService.checkText('废物');
|
||||
expect(matches, isNotEmpty);
|
||||
});
|
||||
|
||||
test('下划线连字符不影响检测', () {
|
||||
final matches = ContentFilterService.checkText('废_物');
|
||||
expect(matches, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 谐音/变体匹配
|
||||
// ============================================================
|
||||
group('谐音变体匹配', () {
|
||||
test('数字谐音 "4" 匹配含 "死" 的词', () {
|
||||
// "去死" 在词库中 → "去4" 应触发匹配
|
||||
final matches = ContentFilterService.checkText('你怎么不去4');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '去死'), isTrue);
|
||||
});
|
||||
|
||||
test('形近字 "草" 匹配含 "操" 的词', () {
|
||||
// "操你" 在词库中 → "草你" 应触发匹配
|
||||
final matches = ContentFilterService.checkText('我草你太牛了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '操你'), isTrue);
|
||||
});
|
||||
|
||||
test('变体 "wc" 匹配 "卧槽"', () {
|
||||
final matches = ContentFilterService.checkText('wc这个好厉害');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '卧槽'), isTrue);
|
||||
});
|
||||
|
||||
test('变体 "莎" 匹配含 "杀" 的词', () {
|
||||
// "杀人" 在词库中 → "莎人" 应触发匹配
|
||||
final matches = ContentFilterService.checkText('我要莎人了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '杀人'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 边界条件
|
||||
// ============================================================
|
||||
group('边界条件', () {
|
||||
test('空字符串返回空列表', () {
|
||||
expect(ContentFilterService.checkText(''), isEmpty);
|
||||
});
|
||||
|
||||
test('纯空格返回空列表', () {
|
||||
expect(ContentFilterService.checkText(' '), isEmpty);
|
||||
});
|
||||
|
||||
test('纯符号返回空列表', () {
|
||||
expect(ContentFilterService.checkText('!@#\$%^&*'), isEmpty);
|
||||
});
|
||||
|
||||
test('超长文本不崩溃', () {
|
||||
final longText = '今天天气很好。' * 10000; // ~80,000 字符
|
||||
final matches = ContentFilterService.checkText(longText);
|
||||
expect(matches, isEmpty); // 正常内容
|
||||
});
|
||||
|
||||
test('多次出现同一词精确匹配只报告一次', () {
|
||||
final matches = ContentFilterService.checkText('白痴白痴');
|
||||
final exactMatches = matches.where((m) => m.word == '白痴' && m.position >= 0).toList();
|
||||
expect(exactMatches.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 辅助方法
|
||||
// ============================================================
|
||||
group('辅助方法', () {
|
||||
test('hasSensitiveContent 正确判断', () {
|
||||
expect(ContentFilterService.hasSensitiveContent('你好世界'), isFalse);
|
||||
expect(ContentFilterService.hasSensitiveContent('你是废物'), isTrue);
|
||||
});
|
||||
|
||||
test('getMatchedCategories 返回分类标签', () {
|
||||
final matches = ContentFilterService.checkText('废物你去死');
|
||||
final categories = ContentFilterService.getMatchedCategories(matches);
|
||||
expect(categories, isNotEmpty);
|
||||
// 至少包含欺凌和暴力
|
||||
expect(categories.any((c) => c == '欺凌'), isTrue);
|
||||
expect(categories.any((c) => c == '暴力'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 词库完整性
|
||||
// ============================================================
|
||||
group('词库完整性', () {
|
||||
test('8 个分类都有词', () {
|
||||
expect(kSensitiveWords.length, 8);
|
||||
for (final entry in kSensitiveWords.entries) {
|
||||
expect(entry.value, isNotEmpty, reason: '${entry.key.label} 分类不应为空');
|
||||
}
|
||||
});
|
||||
|
||||
test('总词量 >= 100', () {
|
||||
final total = kSensitiveWords.values.fold(0, (sum, list) => sum + list.length);
|
||||
expect(total, greaterThanOrEqualTo(100));
|
||||
});
|
||||
|
||||
test('谐音变体映射的 key 都在词库中', () {
|
||||
final allWords = kSensitiveWords.values.expand((w) => w).toSet();
|
||||
for (final key in kHomophoneVariants.keys) {
|
||||
// 变体 key 应该在词库中存在(单字映射除外)
|
||||
// 有些变体 key 是单字如 "死",对应词库中的 "去死" 等
|
||||
expect(
|
||||
allWords.any((w) => w.contains(key)),
|
||||
isTrue,
|
||||
reason: '变体 key "$key" 不在词库中',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user