AI助手优化-头像和对话功能

This commit is contained in:
2026-09-29 09:45:02 +08:00
parent 80222099ff
commit f4ad2f8612
15 changed files with 990 additions and 37 deletions
+2
View File
@@ -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,
);
}
+155
View File
@@ -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, '默认人设');
});
});
}
+83
View File
@@ -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);
});
});
}