328 lines
12 KiB
Dart
328 lines
12 KiB
Dart
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);
|
||
});
|
||
});
|
||
}
|