AI助手优化-人设与会话绑定
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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_config.dart';
|
||||
import 'package:qiangbao_accounting/models/ai_persona.dart';
|
||||
import 'package:qiangbao_accounting/pages/assistant/ai_config_page.dart';
|
||||
import 'package:qiangbao_accounting/providers/ai_persona_provider.dart';
|
||||
|
||||
/// 「AI 设置页只管理人设、不负责选人」的回归测试。
|
||||
///
|
||||
/// 背景:一开始设置页做成「点一条就切换当前人设」,但这和数据模型是冲突的 ——
|
||||
/// 人设是**跟着会话走**的(新建对话时定,之后不改),设置页里再放一个「选人」
|
||||
/// 就会让用户以为「在这儿选了就生效」,实际聊起来还是会话里那个。
|
||||
///
|
||||
/// 所以这里守两件事:
|
||||
/// 1. 设置页上没有任何表示「当前选中」的控件(单选圈),也不能通过点击切换
|
||||
/// 2. 会话的人设不受「预选偏好」影响(这个是核心不变量,另有专门测试)
|
||||
class _FakeAiApi extends AiApi {
|
||||
_FakeAiApi() : super(Dio());
|
||||
|
||||
@override
|
||||
Future<List<AiConfigItem>> configs() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<AiPersonaItem>> personas() async => const [
|
||||
AiPersonaItem(
|
||||
id: null,
|
||||
name: '默认人设',
|
||||
systemPrompt: '内置的',
|
||||
promptLength: 3,
|
||||
isDefault: true,
|
||||
editable: false,
|
||||
defaultPrompt: '内置的',
|
||||
),
|
||||
AiPersonaItem(
|
||||
id: 7,
|
||||
name: '花花',
|
||||
systemPrompt: '你是花花',
|
||||
promptLength: 4,
|
||||
isDefault: false,
|
||||
editable: true,
|
||||
defaultPrompt: null,
|
||||
),
|
||||
AiPersonaItem(
|
||||
id: 8,
|
||||
name: '简洁模式',
|
||||
systemPrompt: '只回一句',
|
||||
promptLength: 4,
|
||||
isDefault: false,
|
||||
editable: true,
|
||||
defaultPrompt: null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
late _FakeAiApi api;
|
||||
|
||||
setUp(() => api = _FakeAiApi());
|
||||
|
||||
Widget wrap() => ProviderScope(
|
||||
overrides: [aiApiProvider.overrideWithValue(api)],
|
||||
child: const MaterialApp(home: AiConfigPage()),
|
||||
);
|
||||
|
||||
Future<void> settle(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
Future<void> goToPersonaTab(WidgetTester tester) async {
|
||||
await tester.tap(find.text('人设'));
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
group('★ AI 设置页不负责选人', () {
|
||||
testWidgets('人设列表里没有任何单选圈(不给「当前选中」的错觉)', (tester) async {
|
||||
await tester.pumpWidget(wrap());
|
||||
await settle(tester);
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
// 三条人设都该在
|
||||
expect(find.text('默认人设'), findsOneWidget);
|
||||
expect(find.text('花花'), findsOneWidget);
|
||||
expect(find.text('简洁模式'), findsOneWidget);
|
||||
|
||||
// 选中的两种圈都不该出现
|
||||
expect(find.byIcon(Icons.radio_button_checked), findsNothing,
|
||||
reason: '设置页不该有「已选中」的视觉标记');
|
||||
expect(find.byIcon(Icons.radio_button_unchecked), findsNothing,
|
||||
reason: '设置页不该有可选的单选圈');
|
||||
});
|
||||
|
||||
testWidgets('★ 点人设卡片不会改变「预选偏好」', (tester) async {
|
||||
await tester.pumpWidget(wrap());
|
||||
await settle(tester);
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.text('花花')),
|
||||
);
|
||||
expect(container.read(preferredPersonaProvider), isNull);
|
||||
|
||||
// 点「花花」整行
|
||||
await tester.tap(find.text('花花'));
|
||||
await settle(tester);
|
||||
|
||||
// 偏好不该被改 —— 设置页没有「选用」这个动作
|
||||
expect(container.read(preferredPersonaProvider), isNull,
|
||||
reason: '点人设卡片不该把它变成「当前使用的人设」');
|
||||
});
|
||||
|
||||
testWidgets('标题和说明写明「这里只管管理,选人是在新建对话时」', (tester) async {
|
||||
await tester.pumpWidget(wrap());
|
||||
await settle(tester);
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
expect(find.text('管理人设'), findsOneWidget);
|
||||
expect(find.textContaining('用哪个是在新建对话时选的', findRichText: true),
|
||||
findsOneWidget,
|
||||
reason: '要明确告诉用户「在哪儿选人」,否则会以为这里能选');
|
||||
});
|
||||
|
||||
testWidgets('仍然能编辑和删除(管理功能不受影响)', (tester) async {
|
||||
await tester.pumpWidget(wrap());
|
||||
await settle(tester);
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
// 两条用户人设各有一个「更多」菜单;默认人设没有
|
||||
expect(find.byIcon(Icons.more_vert), findsNWidgets(2),
|
||||
reason: '可编辑的人设才能有菜单,默认项不给');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -6,6 +7,7 @@ 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';
|
||||
|
||||
@@ -39,7 +41,35 @@ class _CapturingAiApi extends AiApi {
|
||||
Future<List<ChatSessionItem>> sessions() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<AiPersonaItem>> personas() async => const [];
|
||||
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() {
|
||||
@@ -57,7 +87,7 @@ void main() {
|
||||
}
|
||||
|
||||
group('★ 会话内人设固定', () {
|
||||
test('新对话选的人设会一直用下去(中途全局偏好变了也不受影响)', () async {
|
||||
test('新对话选的人设会一直用下去(中途预选偏好变了也不受影响)', () async {
|
||||
final api = _CapturingAiApi();
|
||||
final c = makeContainer(api);
|
||||
final notifier = c.read(assistantProvider.notifier);
|
||||
@@ -66,14 +96,17 @@ void main() {
|
||||
notifier.newChat(personaId: 7);
|
||||
expect(c.read(assistantProvider).personaId, 7);
|
||||
|
||||
// 中途用户在设置页把全局偏好换成了 9
|
||||
c.read(selectedAiPersonaProvider.notifier).select(9);
|
||||
// 之后他又开了一次新对话、选了人设 9(这是偏好,不是「当前会话的人设」)
|
||||
c.read(preferredPersonaProvider.notifier).remember(9);
|
||||
|
||||
// 回到**第一个人设 7 的那条会话**继续说话。
|
||||
// 用 openSession 模拟「切回历史会话」——它会用该会话自己的 personaId
|
||||
await notifier.openSession(1, personaId: 7);
|
||||
|
||||
// 继续在这个会话里说话
|
||||
await notifier.send('第一句');
|
||||
await notifier.send('第二句');
|
||||
|
||||
// 两次都该用 7,不该跟着全局偏好变成 9
|
||||
// 两次都该用 7,不该跟着偏好变成 9
|
||||
expect(api.personaIdsSent, [7, 7],
|
||||
reason: '会话锁定的人设不该被全局偏好覆盖');
|
||||
});
|
||||
@@ -152,4 +185,143 @@ void main() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user