AI 接入 V1.0
UI更新
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:qiangbao_accounting/api/ai_api.dart';
|
||||
import 'package:qiangbao_accounting/models/ai.dart';
|
||||
import 'package:qiangbao_accounting/models/statistics_response.dart';
|
||||
import 'package:qiangbao_accounting/providers/ai_provider.dart';
|
||||
import 'package:qiangbao_accounting/providers/bill_provider.dart';
|
||||
|
||||
/// 假的 AI 接口:按脚本吐一串 SSE 事件。
|
||||
class FakeAiApi extends AiApi {
|
||||
FakeAiApi(this._events) : super(Dio());
|
||||
|
||||
final List<AiEvent> _events;
|
||||
|
||||
@override
|
||||
Stream<AiEvent> chat({int? sessionId, required String content}) async* {
|
||||
for (final e in _events) {
|
||||
yield e;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ChatSessionItem>> sessions() async => const [];
|
||||
}
|
||||
|
||||
/// 回归测试:AI 在服务端写库后,App 必须主动让相关 provider 失效。
|
||||
///
|
||||
/// 背景(这是个真实踩过的 bug):`IndexedStack` 让首页仪表盘和「我的」页常驻,
|
||||
/// 它们 watch 的 monthlyStatisticsProvider / budgetProvider / accountProvider
|
||||
/// **永远不会被 autoDispose 回收**,只能靠「写操作后显式失效」更新。
|
||||
/// 以前所有写操作都在 App 内,会调 invalidateBillData;
|
||||
/// AI 改成在服务端直接写库之后,App 毫不知情 —— 于是账单列表刷新了
|
||||
/// (它是 push 出来的页面,provider 被回收过),但汇总和预算环还是旧数,
|
||||
/// 看起来就是「列表对了、图表没对」。
|
||||
void main() {
|
||||
final stats = StatisticsResponse(
|
||||
totalIncome: 0,
|
||||
totalExpense: 134,
|
||||
balance: -134,
|
||||
categoryStatistics: const [],
|
||||
dailyStatistics: const [],
|
||||
);
|
||||
|
||||
/// 模拟「首页仪表盘一直挂在树上」,让 provider 有常驻监听者
|
||||
Future<(ProviderContainer, int Function())> boot(List<AiEvent> events) async {
|
||||
var builds = 0;
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
aiApiProvider.overrideWithValue(FakeAiApi(events)),
|
||||
monthlyStatisticsProvider.overrideWith((ref, month) {
|
||||
builds++;
|
||||
return Future.value(stats);
|
||||
}),
|
||||
],
|
||||
);
|
||||
final anchor = DateTime(2026, 9);
|
||||
final sub = container.listen(monthlyStatisticsProvider(anchor), (_, _) {});
|
||||
addTearDown(sub.close);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(monthlyStatisticsProvider(anchor).future);
|
||||
return (container, () => builds);
|
||||
}
|
||||
|
||||
test('AI 调了 create_bill 之后,账单汇总会被失效重取', () async {
|
||||
final (container, builds) = await boot([
|
||||
const AiEvent.tool('create_bill'),
|
||||
const AiEvent.delta('记好啦宝'),
|
||||
const AiEvent.done(1),
|
||||
]);
|
||||
expect(builds(), 1, reason: '初始只取过一次');
|
||||
|
||||
await container.read(assistantProvider.notifier).send('帮我记一笔 25 块');
|
||||
await container.read(monthlyStatisticsProvider(DateTime(2026, 9)).future);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(builds(), 2, reason: 'AI 写了账单,汇总必须重取');
|
||||
});
|
||||
|
||||
test('AI 调了 create_note 不会误伤账单数据', () async {
|
||||
final (container, builds) = await boot([
|
||||
const AiEvent.tool('create_note'),
|
||||
const AiEvent.delta('记好了宝'),
|
||||
const AiEvent.done(1),
|
||||
]);
|
||||
expect(builds(), 1);
|
||||
|
||||
await container.read(assistantProvider.notifier).send('帮我记一下:周三开会');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(builds(), 1, reason: '只是记笔记,账单汇总不该被失效');
|
||||
});
|
||||
|
||||
test('纯查询不触发任何重取', () async {
|
||||
final (container, builds) = await boot([
|
||||
const AiEvent.tool('query_bills'),
|
||||
const AiEvent.tool('web_search'),
|
||||
const AiEvent.delta('这个月花了 134'),
|
||||
const AiEvent.done(1),
|
||||
]);
|
||||
expect(builds(), 1);
|
||||
|
||||
await container.read(assistantProvider.notifier).send('这个月花了多少');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(builds(), 1, reason: '只读工具不该让 App 白刷一遍数据');
|
||||
});
|
||||
}
|
||||
@@ -130,7 +130,8 @@ void main() {
|
||||
expect(b.amount, 35.5);
|
||||
expect(b.billDate, '2026-09-21');
|
||||
expect(b.categoryName, '未分类');
|
||||
expect(b.categoryIcon, '📦');
|
||||
// 图标兜底值是「图标名」不再是 emoji(2026-09-24 换成自绘图标集)
|
||||
expect(b.categoryIcon, 'other');
|
||||
expect(b.type, 1); // 缺省按支出
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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 = <String, String>{};
|
||||
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<File>()
|
||||
.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 = <String>[];
|
||||
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')}',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user