AI人设可以配置

This commit is contained in:
2026-09-28 17:16:34 +08:00
parent 2dd89bfde0
commit 80222099ff
10 changed files with 1269 additions and 23 deletions
+229
View File
@@ -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);
});
}