// 真机冒烟测试:在虚拟设备尺寸下真实渲染界面。 // 目的:验证 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 _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({}); }); 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({}); 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>({ 'name': '测试关卡', 'deaths': 3, 'ms': 12345, 'apples': 1, 'totalApples': 3, 'save': 0, 'totalSaves': 4, }); final boss = ValueNotifier(null); final toast = ValueNotifier('测试提示'); addTearDown(() { hud.dispose(); boss.dispose(); toast.dispose(); }); await tester.pumpWidget(MaterialApp( home: Scaffold( body: Stack( children: [ 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({}); await tester.runAsync(() async { await Save.load(); await loadLevels(); }); final visible = ValueNotifier(true); final hasGun = ValueNotifier(false); addTearDown(() { visible.dispose(); hasGun.dispose(); }); // 需要一个 World 才有回调目标(构造不触发任何加载)。 final world = World(_Host()); await tester.pumpWidget(MaterialApp( home: Scaffold( body: Stack( children: [ 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({}); 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 hud) {} @override void onPauseMenu(String label) {} @override void onResult(Map 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() {} }