AI助手优化-头像和对话功能
This commit is contained in:
@@ -66,6 +66,7 @@ class _FakeAiApi extends AiApi {
|
||||
Future<AiPersonaItem> createPersona({
|
||||
required String name,
|
||||
required String systemPrompt,
|
||||
String? avatar,
|
||||
}) async =>
|
||||
AiPersonaItem(
|
||||
id: 99,
|
||||
@@ -75,6 +76,7 @@ class _FakeAiApi extends AiApi {
|
||||
isDefault: false,
|
||||
editable: true,
|
||||
defaultPrompt: null,
|
||||
avatar: avatar,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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, '默认人设');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:qiangbao_accounting/widgets/avatar.dart';
|
||||
|
||||
/// 头像组件的测试。
|
||||
///
|
||||
/// 重点是**不崩**和**不留裂图** —— 头像是从后端 /uploads 拉的,
|
||||
/// 文件被删、地址写错、网络断了都会走到 errorBuilder,
|
||||
/// 那时候必须退回默认图标,而不是显示一个裂开的图。
|
||||
void main() {
|
||||
Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
|
||||
|
||||
group('Avatar 兜底', () {
|
||||
testWidgets('assetPath 为 null 时显示默认图标,不发起网络请求', (tester) async {
|
||||
await tester.pumpWidget(wrap(const Avatar(size: 40)));
|
||||
await tester.pump();
|
||||
|
||||
// 没有 Image 组件 —— 说明压根没去加载图片
|
||||
expect(find.byType(Image), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('assetPath 为空串时也走默认图标', (tester) async {
|
||||
await tester.pumpWidget(wrap(const Avatar(assetPath: '', size: 40)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(Image), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('只有空白字符的路径也当没有(后端可能返回 " ")', (tester) async {
|
||||
await tester.pumpWidget(wrap(const Avatar(assetPath: ' ', size: 40)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(Image), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('★ 给了路径就渲染图片,且加载失败时不崩(退回默认图标)', (tester) async {
|
||||
await tester.pumpWidget(wrap(
|
||||
const Avatar(assetPath: 'persona/2026/09/29/x.jpg', size: 40),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
|
||||
// 测试环境里网络请求必然失败,会走 errorBuilder。
|
||||
// 这里断言的是「失败被接住了,没有把异常抛到 widget 树」
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(tester.takeException(), isNull,
|
||||
reason: '图片加载失败必须被 errorBuilder 接住,不能抛异常');
|
||||
});
|
||||
});
|
||||
|
||||
group('Avatar 尺寸', () {
|
||||
testWidgets('尺寸按传入值设置(聊天气泡和「我的」页面用不同大小)', (tester) async {
|
||||
await tester.pumpWidget(wrap(const Avatar(size: 36)));
|
||||
await tester.pump();
|
||||
|
||||
final container = tester.widget<Container>(find.byType(Container).first);
|
||||
final size = container.constraints;
|
||||
expect(size?.maxWidth, 36);
|
||||
expect(size?.maxHeight, 36);
|
||||
});
|
||||
|
||||
testWidgets('不同尺寸都不溢出', (tester) async {
|
||||
for (final s in [24.0, 36.0, 40.0, 60.0, 76.0]) {
|
||||
await tester.pumpWidget(wrap(Avatar(size: s)));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull, reason: '尺寸 $s 出问题了');
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('在很窄的容器里也不溢出', (tester) async {
|
||||
await tester.pumpWidget(wrap(
|
||||
const SizedBox(width: 40, height: 40, child: Avatar(size: 36)),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user