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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user