import 'package:dio/dio.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/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> personas() async => const []; } 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(selectedAiPersonaProvider.notifier).select(9); // 继续在这个会话里说话 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, '默认人设'); }); }); }