111 lines
4.0 KiB
Dart
111 lines
4.0 KiB
Dart
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, int? configId, 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 白刷一遍数据');
|
||
});
|
||
}
|