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

328 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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<ChatMessageItem>> messages(int sessionId) async => const [
ChatMessageItem(id: 1, role: 'user', content: '你好'),
ChatMessageItem(id: 2, role: 'assistant', content: '你好呀'),
];
/// 两个人设,头像区分明显 —— 用于验证气泡上显示的是哪一个
@override
Future<List<AiPersonaItem>> 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<NetworkImage> 会取不到。
List<String> imageUrls(WidgetTester tester) {
final urls = <String>[];
for (final img in tester.widgetList<Image>(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<void> 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);
});
});
}