Unverified Commit c30fdfbe authored by Casey Hillers's avatar Casey Hillers Committed by GitHub

Revert "[devicelab] Migrate Gallery to BuildTestTask (#77956)" (#78355)

This reverts commit 93fb2586.
parent 43e3328e
...@@ -333,18 +333,11 @@ Future<void> _runBuildTests() async { ...@@ -333,18 +333,11 @@ Future<void> _runBuildTests() async {
..add(Directory(path.join(flutterRoot, 'dev', 'integration_tests', 'non_nullable'))) ..add(Directory(path.join(flutterRoot, 'dev', 'integration_tests', 'non_nullable')))
..add(Directory(path.join(flutterRoot, 'dev', 'integration_tests', 'ui'))); ..add(Directory(path.join(flutterRoot, 'dev', 'integration_tests', 'ui')));
final List<String> devicelabBuildTasks = <String>[
'flutter_gallery__transition_perf',
'flutter_gallery_ios__transition_perf',
];
// The tests are randomly distributed into subshards so as to get a uniform // The tests are randomly distributed into subshards so as to get a uniform
// distribution of costs, but the seed is fixed so that issues are reproducible. // distribution of costs, but the seed is fixed so that issues are reproducible.
final List<ShardRunner> tests = <ShardRunner>[ final List<ShardRunner> tests = <ShardRunner>[
for (final FileSystemEntity exampleDirectory in exampleDirectories) for (final FileSystemEntity exampleDirectory in exampleDirectories)
() => _runExampleProjectBuildTests(exampleDirectory), () => _runExampleProjectBuildTests(exampleDirectory),
for (String devicelabBuildTask in devicelabBuildTasks)
() => _runDeviceLabBuildTask(devicelabBuildTask),
...<ShardRunner>[ ...<ShardRunner>[
// Web compilation tests. // Web compilation tests.
() => _flutterBuildDart2js( () => _flutterBuildDart2js(
...@@ -362,26 +355,6 @@ Future<void> _runBuildTests() async { ...@@ -362,26 +355,6 @@ Future<void> _runBuildTests() async {
await _runShardRunnerIndexOfTotalSubshard(tests); await _runShardRunnerIndexOfTotalSubshard(tests);
} }
Future<void> _runDeviceLabBuildTask(String task) async {
// Run the ios tasks
if (!Platform.isMacOS && task.contains('_ios_')) {
return;
}
final String targetPlatform = (task.contains('_ios_')) ? 'ios' : 'android';
await runCommand(dart, <String>[
'run', path.join('bin', 'test_runner.dart'),
'test',
'--task', task,
'--task-args', 'build',
'--task-args', 'target-platform=$targetPlatform',
],
workingDirectory: path.join('dev', 'devicelab'));
}
Future<void> _runExampleProjectBuildTests(FileSystemEntity exampleDirectory) async { Future<void> _runExampleProjectBuildTests(FileSystemEntity exampleDirectory) async {
// Only verify caching with flutter gallery. // Only verify caching with flutter gallery.
final bool verifyCaching = exampleDirectory.path.contains('flutter_gallery'); final bool verifyCaching = exampleDirectory.path.contains('flutter_gallery');
......
...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart'; ...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart';
import 'package:flutter_devicelab/framework/adb.dart'; import 'package:flutter_devicelab/framework/adb.dart';
import 'package:flutter_devicelab/framework/framework.dart'; import 'package:flutter_devicelab/framework/framework.dart';
Future<void> main(List<String> args) async { Future<void> main() async {
deviceOperatingSystem = DeviceOperatingSystem.android; deviceOperatingSystem = DeviceOperatingSystem.android;
await task(createGalleryTransitionTest(args)); await task(createGalleryTransitionTest());
} }
...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart'; ...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart';
import 'package:flutter_devicelab/framework/adb.dart'; import 'package:flutter_devicelab/framework/adb.dart';
import 'package:flutter_devicelab/framework/framework.dart'; import 'package:flutter_devicelab/framework/framework.dart';
Future<void> main(List<String> args) async { Future<void> main() async {
deviceOperatingSystem = DeviceOperatingSystem.android; deviceOperatingSystem = DeviceOperatingSystem.android;
await task(createGalleryTransitionE2ETest(args)); await task(createGalleryTransitionE2ETest());
} }
...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart'; ...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart';
import 'package:flutter_devicelab/framework/adb.dart'; import 'package:flutter_devicelab/framework/adb.dart';
import 'package:flutter_devicelab/framework/framework.dart'; import 'package:flutter_devicelab/framework/framework.dart';
Future<void> main(List<String> args) async { Future<void> main() async {
deviceOperatingSystem = DeviceOperatingSystem.ios; deviceOperatingSystem = DeviceOperatingSystem.ios;
await task(createGalleryTransitionE2ETest(args)); await task(createGalleryTransitionE2ETest());
} }
...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart'; ...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart';
import 'package:flutter_devicelab/framework/adb.dart'; import 'package:flutter_devicelab/framework/adb.dart';
import 'package:flutter_devicelab/framework/framework.dart'; import 'package:flutter_devicelab/framework/framework.dart';
Future<void> main(List<String> args) async { Future<void> main() async {
deviceOperatingSystem = DeviceOperatingSystem.ios; deviceOperatingSystem = DeviceOperatingSystem.ios;
await task(createGalleryTransitionE2ETest(args)); await task(createGalleryTransitionE2ETest());
} }
...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart'; ...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart';
import 'package:flutter_devicelab/framework/adb.dart'; import 'package:flutter_devicelab/framework/adb.dart';
import 'package:flutter_devicelab/framework/framework.dart'; import 'package:flutter_devicelab/framework/framework.dart';
Future<void> main(List<String> args) async { Future<void> main() async {
deviceOperatingSystem = DeviceOperatingSystem.android; deviceOperatingSystem = DeviceOperatingSystem.android;
await task(createGalleryTransitionHybridTest(args)); await task(createGalleryTransitionHybridTest());
} }
...@@ -7,11 +7,11 @@ import 'package:flutter_devicelab/framework/adb.dart'; ...@@ -7,11 +7,11 @@ import 'package:flutter_devicelab/framework/adb.dart';
import 'package:flutter_devicelab/framework/framework.dart'; import 'package:flutter_devicelab/framework/framework.dart';
import 'package:flutter_devicelab/framework/task_result.dart'; import 'package:flutter_devicelab/framework/task_result.dart';
Future<void> main(List<String> args) async { Future<void> main() async {
deviceOperatingSystem = DeviceOperatingSystem.android; deviceOperatingSystem = DeviceOperatingSystem.android;
await task(() async { await task(() async {
final TaskResult withoutSemantics = await createGalleryTransitionTest(args)(); final TaskResult withoutSemantics = await createGalleryTransitionTest()();
final TaskResult withSemantics = await createGalleryTransitionTest(args, semanticsEnabled: true)(); final TaskResult withSemantics = await createGalleryTransitionTest(semanticsEnabled: true)();
if (withSemantics.benchmarkScoreKeys.isEmpty || withoutSemantics.benchmarkScoreKeys.isEmpty) { if (withSemantics.benchmarkScoreKeys.isEmpty || withoutSemantics.benchmarkScoreKeys.isEmpty) {
String message = 'Lack of data'; String message = 'Lack of data';
if (withSemantics.benchmarkScoreKeys.isEmpty) { if (withSemantics.benchmarkScoreKeys.isEmpty) {
......
...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart'; ...@@ -6,7 +6,7 @@ import 'package:flutter_devicelab/tasks/gallery.dart';
import 'package:flutter_devicelab/framework/adb.dart'; import 'package:flutter_devicelab/framework/adb.dart';
import 'package:flutter_devicelab/framework/framework.dart'; import 'package:flutter_devicelab/framework/framework.dart';
Future<void> main(List<String> args) async { Future<void> main() async {
deviceOperatingSystem = DeviceOperatingSystem.ios; deviceOperatingSystem = DeviceOperatingSystem.ios;
await task(createGalleryTransitionTest(args)); await task(createGalleryTransitionTest());
} }
...@@ -65,6 +65,7 @@ class TestCommand extends Command<void> { ...@@ -65,6 +65,7 @@ class TestCommand extends Command<void> {
final List<String> taskArgsRaw = argResults['task-args'] as List<String>; final List<String> taskArgsRaw = argResults['task-args'] as List<String>;
// Prepend '--' to convert args to options when passed to task // Prepend '--' to convert args to options when passed to task
final List<String> taskArgs = taskArgsRaw.map((String taskArg) => '--$taskArg').toList(); final List<String> taskArgs = taskArgsRaw.map((String taskArg) => '--$taskArg').toList();
print(taskArgs);
await runTasks( await runTasks(
<String>[argResults['task'] as String], <String>[argResults['task'] as String],
deviceId: argResults['device-id'] as String, deviceId: argResults['device-id'] as String,
......
...@@ -54,26 +54,6 @@ DeviceDiscovery get devices => DeviceDiscovery(); ...@@ -54,26 +54,6 @@ DeviceDiscovery get devices => DeviceDiscovery();
/// Device operating system the test is configured to test. /// Device operating system the test is configured to test.
enum DeviceOperatingSystem { android, androidArm, androidArm64 ,ios, fuchsia, fake } enum DeviceOperatingSystem { android, androidArm, androidArm64 ,ios, fuchsia, fake }
/// Helper function to allow passing the target platform as a task arg instead
/// of hardcoding it in the task.
DeviceOperatingSystem deviceOperatingSystemFromString(String os) {
switch (os) {
case 'android':
return DeviceOperatingSystem.android;
case 'android_arm':
return DeviceOperatingSystem.androidArm;
case 'android_arm64':
return DeviceOperatingSystem.androidArm64;
case 'fake':
return DeviceOperatingSystem.fake;
case 'fuchsia':
return DeviceOperatingSystem.fuchsia;
case 'ios':
return DeviceOperatingSystem.ios;
}
throw UnimplementedError('$os is not defined in function deviceOperatingSystemFromString');
}
/// Device OS to test on. /// Device OS to test on.
DeviceOperatingSystem deviceOperatingSystem = DeviceOperatingSystem.android; DeviceOperatingSystem deviceOperatingSystem = DeviceOperatingSystem.android;
......
...@@ -19,23 +19,16 @@ abstract class BuildTestTask { ...@@ -19,23 +19,16 @@ abstract class BuildTestTask {
applicationBinaryPath = argResults[kApplicationBinaryPathOption] as String; applicationBinaryPath = argResults[kApplicationBinaryPathOption] as String;
buildOnly = argResults[kBuildOnlyFlag] as bool; buildOnly = argResults[kBuildOnlyFlag] as bool;
testOnly = argResults[kTestOnlyFlag] as bool; testOnly = argResults[kTestOnlyFlag] as bool;
if (argResults.wasParsed(kTargetPlatformOption)) {
// Override deviceOperatingSystem to prevent extra utilities from being used.
targetPlatform = deviceOperatingSystemFromString(argResults[kTargetPlatformOption] as String);
_originalDeviceOperatingSystem = deviceOperatingSystem;
deviceOperatingSystem = DeviceOperatingSystem.fake;
}
} }
static const String kApplicationBinaryPathOption = 'application-binary-path'; static const String kApplicationBinaryPathOption = 'application-binary-path';
static const String kBuildOnlyFlag = 'build'; static const String kBuildOnlyFlag = 'build';
static const String kTargetPlatformOption = 'target-platform';
static const String kTestOnlyFlag = 'test'; static const String kTestOnlyFlag = 'test';
final ArgParser argParser = ArgParser() final ArgParser argParser = ArgParser()
..addOption(kApplicationBinaryPathOption) ..addOption(kApplicationBinaryPathOption)
..addFlag(kBuildOnlyFlag) ..addFlag(kBuildOnlyFlag)
..addOption(kTargetPlatformOption)
..addFlag(kTestOnlyFlag); ..addFlag(kTestOnlyFlag);
/// Args passed from the test runner via "--task-arg". /// Args passed from the test runner via "--task-arg".
...@@ -44,15 +37,6 @@ abstract class BuildTestTask { ...@@ -44,15 +37,6 @@ abstract class BuildTestTask {
/// If true, skip [test]. /// If true, skip [test].
bool buildOnly = false; bool buildOnly = false;
/// The [DeviceOperatingSystem] being targeted for this build.
///
/// If passed, no connected device checks are run as the current connected device
/// will be set as [DeviceOperatingSystem.fake].
DeviceOperatingSystem targetPlatform;
/// Original [deviceOperatingSystem] if [targetPlatform] is given.
DeviceOperatingSystem _originalDeviceOperatingSystem;
/// If true, skip [build]. /// If true, skip [build].
bool testOnly = false; bool testOnly = false;
...@@ -75,7 +59,7 @@ abstract class BuildTestTask { ...@@ -75,7 +59,7 @@ abstract class BuildTestTask {
await flutter('clean'); await flutter('clean');
} }
section('BUILDING APPLICATION'); section('BUILDING APPLICATION');
await flutter('build', options: getBuildArgs()); await flutter('build', options: getBuildArgs(deviceOperatingSystem));
}); });
} }
...@@ -84,25 +68,21 @@ abstract class BuildTestTask { ...@@ -84,25 +68,21 @@ abstract class BuildTestTask {
/// ///
/// This assumes that [applicationBinaryPath] exists. /// This assumes that [applicationBinaryPath] exists.
Future<TaskResult> test() async { Future<TaskResult> test() async {
// Ensure deviceOperatingSystem is the one set in bin/task.
if (deviceOperatingSystem == DeviceOperatingSystem.fake) {
deviceOperatingSystem = _originalDeviceOperatingSystem;
}
final Device device = await devices.workingDevice; final Device device = await devices.workingDevice;
await device.unlock(); await device.unlock();
await inDirectory<void>(workingDirectory, () async { await inDirectory<void>(workingDirectory, () async {
section('DRIVE START'); section('DRIVE START');
await flutter('drive', options: getTestArgs(device.deviceId)); await flutter('drive', options: getTestArgs(deviceOperatingSystem, device.deviceId));
}); });
return parseTaskResult(); return parseTaskResult();
} }
/// Args passed to flutter build to build the application under test. /// Args passed to flutter build to build the application under test.
List<String> getBuildArgs() => throw UnimplementedError('getBuildArgs is not implemented'); List<String> getBuildArgs(DeviceOperatingSystem deviceOperatingSystem) => throw UnimplementedError('getBuildArgs is not implemented');
/// Args passed to flutter drive to test the built application. /// Args passed to flutter drive to test the built application.
List<String> getTestArgs(String deviceId) => throw UnimplementedError('getTestArgs is not implemented'); List<String> getTestArgs(DeviceOperatingSystem deviceOperatingSystem, String deviceId) => throw UnimplementedError('getTestArgs is not implemented');
/// Logic to construct [TaskResult] from this test's results. /// Logic to construct [TaskResult] from this test's results.
Future<TaskResult> parseTaskResult() => throw UnimplementedError('parseTaskResult is not implemented'); Future<TaskResult> parseTaskResult() => throw UnimplementedError('parseTaskResult is not implemented');
...@@ -126,7 +106,7 @@ abstract class BuildTestTask { ...@@ -126,7 +106,7 @@ abstract class BuildTestTask {
} }
if (!testOnly) { if (!testOnly) {
await build(); build();
} }
if (buildOnly) { if (buildOnly) {
......
...@@ -11,17 +11,13 @@ import '../framework/adb.dart'; ...@@ -11,17 +11,13 @@ import '../framework/adb.dart';
import '../framework/framework.dart'; import '../framework/framework.dart';
import '../framework/task_result.dart'; import '../framework/task_result.dart';
import '../framework/utils.dart'; import '../framework/utils.dart';
import 'build_test_task.dart';
final Directory galleryDirectory = dir('${flutterDirectory.path}/dev/integration_tests/flutter_gallery'); TaskFunction createGalleryTransitionTest({bool semanticsEnabled = false}) {
return GalleryTransitionTest(semanticsEnabled: semanticsEnabled);
TaskFunction createGalleryTransitionTest(List<String> args, {bool semanticsEnabled = false}) {
return GalleryTransitionTest(args, semanticsEnabled: semanticsEnabled, workingDirectory: galleryDirectory,);
} }
TaskFunction createGalleryTransitionE2ETest(List<String> args, {bool semanticsEnabled = false}) { TaskFunction createGalleryTransitionE2ETest({bool semanticsEnabled = false}) {
return GalleryTransitionTest( return GalleryTransitionTest(
args,
testFile: semanticsEnabled testFile: semanticsEnabled
? 'transitions_perf_e2e_with_semantics' ? 'transitions_perf_e2e_with_semantics'
: 'transitions_perf_e2e', : 'transitions_perf_e2e',
...@@ -30,23 +26,21 @@ TaskFunction createGalleryTransitionE2ETest(List<String> args, {bool semanticsEn ...@@ -30,23 +26,21 @@ TaskFunction createGalleryTransitionE2ETest(List<String> args, {bool semanticsEn
transitionDurationFile: null, transitionDurationFile: null,
timelineTraceFile: null, timelineTraceFile: null,
driverFile: 'transitions_perf_e2e_test', driverFile: 'transitions_perf_e2e_test',
workingDirectory: galleryDirectory,
); );
} }
TaskFunction createGalleryTransitionHybridTest(List<String> args, {bool semanticsEnabled = false}) { TaskFunction createGalleryTransitionHybridTest({bool semanticsEnabled = false}) {
return GalleryTransitionTest( return GalleryTransitionTest(
args,
semanticsEnabled: semanticsEnabled, semanticsEnabled: semanticsEnabled,
driverFile: semanticsEnabled driverFile: semanticsEnabled
? 'transitions_perf_hybrid_with_semantics_test' ? 'transitions_perf_hybrid_with_semantics_test'
: 'transitions_perf_hybrid_test', : 'transitions_perf_hybrid_test',
workingDirectory: galleryDirectory,
); );
} }
class GalleryTransitionTest extends BuildTestTask { class GalleryTransitionTest {
GalleryTransitionTest(List<String> args, {
GalleryTransitionTest({
this.semanticsEnabled = false, this.semanticsEnabled = false,
this.testFile = 'transitions_perf', this.testFile = 'transitions_perf',
this.needFullTimeline = true, this.needFullTimeline = true,
...@@ -54,8 +48,7 @@ class GalleryTransitionTest extends BuildTestTask { ...@@ -54,8 +48,7 @@ class GalleryTransitionTest extends BuildTestTask {
this.timelineTraceFile = 'transitions.timeline', this.timelineTraceFile = 'transitions.timeline',
this.transitionDurationFile = 'transition_durations.timeline', this.transitionDurationFile = 'transition_durations.timeline',
this.driverFile, this.driverFile,
Directory workingDirectory, });
}) : super(args, workingDirectory: workingDirectory);
final bool semanticsEnabled; final bool semanticsEnabled;
final bool needFullTimeline; final bool needFullTimeline;
...@@ -65,58 +58,59 @@ class GalleryTransitionTest extends BuildTestTask { ...@@ -65,58 +58,59 @@ class GalleryTransitionTest extends BuildTestTask {
final String transitionDurationFile; final String transitionDurationFile;
final String driverFile; final String driverFile;
@override Future<TaskResult> call() async {
List<String> getBuildArgs() { final Device device = await devices.workingDevice;
switch (targetPlatform) { await device.unlock();
case DeviceOperatingSystem.android: final String deviceId = device.deviceId;
return <String>[ final Directory galleryDirectory = dir('${flutterDirectory.path}/dev/integration_tests/flutter_gallery');
'apk', await inDirectory<void>(galleryDirectory, () async {
'--no-android-gradle-daemon', String applicationBinaryPath;
'--profile', if (deviceOperatingSystem == DeviceOperatingSystem.android) {
'-t', section('BUILDING APPLICATION');
'test_driver/$testFile.dart', await flutter(
'--target-platform', 'build',
'android-arm,android-arm64', options: <String>[
]; 'apk',
case DeviceOperatingSystem.ios: '--no-android-gradle-daemon',
return <String>[
'ios',
// Skip codesign on presubmit checks
if (targetPlatform != null)
'--no-codesign',
'--profile', '--profile',
'-t', '-t',
'test_driver/$testFile.dart', 'test_driver/$testFile.dart',
]; '--target-platform',
default: 'android-arm,android-arm64',
throw Exception('$deviceOperatingSystem has no build configuration'); ],
);
applicationBinaryPath = 'build/app/outputs/flutter-apk/app-profile.apk';
} }
}
@override final String testDriver = driverFile ?? (semanticsEnabled
List<String> getTestArgs(String deviceId) { ? '${testFile}_with_semantics_test'
final String testDriver = driverFile ?? (semanticsEnabled : '${testFile}_test');
? '${testFile}_with_semantics_test' section('DRIVE START');
: '${testFile}_test'); await flutter('drive', options: <String>[
return <String>[
'--profile', '--profile',
if (needFullTimeline) if (needFullTimeline)
'--trace-startup', '--trace-startup',
'--use-application-binary="${getApplicationBinaryPath()}"', if (applicationBinaryPath != null)
'--driver', 'test_driver/$testDriver.dart', '--use-application-binary=$applicationBinaryPath'
'-d', deviceId, else
]; ...<String>[
} '-t',
'test_driver/$testFile.dart',
],
'--driver',
'test_driver/$testDriver.dart',
'-d',
deviceId,
]);
});
@override
Future<TaskResult> parseTaskResult() async {
final Map<String, dynamic> summary = json.decode( final Map<String, dynamic> summary = json.decode(
file('${workingDirectory.path}/build/$timelineSummaryFile.json').readAsStringSync(), file('${galleryDirectory.path}/build/$timelineSummaryFile.json').readAsStringSync(),
) as Map<String, dynamic>; ) as Map<String, dynamic>;
if (transitionDurationFile != null) { if (transitionDurationFile != null) {
final Map<String, dynamic> original = json.decode( final Map<String, dynamic> original = json.decode(
file('${workingDirectory.path}/build/$transitionDurationFile.json').readAsStringSync(), file('${galleryDirectory.path}/build/$transitionDurationFile.json').readAsStringSync(),
) as Map<String, dynamic>; ) as Map<String, dynamic>;
final Map<String, List<int>> transitions = <String, List<int>>{}; final Map<String, List<int>> transitions = <String, List<int>>{};
for (final String key in original.keys) { for (final String key in original.keys) {
...@@ -129,9 +123,9 @@ class GalleryTransitionTest extends BuildTestTask { ...@@ -129,9 +123,9 @@ class GalleryTransitionTest extends BuildTestTask {
return TaskResult.success(summary, return TaskResult.success(summary,
detailFiles: <String>[ detailFiles: <String>[
if (transitionDurationFile != null) if (transitionDurationFile != null)
'${workingDirectory.path}/build/$transitionDurationFile.json', '${galleryDirectory.path}/build/$transitionDurationFile.json',
if (timelineTraceFile != null) if (timelineTraceFile != null)
'${workingDirectory.path}/build/$timelineTraceFile.json' '${galleryDirectory.path}/build/$timelineTraceFile.json'
], ],
benchmarkScoreKeys: <String>[ benchmarkScoreKeys: <String>[
if (transitionDurationFile != null) if (transitionDurationFile != null)
...@@ -147,22 +141,6 @@ class GalleryTransitionTest extends BuildTestTask { ...@@ -147,22 +141,6 @@ class GalleryTransitionTest extends BuildTestTask {
], ],
); );
} }
@override
String getApplicationBinaryPath() {
if (applicationBinaryPath != null) {
return applicationBinaryPath;
}
switch (targetPlatform) {
case DeviceOperatingSystem.android:
return 'build/app/outputs/flutter-apk/app-profile.apk';
case DeviceOperatingSystem.ios:
return 'build/ios/iphoneos/Flutter Gallery.app';
default:
throw UnimplementedError('getApplicationBinaryPath does not support $deviceOperatingSystem');
}
}
} }
int _countMissedTransitions(Map<String, List<int>> transitions) { int _countMissedTransitions(Map<String, List<int>> transitions) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment