338 lines
13 KiB
Dart
338 lines
13 KiB
Dart
// 真机冒烟测试:在虚拟设备尺寸下真实渲染界面。
|
||
// 目的:验证 AppRoot 完成初始化后能正常出画面,而不是停在加载态/错误界面(黑屏)。
|
||
// 任何 build / layout / paint 阶段的异常都会被测试捕获成失败。
|
||
//
|
||
// 关键回归点:Positioned 必须是 Stack 的**直接子级**。若把它写在 ValueListenableBuilder
|
||
// 的返回值里(隔着一层),定位数据被丢弃、子树横向塌缩为 0 宽 —— 这正是真机黑屏的根因。
|
||
// 因此这里断言的是「可见尺寸」,而不是「widget 存在」(find 不检查可见性,测不出塌缩)。
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:iwbtc/src/app_root.dart' show AppRoot;
|
||
import 'package:iwbtc/src/core/app_error.dart' show AppError;
|
||
import 'package:iwbtc/src/core/save.dart' show Save;
|
||
import 'package:iwbtc/src/game/levels.dart' show loadLevels, LEVELS;
|
||
import 'package:iwbtc/src/game/world.dart'
|
||
show GameHost, World, W, H;
|
||
import 'package:iwbtc/src/ui/game_canvas.dart'
|
||
show GameCanvas, letterboxRect;
|
||
import 'package:iwbtc/src/ui/overlays.dart' show HudOverlay;
|
||
import 'package:iwbtc/src/ui/screens.dart' show SettingsScreen;
|
||
import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls;
|
||
|
||
/// 按典型**横屏手机**分辨率设定虚拟窗口(安卓端已锁定横屏)。
|
||
const Size kLandscapePhone = Size(2340, 1080);
|
||
|
||
/// 完成 AppRoot 初始化并出到稳定帧。
|
||
Future<void> _boot(WidgetTester tester) async {
|
||
await tester.binding.setSurfaceSize(kLandscapePhone);
|
||
await tester.runAsync(() async {
|
||
await Save.load();
|
||
await loadLevels();
|
||
});
|
||
await tester.pumpWidget(const AppRoot());
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 200));
|
||
await tester.pump();
|
||
expect(AppError.current.value, isNull, reason: '初始化/渲染过程中抛出了异常');
|
||
}
|
||
|
||
void main() {
|
||
TestWidgetsFlutterBinding.ensureInitialized();
|
||
|
||
setUp(() async {
|
||
AppError.current.value = null;
|
||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||
});
|
||
|
||
testWidgets('标题界面能真实渲染出来(非黑屏)', (WidgetTester tester) async {
|
||
await _boot(tester);
|
||
|
||
expect(find.textContaining('启动失败'), findsNothing);
|
||
// 不得仍停留在加载态
|
||
expect(find.text('正在加载关卡…'), findsNothing, reason: '一直停在加载态,等同于黑屏');
|
||
|
||
// 标题菜单必须存在
|
||
expect(find.text('关卡选择'), findsOneWidget);
|
||
expect(find.text('自制关卡'), findsOneWidget);
|
||
expect(find.text('设置'), findsOneWidget);
|
||
expect(find.textContaining('23 个内置关卡'), findsOneWidget);
|
||
|
||
// 关键回归:按钮必须有可见尺寸且在屏内
|
||
final btn = find.text('关卡选择');
|
||
final btnSize = tester.getSize(btn);
|
||
final btnPos = tester.getTopLeft(btn);
|
||
expect(btnSize.width, greaterThan(0),
|
||
reason: '按钮宽度塌缩为 0,标题树布局被破坏(Positioned 必须是 Stack 直接子级)');
|
||
expect(btnSize.height, greaterThan(0));
|
||
expect(btnPos.dx, greaterThanOrEqualTo(0), reason: '按钮被定位到屏外');
|
||
expect(btnPos.dy, greaterThanOrEqualTo(0));
|
||
});
|
||
|
||
testWidgets('横屏小逻辑高度下标题不溢出(真机 BOTTOM OVERFLOWED 回归)',
|
||
(WidgetTester tester) async {
|
||
// 真机横屏 1080 物理高 ÷ DPR ≈ 360~400 **逻辑**像素;此前测试用的
|
||
// 2340×1080 是逻辑尺寸、远高于真机,所以测不出溢出。
|
||
await tester.binding.setSurfaceSize(const Size(800, 360));
|
||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||
await tester.runAsync(() async {
|
||
await Save.load();
|
||
await loadLevels();
|
||
});
|
||
|
||
// 布局溢出(RenderFlex overflow)在测试里会作为异常抛出并使测试失败。
|
||
await tester.pumpWidget(const AppRoot());
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 200));
|
||
await tester.pump();
|
||
|
||
expect(AppError.current.value, isNull, reason: '小高度下渲染抛出了异常');
|
||
expect(find.text('关卡选择'), findsOneWidget);
|
||
expect(tester.getSize(find.text('关卡选择')).height, greaterThan(0));
|
||
|
||
// 一屏放不下的底部文案必须滚动可达
|
||
final footer = find.textContaining('非商业同人作品');
|
||
await tester.scrollUntilVisible(footer, 120);
|
||
expect(footer, findsOneWidget);
|
||
});
|
||
|
||
testWidgets('游戏画布拿到真实尺寸并按信箱缩放', (WidgetTester tester) async {
|
||
await _boot(tester);
|
||
|
||
final canvas = find.byType(GameCanvas);
|
||
expect(canvas, findsOneWidget);
|
||
final Size size = tester.getSize(canvas);
|
||
// 必须是全屏尺寸,而不是 0(之前整棵 Stack 布局塌缩时画布也是 0×0)
|
||
expect(size.width, greaterThan(0), reason: '画布宽度为 0,界面不会显示任何内容');
|
||
expect(size.height, greaterThan(0));
|
||
expect(size.width, kLandscapePhone.width);
|
||
// 800×608 的画面按信箱缩放居中:横屏下高度占满、左右留黑边
|
||
final double s =
|
||
size.width / W < size.height / H ? size.width / W : size.height / H;
|
||
final stage = letterboxRect(size);
|
||
expect(s, greaterThan(0));
|
||
expect(stage.width, closeTo(800 * s, 0.001));
|
||
expect(stage.height, closeTo(608 * s, 0.001));
|
||
expect(stage.left, closeTo((size.width - 800 * s) / 2, 0.001));
|
||
expect(stage.width, lessThan(size.width),
|
||
reason: '横屏宽高比大于画面比例,应当左右留黑边');
|
||
expect(stage.height, closeTo(size.height, 0.001),
|
||
reason: '横屏下画面高度应被占满');
|
||
});
|
||
|
||
testWidgets('HUD / Toast 有可见尺寸(不得横向塌缩)', (WidgetTester tester) async {
|
||
await tester.binding.setSurfaceSize(const Size(1080, 2340));
|
||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||
|
||
final hud = ValueNotifier<Map<String, dynamic>>(<String, dynamic>{
|
||
'name': '测试关卡',
|
||
'deaths': 3,
|
||
'ms': 12345,
|
||
'apples': 1,
|
||
'totalApples': 3,
|
||
'save': 0,
|
||
'totalSaves': 4,
|
||
});
|
||
final boss = ValueNotifier<dynamic>(null);
|
||
final toast = ValueNotifier<String>('测试提示');
|
||
addTearDown(() {
|
||
hud.dispose();
|
||
boss.dispose();
|
||
toast.dispose();
|
||
});
|
||
|
||
await tester.pumpWidget(MaterialApp(
|
||
home: Scaffold(
|
||
body: Stack(
|
||
children: <Widget>[
|
||
Positioned.fill(child: HudOverlay(hud: hud, boss: boss, toast: toast)),
|
||
],
|
||
),
|
||
),
|
||
));
|
||
await tester.pump();
|
||
|
||
// 关卡名必须被撑开到有宽度(Positioned 嵌套错误时会塌缩为 0)
|
||
final name = find.text('测试关卡');
|
||
expect(name, findsOneWidget);
|
||
final Size size = tester.getSize(name);
|
||
expect(size.width, greaterThan(0),
|
||
reason: 'HUD 宽度为 0:Positioned 未作为 Stack 直接子级,布局被丢弃');
|
||
expect(size.height, greaterThan(0));
|
||
|
||
// Toast 同样必须可见
|
||
final tip = find.text('测试提示');
|
||
expect(tip, findsOneWidget);
|
||
expect(tester.getSize(tip).width, greaterThan(0));
|
||
|
||
// 苹果 / 存档 文案与 web 版保持一致
|
||
expect(find.text('苹果 1/3'), findsOneWidget);
|
||
expect(find.text('存档 0/4'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('从标题点进关卡能真正开始游戏(端到端)',
|
||
(WidgetTester tester) async {
|
||
await _boot(tester);
|
||
|
||
// 进入关卡选择(注意:游戏 Ticker 常驻,不能用 pumpAndSettle)
|
||
await tester.tap(find.text('关卡选择'));
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
await tester.pump();
|
||
|
||
// 点第一张关卡卡片 → 应进入 playing 态并显示 HUD
|
||
final card = find.text(LEVELS.first['name'] as String);
|
||
expect(card, findsWidgets);
|
||
await tester.tap(card.first);
|
||
await tester.pump(const Duration(milliseconds: 200));
|
||
await tester.pump();
|
||
|
||
expect(AppError.current.value, isNull, reason: '进入关卡的过程中抛出了异常');
|
||
// 画布仍在正常出帧
|
||
expect(tester.getSize(find.byType(GameCanvas)).width, greaterThan(0));
|
||
// HUD 的关卡名已显示且有可见宽度
|
||
final hudName = find.text(LEVELS.first['name'] as String);
|
||
expect(hudName, findsWidgets);
|
||
});
|
||
|
||
testWidgets('横屏下虚拟手柄按钮落在画面区域内(不飘到黑边上)',
|
||
(WidgetTester tester) async {
|
||
await tester.binding.setSurfaceSize(kLandscapePhone);
|
||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||
await tester.runAsync(() async {
|
||
await Save.load();
|
||
await loadLevels();
|
||
});
|
||
|
||
final visible = ValueNotifier<bool>(true);
|
||
final hasGun = ValueNotifier<bool>(false);
|
||
addTearDown(() {
|
||
visible.dispose();
|
||
hasGun.dispose();
|
||
});
|
||
|
||
// 需要一个 World 才有回调目标(构造不触发任何加载)。
|
||
final world = World(_Host());
|
||
|
||
await tester.pumpWidget(MaterialApp(
|
||
home: Scaffold(
|
||
body: Stack(
|
||
children: <Widget>[
|
||
Positioned.fill(
|
||
child: TouchControls(
|
||
world: world, visible: visible, hasGun: hasGun),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
));
|
||
await tester.pump();
|
||
|
||
final Size screen = tester.getSize(find.byType(TouchControls));
|
||
final stage = letterboxRect(screen);
|
||
expect(stage.left, greaterThan(0), reason: '横屏下应存在左右黑边');
|
||
|
||
// 「◀」左移键必须落在画面矩形内,而不是黑边上
|
||
final leftBtn = find.text('◀');
|
||
expect(leftBtn, findsOneWidget);
|
||
final Size btnSize = tester.getSize(leftBtn);
|
||
expect(btnSize.width, greaterThan(0));
|
||
final Offset pos = tester.getTopLeft(leftBtn);
|
||
expect(pos.dx, greaterThanOrEqualTo(stage.left),
|
||
reason: '左移键飘到了左侧黑边上(应锚定在画面区域内)');
|
||
expect(pos.dx + btnSize.width, lessThanOrEqualTo(stage.right));
|
||
|
||
// 「跳」键同理,且必须完整在屏内
|
||
final jumpBtn = find.text('跳');
|
||
expect(jumpBtn, findsOneWidget);
|
||
final Offset jp = tester.getTopLeft(jumpBtn);
|
||
final Size js = tester.getSize(jumpBtn);
|
||
expect(jp.dx, greaterThanOrEqualTo(stage.left));
|
||
expect(jp.dx + js.width, lessThanOrEqualTo(screen.width));
|
||
expect(js.width, greaterThan(0));
|
||
});
|
||
|
||
testWidgets('设置界面能渲染,手柄预览可见', (WidgetTester tester) async {
|
||
await _boot(tester);
|
||
|
||
await tester.tap(find.text('设置'));
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
await tester.pump();
|
||
|
||
expect(AppError.current.value, isNull, reason: '设置界面渲染时抛出了异常');
|
||
expect(find.byType(SettingsScreen), findsOneWidget);
|
||
// 手柄预览区(无线端:安卓默认 isTouch=true,键位区按设计不显示)
|
||
expect(find.text('手机操作按钮'), findsOneWidget);
|
||
final Size btn = tester.getSize(find.text('跳').first);
|
||
expect(btn.width, greaterThan(0), reason: '手柄预览按钮塌缩为 0 宽');
|
||
expect(find.text('恢复默认'), findsOneWidget);
|
||
expect(find.text('确定'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('桌面(非触屏)设置界面显示键位重绑', (WidgetTester tester) async {
|
||
await tester.binding.setSurfaceSize(const Size(1280, 720));
|
||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||
await tester.runAsync(() async {
|
||
await Save.load();
|
||
await loadLevels();
|
||
});
|
||
|
||
await tester.pumpWidget(MaterialApp(
|
||
home: Scaffold(
|
||
body: SettingsScreen(
|
||
world: World(_Host()),
|
||
isTouch: false,
|
||
onBack: () {},
|
||
),
|
||
),
|
||
));
|
||
await tester.pump();
|
||
|
||
expect(AppError.current.value, isNull, reason: '设置界面构建时抛出了异常');
|
||
// 键位行(web 版 settings.js 的 KEY_ACTIONS)
|
||
expect(find.text('键盘按键'), findsOneWidget);
|
||
expect(find.text('左移'), findsOneWidget);
|
||
expect(find.text('跳跃'), findsOneWidget);
|
||
expect(find.text('自杀 / 重生'), findsOneWidget);
|
||
expect(find.text('整关重来'), findsOneWidget);
|
||
// 已绑定键位会以标签形式出现
|
||
expect(find.text('←'), findsWidgets);
|
||
expect(find.text('Shift'), findsWidgets);
|
||
});
|
||
|
||
testWidgets('内置关卡清单能从 assets 正确加载', (WidgetTester tester) async {
|
||
await tester.runAsync(() async {
|
||
await Save.load();
|
||
await loadLevels();
|
||
});
|
||
expect(LEVELS.isNotEmpty, isTrue, reason: 'levels.json 未加载到任何关卡');
|
||
expect(LEVELS.length, 23);
|
||
});
|
||
}
|
||
|
||
/// 仅供测试用的空 GameHost 实现。
|
||
class _Host implements GameHost {
|
||
@override
|
||
void onBossBar(dynamic boss) {}
|
||
@override
|
||
void onHud(Map<String, dynamic> hud) {}
|
||
@override
|
||
void onPauseMenu(String label) {}
|
||
@override
|
||
void onResult(Map<String, dynamic> result) {}
|
||
@override
|
||
void onShowScreen(String screen) {}
|
||
@override
|
||
void onToast(String text) {}
|
||
@override
|
||
void editorShow() {}
|
||
@override
|
||
void touchHide() {}
|
||
@override
|
||
void touchSetGun(bool has) {}
|
||
@override
|
||
void touchShow() {}
|
||
}
|