From a1f6ea14cfd05f388267fc23e7613e32cf6ab05a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Thu, 9 Jul 2026 12:26:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=AE=BE=E5=A4=87=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E4=BF=A1=E6=81=AF=E5=88=B0=20Android=20=E5=92=8C=20iO?= =?UTF-8?q?S=20=E5=B9=B3=E5=8F=B0=E4=BF=A1=E6=81=AF=EF=BC=9B=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20Auth=20=E9=A1=B5=E9=9D=A2=E4=BB=A5=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=96=B0=E7=9A=84=20TextEditingController=20=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=BE=93=E5=85=A5=EF=BC=9B=E9=87=8D=E6=9E=84=E6=8E=A8?= =?UTF-8?q?=E6=B5=81=E6=B5=8B=E8=AF=95=E9=A1=B5=E9=9D=A2=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8A=A8=E6=80=81=20RTMP=20URL=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=EF=BC=9B=E5=88=A0=E9=99=A4=E4=B8=8D=E5=86=8D=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E7=9A=84=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 36 ++ .../kotlin/com/dronex/rec/MainActivity.kt | 2 + ios/Runner/PlatformInfoPlugin.swift | 1 + lib/app/config/api_common.dart | 7 + lib/app/config/app_config.dart | 2 +- lib/core/network/api_response.dart | 2 +- lib/core/network/header_interceptor.dart | 11 + lib/core/platform/app_platform_info.dart | 3 + lib/core/utils/device_utils.dart | 8 + lib/features/auth/model/model_auth.dart | 16 + lib/features/auth/pages/page_auth.dart | 43 +- lib/features/auth/server/server_auth.dart | 31 + lib/features/auth/state/state_auth.dart | 13 + .../auth/view_model_auth/view_model_auth.dart | 39 ++ .../scan_qrcode/pages/page_push_test.dart | 42 +- .../scan_qrcode/pages/page_scan_qrcode.dart | 2 +- .../scan_qrcode/utils/rtmp_stream_target.dart | 33 + .../permission/permission_service_test.dart | 131 ---- .../platform/device_health_checker_test.dart | 88 --- test/features/dialog/record_dialog_test.dart | 130 ---- .../recording/model_clipboard_test.dart | 77 --- .../recording_display_name_test.dart | 69 -- .../recording/recording_platform_test.dart | 45 -- .../recording/view_model_recording_test.dart | 595 ------------------ .../recording/widget_record_header_test.dart | 52 -- .../widget_recording_button_test.dart | 138 ---- .../recording/widget_recording_hud_test.dart | 231 ------- ...get_recording_touch_lock_overlay_test.dart | 126 ---- test/widget_test.dart | 91 --- 29 files changed, 267 insertions(+), 1797 deletions(-) create mode 100644 AGENTS.md create mode 100644 lib/app/config/api_common.dart create mode 100644 lib/features/auth/model/model_auth.dart create mode 100644 lib/features/auth/server/server_auth.dart create mode 100644 lib/features/auth/state/state_auth.dart create mode 100644 lib/features/auth/view_model_auth/view_model_auth.dart create mode 100644 lib/features/scan_qrcode/utils/rtmp_stream_target.dart delete mode 100644 test/core/permission/permission_service_test.dart delete mode 100644 test/core/platform/device_health_checker_test.dart delete mode 100644 test/features/dialog/record_dialog_test.dart delete mode 100644 test/features/recording/model_clipboard_test.dart delete mode 100644 test/features/recording/recording_display_name_test.dart delete mode 100644 test/features/recording/recording_platform_test.dart delete mode 100644 test/features/recording/view_model_recording_test.dart delete mode 100644 test/features/recording/widget_record_header_test.dart delete mode 100644 test/features/recording/widget_recording_button_test.dart delete mode 100644 test/features/recording/widget_recording_hud_test.dart delete mode 100644 test/features/recording/widget_recording_touch_lock_overlay_test.dart delete mode 100644 test/widget_test.dart diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3a2f23f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Repository Guidelines + +## 项目结构与模块组织 + +这是一个 Flutter 移动应用项目,当前产品方向仅保留 Android,后续开发与验证不需要关注 iOS 平台。应用代码位于 `lib/`。`lib/app/` 存放启动、路由、主题和配置;`lib/core/` 存放网络、缓存、权限、日志和平台工具;`lib/shared/widgets/` 存放通用 UI 组件;`lib/features/` 按业务模块组织,例如登录、录制、扫码和弹窗。生成的资源访问代码位于 `lib/gen/`。测试文件位于 `test/`,尽量与源码目录对应。静态资源在 `pubspec.yaml` 中声明,主要放在 `assets/images/` 和 `assets/html/`。Android 原生实现位于 `android/`。 + +## 构建、测试与开发命令 + +- `flutter pub get`:安装 Dart 和 Flutter 依赖。 +- `flutter analyze`:按 `analysis_options.yaml` 执行静态检查。 +- `flutter test`:运行全部单元测试和 widget 测试。 +- `flutter run -d `:在指定 Android 设备或模拟器上运行应用。 +- `flutter build apk --debug`:构建 Android debug APK。 +- `dart run build_runner build --delete-conflicting-outputs`:资源或生成代码变化后重新生成文件,例如 `lib/gen/assets.gen.dart`。 + +项目还包含 `build-apk.sh`、`build-apk-split.sh` 和 `clean.sh` 等辅助脚本,使用前先阅读脚本内容。 + +## 代码风格与命名规范 + +遵循 Dart 和 Flutter 常规风格,使用两个空格缩进。Analyzer 继承 `package:flutter_lints/flutter.yaml`,并启用 `prefer_single_quotes`,因此字符串优先使用单引号。文件名使用 `snake_case.dart`,类和 widget 使用 `PascalCase`,方法、变量、provider 和字段使用 `lowerCamelCase`。新增能力前优先复用现有 `shared/widgets`、`core` 服务和 feature 内已有模式,避免重复抽象。 + +## 测试规范 + +使用 `flutter_test` 编写单元测试和 widget 测试。测试文件放在 `test/` 下,并以 `_test.dart` 结尾,例如 `test/features/recording/view_model_recording_test.dart`。业务逻辑、权限判断、Android 平台通道边界、录制流程和可复用 widget 需要补充聚焦测试。提交前运行 `flutter test` 和 `flutter analyze`。 + +## 提交与 PR 规范 + +当前 Git 历史多使用简洁中文提交信息,描述实际行为变化,例如 `更新 AndroidManifest.xml 以启用明文流量`。保持提交范围单一,避免混入无关格式化。PR 应包含变更说明、测试结果、关联 issue,以及 UI、录制、扫码、WebView 或权限流程变化对应的截图或录屏。涉及原生代码时,只需说明 Android 设备或系统版本覆盖情况。 + +## Agent 特定说明 + +产品已放弃 iOS 方向,后续 agent 不需要主动检查、构建、修复或维护 `ios/` 目录内容。除非用户明确要求处理历史 iOS 文件,否则所有实现、调试和验证都应以 Android 为准。 + +## 安全与配置提示 + +不要提交私有 token、签名文件或环境专用密钥。修改 `lib/app/config/`、Android manifest、网络安全配置、摄像头、麦克风、存储或后台录制相关行为时,需要重点核对权限和运行时兼容性。 diff --git a/android/app/src/main/kotlin/com/dronex/rec/MainActivity.kt b/android/app/src/main/kotlin/com/dronex/rec/MainActivity.kt index 52571a0..55b6a4b 100644 --- a/android/app/src/main/kotlin/com/dronex/rec/MainActivity.kt +++ b/android/app/src/main/kotlin/com/dronex/rec/MainActivity.kt @@ -6,6 +6,7 @@ import android.os.BatteryManager import android.os.Build import android.os.Environment import android.os.StatFs +import android.provider.Settings import androidx.camera.view.PreviewView import com.dronex.rec.recording.RecordingPlatformHandler import com.dronex.rec.recording.RecordingPreviewFactory @@ -111,6 +112,7 @@ class MainActivity : FlutterActivity() { return mapOf( "platform" to "android", + "deviceCode" to Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID).orEmpty(), "brand" to Build.BRAND, "model" to Build.MODEL, "systemVersion" to Build.VERSION.RELEASE, diff --git a/ios/Runner/PlatformInfoPlugin.swift b/ios/Runner/PlatformInfoPlugin.swift index f219908..5b5de45 100644 --- a/ios/Runner/PlatformInfoPlugin.swift +++ b/ios/Runner/PlatformInfoPlugin.swift @@ -62,6 +62,7 @@ final class PlatformInfoPlugin: NSObject, FlutterPlugin { let device = UIDevice.current return [ "platform": "ios", + "deviceCode": "", "brand": device.systemName, "model": machineIdentifier(), "systemVersion": device.systemVersion, diff --git a/lib/app/config/api_common.dart b/lib/app/config/api_common.dart new file mode 100644 index 0000000..0de1f25 --- /dev/null +++ b/lib/app/config/api_common.dart @@ -0,0 +1,7 @@ +enum AuthApi { + /// 获取 token + getToken('/api/events/device/token'); + + final String path; + const AuthApi(this.path); +} diff --git a/lib/app/config/app_config.dart b/lib/app/config/app_config.dart index 202c45c..162758c 100644 --- a/lib/app/config/app_config.dart +++ b/lib/app/config/app_config.dart @@ -32,7 +32,7 @@ class AppConfig { current = switch (environment) { AppEnvironment.dev => const EnvironmentValues( environment: AppEnvironment.dev, - baseUrl: 'https://example.com/api', + baseUrl: 'http://192.168.1.104:8000', enableNetworkLog: true, ), AppEnvironment.staging => const EnvironmentValues( diff --git a/lib/core/network/api_response.dart b/lib/core/network/api_response.dart index bcd55d9..1f9fb46 100644 --- a/lib/core/network/api_response.dart +++ b/lib/core/network/api_response.dart @@ -5,7 +5,7 @@ class ApiResponse { final String message; final T? data; - bool get isSuccess => code >= 200 && code < 300; + bool get isSuccess => code == 0 || code >= 200 && code < 300; factory ApiResponse.fromJson( Map json, { diff --git a/lib/core/network/header_interceptor.dart b/lib/core/network/header_interceptor.dart index 87f29dc..3723809 100644 --- a/lib/core/network/header_interceptor.dart +++ b/lib/core/network/header_interceptor.dart @@ -28,4 +28,15 @@ class HeaderInterceptor extends Interceptor { handler.next(options); } + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + if (err.response?.statusCode == 401) { + await AppStorage.remove(StorageKeys.authToken); + } + handler.next(err); + } } diff --git a/lib/core/platform/app_platform_info.dart b/lib/core/platform/app_platform_info.dart index 3f70f66..830839c 100644 --- a/lib/core/platform/app_platform_info.dart +++ b/lib/core/platform/app_platform_info.dart @@ -31,6 +31,7 @@ class AppDeviceInfo { required this.platform, required this.isPhysicalDevice, required this.values, + required this.deviceCode, }); factory AppDeviceInfo.fromMap(Map map) { @@ -48,12 +49,14 @@ class AppDeviceInfo { platform: map['platform'] as String? ?? Platform.operatingSystem, isPhysicalDevice: isPhysicalDevice is bool ? isPhysicalDevice : true, values: values, + deviceCode: values['deviceCode'] ?? '', ); } final String platform; final bool isPhysicalDevice; final Map values; + final String deviceCode; } class AppPlatformInfo { diff --git a/lib/core/utils/device_utils.dart b/lib/core/utils/device_utils.dart index 73a8f4f..e08b528 100644 --- a/lib/core/utils/device_utils.dart +++ b/lib/core/utils/device_utils.dart @@ -28,4 +28,12 @@ class DeviceUtils { } return (await AppPlatformInfo.deviceInfo()).values; } + + static Future deviceCode() async { + try { + return (await AppPlatformInfo.deviceInfo()).deviceCode.trim(); + } catch (_) { + return ''; + } + } } diff --git a/lib/features/auth/model/model_auth.dart b/lib/features/auth/model/model_auth.dart new file mode 100644 index 0000000..db0172a --- /dev/null +++ b/lib/features/auth/model/model_auth.dart @@ -0,0 +1,16 @@ +/// 获取 TOKEN 响应模型 +class GetTokenResModel { + GetTokenResModel({required this.deviceAccessToken}); + + final String deviceAccessToken; + + factory GetTokenResModel.fromJson(Map json) { + return GetTokenResModel( + deviceAccessToken: (json['deviceAccessToken'] ?? '').toString(), + ); + } + + Map toJson() { + return {'deviceAccessToken': deviceAccessToken}; + } +} diff --git a/lib/features/auth/pages/page_auth.dart b/lib/features/auth/pages/page_auth.dart index b605432..b733238 100644 --- a/lib/features/auth/pages/page_auth.dart +++ b/lib/features/auth/pages/page_auth.dart @@ -1,19 +1,37 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:recording_tool/app/router/app_navigator.dart'; +import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart'; import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart'; import 'package:recording_tool/shared/widgets/widgets.dart'; -class AuthPageWidget extends StatefulWidget { +class AuthPageWidget extends ConsumerStatefulWidget { const AuthPageWidget({super.key}); @override - State createState() => _AuthPageWidgetState(); + ConsumerState createState() => _AuthPageWidgetState(); } -class _AuthPageWidgetState extends State { +class _AuthPageWidgetState extends ConsumerState { + late TextEditingController? _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + return Center( child: Column( children: [ @@ -26,7 +44,7 @@ class _AuthPageWidgetState extends State { SizedBox( width: 280.w, height: 80.h, - child: AppTextField(initialValue: 'hhh'), + child: AppTextField(initialValue: 'hhh', controller: _controller), ), SizedBox(height: 20.h), SizedBox( @@ -34,8 +52,20 @@ class _AuthPageWidgetState extends State { height: 80.h, child: AppButton( label: '确定', - onPressed: () { - AppNavigator.push(const ScanQrCodePage()); + onPressed: () async { + final code = _controller?.text; + final success = await ref + .read(authProvider.notifier) + .auth(code ?? ''); + if (!mounted) return; + if (success) { + AppNavigator.push(const ScanQrCodePage()); + return; + } + final message = ref.read(authProvider).errorMessage; + if (message != null && message.isNotEmpty) { + AppToast.show(message); + } // return; // AppNavigator.push( // const WebviewPage( @@ -45,6 +75,7 @@ class _AuthPageWidgetState extends State { // ); }, variant: AppButtonVariant.secondary, + isLoading: authState.isLoading, ), ), ], diff --git a/lib/features/auth/server/server_auth.dart b/lib/features/auth/server/server_auth.dart new file mode 100644 index 0000000..20679ba --- /dev/null +++ b/lib/features/auth/server/server_auth.dart @@ -0,0 +1,31 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:recording_tool/app/config/api_common.dart'; +import 'package:recording_tool/core/cache/app_storage.dart'; +import 'package:recording_tool/core/cache/storage_keys.dart'; +import 'package:recording_tool/core/network/providers/dio_providers.dart'; +import 'package:recording_tool/core/utils/device_utils.dart'; +import 'package:recording_tool/features/auth/model/model_auth.dart'; + +class AuthServer { + /// [passCode] 口令码 + static Future login(String passCode, Ref ref) async { + final deviceCode = await DeviceUtils.deviceCode(); + if (deviceCode.isEmpty) { + throw const FormatException('无法获取设备标识'); + } + + final apiClient = ref.read(apiClientProvider); + final data = await apiClient.post( + AuthApi.getToken.path, + data: {'deviceCode': deviceCode, 'passCode': passCode}, + parser: (json) => GetTokenResModel.fromJson(json as Map), + ); + + if (data.deviceAccessToken.isEmpty) { + throw const FormatException('登录响应缺少 TOKEN'); + } + + await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken); + return data; + } +} diff --git a/lib/features/auth/state/state_auth.dart b/lib/features/auth/state/state_auth.dart new file mode 100644 index 0000000..910a48a --- /dev/null +++ b/lib/features/auth/state/state_auth.dart @@ -0,0 +1,13 @@ +class AuthState { + const AuthState({this.isLoading = false, this.errorMessage}); + + final bool isLoading; + final String? errorMessage; + + AuthState copyWith({bool? isLoading, String? errorMessage}) { + return AuthState( + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + ); + } +} diff --git a/lib/features/auth/view_model_auth/view_model_auth.dart b/lib/features/auth/view_model_auth/view_model_auth.dart new file mode 100644 index 0000000..5054d9d --- /dev/null +++ b/lib/features/auth/view_model_auth/view_model_auth.dart @@ -0,0 +1,39 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:recording_tool/core/network/api_exception.dart'; +import 'package:recording_tool/features/auth/server/server_auth.dart'; +import 'package:recording_tool/features/auth/state/state_auth.dart'; + +final authProvider = StateNotifierProvider((ref) { + return AuthViewModel(ref); +}); + +class AuthViewModel extends StateNotifier { + AuthViewModel(this._ref) : super(const AuthState()); + final Ref _ref; + + /// 鉴权获取 token + Future auth(String code) async { + final passCode = code.trim(); + if (passCode.isEmpty) { + state = const AuthState(errorMessage: '请输入执裁口令'); + return false; + } + + state = const AuthState(isLoading: true); + try { + await AuthServer.login(passCode, _ref); + state = const AuthState(); + return true; + } on FormatException catch (error) { + state = AuthState(errorMessage: error.message); + return false; + } on ApiException catch (error) { + state = AuthState(errorMessage: error.message); + return false; + } catch (_) { + state = const AuthState(errorMessage: '认证失败,请重试'); + return false; + } + } +} diff --git a/lib/features/scan_qrcode/pages/page_push_test.dart b/lib/features/scan_qrcode/pages/page_push_test.dart index 5b8bd1b..5551031 100644 --- a/lib/features/scan_qrcode/pages/page_push_test.dart +++ b/lib/features/scan_qrcode/pages/page_push_test.dart @@ -1,11 +1,18 @@ import 'package:apivideo_live_stream/apivideo_live_stream.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart'; import 'package:recording_tool/shared/widgets/app_bar.dart'; +import 'package:recording_tool/shared/widgets/app_toast.dart'; class PushSteamTestWidget extends StatefulWidget { - const PushSteamTestWidget({super.key}); - final String steamKey = '20260708'; - final String rtmpUrl = 'rtmp://192.168.1.180:19090/live/'; + const PushSteamTestWidget({ + super.key, + this.rtmpUrl = + 'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组', + }); + + final String rtmpUrl; @override State createState() => _PushSteamTestWidgetState(); @@ -57,19 +64,24 @@ class _PushSteamTestWidgetState extends State } } - // 把服务端下发的完整 rtmp:// 地址拆成 "服务器地址" + "streamKey" 两部分 Future _startPush() async { - // final uri = Uri.parse(widget.rtmpUrl); - final streamKey = widget.steamKey; - // final streamKey = - // '${DateTime.timestamp()}-${Random(1000).nextInt(1000).toString()}'; - - // debugPrint('streamKey- $streamKey '); - // final baseUrl = widget.rtmpUrl.substring( - // 0, - // widget.rtmpUrl.lastIndexOf(streamKey), - // ); - await _controller.startStreaming(streamKey: streamKey, url: widget.rtmpUrl); + try { + final target = RtmpStreamTarget.parse(widget.rtmpUrl); + await _controller.startStreaming( + streamKey: target.streamKey, + url: target.url, + ); + } on FormatException catch (e) { + debugPrint('推流地址错误: ${e.message}'); + AppToast.show(e.message); + } on PlatformException catch (e) { + final message = e.message ?? e.code; + debugPrint('推流失败: ${e.code} $message'); + AppToast.show('推流失败: $message'); + } catch (e) { + debugPrint('推流失败: $e'); + AppToast.showError(e); + } } Future _stopPush() => _controller.stopStreaming(); diff --git a/lib/features/scan_qrcode/pages/page_scan_qrcode.dart b/lib/features/scan_qrcode/pages/page_scan_qrcode.dart index eb2c30b..0f11088 100644 --- a/lib/features/scan_qrcode/pages/page_scan_qrcode.dart +++ b/lib/features/scan_qrcode/pages/page_scan_qrcode.dart @@ -40,7 +40,7 @@ class _AuthPageWidgetState extends State { if (result == null || result.isEmpty) return; debugPrint('扫码结果: $result'); Future.delayed(const Duration(milliseconds: 1)).then((_) { - AppNavigator.push(PushSteamTestWidget()); + AppNavigator.push(PushSteamTestWidget(rtmpUrl: result)); // AppNavigator.push( // const WebviewPage(url: 'https://www.dronex.cc/'), // ); diff --git a/lib/features/scan_qrcode/utils/rtmp_stream_target.dart b/lib/features/scan_qrcode/utils/rtmp_stream_target.dart new file mode 100644 index 0000000..5e0d523 --- /dev/null +++ b/lib/features/scan_qrcode/utils/rtmp_stream_target.dart @@ -0,0 +1,33 @@ +class RtmpStreamTarget { + const RtmpStreamTarget({required this.url, required this.streamKey}); + + final String url; + final String streamKey; + + factory RtmpStreamTarget.parse(String value) { + final source = value.trim(); + final uri = Uri.tryParse(source); + if (uri == null || + (uri.scheme != 'rtmp' && uri.scheme != 'rtmps') || + uri.host.isEmpty) { + throw const FormatException('推流地址必须是 rtmp:// 或 rtmps:// 开头的完整地址'); + } + + final pathSegments = uri.pathSegments + .where((segment) => segment.trim().isNotEmpty) + .toList(growable: false); + if (pathSegments.length < 2) { + throw const FormatException('推流地址路径必须包含 app 和 streamKey,例如 /app/xxxx'); + } + + final appPath = pathSegments.take(pathSegments.length - 1).join('/'); + final streamKey = pathSegments.last; + final baseUrl = uri + .replace(path: '/$appPath', query: null, fragment: null) + .toString(); + + return RtmpStreamTarget(url: baseUrl, streamKey: streamKey); + } + + String get fullUrl => '$url/$streamKey'; +} diff --git a/test/core/permission/permission_service_test.dart b/test/core/permission/permission_service_test.dart deleted file mode 100644 index 90197fa..0000000 --- a/test/core/permission/permission_service_test.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -// ignore: depend_on_referenced_packages -import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart'; - -import 'package:recording_tool/core/permission/permission_service.dart'; - -void main() { - group('PermissionService.requestMissing', () { - late PermissionHandlerPlatform originalPlatform; - - setUp(() { - originalPlatform = PermissionHandlerPlatform.instance; - }); - - tearDown(() { - PermissionHandlerPlatform.instance = originalPlatform; - }); - - test('requests only missing permissions and skips granted ones', () async { - final platform = FakePermissionHandlerPlatform( - statuses: { - Permission.camera: PermissionStatus.granted, - Permission.microphone: PermissionStatus.denied, - }, - requestResults: { - Permission.microphone: PermissionStatus.granted, - }, - ); - PermissionHandlerPlatform.instance = platform; - - final result = await PermissionService.requestMissing([ - Permission.camera, - Permission.microphone, - ]); - - expect(platform.requestCalls, >[ - [Permission.microphone], - ]); - expect(result[Permission.camera], PermissionStatus.granted); - expect(result[Permission.microphone], PermissionStatus.granted); - }); - - test( - 'preserves permanently denied permissions without requesting them', - () async { - final platform = FakePermissionHandlerPlatform( - statuses: { - Permission.camera: PermissionStatus.permanentlyDenied, - Permission.microphone: PermissionStatus.denied, - }, - requestResults: { - Permission.microphone: PermissionStatus.granted, - }, - ); - PermissionHandlerPlatform.instance = platform; - - final result = await PermissionService.requestMissing([ - Permission.camera, - Permission.microphone, - ]); - - expect(platform.requestCalls, >[ - [Permission.microphone], - ]); - expect(result[Permission.camera], PermissionStatus.permanentlyDenied); - expect(result[Permission.microphone], PermissionStatus.granted); - }, - ); - }); - - group('iOS permission configuration', () { - test('Podfile enables camera and microphone permission macros only', () { - final podfile = File('ios/Podfile').readAsStringSync(); - - expect( - podfile, - contains('flutter_additional_ios_build_settings(target)'), - ); - expect(podfile, contains("'PERMISSION_CAMERA=1'")); - expect(podfile, contains("'PERMISSION_MICROPHONE=1'")); - expect(podfile, isNot(contains("'PERMISSION_PHOTOS=1'"))); - expect(podfile, isNot(contains("'PERMISSION_PHOTOS_ADD_ONLY=1'"))); - }); - }); -} - -class FakePermissionHandlerPlatform extends PermissionHandlerPlatform { - FakePermissionHandlerPlatform({ - required this.statuses, - required this.requestResults, - }); - - final Map statuses; - final Map requestResults; - final List> requestCalls = >[]; - - @override - Future checkPermissionStatus(Permission permission) async { - return statuses[permission] ?? PermissionStatus.denied; - } - - @override - Future checkServiceStatus(Permission permission) async { - return ServiceStatus.enabled; - } - - @override - Future openAppSettings() async { - return true; - } - - @override - Future> requestPermissions( - List permissions, - ) async { - requestCalls.add(List.unmodifiable(permissions)); - return { - for (final permission in permissions) - permission: requestResults[permission] ?? PermissionStatus.granted, - }; - } - - @override - Future shouldShowRequestPermissionRationale( - Permission permission, - ) async { - return false; - } -} diff --git a/test/core/platform/device_health_checker_test.dart b/test/core/platform/device_health_checker_test.dart deleted file mode 100644 index ba4373b..0000000 --- a/test/core/platform/device_health_checker_test.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/core/platform/device_health_checker.dart'; -import 'package:recording_tool/core/platform/device_health_snapshot.dart'; - -void main() { - group('DeviceHealthChecker.warningLines', () { - test('returns empty when battery and storage are healthy', () { - const snapshot = DeviceHealthSnapshot( - batteryLevelPercent: 50, - storageAvailablePercent: 50, - ); - - expect(DeviceHealthChecker.warningLines(snapshot), isEmpty); - }); - - test('returns low battery message only', () { - const snapshot = DeviceHealthSnapshot( - batteryLevelPercent: 9, - storageAvailablePercent: 50, - ); - - expect( - DeviceHealthChecker.warningLines(snapshot), - [DeviceHealthChecker.lowBatteryMessage], - ); - }); - - test('returns low storage message only', () { - const snapshot = DeviceHealthSnapshot( - batteryLevelPercent: 50, - storageAvailablePercent: 9.9, - ); - - expect( - DeviceHealthChecker.warningLines(snapshot), - [DeviceHealthChecker.lowStorageMessage], - ); - }); - - test('returns both messages when battery and storage are low', () { - const snapshot = DeviceHealthSnapshot( - batteryLevelPercent: 5, - storageAvailablePercent: 5, - ); - - expect( - DeviceHealthChecker.warningLines(snapshot), - [ - DeviceHealthChecker.lowBatteryMessage, - DeviceHealthChecker.lowStorageMessage, - ], - ); - }); - - test('does not warn at exactly threshold percent', () { - const snapshot = DeviceHealthSnapshot( - batteryLevelPercent: 10, - storageAvailablePercent: 10, - ); - - expect(DeviceHealthChecker.warningLines(snapshot), isEmpty); - }); - - test('skips battery warning when level is unknown', () { - const snapshot = DeviceHealthSnapshot( - batteryLevelPercent: null, - storageAvailablePercent: 5, - ); - - expect( - DeviceHealthChecker.warningLines(snapshot), - [DeviceHealthChecker.lowStorageMessage], - ); - }); - }); - - group('DeviceHealthSnapshot.fromMap', () { - test('parses native map fields', () { - final snapshot = DeviceHealthSnapshot.fromMap({ - 'batteryLevelPercent': 42, - 'storageAvailablePercent': 12.5, - }); - - expect(snapshot.batteryLevelPercent, 42); - expect(snapshot.storageAvailablePercent, 12.5); - }); - }); -} diff --git a/test/features/dialog/record_dialog_test.dart b/test/features/dialog/record_dialog_test.dart deleted file mode 100644 index 4283abe..0000000 --- a/test/features/dialog/record_dialog_test.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/features/dialog/dialog-record.dart'; -import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart'; -import 'package:recording_tool/gen/assets.gen.dart'; - -void main() { - Future pumpDialogHost(WidgetTester tester, Widget child) async { - await tester.pumpWidget( - ScreenUtilInit( - designSize: const Size(375, 812), - builder: (context, _) { - return MaterialApp(home: Scaffold(body: child)); - }, - ), - ); - } - - testWidgets('single button dialog shows configured content and closes', ( - tester, - ) async { - var tapped = false; - - await pumpDialogHost( - tester, - Builder( - builder: (context) { - return TextButton( - onPressed: () { - RecordDialog.showSingle( - context, - title: '无选手信息!', - buttonText: '粘贴', - onPressed: () => tapped = true, - ); - }, - child: const Text('show'), - ); - }, - ), - ); - - await tester.tap(find.text('show')); - await tester.pumpAndSettle(); - - expect(find.byType(Image), findsOneWidget); - expect(find.image(AssetImage(Assets.images.imageDialogBg.path)), findsOne); - expect(find.text('无选手信息!'), findsOneWidget); - expect(find.text('粘贴'), findsOneWidget); - - await tester.tap(find.text('粘贴')); - await tester.pumpAndSettle(); - - expect(tapped, isTrue); - expect(find.text('无选手信息!'), findsNothing); - }); - - testWidgets('double button dialog dispatches each action', (tester) async { - var leftTapped = false; - var rightTapped = false; - - await pumpDialogHost( - tester, - Builder( - builder: (context) { - return TextButton( - onPressed: () { - RecordDialog.showDouble( - context, - title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息', - leftText: '继续本轮', - rightText: '录制新轮', - onLeftPressed: () => leftTapped = true, - onRightPressed: () => rightTapped = true, - ); - }, - child: const Text('show'), - ); - }, - ), - ); - - await tester.tap(find.text('show')); - await tester.pumpAndSettle(); - await tester.tap(find.text('继续本轮')); - await tester.pumpAndSettle(); - - expect(leftTapped, isTrue); - expect(rightTapped, isFalse); - - await tester.tap(find.text('show')); - await tester.pumpAndSettle(); - await tester.tap(find.text('录制新轮')); - await tester.pumpAndSettle(); - - expect(rightTapped, isTrue); - }); - - testWidgets('recording saved dialog follows design title only', ( - tester, - ) async { - await pumpDialogHost( - tester, - Builder( - builder: (context) { - return TextButton( - onPressed: () { - showRecordingSavedDialog( - context, - sessionTitle: '王东方 丨李想 空中格斗赛', - onContinueRound: () {}, - onRecordNewRound: () {}, - ); - }, - child: const Text('show'), - ); - }, - ), - ); - - await tester.tap(find.text('show')); - await tester.pumpAndSettle(); - - expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing); - expect(find.text('本轮比赛视频已保存到文件夹\n请选择后续录制信息'), findsOneWidget); - expect(find.text('继续本轮'), findsOneWidget); - expect(find.text('录制新轮'), findsOneWidget); - }); -} diff --git a/test/features/recording/model_clipboard_test.dart b/test/features/recording/model_clipboard_test.dart deleted file mode 100644 index 5d3e674..0000000 --- a/test/features/recording/model_clipboard_test.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/features/recording/model/model_clipboard.dart'; - -void main() { - group('ClipboardRecordingModel', () { - const clipboardJson = { - 'title': '王东方 丨李想 空中格斗赛', - 'startTimestamp': 1717334400, - 'endTimestamp': 1717334400, - 'address': '广州市番禺区·粤港澳大湾区青年人才双创小镇', - }; - - test('parses mini program clipboard JSON', () { - final model = ClipboardRecordingModel.fromJson(clipboardJson); - - expect(model.title, '王东方 丨李想 空中格斗赛'); - expect(model.startTimestamp, 1717334400); - expect(model.endTimestamp, 1717334400); - expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇'); - expect(model.filename, isNull); - expect(model.toJson(), clipboardJson); - }); - - test('parses JSON without optional timestamps', () { - final json = { - 'title': '郑昌梦 丨黄伟依 空中格斗赛 小学组', - 'address': '广东省汕头市番禺区青蓝街 111 号', - 'filename': '郑昌梦_黄伟依_6月3日测试-1_空中格斗赛', - }; - final model = ClipboardRecordingModel.fromJson(json); - - expect(model.title, json['title']); - expect(model.address, json['address']); - expect(model.filename, json['filename']); - expect(model.startTimestamp, isNull); - expect(model.endTimestamp, isNull); - }); - - test('parses optional filename from mini program JSON', () { - final json = { - ...clipboardJson, - 'filename': '选手名称_选手ID_赛事名称_赛项', - }; - final model = ClipboardRecordingModel.fromJson(json); - - expect(model.filename, '选手名称_选手ID_赛事名称_赛项'); - expect(model.toJson(), json); - }); - - test('throws FormatException when required field is missing', () { - final json = Map.from(clipboardJson)..remove('title'); - - expect( - () => ClipboardRecordingModel.fromJson(json), - throwsA(isA()), - ); - }); - - test('throws FormatException when optional int field has wrong type', () { - final json = {...clipboardJson, 'startTimestamp': '1717334400'}; - - expect( - () => ClipboardRecordingModel.fromJson(json), - throwsA(isA()), - ); - }); - - test('throws FormatException when required address is missing', () { - final json = Map.from(clipboardJson)..remove('address'); - - expect( - () => ClipboardRecordingModel.fromJson(json), - throwsA(isA()), - ); - }); - }); -} diff --git a/test/features/recording/recording_display_name_test.dart b/test/features/recording/recording_display_name_test.dart deleted file mode 100644 index b4b6665..0000000 --- a/test/features/recording/recording_display_name_test.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/features/recording/utils/recording_display_name.dart'; - -void main() { - group('sanitizeRecordingBaseName', () { - test('removes invalid path characters', () { - expect( - sanitizeRecordingBaseName(r'a/b:c*d?e"fh|i'), - 'a_b_c_d_e_f_g_h_i', - ); - }); - - test('returns null for blank input', () { - expect(sanitizeRecordingBaseName(' '), isNull); - }); - - test('truncates overly long names', () { - final long = 'a' * 200; - expect(sanitizeRecordingBaseName(long)!.length, 120); - }); - }); - - group('resolveRecordingDisplayName', () { - test('uses sanitized clipboard filename when present', () { - expect( - resolveRecordingDisplayName('选手名称_选手ID_赛事名称_赛项'), - '选手名称_选手ID_赛事名称_赛项', - ); - }); - - test('falls back to REC_ prefix when clipboard filename is empty', () { - expect(resolveRecordingDisplayName(null), startsWith('REC_')); - expect(resolveRecordingDisplayName(''), startsWith('REC_')); - }); - }); - - group('withVideoExtension', () { - test('appends mp4 on Android', () { - expect( - withVideoExtension('match', isIOS: false), - 'match.mp4', - ); - }); - - test('appends mov on iOS', () { - expect( - withVideoExtension('match', isIOS: true), - 'match.mov', - ); - }); - - test('keeps existing extension', () { - expect(withVideoExtension('a.mp4', isIOS: false), 'a.mp4'); - expect(withVideoExtension('a.MOV', isIOS: true), 'a.MOV'); - }); - }); - - group('recordingFileNameForPlatform', () { - test('combines clipboard name with platform extension', () { - expect( - recordingFileNameForPlatform( - '选手名称_选手ID_赛事名称_赛项', - isIOS: false, - ), - '选手名称_选手ID_赛事名称_赛项.mp4', - ); - }); - }); -} diff --git a/test/features/recording/recording_platform_test.dart b/test/features/recording/recording_platform_test.dart deleted file mode 100644 index 9774d92..0000000 --- a/test/features/recording/recording_platform_test.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/features/recording/platform/recording_channel_names.dart'; -import 'package:recording_tool/features/recording/platform/recording_platform.dart'; - -void main() { - group('RecordingChannelNames', () { - test('uses stable bridge names without package binding', () { - expect(RecordingChannelNames.method, 'app.record_tool/recording'); - expect(RecordingChannelNames.events, 'app.record_tool/recording_events'); - }); - }); - - group('RecordingPlatform support', () { - test('supports Android and iOS hosts only', () { - expect( - RecordingPlatform.supportsHost(isAndroid: true, isIOS: false), - isTrue, - ); - expect( - RecordingPlatform.supportsHost(isAndroid: false, isIOS: true), - isTrue, - ); - expect( - RecordingPlatform.supportsHost(isAndroid: false, isIOS: false), - isFalse, - ); - }); - }); - - group('RecordingStopResult', () { - test('parses file save result fields from platform payload', () { - final result = RecordingStopResult.fromMap({ - 'outputPath': '/Documents/recordings/test.mov', - 'status': {'state': 'previewing'}, - 'fileSaved': false, - 'fileErrorMessage': '保存到文件夹失败', - }); - - expect(result.outputPath, '/Documents/recordings/test.mov'); - expect(result.status.state, RecordingState.previewing); - expect(result.fileSaved, isFalse); - expect(result.fileErrorMessage, '保存到文件夹失败'); - }); - }); -} diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart deleted file mode 100644 index a2d28f7..0000000 --- a/test/features/recording/view_model_recording_test.dart +++ /dev/null @@ -1,595 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:recording_tool/features/recording/model/model_recording_session.dart'; -import 'package:recording_tool/features/recording/platform/recording_channel_names.dart'; -import 'package:recording_tool/features/recording/platform/recording_platform.dart'; -import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const defaultClipboardTitle = ''; - const validClipboardText = - '{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}'; - - Future setClipboardText(String? text) async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'Clipboard.getData') { - return text == null ? null : {'text': text}; - } - return null; - }); - } - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - null, - ); - }); - - group('RecordingViewModel', () { - test('initializes with default clipboard and session state', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - - final model = container.read(recordingViewModelProvider); - expect(model.hasValidClipboardInfo, isFalse); - expect(model.clipboardRecordingModel.title, defaultClipboardTitle); - expect(model.session.isPreviewReady, isFalse); - expect(model.session.isRecording, isFalse); - expect(model.session.zoomRatio, 1.0); - expect(model.session.minZoomRatio, 1.0); - expect(model.session.maxZoomRatio, 3.0); - }); - }); - - group('RecordingViewModel.setZoomRatio', () { - test('updates zoom ratio from native response', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - expect(call.method, 'setZoomRatio'); - expect(call.arguments, {'zoomRatio': 2.0}); - return { - 'zoomRatio': 2.0, - 'minZoomRatio': 1.0, - 'maxZoomRatio': 3.0, - }; - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(recordingViewModelProvider.notifier).setZoomRatio(2); - - final session = container.read(recordingViewModelProvider).session; - expect(session.zoomRatio, 2.0); - expect(session.minZoomRatio, 1.0); - expect(session.maxZoomRatio, 3.0); - expect(session.errorMessage, isNull); - }); - - test('passes 0.5x to native when camera capabilities allow it', () async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - calls.add(call); - return { - 'zoomRatio': 0.5, - 'minZoomRatio': 0.5, - 'maxZoomRatio': 3.0, - }; - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(recordingViewModelProvider.notifier); - // ignore: invalid_use_of_protected_member - notifier.state = container - .read(recordingViewModelProvider) - .copyWith( - session: const RecordingSessionState( - zoomRatio: 1.0, - minZoomRatio: 0.5, - maxZoomRatio: 3.0, - ), - ); - - await notifier.setZoomRatio(0.5); - - expect(calls.single.arguments, {'zoomRatio': 0.5}); - final session = container.read(recordingViewModelProvider).session; - expect(session.zoomRatio, 0.5); - expect(session.minZoomRatio, 0.5); - expect(session.maxZoomRatio, 3.0); - }); - - test('passes 0.6x to native when camera capabilities allow it', () async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - calls.add(call); - return { - 'zoomRatio': 0.6, - 'minZoomRatio': 0.6, - 'maxZoomRatio': 3.0, - }; - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(recordingViewModelProvider.notifier); - // ignore: invalid_use_of_protected_member - notifier.state = container - .read(recordingViewModelProvider) - .copyWith( - session: const RecordingSessionState( - zoomRatio: 1.0, - minZoomRatio: 0.6, - maxZoomRatio: 3.0, - ), - ); - - await notifier.setZoomRatio(0.6); - - expect(calls.single.arguments, {'zoomRatio': 0.6}); - final session = container.read(recordingViewModelProvider).session; - expect(session.zoomRatio, 0.6); - expect(session.minZoomRatio, 0.6); - expect(session.maxZoomRatio, 3.0); - }); - - test('clamps requested zoom ratio before invoking native', () async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - calls.add(call); - return { - 'zoomRatio': 1.0, - 'minZoomRatio': 1.0, - 'maxZoomRatio': 1.0, - }; - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(recordingViewModelProvider.notifier).setZoomRatio(4); - - expect(calls.single.arguments, {'zoomRatio': 3.0}); - expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0); - }); - - test( - 'clamps 0.6x to 1x when camera capabilities do not allow it', - () async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - calls.add(call); - return { - 'zoomRatio': 1.0, - 'minZoomRatio': 1.0, - 'maxZoomRatio': 3.0, - }; - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container - .read(recordingViewModelProvider.notifier) - .setZoomRatio(0.6); - - expect(calls.single.arguments, {'zoomRatio': 1.0}); - expect( - container.read(recordingViewModelProvider).session.zoomRatio, - 1.0, - ); - }, - ); - - test( - 'keeps previous zoom ratio and stores error when native fails', - () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - throw PlatformException( - code: 'ZOOM_FAILED', - message: 'Zoom is unavailable', - ); - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container - .read(recordingViewModelProvider.notifier) - .setZoomRatio(2); - - final session = container.read(recordingViewModelProvider).session; - expect(session.zoomRatio, 1.0); - expect(session.errorMessage, '切换镜头失败,请重试'); - }, - ); - - test( - 'maps native zoom failure to user friendly lens switch message', - () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - throw PlatformException( - code: 'ZOOM_FAILED', - message: 'Cannot switch physical camera while recording', - ); - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container - .read(recordingViewModelProvider.notifier) - .setZoomRatio(0.6); - - final session = container.read(recordingViewModelProvider).session; - expect(session.zoomRatio, 1.0); - expect(session.errorMessage, '切换镜头失败,请重试'); - }, - ); - - test('sets switching lens while native zoom request is pending', () async { - final completer = Completer>(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) => completer.future, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(recordingViewModelProvider.notifier); - - final future = notifier.setZoomRatio(2); - await Future.delayed(Duration.zero); - - expect( - container.read(recordingViewModelProvider).session.isSwitchingLens, - isTrue, - ); - - completer.complete({ - 'zoomRatio': 2.0, - 'minZoomRatio': 1.0, - 'maxZoomRatio': 3.0, - }); - await future; - - expect( - container.read(recordingViewModelProvider).session.isSwitchingLens, - isFalse, - ); - }); - }); - - group('RecordingViewModel.stopRecording', () { - test('does not call native stop while switching lens', () async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - calls.add(call); - return {}; - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(recordingViewModelProvider.notifier); - // ignore: invalid_use_of_protected_member - notifier.state = container - .read(recordingViewModelProvider) - .copyWith( - session: const RecordingSessionState( - status: RecordingStatus(state: RecordingState.recording), - isSwitchingLens: true, - ), - ); - - await notifier.stopRecording(); - - expect(calls, isEmpty); - }); - - test( - 'stores segment output paths when native save falls back to parts', - () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - switch (call.method) { - case 'stopRecording': - return { - 'outputPath': 'content://recordings/part1', - 'status': { - 'state': 'error', - 'message': 'Merge failed', - }, - 'fileSaved': false, - 'fileErrorMessage': 'Merge failed', - 'segmentOutputPaths': [ - 'content://recordings/part1', - 'content://recordings/part2', - ], - }; - case 'initializePreview': - return {'state': 'previewing'}; - case 'getZoomCapabilities': - return { - 'zoomRatio': 1.0, - 'minZoomRatio': 1.0, - 'maxZoomRatio': 3.0, - }; - } - return {}; - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(recordingViewModelProvider.notifier); - // ignore: invalid_use_of_protected_member - notifier.state = container - .read(recordingViewModelProvider) - .copyWith( - session: const RecordingSessionState( - status: RecordingStatus(state: RecordingState.recording), - ), - ); - - await notifier.stopRecording(); - - final session = container.read(recordingViewModelProvider).session; - expect(session.fileSaveFailed, isTrue); - expect(session.segmentOutputPaths, [ - 'content://recordings/part1', - 'content://recordings/part2', - ]); - }, - ); - }); - - group('recordingFileSavePermissionsForHost', () { - test('does not request photo permission on iOS', () { - final permissions = recordingFileSavePermissionsForHost( - isIOS: true, - isAndroid: false, - ); - - expect(permissions, isEmpty); - expect(permissions, isNot(contains(Permission.photosAddOnly))); - expect(permissions, isNot(contains(Permission.photos))); - }); - - test('requests storage permission on Android 9 and below', () { - final permissions = recordingFileSavePermissionsForHost( - isIOS: false, - isAndroid: true, - androidSdkInt: 28, - ); - - expect(permissions, [Permission.storage]); - expect(permissions, isNot(contains(Permission.videos))); - }); - - test('does not request file save permission on Android 10 and above', () { - final permissions = recordingFileSavePermissionsForHost( - isIOS: false, - isAndroid: true, - androidSdkInt: 29, - ); - - expect(permissions, isEmpty); - }); - - test('does not request file save permission on Android 13 and above', () { - final permissions = recordingFileSavePermissionsForHost( - isIOS: false, - isAndroid: true, - androidSdkInt: 33, - ); - - expect(permissions, isEmpty); - }); - - test('does not request file save permission when Android SDK is unknown', () { - final permissions = recordingFileSavePermissionsForHost( - isIOS: false, - isAndroid: true, - ); - - expect(permissions, isEmpty); - }); - }); - - group('RecordingViewModel.getClipboardContent', () { - test( - 'updates state when clipboard contains valid mini program JSON', - () async { - await setClipboardText(validClipboardText); - final container = ProviderContainer(); - addTearDown(container.dispose); - - final result = await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); - - expect(result, ClipboardReadResult.success); - final model = container.read(recordingViewModelProvider); - expect(model.hasValidClipboardInfo, isTrue); - expect(model.clipboardRecordingModel.title, '王东方 丨李想 空中格斗赛'); - expect(model.clipboardRecordingModel.startTimestamp, 1717334400); - expect(model.clipboardRecordingModel.endTimestamp, 1717334400); - expect(model.clipboardRecordingModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇'); - expect(model.clipboardRecordingModel.filename, '选手名称_选手ID_赛事名称_赛项'); - }, - ); - - test('returns empty when clipboard is empty', () async { - await setClipboardText(''); - final container = ProviderContainer(); - addTearDown(container.dispose); - - final result = await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); - - expect(result, ClipboardReadResult.empty); - final model = container.read(recordingViewModelProvider); - expect(model.hasValidClipboardInfo, isFalse); - expect(model.clipboardRecordingModel.title, defaultClipboardTitle); - }); - - test('returns invalid when clipboard is not JSON', () async { - await setClipboardText('hello'); - final container = ProviderContainer(); - addTearDown(container.dispose); - - final result = await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); - - expect(result, ClipboardReadResult.invalid); - expect( - container - .read(recordingViewModelProvider) - .clipboardRecordingModel - .title, - defaultClipboardTitle, - ); - expect( - container.read(recordingViewModelProvider).hasValidClipboardInfo, - isFalse, - ); - }); - - test('returns invalid when clipboard JSON is not an object', () async { - await setClipboardText('[1,2,3]'); - final container = ProviderContainer(); - addTearDown(container.dispose); - - final result = await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); - - expect(result, ClipboardReadResult.invalid); - expect( - container - .read(recordingViewModelProvider) - .clipboardRecordingModel - .title, - defaultClipboardTitle, - ); - }); - - test( - 'returns invalid when clipboard JSON misses required address', - () async { - await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}'); - final container = ProviderContainer(); - addTearDown(container.dispose); - - final result = await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); - - expect(result, ClipboardReadResult.invalid); - expect( - container - .read(recordingViewModelProvider) - .clipboardRecordingModel - .title, - defaultClipboardTitle, - ); - }, - ); - - test('updates state when clipboard omits optional timestamps', () async { - await setClipboardText( - '{"title":"郑昌梦 丨黄伟依 空中格斗赛 小学组","address":"广东省汕头市番禺区青蓝街 111 号","filename":"郑昌梦_黄伟依_6月3日测试-1_空中格斗赛"}', - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - final result = await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); - - expect(result, ClipboardReadResult.success); - final model = container.read(recordingViewModelProvider); - expect(model.hasValidClipboardInfo, isTrue); - expect(model.clipboardRecordingModel.startTimestamp, isNull); - expect(model.clipboardRecordingModel.endTimestamp, isNull); - expect(model.clipboardRecordingModel.filename, '郑昌梦_黄伟依_6月3日测试-1_空中格斗赛'); - }); - - test('returns invalid when clipboard JSON has wrong field type', () async { - await setClipboardText( - '{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}', - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - final result = await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); - - expect(result, ClipboardReadResult.invalid); - expect( - container - .read(recordingViewModelProvider) - .clipboardRecordingModel - .title, - defaultClipboardTitle, - ); - }); - - test('returns invalid when title is blank', () async { - await setClipboardText( - '{"title":" ","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市"}', - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - final result = await container - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); - - expect(result, ClipboardReadResult.invalid); - expect( - container.read(recordingViewModelProvider).hasValidClipboardInfo, - isFalse, - ); - }); - }); -} diff --git a/test/features/recording/widget_record_header_test.dart b/test/features/recording/widget_record_header_test.dart deleted file mode 100644 index 4a85e43..0000000 --- a/test/features/recording/widget_record_header_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/features/recording/widgets/widget_record_header.dart'; -import 'package:recording_tool/gen/assets.gen.dart'; - -void main() { - Future pumpHeader( - WidgetTester tester, { - required bool hasValidClipboardInfo, - String? eventTitle, - }) async { - await tester.pumpWidget( - ScreenUtilInit( - designSize: const Size(375, 812), - builder: (context, _) { - return MaterialApp( - home: Scaffold( - body: RecordHeaderWidget( - hasValidClipboardInfo: hasValidClipboardInfo, - eventTitle: eventTitle, - isRecording: false, - onPasteEventInfo: () async {}, - onClearEventInfo: () {}, - ), - ), - ); - }, - ), - ); - } - - testWidgets('paste player info button uses copy image asset', (tester) async { - await pumpHeader(tester, hasValidClipboardInfo: false); - - expect(find.text('粘贴选手信息'), findsOneWidget); - expect(find.image(AssetImage(Assets.images.imageCopy.path)), findsOne); - }); - - testWidgets('clear player info button uses delete image asset', ( - tester, - ) async { - await pumpHeader( - tester, - hasValidClipboardInfo: true, - eventTitle: '王东方 丨李想 空中格斗赛', - ); - - expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget); - expect(find.image(AssetImage(Assets.images.imageDelete.path)), findsOne); - }); -} diff --git a/test/features/recording/widget_recording_button_test.dart b/test/features/recording/widget_recording_button_test.dart deleted file mode 100644 index 2bf4680..0000000 --- a/test/features/recording/widget_recording_button_test.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart'; - -void main() { - const designSize = Size(375, 812); - const morphDuration = Duration(milliseconds: 380); - - Future pumpButton( - WidgetTester tester, { - required bool isRecording, - bool isStartingRecording = false, - }) async { - await tester.pumpWidget( - ScreenUtilInit( - designSize: designSize, - builder: (context, _) { - return MaterialApp( - home: Scaffold( - body: Center( - child: RecordingControlButton( - isRecording: isRecording, - isStartingRecording: isStartingRecording, - onTap: () {}, - ), - ), - ), - ); - }, - ), - ); - await tester.pump(); - } - - Size innerCoreSize(WidgetTester tester) { - final finder = find.byWidgetPredicate( - (widget) => - widget is Container && - widget.decoration is BoxDecoration && - (widget.decoration! as BoxDecoration).color == Colors.red, - ); - return tester.getSize(finder); - } - - testWidgets('idle state uses large circular inner core', (tester) async { - await pumpButton(tester, isRecording: false); - - final size = innerCoreSize(tester); - expect(size.width, closeTo(62.r, 0.5)); - expect(size.height, closeTo(62.r, 0.5)); - }); - - testWidgets('isStartingRecording morphs to stop square before isRecording', ( - tester, - ) async { - await pumpButton( - tester, - isRecording: false, - isStartingRecording: true, - ); - - await tester.pump(morphDuration); - await tester.pump(); - - final size = innerCoreSize(tester); - expect(size.width, closeTo(22.r, 0.5)); - expect(size.height, closeTo(22.r, 0.5)); - }); - - testWidgets('isRecording forward and reverse morph without errors', ( - tester, - ) async { - await pumpButton(tester, isRecording: false); - - await tester.pumpWidget( - ScreenUtilInit( - designSize: designSize, - builder: (context, _) { - return MaterialApp( - home: Scaffold( - body: Center( - child: RecordingControlButton( - isRecording: true, - onTap: () {}, - ), - ), - ), - ); - }, - ), - ); - await tester.pump(morphDuration); - await tester.pump(); - - expect(innerCoreSize(tester).width, closeTo(22.r, 0.5)); - - await tester.pumpWidget( - ScreenUtilInit( - designSize: designSize, - builder: (context, _) { - return MaterialApp( - home: Scaffold( - body: Center( - child: RecordingControlButton( - isRecording: false, - onTap: () {}, - ), - ), - ), - ); - }, - ), - ); - await tester.pump(morphDuration); - await tester.pump(); - - expect(innerCoreSize(tester).width, closeTo(62.r, 0.5)); - }); - - testWidgets('failed start rolls morph back to idle circle', (tester) async { - await pumpButton( - tester, - isRecording: false, - isStartingRecording: true, - ); - await tester.pump(morphDuration); - await tester.pump(); - - expect(innerCoreSize(tester).width, closeTo(22.r, 0.5)); - - await pumpButton(tester, isRecording: false, isStartingRecording: false); - await tester.pump(morphDuration); - await tester.pump(); - - expect(innerCoreSize(tester).width, closeTo(62.r, 0.5)); - }); -} diff --git a/test/features/recording/widget_recording_hud_test.dart b/test/features/recording/widget_recording_hud_test.dart deleted file mode 100644 index 867f036..0000000 --- a/test/features/recording/widget_recording_hud_test.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/features/recording/widgets/widget_recording_hud.dart'; - -void main() { - Future pumpHud( - WidgetTester tester, { - double zoomRatio = 1.0, - double minZoomRatio = 1.0, - double maxZoomRatio = 3.0, - bool isRecording = false, - bool isSwitchingLens = false, - Future Function()? onStop, - ValueChanged? onZoomSelected, - }) async { - await tester.pumpWidget( - ScreenUtilInit( - designSize: const Size(375, 812), - builder: (context, _) { - return MaterialApp( - home: Scaffold( - backgroundColor: Colors.black, - body: RecordingHudWidget( - hasDndAccess: true, - isBatteryOptimizedIgnored: true, - notificationsGranted: true, - isRecording: isRecording, - isStartingRecording: false, - isSwitchingLens: isSwitchingLens, - isTouchLocked: false, - zoomRatio: zoomRatio, - minZoomRatio: minZoomRatio, - maxZoomRatio: maxZoomRatio, - onStart: () async {}, - onStop: onStop ?? () async {}, - onOpenDnd: () {}, - onOpenBattery: () {}, - onToggleTouchLock: () {}, - onZoomSelected: onZoomSelected ?? (_) {}, - ), - ), - ); - }, - ), - ); - await tester.pump(); - } - - testWidgets('shows preset zoom buttons', (tester) async { - await pumpHud(tester); - - expect(find.text('广角'), findsNothing); - expect(find.text('1x'), findsOneWidget); - expect(find.text('2x'), findsNothing); - expect(find.text('3x'), findsNothing); - }); - - testWidgets('shows wide angle when ultra-wide camera capability is 0.5', ( - tester, - ) async { - await pumpHud(tester, minZoomRatio: 0.5); - - expect(find.text('广角'), findsOneWidget); - expect(find.text('1x'), findsOneWidget); - expect(find.text('2x'), findsNothing); - expect(find.text('3x'), findsNothing); - }); - - testWidgets('shows wide angle when 0.6x camera capability supports it', ( - tester, - ) async { - await pumpHud(tester, minZoomRatio: 0.6); - - expect(find.text('广角'), findsOneWidget); - expect(find.text('1x'), findsOneWidget); - }); - - testWidgets('marks current 0.5x wide angle ratio as selected', ( - tester, - ) async { - await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5); - - final selectedButton = tester.widget( - find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)), - ); - expect(selectedButton.enabled, isFalse); - }); - - testWidgets('marks current 0.6x wide angle ratio as selected', ( - tester, - ) async { - await pumpHud(tester, zoomRatio: 0.6, minZoomRatio: 0.6); - - final selectedButton = tester.widget( - find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)), - ); - expect(selectedButton.enabled, isFalse); - }); - - testWidgets('does not expose presets beyond max zoom ratio', (tester) async { - await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55); - - expect(find.text('广角'), findsOneWidget); - expect(find.text('1x'), findsNothing); - }); - - testWidgets('tapping 0.5x reports 0.5 when camera capability is 0.5', ( - tester, - ) async { - double? selected; - await pumpHud( - tester, - minZoomRatio: 0.5, - onZoomSelected: (ratio) => selected = ratio, - ); - - await tester.tap(find.text('广角')); - await tester.pump(const Duration(milliseconds: 350)); - - expect(selected, 0.5); - }); - - testWidgets('tapping 0.6x reports 0.6 when camera only supports 0.6x', ( - tester, - ) async { - double? selected; - await pumpHud( - tester, - minZoomRatio: 0.6, - onZoomSelected: (ratio) => selected = ratio, - ); - - await tester.tap(find.text('广角')); - await tester.pump(const Duration(milliseconds: 350)); - - expect(selected, 0.6); - }); - - testWidgets('allows 0.6x while recording on main camera after unlock', ( - tester, - ) async { - double? selected; - await pumpHud( - tester, - minZoomRatio: 0.6, - isRecording: true, - onZoomSelected: (ratio) => selected = ratio, - ); - - final ultraWideButton = tester.widget( - find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)), - ); - final mainButton = tester.widget( - find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)), - ); - - expect(ultraWideButton.enabled, isTrue); - expect(mainButton.enabled, isFalse); - - await tester.tap(find.text('广角')); - await tester.pump(const Duration(milliseconds: 350)); - - expect(selected, 0.6); - }); - - testWidgets('allows 1x while recording on ultra-wide after unlock', ( - tester, - ) async { - double? selected; - await pumpHud( - tester, - zoomRatio: 0.5, - minZoomRatio: 0.5, - isRecording: true, - onZoomSelected: (ratio) => selected = ratio, - ); - - final ultraWideButton = tester.widget( - find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)), - ); - final mainButton = tester.widget( - find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)), - ); - - expect(ultraWideButton.enabled, isFalse); - expect(mainButton.enabled, isTrue); - - await tester.tap(find.text('1x')); - await tester.pump(const Duration(milliseconds: 350)); - - expect(selected, 1.0); - }); - - testWidgets('disables stop button while switching lens', (tester) async { - var stopped = false; - await pumpHud( - tester, - minZoomRatio: 0.6, - isRecording: true, - isSwitchingLens: true, - onStop: () async => stopped = true, - ); - - await tester.tap(find.byType(GestureDetector).last); - await tester.pump(); - - expect(stopped, isFalse); - }); - - testWidgets('disables zoom buttons while switching lens', (tester) async { - double? selected; - await pumpHud( - tester, - minZoomRatio: 0.6, - isRecording: true, - isSwitchingLens: true, - onZoomSelected: (ratio) => selected = ratio, - ); - - final ultraWideButton = tester.widget( - find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)), - ); - - expect(ultraWideButton.enabled, isFalse); - await tester.tap(find.text('广角')); - await tester.pump(const Duration(milliseconds: 350)); - - expect(selected, isNull); - }); -} diff --git a/test/features/recording/widget_recording_touch_lock_overlay_test.dart b/test/features/recording/widget_recording_touch_lock_overlay_test.dart deleted file mode 100644 index 24bbf60..0000000 --- a/test/features/recording/widget_recording_touch_lock_overlay_test.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart'; - -void main() { - group('resolveRecordingTouchLockUnlockIntent', () { - test('returns stopRecording for portrait bottom 30 percent', () { - final intent = resolveRecordingTouchLockUnlockIntent( - position: const Offset(120, 466.9), - size: const Size(375, 667), - ); - - expect(intent, RecordingTouchLockUnlockIntent.stopRecording); - }); - - test('returns unlockOnly for portrait area outside bottom 30 percent', () { - final intent = resolveRecordingTouchLockUnlockIntent( - position: const Offset(120, 320), - size: const Size(375, 667), - ); - - expect(intent, RecordingTouchLockUnlockIntent.unlockOnly); - }); - - test('returns stopRecording for landscape right 30 percent', () { - final intent = resolveRecordingTouchLockUnlockIntent( - position: const Offset(466.9, 120), - size: const Size(667, 375), - ); - - expect(intent, RecordingTouchLockUnlockIntent.stopRecording); - }); - - test('returns unlockOnly for landscape area outside right 30 percent', () { - final intent = resolveRecordingTouchLockUnlockIntent( - position: const Offset(320, 120), - size: const Size(667, 375), - ); - - expect(intent, RecordingTouchLockUnlockIntent.unlockOnly); - }); - }); - - group('RecordingTouchLockOverlayWidget', () { - Future pumpOverlay( - WidgetTester tester, { - required Size surfaceSize, - required ValueChanged onUnlocked, - }) async { - await tester.binding.setSurfaceSize(surfaceSize); - addTearDown(() => tester.binding.setSurfaceSize(null)); - - await tester.pumpWidget( - ScreenUtilInit( - designSize: const Size(375, 812), - builder: (context, _) { - return MaterialApp( - home: Scaffold( - body: Stack( - children: [ - RecordingTouchLockOverlayWidget( - enabled: true, - unlockHoldDuration: const Duration(seconds: 2), - onUnlocked: onUnlocked, - ), - ], - ), - ), - ); - }, - ), - ); - } - - testWidgets('long press in portrait bottom 30 percent stops recording', ( - tester, - ) async { - RecordingTouchLockUnlockIntent? receivedIntent; - await pumpOverlay( - tester, - surfaceSize: const Size(375, 667), - onUnlocked: (intent) => receivedIntent = intent, - ); - - final gesture = await tester.startGesture(const Offset(120, 600)); - await tester.pump(const Duration(seconds: 2)); - await gesture.up(); - - expect(receivedIntent, RecordingTouchLockUnlockIntent.stopRecording); - }); - - testWidgets('long press outside stop area only unlocks', (tester) async { - RecordingTouchLockUnlockIntent? receivedIntent; - await pumpOverlay( - tester, - surfaceSize: const Size(375, 667), - onUnlocked: (intent) => receivedIntent = intent, - ); - - final gesture = await tester.startGesture(const Offset(120, 320)); - await tester.pump(const Duration(seconds: 2)); - await gesture.up(); - - expect(receivedIntent, RecordingTouchLockUnlockIntent.unlockOnly); - }); - - testWidgets('releasing before hold duration does not unlock', ( - tester, - ) async { - RecordingTouchLockUnlockIntent? receivedIntent; - await pumpOverlay( - tester, - surfaceSize: const Size(375, 667), - onUnlocked: (intent) => receivedIntent = intent, - ); - - final gesture = await tester.startGesture(const Offset(120, 600)); - await tester.pump(const Duration(milliseconds: 1500)); - await gesture.up(); - await tester.pump(const Duration(seconds: 1)); - - expect(receivedIntent, isNull); - }); - }); -} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 892a9cf..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:recording_tool/app/app.dart'; -import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const validClipboardText = - '{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}'; - - String? clipboardText; - - setUp(() { - clipboardText = null; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'Clipboard.getData') { - return clipboardText == null - ? null - : {'text': clipboardText}; - } - return null; - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null); - }); - - Future pumpRecordingApp(WidgetTester tester) async { - await tester.pumpWidget(const ProviderScope(child: FlutterTemplateApp())); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 500)); - await tester.pump(const Duration(seconds: 1)); - } - - testWidgets('recording app renders recording page', (tester) async { - await pumpRecordingApp(tester); - - final recordButton = find.byType(RecordingControlButton); - - expect(recordButton, findsOneWidget); - expect( - tester.getCenter(recordButton).dx, - closeTo(tester.getCenter(find.byType(Scaffold)).dx, 0.5), - ); - }); - - testWidgets('shows paste event info button when title is empty', ( - tester, - ) async { - clipboardText = ''; - - await pumpRecordingApp(tester); - - expect(find.text('粘贴选手信息'), findsOneWidget); - }); - - testWidgets('pastes valid event info from clipboard', (tester) async { - clipboardText = ''; - - await pumpRecordingApp(tester); - - clipboardText = validClipboardText; - await tester.tap(find.text('粘贴选手信息')); - await tester.pump(const Duration(milliseconds: 700)); - - expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget); - }); - - testWidgets('shows no event info toast when pasted clipboard is invalid', ( - tester, - ) async { - clipboardText = ''; - - await pumpRecordingApp(tester); - - clipboardText = 'hello'; - await tester.tap(find.text('粘贴选手信息')); - await tester.pump(); - - expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing); - expect(find.text('无选手信息'), findsOneWidget); - - await tester.pump(const Duration(seconds: 2)); - }); -}