AI人设可以配置
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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: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';
|
||||
|
||||
/// 「AI 设置」页(模型 / 人设 双 Tab)的布局测试。
|
||||
///
|
||||
/// 重点是小屏不溢出 —— 这类测试在健康模块抓过真 bug(320 宽溢出 9.8px)。
|
||||
/// 这里风险更高的地方是**人设编辑弹层**:多行 TextField + 软提示 +
|
||||
/// 「恢复默认」按钮,元素比模型那个弹层多。
|
||||
///
|
||||
/// 另外守两件事:
|
||||
/// · 默认人设项的提示词预览能正常显示(换行被压平,不会撑爆卡片)
|
||||
/// · 长提示词在列表里被两行截断,不会把列表撑散
|
||||
|
||||
/// 假 API:不发网络请求,返回固定的模型与人设列表
|
||||
class _FakeAiApi extends AiApi {
|
||||
_FakeAiApi() : super(Dio());
|
||||
|
||||
@override
|
||||
Future<List<AiConfigItem>> configs() async => const [
|
||||
AiConfigItem(
|
||||
id: null,
|
||||
name: '默认',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
apiKeyMasked: 'sk-****cdef',
|
||||
model: 'deepseek-chat',
|
||||
isDefault: true,
|
||||
editable: false,
|
||||
fromEnv: true,
|
||||
ready: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<AiPersonaItem>> personas() async => [
|
||||
const AiPersonaItem(
|
||||
id: null,
|
||||
name: '默认人设',
|
||||
systemPrompt: '大名:王洋 小名:花花 关系:你的铁杆好朋友,俩人互相喊对方宝',
|
||||
promptLength: 30,
|
||||
isDefault: true,
|
||||
editable: false,
|
||||
defaultPrompt: '大名:王洋 小名:花花',
|
||||
),
|
||||
const AiPersonaItem(
|
||||
id: 7,
|
||||
name: '简洁模式',
|
||||
// 故意很长 + 带换行,验证预览会被压平和截断
|
||||
systemPrompt: '你是极简助手,只回一句话。\n必须用工具查询数据,不要编造数字。'
|
||||
'这是一段很长的文本用来测试列表项会不会被撑爆,'
|
||||
'再补一些字凑长度让预览一定要截断。',
|
||||
promptLength: 60,
|
||||
isDefault: false,
|
||||
editable: true,
|
||||
defaultPrompt: null,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<AiPersonaItem> createPersona({
|
||||
required String name,
|
||||
required String systemPrompt,
|
||||
}) async =>
|
||||
AiPersonaItem(
|
||||
id: 99,
|
||||
name: name.isEmpty ? '新的' : name,
|
||||
systemPrompt: systemPrompt,
|
||||
promptLength: systemPrompt.length,
|
||||
isDefault: false,
|
||||
editable: true,
|
||||
defaultPrompt: null,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
const sizes = [
|
||||
('iPhone SE 窄屏', Size(320, 568)),
|
||||
('常见安卓机', Size(393, 873)),
|
||||
('大屏', Size(480, 1000)),
|
||||
];
|
||||
|
||||
void useSize(WidgetTester tester, Size size) {
|
||||
tester.view.physicalSize = size * 3;
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
}
|
||||
|
||||
Widget wrap() => ProviderScope(
|
||||
overrides: [aiApiProvider.overrideWithValue(_FakeAiApi())],
|
||||
child: const MaterialApp(home: AiConfigPage()),
|
||||
);
|
||||
|
||||
/// 进人设 Tab
|
||||
///
|
||||
/// 必须 pumpAndSettle:TabBarView 的切换是**动画**,只 pump 几帧的话
|
||||
/// 人设内容还没被构建出来(实测要 settle 完才出现)。
|
||||
/// 带个超时上限,避免哪天真卡死时整个测试挂住。
|
||||
Future<void> goToPersonaTab(WidgetTester tester) async {
|
||||
await tester.tap(find.text('人设'));
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
/// 等列表数据加载完(FutureProvider 走完一轮)
|
||||
Future<void> settleData(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
for (final (label, size) in sizes) {
|
||||
testWidgets('AI 设置页(模型 Tab)$label 不溢出', (tester) async {
|
||||
useSize(tester, size);
|
||||
await tester.pumpWidget(wrap());
|
||||
await settleData(tester);
|
||||
|
||||
expect(find.text('模型'), findsOneWidget);
|
||||
expect(find.text('人设'), findsOneWidget);
|
||||
expect(find.text('默认'), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('AI 设置页(人设 Tab)$label 不溢出', (tester) async {
|
||||
useSize(tester, size);
|
||||
await tester.pumpWidget(wrap());
|
||||
await settleData(tester);
|
||||
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
expect(find.text('默认人设'), findsOneWidget);
|
||||
expect(find.text('简洁模式'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull,
|
||||
reason: '$label 下人设 Tab 溢出了');
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('★ 长提示词在列表里被两行截断(否则卡片会被撑得很高)', (tester) async {
|
||||
useSize(tester, const Size(393, 873));
|
||||
await tester.pumpWidget(wrap());
|
||||
await settleData(tester);
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
// 直接断言那个预览 Text 的属性。
|
||||
//
|
||||
// 为什么不靠「溢出」断言:这是纵向列表,卡片变高只会让列表更长、
|
||||
// 不会报 RenderFlex overflow —— 我实测过,把 maxLines 去掉后
|
||||
// 溢出断言照样通过。所以必须查组件本身。
|
||||
final previewTexts = tester
|
||||
.widgetList<Text>(find.byType(Text))
|
||||
.where((t) => t.data != null && t.data!.contains('你是极简助手'))
|
||||
.toList();
|
||||
|
||||
expect(previewTexts, isNotEmpty, reason: '没找到人设预览文本');
|
||||
for (final t in previewTexts) {
|
||||
expect(t.maxLines, 2, reason: '预览必须限 2 行,否则长提示词会把卡片撑爆');
|
||||
expect(t.overflow, TextOverflow.ellipsis, reason: '超出部分要省略号截断');
|
||||
}
|
||||
|
||||
// 预览里不该有原始换行(换行来自未压平的 systemPrompt)
|
||||
final preview = previewTexts.first.data!;
|
||||
expect(preview.contains('\n'), isFalse, reason: '预览应该已把换行压成空格');
|
||||
});
|
||||
|
||||
testWidgets('人设编辑弹层在窄屏打开不溢出(多行输入 + 软提示 + 恢复默认)', (tester) async {
|
||||
useSize(tester, const Size(320, 568));
|
||||
await tester.pumpWidget(wrap());
|
||||
await settleData(tester);
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
// 点右下角 FAB 新增 —— 新增时会预填内置默认文本
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
// ⚠️ 不能用 pumpAndSettle:弹层里的 TextField 光标闪烁是**无限动画**,
|
||||
// settle 会一直等下去(这个坑在 watermark_layout_test 里踩过)
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.text('新增人设'), findsOneWidget);
|
||||
expect(find.text('提示词'), findsOneWidget);
|
||||
expect(find.text('恢复默认'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull, reason: '窄屏下人设编辑弹层溢出了');
|
||||
});
|
||||
|
||||
testWidgets('★ 编辑人设时提示词预填内置默认(不是空白框)', (tester) async {
|
||||
useSize(tester, const Size(393, 873));
|
||||
await tester.pumpWidget(wrap());
|
||||
await settleData(tester);
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
// ⚠️ 不能用 pumpAndSettle:弹层里的 TextField 光标闪烁是**无限动画**,
|
||||
// settle 会一直等下去(这个坑在 watermark_layout_test 里踩过)
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
// 找到提示词那个多行 TextField,它应该有内容
|
||||
final fields = tester.widgetList<TextField>(find.byType(TextField)).toList();
|
||||
// 第一个是「名称」,第二个是「提示词」
|
||||
expect(fields.length, greaterThanOrEqualTo(2));
|
||||
expect(fields[1].controller?.text, isNotEmpty,
|
||||
reason: '新增人设时提示词应该预填内置默认,而不是空框');
|
||||
});
|
||||
|
||||
testWidgets('人设列表项点一下能切换选中状态', (tester) async {
|
||||
useSize(tester, const Size(393, 873));
|
||||
await tester.pumpWidget(wrap());
|
||||
await settleData(tester);
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
// 点「简洁模式」,应该弹 toast(不崩)
|
||||
await tester.tap(find.text('简洁模式'));
|
||||
await settleData(tester);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('默认人设项没有编辑/删除入口(只有一排菜单图标 = 用户项数)', (tester) async {
|
||||
useSize(tester, const Size(393, 873));
|
||||
await tester.pumpWidget(wrap());
|
||||
await settleData(tester);
|
||||
await goToPersonaTab(tester);
|
||||
|
||||
// 两条人设里只有一条(简洁模式)可编辑 → 只该有一个 more_vert 菜单
|
||||
expect(find.byIcon(Icons.more_vert), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:qiangbao_accounting/models/ai_persona.dart';
|
||||
|
||||
/// AiPersonaItem 的解析与预览测试。
|
||||
///
|
||||
/// 重点是**不崩**:服务端字段缺失(老版本后端、网络截断)时 fromJson
|
||||
/// 不能抛异常,否则整个配置页会白屏。
|
||||
void main() {
|
||||
group('AiPersonaItem 解析', () {
|
||||
test('完整字段正常解析', () {
|
||||
final item = AiPersonaItem.fromJson({
|
||||
'id': 5,
|
||||
'name': '花花',
|
||||
'systemPrompt': '你是助手。',
|
||||
'promptLength': 6,
|
||||
'isDefault': false,
|
||||
'editable': true,
|
||||
'defaultPrompt': null,
|
||||
});
|
||||
|
||||
expect(item.id, 5);
|
||||
expect(item.name, '花花');
|
||||
expect(item.systemPrompt, '你是助手。');
|
||||
expect(item.promptLength, 6);
|
||||
expect(item.isDefault, isFalse);
|
||||
expect(item.editable, isTrue);
|
||||
expect(item.defaultPrompt, isNull);
|
||||
});
|
||||
|
||||
test('默认项:id 为 null、不可编辑、带 defaultPrompt', () {
|
||||
final item = AiPersonaItem.fromJson({
|
||||
'id': null,
|
||||
'name': '默认人设',
|
||||
'systemPrompt': '内置文本',
|
||||
'isDefault': true,
|
||||
'editable': false,
|
||||
'defaultPrompt': '内置文本',
|
||||
});
|
||||
|
||||
expect(item.id, isNull);
|
||||
expect(item.isDefault, isTrue);
|
||||
expect(item.editable, isFalse);
|
||||
expect(item.defaultPrompt, '内置文本');
|
||||
});
|
||||
|
||||
test('★ 字段全缺也不抛异常(老后端 / 网络截断)', () {
|
||||
final item = AiPersonaItem.fromJson(const {});
|
||||
|
||||
expect(item.id, isNull);
|
||||
expect(item.name, '未命名');
|
||||
expect(item.systemPrompt, '');
|
||||
expect(item.promptLength, 0);
|
||||
expect(item.isDefault, isFalse);
|
||||
// editable 默认 true —— 缺字段时倾向「让用户能改」,而不是锁死
|
||||
expect(item.editable, isTrue);
|
||||
expect(item.defaultPrompt, isNull);
|
||||
});
|
||||
|
||||
test('selectionKey:默认项是 -1,具体项是它的 id', () {
|
||||
final def = AiPersonaItem.fromJson({'id': null, 'isDefault': true});
|
||||
final mine = AiPersonaItem.fromJson({'id': 42});
|
||||
|
||||
expect(def.selectionKey, -1);
|
||||
expect(mine.selectionKey, 42);
|
||||
});
|
||||
|
||||
test('defaultPrompt 为空串时归一成 null(不能拿空串去判断「有没有」)', () {
|
||||
final item = AiPersonaItem.fromJson({'id': 1, 'defaultPrompt': ''});
|
||||
expect(item.defaultPrompt, isNull);
|
||||
});
|
||||
|
||||
test('promptLength 缺失时按 0,不会让界面显示 null', () {
|
||||
final item = AiPersonaItem.fromJson({'id': 1});
|
||||
expect(item.promptLength, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('preview(列表里的两行预览)', () {
|
||||
test('短文本原样返回', () {
|
||||
final item = AiPersonaItem.fromJson({
|
||||
'id': 1,
|
||||
'systemPrompt': '你是助手。',
|
||||
});
|
||||
expect(item.preview, '你是助手。');
|
||||
});
|
||||
|
||||
test('换行被压成空格 —— 否则列表项会被撑成好几行', () {
|
||||
final item = AiPersonaItem.fromJson({
|
||||
'id': 1,
|
||||
'systemPrompt': '第一行\n第二行\n\n第三行',
|
||||
});
|
||||
expect(item.preview, '第一行 第二行 第三行');
|
||||
expect(item.preview.contains('\n'), isFalse);
|
||||
});
|
||||
|
||||
test('连续空白(含缩进)被压成一个空格', () {
|
||||
final item = AiPersonaItem.fromJson({
|
||||
'id': 1,
|
||||
'systemPrompt': '甲 乙\t丙',
|
||||
});
|
||||
expect(item.preview, '甲 乙 丙');
|
||||
});
|
||||
|
||||
test('超长文本截断并加省略号', () {
|
||||
final long = '啊' * 100;
|
||||
final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': long});
|
||||
|
||||
expect(item.preview.length, 51); // 50 + 省略号
|
||||
expect(item.preview.endsWith('…'), isTrue);
|
||||
});
|
||||
|
||||
test('刚好 50 字不截断', () {
|
||||
final exact = '啊' * 50;
|
||||
final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': exact});
|
||||
expect(item.preview, exact);
|
||||
expect(item.preview.endsWith('…'), isFalse);
|
||||
});
|
||||
|
||||
test('空提示词不会崩', () {
|
||||
final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': ''});
|
||||
expect(item.preview, '');
|
||||
});
|
||||
|
||||
test('只有空白字符时预览是空的', () {
|
||||
final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': ' \n\t '});
|
||||
expect(item.preview, '');
|
||||
});
|
||||
|
||||
test('首尾空白被去掉', () {
|
||||
final item = AiPersonaItem.fromJson({
|
||||
'id': 1,
|
||||
'systemPrompt': ' 你是助手。 ',
|
||||
});
|
||||
expect(item.preview, '你是助手。');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,12 @@ class FakeAiApi extends AiApi {
|
||||
final List<AiEvent> _events;
|
||||
|
||||
@override
|
||||
Stream<AiEvent> chat({int? sessionId, int? configId, required String content}) async* {
|
||||
Stream<AiEvent> chat({
|
||||
int? sessionId,
|
||||
int? configId,
|
||||
int? personaId,
|
||||
required String content,
|
||||
}) async* {
|
||||
for (final e in _events) {
|
||||
yield e;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user