Files
Aq-Accounting-Flutter/test/ai_persona_layout_test.dart

232 lines
8.7 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
String? avatar,
}) async =>
AiPersonaItem(
id: 99,
name: name.isEmpty ? '新的' : name,
systemPrompt: systemPrompt,
promptLength: systemPrompt.length,
isDefault: false,
editable: true,
defaultPrompt: null,
avatar: avatar,
);
}
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);
});
}