Files
Aq-Accounting-Flutter/test/ai_session_persona_test.dart
T

156 lines
5.3 KiB
Dart

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<int?> personaIdsSent = [];
@override
Stream<AiEvent> 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<List<ChatSessionItem>> sessions() async => const [];
@override
Future<List<AiPersonaItem>> 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, '默认人设');
});
});
}