import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:qiangbao_accounting/api/ai_api.dart'; import 'package:qiangbao_accounting/models/ai.dart'; import 'package:qiangbao_accounting/models/ai_persona.dart'; import 'package:qiangbao_accounting/pages/assistant/assistant_page.dart'; import 'package:qiangbao_accounting/providers/ai_provider.dart'; import 'package:qiangbao_accounting/providers/ai_persona_provider.dart'; /// 「会话内人设固定」的回归测试。 /// /// 这是这次需求里**最容易悄悄坏掉**的一条:人设现在是「每轮 send 时现读」还是 /// 「建会话时定下」,代码上只差一行 —— 但坏掉的表现是「聊到一半助手换了性格」, /// 而且要用户真的去设置页切一次人设才能发现。 /// /// 所以这里直接断言「send 时带上的是 state.personaId,不是全局偏好」。 class _CapturingAiApi extends AiApi { _CapturingAiApi() : super(Dio()); /// 每次 chat 调用时带上的 personaId,按调用顺序记下来 final List personaIdsSent = []; @override Stream chat({ int? sessionId, int? configId, int? personaId, required String content, }) async* { personaIdsSent.add(personaId); // 模拟一个最小可用的回答,让 send() 能正常走完 yield const AiEvent.delta('好'); yield const AiEvent.done(1); } @override Future> sessions() async => const []; @override Future> messages(int sessionId) async => const [ ChatMessageItem(id: 1, role: 'user', content: '你好'), ChatMessageItem(id: 2, role: 'assistant', content: '你好呀'), ]; /// 两个人设,头像区分明显 —— 用于验证气泡上显示的是哪一个 @override Future> personas() async => const [ AiPersonaItem( id: 7, name: '花花', systemPrompt: '你是花花', promptLength: 4, isDefault: false, editable: true, defaultPrompt: null, avatar: 'persona/huahua.jpg', ), AiPersonaItem( id: 9, name: '简洁', systemPrompt: '只回一句', promptLength: 4, isDefault: false, editable: true, defaultPrompt: null, avatar: 'persona/jianjie.jpg', ), ]; } void main() { // LocalStorage 是单例,select() 会写盘。测试里给它一个空实现, // 否则会去真的要 SharedPreferences(在纯单测环境里拿不到) TestWidgetsFlutterBinding.ensureInitialized(); SharedPreferences.setMockInitialValues({}); ProviderContainer makeContainer(_CapturingAiApi api) { final c = ProviderContainer(overrides: [ aiApiProvider.overrideWithValue(api), ]); addTearDown(c.dispose); return c; } group('★ 会话内人设固定', () { test('新对话选的人设会一直用下去(中途预选偏好变了也不受影响)', () async { final api = _CapturingAiApi(); final c = makeContainer(api); final notifier = c.read(assistantProvider.notifier); // 用户在「新对话」弹层里选了人设 7 notifier.newChat(personaId: 7); expect(c.read(assistantProvider).personaId, 7); // 之后他又开了一次新对话、选了人设 9(这是偏好,不是「当前会话的人设」) c.read(preferredPersonaProvider.notifier).remember(9); // 回到**第一个人设 7 的那条会话**继续说话。 // 用 openSession 模拟「切回历史会话」——它会用该会话自己的 personaId await notifier.openSession(1, personaId: 7); await notifier.send('第一句'); await notifier.send('第二句'); // 两次都该用 7,不该跟着偏好变成 9 expect(api.personaIdsSent, [7, 7], reason: '会话锁定的人设不该被全局偏好覆盖'); }); test('新对话不选人设时用 null(内置默认)', () async { final api = _CapturingAiApi(); final c = makeContainer(api); final notifier = c.read(assistantProvider.notifier); notifier.newChat(); expect(c.read(assistantProvider).personaId, isNull); await notifier.send('随便说点'); expect(api.personaIdsSent, [null]); }); test('newChat 会清掉上一个人设(不残留)', () async { final api = _CapturingAiApi(); final c = makeContainer(api); final notifier = c.read(assistantProvider.notifier); notifier.newChat(personaId: 3); expect(c.read(assistantProvider).personaId, 3); notifier.newChat(); expect(c.read(assistantProvider).personaId, isNull, reason: '第二次新对话没选人设,不该残留上一次的'); }); test('打开历史会话时用那条会话绑定的人设', () async { final api = _CapturingAiApi(); final c = makeContainer(api); final notifier = c.read(assistantProvider.notifier); // 打开一个三个月前的会话,它绑的是人设 5 await notifier.openSession(42, personaId: 5); expect(c.read(assistantProvider).personaId, 5); expect(c.read(assistantProvider).sessionId, 42); // 在这条历史会话里继续说话,仍然用 5 await notifier.send('接着聊'); expect(api.personaIdsSent, [5]); }); }); group('ChatSessionItem 解析(会话列表带人设)', () { test('完整字段', () { final s = ChatSessionItem.fromJson({ 'id': 1, 'title': '聊记账', 'updateTime': '2026-09-29 10:00:00', 'personaId': 7, 'personaName': '花花', 'personaAvatar': 'persona/a.jpg', }); expect(s.id, 1); expect(s.personaId, 7); expect(s.personaName, '花花'); expect(s.personaAvatar, 'persona/a.jpg'); }); test('★ 缺字段不崩(老后端 / 人设已删)', () { final s = ChatSessionItem.fromJson({'id': 1, 'title': 'x'}); expect(s.personaId, isNull); expect(s.personaName, '默认人设'); // 头像空串归一成 null,Avatar 组件据此显示默认图标 expect(s.personaAvatar, ''); }); test('人设名缺失时回落成「默认人设」,不显示空白', () { final s = ChatSessionItem.fromJson({'id': 1, 'personaName': ''}); expect(s.personaName, '默认人设'); }); }); group('★ 气泡头像跟着会话的人设走', () { /// 从渲染出来的 Image 里收集所有网络图片地址。 /// /// 要拆一层 ResizeImage:Avatar 传了 cacheWidth,Flutter 会把 /// NetworkImage 包成 ResizeImage,直接 whereType 会取不到。 List imageUrls(WidgetTester tester) { final urls = []; for (final img in tester.widgetList(find.byType(Image))) { final provider = img.image; if (provider is NetworkImage) { urls.add(provider.url); } else if (provider is ResizeImage && provider.imageProvider is NetworkImage) { urls.add((provider.imageProvider as NetworkImage).url); } } return urls; } testWidgets('会话绑 7 → 气泡显示 7 的头像,不是预选偏好的 9', (tester) async { final api = _CapturingAiApi(); await tester.pumpWidget(ProviderScope( overrides: [aiApiProvider.overrideWithValue(api)], child: const MaterialApp(home: AssistantPage()), )); await tester.pump(); final container = ProviderScope.containerOf( tester.element(find.byType(AssistantPage)), ); // 预选偏好是 9(比如用户上次新对话选了「简洁」) container.read(preferredPersonaProvider.notifier).remember(9); // 但打开的是绑定人设 7 的那条会话 await container.read(assistantProvider.notifier).openSession(1, personaId: 7); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); final urls = imageUrls(tester); expect(urls.any((u) => u.contains('huahua')), isTrue, reason: '气泡该显示会话绑定的人设(7=花花)的头像'); expect(urls.any((u) => u.contains('jianjie')), isFalse, reason: '不该显示预选偏好那个人(9=简洁)的头像 —— 那是另一个会话的人'); expect(tester.takeException(), isNull); }); testWidgets('会话没绑人设(内置默认)时不加载任何头像图,退回默认图标', (tester) async { final api = _CapturingAiApi(); await tester.pumpWidget(ProviderScope( overrides: [aiApiProvider.overrideWithValue(api)], child: const MaterialApp(home: AssistantPage()), )); await tester.pump(); final container = ProviderScope.containerOf( tester.element(find.byType(AssistantPage)), ); // 预选偏好 9,但会话用的是内置默认(personaId=null) container.read(preferredPersonaProvider.notifier).remember(9); await container.read(assistantProvider.notifier).openSession(1); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); final urls = imageUrls(tester); expect(urls.any((u) => u.contains('jianjie')), isFalse, reason: '会话用内置默认人设时,不该借预选偏好那个人来用'); expect(tester.takeException(), isNull); }); }); group('★ 新建对话:选了才开,关掉就是取消', () { Future openAssistant(WidgetTester tester, _CapturingAiApi api) async { await tester.pumpWidget(ProviderScope( overrides: [aiApiProvider.overrideWithValue(api)], child: const MaterialApp(home: AssistantPage()), )); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); } testWidgets('点「新对话」会弹出选人设;选一个 → 该会话锁定它', (tester) async { final api = _CapturingAiApi(); await openAssistant(tester, api); final container = ProviderScope.containerOf( tester.element(find.byType(AssistantPage)), ); await tester.tap(find.byTooltip('新对话')); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); // 弹层里应该有两个人设 expect(find.text('这次想找谁聊?'), findsOneWidget); expect(find.text('花花'), findsOneWidget); expect(find.text('简洁'), findsOneWidget); await tester.tap(find.text('简洁')); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); expect(container.read(assistantProvider).personaId, 9, reason: '选了「简洁」就该锁定 9'); expect(tester.takeException(), isNull); }); testWidgets('★ 关掉弹层 = 取消,不会偷偷开一个新对话', (tester) async { final api = _CapturingAiApi(); await openAssistant(tester, api); final container = ProviderScope.containerOf( tester.element(find.byType(AssistantPage)), ); // 先打开一条历史会话,制造「当前有对话」的状态 await container.read(assistantProvider.notifier).openSession(1, personaId: 7); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(container.read(assistantProvider).sessionId, 1); await tester.tap(find.byTooltip('新对话')); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); expect(find.text('这次想找谁聊?'), findsOneWidget); // 点弹层外面(左上角)关掉它 await tester.tapAt(const Offset(5, 5)); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); // 当前会话不该被清掉 —— 误触一下不该丢掉正在看的对话 expect(container.read(assistantProvider).sessionId, 1, reason: '取消选人设时不该开新对话'); expect(container.read(assistantProvider).personaId, 7); expect(tester.takeException(), isNull); }); }); }