654 lines
25 KiB
Dart
654 lines
25 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/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<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));
|
||
|
||
// 整体等比缩小方案(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<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 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(<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();
|
||
Input.clear();
|
||
});
|
||
|
||
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 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(<String, Object>{});
|
||
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 <String>['◀', '▶', '跳', '🔫', '▼', '❚❚', '↺']) {
|
||
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(<String, Object>{});
|
||
await tester.runAsync(() async {
|
||
await Save.load();
|
||
await loadLevels();
|
||
});
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: Scaffold(
|
||
body: ResultScreen(
|
||
world: World(_Host()),
|
||
result: <String, dynamic>{
|
||
'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(<String, Object>{});
|
||
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<int>(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(<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: () {},
|
||
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 = <String, dynamic>{
|
||
'id': 'test-warp',
|
||
'name': 'warp 关卡',
|
||
'type': 'custom',
|
||
'width': 12,
|
||
'grid': <String>[
|
||
' ',
|
||
' W ',
|
||
' ',
|
||
],
|
||
'doors': <dynamic>[],
|
||
};
|
||
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<int>(), 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<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() {}
|
||
}
|