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> 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> 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 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 goToPersonaTab(WidgetTester tester) async { await tester.tap(find.text('人设')); await tester.pumpAndSettle(const Duration(milliseconds: 100)); } /// 等列表数据加载完(FutureProvider 走完一轮) Future 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(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(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); }); }