Files
kidgo/app/test/title_render_test.dart
T

338 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 真机冒烟测试:在虚拟设备尺寸下真实渲染界面。
// 目的:验证 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() {}
}