// 真机冒烟测试:在虚拟设备尺寸下真实渲染界面。 // 目的:验证 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/level.dart' show Level; import 'package:iwbtc/src/game/world.dart' show GameHost, World, W, H; import 'package:iwbtc/src/ui/game_canvas.dart' show GameCanvas, stageMetrics; import 'package:iwbtc/src/ui/overlays.dart' show HudOverlay; import 'package:iwbtc/src/ui/screens.dart' show SettingsScreen, ResultScreen, LevelSelectScreen; import 'package:iwbtc/src/ui/editor_panel.dart' show EditorPanel, EditorController; import 'package:iwbtc/src/ui/touch_controls.dart' show TouchControls; import 'package:iwbtc/src/core/input.dart' show Input; /// 按典型**横屏手机**分辨率设定虚拟窗口(安卓端已锁定横屏)。 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)); // 整体等比缩小方案(FittedBox.scaleDown):不滚动、不溢出, // 底部版权行在一屏内直接可见(只是整体缩小了)。 final footer = find.textContaining('非商业同人作品'); expect(footer, findsOneWidget, reason: '标题内容应等比缩小后完整可见,而不是被裁掉'); final btn = tester.getSize(find.text('关卡选择')); expect(btn.height, greaterThan(0)); }); testWidgets('游戏画布铺满全屏(无信箱黑边)且虚拟视口完整包含 800×608', (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 宽 → 高度定标 608、视口横向加宽,铺满全屏无黑边 final m = stageMetrics(size); expect(m.isValid, isTrue); expect(m.scale, closeTo(size.height / H, 0.001)); expect(m.vh, closeTo(H, 0.001)); expect(m.vw, closeTo(size.width / m.scale, 0.001)); expect(m.vw, greaterThanOrEqualTo(W), reason: '视口必须完整包含原 800 宽的画面(不裁游戏内容)'); }); 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 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(0), reason: '左移键被定位到屏外'); expect(pos.dx + btnSize.width, lessThanOrEqualTo(screen.width)); // 「跳」键同理,且必须完整在屏内 final jumpBtn = find.text('跳'); expect(jumpBtn, findsOneWidget); final Offset jp = tester.getTopLeft(jumpBtn); final Size js = tester.getSize(jumpBtn); expect(jp.dx, greaterThanOrEqualTo(0)); expect(jp.dx + js.width, lessThanOrEqualTo(screen.width)); expect(js.width, greaterThan(0)); }); testWidgets('虚拟跳跃键:在按键外松手也能复位(不卡住常按)', (WidgetTester tester) async { // 回归:旧实现用 Listener(不捕获指针),手指在键上滑动后于键外松手时, // 松手事件落到别的控件上 → touch['jump'] 永久卡在 true,之后每次按下都 // 因 setTouch 短路而不触发新边沿 → 跳跃键经常失灵。改用 GestureDetector(Pan) // 后,抬起/取消事件必定回传本按钮,松手即复位。 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(); Input.clear(); }); 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 center = tester.getCenter(find.text('跳')); final g = await tester.startGesture(center); await tester.pump(); expect(Input.held('jump'), isTrue, reason: '按下后跳跃键应为按下态'); // 手指滑到屏幕左上角再松手(旧实现会丢失松手事件 → 卡住常按) await g.moveTo(const Offset(5, 5)); await g.up(); await tester.pump(); expect(Input.held('jump'), isFalse, reason: '在键外松手后跳跃键必须复位,否则会卡住常按导致后续失灵'); }); testWidgets('系统返回手势:关卡选择 → 返回标题(不退出应用)', (WidgetTester tester) async { await _boot(tester); // 进入关卡选择 await tester.tap(find.text('关卡选择')); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); expect(find.text('返回'), findsWidgets); // 模拟系统返回手势/返回键 await tester.binding.handlePopRoute(); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); // 回到标题(标题菜单重新出现),应用未退出 expect(find.text('自制关卡'), findsOneWidget); expect(find.textContaining('23 个内置关卡'), findsOneWidget); }); testWidgets('系统返回手势:游戏中 → 暂停菜单;再按 → 继续游戏', (WidgetTester tester) async { await _boot(tester); await tester.tap(find.text('关卡选择')); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); final card = find.text(LEVELS.first['name'] as String); await tester.tap(card.first); await tester.pump(const Duration(milliseconds: 200)); await tester.pump(); // 游戏中按返回 → 应弹出暂停菜单,而不是退出应用 await tester.binding.handlePopRoute(); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); expect(find.text('继续'), findsOneWidget, reason: '返回应打开暂停菜单'); // 暂停中再按返回 → 继续游戏(暂停菜单消失) await tester.binding.handlePopRoute(); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); expect(find.text('继续'), findsNothing, reason: '返回应恢复游戏'); }); testWidgets('自制关卡编辑器在横屏小屏下不溢出(RIGHT OVERFLOWED 回归)', ( WidgetTester tester, ) async { // 回归:属性栏 200px 宽里塞三个默认 64px 最小宽的按钮必然横向溢出; // 顶部工具行在窄屏也可能溢出。修复后按钮紧凑化 + 工具行可换行。 await tester.binding.setSurfaceSize(const Size(800, 360)); addTearDown(() => tester.binding.setSurfaceSize(null)); SharedPreferences.setMockInitialValues({}); await tester.runAsync(() async { await Save.load(); await loadLevels(); }); final c = EditorController(); c.open(25, 19); await tester.pumpWidget( MaterialApp( home: Scaffold( body: EditorPanel(controller: c, onBack: () {}), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(AppError.current.value, isNull, reason: '编辑器渲染时抛出了溢出/布局异常'); expect(find.text('删除'), findsOneWidget); expect(find.text('+面'), findsOneWidget); expect(find.text('保存'), findsOneWidget); }); 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); expect(find.text('手机操作按钮'), findsOneWidget); // 触屏端入口:进入独立全屏编辑页 expect(find.text('自定义按键'), findsOneWidget); expect(find.text('恢复默认'), findsOneWidget); expect(find.text('确定'), findsOneWidget); // 进入自定义按键页:应全屏显示,所有虚拟键可编辑 await tester.tap(find.text('自定义按键')); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); expect(AppError.current.value, isNull, reason: '自定义按键页渲染时抛出了异常'); expect(find.text('返回'), findsWidgets); // 编辑页返回按钮 expect(find.text('恢复默认'), findsOneWidget); // 所有默认虚拟键都在编辑页里 for (final label in ['◀', '▶', '跳', '🔫', '▼', '❚❚', '↺']) { expect(find.text(label), findsOneWidget, reason: '缺少 $label 按钮'); } // 跳跃键必须有可见尺寸(布局编辑器按钮不能塌缩) expect(tester.getSize(find.text('跳')).width, greaterThan(0)); // 拖动跳跃键 → 布局即时保存;返回设置页 final before = tester.getTopLeft(find.text('跳')); await tester.dragFrom( tester.getCenter(find.text('跳')), const Offset(60, -40), ); await tester.pump(); final after = tester.getTopLeft(find.text('跳')); expect(after.dx, greaterThan(before.dx), reason: '拖动后按钮应移动'); await tester.tap(find.text('返回').last); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); expect(find.text('自定义按键'), findsOneWidget, reason: '应回到设置页'); }); testWidgets('结算界面在横屏小高度下不溢出(BOTTOM OVERFLOWED 回归)', ( WidgetTester tester, ) async { // 回归:_Modal 固定内容(标题+4 行数据+新纪录+3 按钮)在 ~360 逻辑高的 // 横屏手机上曾超出屏高。修复后改为紧凑固定卡片(不滚动、不撑满全屏), // 内容压到能在 360 逻辑高内完整放下。 await tester.binding.setSurfaceSize(const Size(800, 360)); addTearDown(() => tester.binding.setSurfaceSize(null)); SharedPreferences.setMockInitialValues({}); await tester.runAsync(() async { await Save.load(); await loadLevels(); }); await tester.pumpWidget( MaterialApp( home: Scaffold( body: ResultScreen( world: World(_Host()), result: { 'name': 'DOUBLE JUMP', 'ms': 13490, 'deaths': 0, 'apples': 0, 'totalApples': 0, 'better': true, 'hasNext': true, }, ), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(AppError.current.value, isNull, reason: '结算界面渲染时抛出了溢出/布局异常'); expect(find.text('CLEAR!'), findsOneWidget); // 三个按钮都必须构建且可见(在滚动区内) expect(find.text('下一关'), findsOneWidget); expect(find.text('再挑战'), findsOneWidget); expect(tester.getSize(find.text('下一关')).height, greaterThan(0)); }); testWidgets('关卡选择:已通关卡片的 ✔ 角标不报 ParentDataWidget 错误', ( WidgetTester tester, ) async { // 回归:_LevelCard 曾把「已通关 ✔ 角标」写成内层 Column 的直接子级 // Positioned(Positioned 必须是 Stack 直接子级)→ 一旦该卡片被构建就抛 // Incorrect use of ParentDataWidget。先标记一关为已通关,使其渲染角标。 await tester.binding.setSurfaceSize(const Size(800, 360)); addTearDown(() => tester.binding.setSurfaceSize(null)); SharedPreferences.setMockInitialValues({}); await tester.runAsync(() async { await Save.load(); await loadLevels(); }); final id = LEVELS.first['id'] as String? ?? ''; Save.clear(id, 3, 12345, 0); await tester.pumpWidget( MaterialApp( home: Scaffold( body: LevelSelectScreen( world: World(_Host()), reload: ValueNotifier(0), onChanged: () {}, ), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(AppError.current.value, isNull, reason: '已通关卡片渲染时抛出了 ParentDataWidget 异常'); // 角标应作为外层 Stack 的直接子级正常渲染(不报错)。 expect(find.text('✔'), findsWidgets, reason: '已通关角标未正常渲染'); }); 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: () {}, onTouchEditor: () {}, ), ), ), ); 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('进出编辑器多次不报 setState-after-dispose', ( WidgetTester tester, ) async { // 回归:EditorPanel 在 initState 把 setState 闭包挂到共享的 // EditorController.onChange,离开时若不清掉,再次进入触发 // controller.showOrOpen() → onChange?.call() 会对已 dispose 的 State // 调 setState(setState() called after dispose)。 await _boot(tester); // 进编辑器 await tester.tap(find.text('自制关卡')); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); expect(find.byType(EditorPanel), findsOneWidget, reason: '应进入编辑器'); expect(AppError.current.value, isNull, reason: '首次进入不应抛异常'); // 返回标题(EditorPanel 被 dispose) await tester.tap(find.text('返回').first); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); expect(find.byType(EditorPanel), findsNothing, reason: '应离开编辑器'); // 再次进入:此处同步触发 controller.showOrOpen() → onChange?.call() await tester.tap(find.text('自制关卡')); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); expect(AppError.current.value, isNull, reason: '再次进入编辑器时不应抛 setState-after-dispose'); expect(find.byType(EditorPanel), findsOneWidget); }); testWidgets('含 warp 但无匹配门的关卡可正常构建(doorId 类型回归)', ( WidgetTester tester, ) async { // 回归:Level.reset 给 warp 实体的 doorId 赋值时,若 (tx,ty) 在 data.doors // 里找不到对应门,会回退到实体序号 idx(int)。原字段声明为 String?,把 // int 赋给 String? 即抛 "type 'int' is not a subtype of type 'String?'", // 在关卡选择里点开这类自制关卡便崩溃(崩溃栈:Level.reset → new Level → // World.startLevel → LevelSelectScreen._play)。修复后 doorId 为 Object?。 final data = { 'id': 'test-warp', 'name': 'warp 关卡', 'type': 'custom', 'width': 12, 'grid': [ ' ', ' W ', ' ', ], 'doors': [], }; late Level lv; // 此前此处直接抛 TypeError,导致第二次点开地图即崩溃 expect(() { lv = Level(data); }, returnsNormally, reason: '含 warp 但无门的关卡不应抛类型错误'); final warp = lv.entities.firstWhere( (e) => e.type == 'warp', orElse: () => throw StateError('未生成 warp 实体'), ); // 无匹配门时 doorId 回退为序号(int),与 web 原版弱类型语义一致 expect(warp.doorId, isA(), reason: '无门时 doorId 应回退为 int 序号'); }); 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() {} }