Compare commits
2
Commits
f6c477c9be
...
613bd90bad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613bd90bad | ||
|
|
9818a56fe1 |
@@ -49,3 +49,5 @@ app.*.map.json
|
||||
/android/.kotlin
|
||||
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
test/
|
||||
@@ -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, PermissionStatus>{
|
||||
Permission.camera: PermissionStatus.granted,
|
||||
Permission.microphone: PermissionStatus.denied,
|
||||
},
|
||||
requestResults: <Permission, PermissionStatus>{
|
||||
Permission.microphone: PermissionStatus.granted,
|
||||
},
|
||||
);
|
||||
PermissionHandlerPlatform.instance = platform;
|
||||
|
||||
final result = await PermissionService.requestMissing(<Permission>[
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
expect(platform.requestCalls, <List<Permission>>[
|
||||
<Permission>[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, PermissionStatus>{
|
||||
Permission.camera: PermissionStatus.permanentlyDenied,
|
||||
Permission.microphone: PermissionStatus.denied,
|
||||
},
|
||||
requestResults: <Permission, PermissionStatus>{
|
||||
Permission.microphone: PermissionStatus.granted,
|
||||
},
|
||||
);
|
||||
PermissionHandlerPlatform.instance = platform;
|
||||
|
||||
final result = await PermissionService.requestMissing(<Permission>[
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
expect(platform.requestCalls, <List<Permission>>[
|
||||
<Permission>[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<Permission, PermissionStatus> statuses;
|
||||
final Map<Permission, PermissionStatus> requestResults;
|
||||
final List<List<Permission>> requestCalls = <List<Permission>>[];
|
||||
|
||||
@override
|
||||
Future<PermissionStatus> checkPermissionStatus(Permission permission) async {
|
||||
return statuses[permission] ?? PermissionStatus.denied;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ServiceStatus> checkServiceStatus(Permission permission) async {
|
||||
return ServiceStatus.enabled;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> openAppSettings() async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<Permission, PermissionStatus>> requestPermissions(
|
||||
List<Permission> permissions,
|
||||
) async {
|
||||
requestCalls.add(List<Permission>.unmodifiable(permissions));
|
||||
return <Permission, PermissionStatus>{
|
||||
for (final permission in permissions)
|
||||
permission: requestResults[permission] ?? PermissionStatus.granted,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> shouldShowRequestPermissionRationale(
|
||||
Permission permission,
|
||||
) async {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<void> 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);
|
||||
});
|
||||
}
|
||||
@@ -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<String, dynamic>.from(clipboardJson)..remove('title');
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException when optional int field has wrong type', () {
|
||||
final json = {...clipboardJson, 'startTimestamp': '1717334400'};
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException when required address is missing', () {
|
||||
final json = Map<String, dynamic>.from(clipboardJson)..remove('address');
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"f<g>h|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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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(<String, dynamic>{
|
||||
'outputPath': '/Documents/recordings/test.mov',
|
||||
'status': <String, dynamic>{'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, '保存到文件夹失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<void> setClipboardText(String? text) async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.getData') {
|
||||
return text == null ? null : <String, dynamic>{'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, <String, dynamic>{'zoomRatio': 2.0});
|
||||
return <String, dynamic>{
|
||||
'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 = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{
|
||||
'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, <String, dynamic>{'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 = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{
|
||||
'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, <String, dynamic>{'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 = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{
|
||||
'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, <String, dynamic>{'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 = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{
|
||||
'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, <String, dynamic>{'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<Map<String, dynamic>>();
|
||||
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<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).session.isSwitchingLens,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
completer.complete(<String, dynamic>{
|
||||
'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 = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{};
|
||||
},
|
||||
);
|
||||
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 <String, dynamic>{
|
||||
'outputPath': 'content://recordings/part1',
|
||||
'status': <String, dynamic>{
|
||||
'state': 'error',
|
||||
'message': 'Merge failed',
|
||||
},
|
||||
'fileSaved': false,
|
||||
'fileErrorMessage': 'Merge failed',
|
||||
'segmentOutputPaths': <String>[
|
||||
'content://recordings/part1',
|
||||
'content://recordings/part2',
|
||||
],
|
||||
};
|
||||
case 'initializePreview':
|
||||
return <String, dynamic>{'state': 'previewing'};
|
||||
case 'getZoomCapabilities':
|
||||
return <String, dynamic>{
|
||||
'zoomRatio': 1.0,
|
||||
'minZoomRatio': 1.0,
|
||||
'maxZoomRatio': 3.0,
|
||||
};
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
},
|
||||
);
|
||||
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, <String>[
|
||||
'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>[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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<void> 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);
|
||||
});
|
||||
}
|
||||
@@ -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<void> 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));
|
||||
});
|
||||
}
|
||||
@@ -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<void> pumpHud(
|
||||
WidgetTester tester, {
|
||||
double zoomRatio = 1.0,
|
||||
double minZoomRatio = 1.0,
|
||||
double maxZoomRatio = 3.0,
|
||||
bool isRecording = false,
|
||||
bool isSwitchingLens = false,
|
||||
Future<void> Function()? onStop,
|
||||
ValueChanged<double>? 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<TextButton>(
|
||||
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<TextButton>(
|
||||
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<TextButton>(
|
||||
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
|
||||
);
|
||||
final mainButton = tester.widget<TextButton>(
|
||||
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<TextButton>(
|
||||
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
|
||||
);
|
||||
final mainButton = tester.widget<TextButton>(
|
||||
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<TextButton>(
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<void> pumpOverlay(
|
||||
WidgetTester tester, {
|
||||
required Size surfaceSize,
|
||||
required ValueChanged<RecordingTouchLockUnlockIntent> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
: <String, dynamic>{'text': clipboardText};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
});
|
||||
|
||||
Future<void> 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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user