mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
test(performance): benchmark Mornye on iOS and Android emulators
This commit is contained in:
1 parent
5b28db1375
commit
fec2f86cfc
7 files changed
+1435
No files matched your search
@@ -0,0 +1,186 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
|
||||
|
||||
import 'performance_probe.dart';
|
||||
|
||||
class _Settings extends SettingsNotifier {
|
||||
@override
|
||||
AppSettings build() => const AppSettings(motionArtworkEnabled: false);
|
||||
}
|
||||
|
||||
class _Collections extends LibraryCollectionsNotifier {
|
||||
@override
|
||||
LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = PerformanceTestBinding.ensureInitialized();
|
||||
testWidgets('Mornye player panel transitions and artwork cache', (
|
||||
tester,
|
||||
) async {
|
||||
final probe = PerformanceProbe(binding, tester, suite: 'mornye-artwork');
|
||||
final directory = await tester.runAsync(getTemporaryDirectory);
|
||||
final cover = File('${directory!.path}/mornye-performance-cover.png');
|
||||
await tester.runAsync(() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
const bounds = Rect.fromLTWH(0, 0, 2400, 2400);
|
||||
canvas.drawRect(
|
||||
bounds,
|
||||
Paint()
|
||||
..shader = const LinearGradient(
|
||||
colors: [Colors.deepPurple, Colors.orange, Colors.cyan],
|
||||
).createShader(bounds),
|
||||
);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(2400, 2400);
|
||||
try {
|
||||
final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!;
|
||||
await cover.writeAsBytes(bytes.buffer.asUint8List());
|
||||
} finally {
|
||||
image.dispose();
|
||||
picture.dispose();
|
||||
}
|
||||
});
|
||||
addTearDown(() async {
|
||||
if (await cover.exists()) await cover.delete();
|
||||
});
|
||||
final item = MediaItem(
|
||||
id: 'performance-cover',
|
||||
title: 'Performance track',
|
||||
artist: 'Fixture artist',
|
||||
duration: const Duration(minutes: 4),
|
||||
artUri: cover.uri,
|
||||
);
|
||||
final theme = MornyeTheme.build(Brightness.dark);
|
||||
final page = ValueNotifier(0);
|
||||
final metadata = PlayerMetadataController();
|
||||
final seek = PlaybackSeekPreview();
|
||||
final foreground = ValueNotifier(<String, Color>{});
|
||||
addTearDown(page.dispose);
|
||||
addTearDown(metadata.dispose);
|
||||
addTearDown(seek.dispose);
|
||||
addTearDown(foreground.dispose);
|
||||
final keys = (
|
||||
header: GlobalKey(),
|
||||
controls: GlobalKey(),
|
||||
volume: GlobalKey(),
|
||||
expanded: GlobalKey(),
|
||||
compact: GlobalKey(),
|
||||
);
|
||||
final cache = PaintingBinding.instance.imageCache;
|
||||
cache.clear();
|
||||
cache.clearLiveImages();
|
||||
Map<String, Object> snapshot() => {
|
||||
'cache_bytes': cache.currentSizeBytes,
|
||||
'cache_count': cache.currentSize,
|
||||
'live_images': cache.liveImageCount,
|
||||
'rss_bytes': ProcessInfo.currentRss,
|
||||
};
|
||||
final snapshots = <String, Map<String, Object>>{'empty': snapshot()};
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
libraryCollectionsProvider.overrideWith(_Collections.new),
|
||||
currentMediaItemProvider.overrideWith((ref) => Stream.value(item)),
|
||||
playQueueProvider.overrideWith((ref) => Stream.value([item])),
|
||||
playbackStateProvider.overrideWith(
|
||||
(ref) => Stream.value(PlaybackState(queueIndex: 0)),
|
||||
),
|
||||
playerCollectionTrackProvider.overrideWith(
|
||||
(ref, item) async => Track(
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
artistName: item.artist ?? '',
|
||||
albumName: '',
|
||||
duration: item.duration?.inSeconds ?? 0,
|
||||
),
|
||||
),
|
||||
systemVolumeProvider.overrideWith((ref) => Stream.value(0.5)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: theme,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: page,
|
||||
builder: (context, value, _) => MornyePlayerLayout(
|
||||
mediaItem: item,
|
||||
metadata: metadata,
|
||||
seekPreview: seek,
|
||||
colorScheme: theme.colorScheme,
|
||||
page: value,
|
||||
motionArtwork: MornyePlayerArtwork(mediaItem: item),
|
||||
insetArtwork: true,
|
||||
artworkTopInset: 0,
|
||||
artworkKeys: keys,
|
||||
artworkForeground: foreground,
|
||||
lyricsControlsHidden: false,
|
||||
lyricsOptionsOpen: false,
|
||||
lyricsBuilder: (_) => ListView.builder(
|
||||
itemCount: 200,
|
||||
itemBuilder: (_, index) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text('Lyric line $index'),
|
||||
),
|
||||
),
|
||||
lyricsOptions: null,
|
||||
onPageChanged: (value) => page.value = value,
|
||||
onTrackNavigation: (_, _) async {},
|
||||
onMoreActions: (_, _, _) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.runAsync(() => probe.wait(const Duration(seconds: 1)));
|
||||
await tester.pump();
|
||||
snapshots['expanded'] = snapshot();
|
||||
await probe.measure('player_lyrics_queue_transitions', () async {
|
||||
for (final value in [1, 0, 2, 0]) {
|
||||
page.value = value;
|
||||
await probe.wait(const Duration(milliseconds: 500));
|
||||
}
|
||||
});
|
||||
snapshots['after_panels'] = snapshot();
|
||||
// Allow the queue's small thumbnail, but not a second full-size decode.
|
||||
expect(
|
||||
cache.currentSizeBytes,
|
||||
lessThan((snapshots['expanded']!['cache_bytes']! as int) * 1.25),
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.runAsync(() => probe.wait(const Duration(milliseconds: 500)));
|
||||
snapshots['unmounted'] = snapshot();
|
||||
await probe.finish(
|
||||
metadata: {
|
||||
'fixture':
|
||||
'Production MornyePlayerLayout, local 2400px static cover; '
|
||||
'synthetic lyrics/queue and no playback or persistent data',
|
||||
'cache_snapshots': snapshots,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
import 'package:spotiflac_android/widgets/player_lyrics.dart';
|
||||
|
||||
import 'performance_probe.dart';
|
||||
|
||||
final _lyrics = ParsedLyrics(
|
||||
synced: true,
|
||||
wordSynced: true,
|
||||
plainText: '',
|
||||
lines: List.generate(200, (index) {
|
||||
final words = ['Fixture ', 'line ', '$index ', 'flows ', 'on'];
|
||||
return LyricLine(
|
||||
time: Duration(seconds: index * 2),
|
||||
end: Duration(seconds: (index + 1) * 2),
|
||||
text: words.join(),
|
||||
words: [
|
||||
for (var word = 0; word < words.length; word++)
|
||||
LyricWord(
|
||||
time: Duration(milliseconds: index * 2000 + word * 400),
|
||||
end: Duration(milliseconds: index * 2000 + (word + 1) * 400),
|
||||
text: words[word],
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
class _Settings extends SettingsNotifier {
|
||||
@override
|
||||
AppSettings build() => const AppSettings();
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = PerformanceTestBinding.ensureInitialized();
|
||||
testWidgets('Mornye production word-synced lyrics and manual scrolling', (
|
||||
tester,
|
||||
) async {
|
||||
final probe = PerformanceProbe(binding, tester, suite: 'mornye_lyrics');
|
||||
final playback = StreamController<PlaybackState>.broadcast();
|
||||
final seek = ValueNotifier<Duration?>(null);
|
||||
final active = ValueNotifier(true);
|
||||
final elapsed = Stopwatch();
|
||||
Timer? timer;
|
||||
void report(bool playing) => playback.add(
|
||||
PlaybackState(
|
||||
playing: playing,
|
||||
processingState: AudioProcessingState.ready,
|
||||
updatePosition: const Duration(seconds: 40) + elapsed.elapsed,
|
||||
),
|
||||
);
|
||||
void stop() {
|
||||
timer?.cancel();
|
||||
timer = null;
|
||||
elapsed.stop();
|
||||
report(false);
|
||||
}
|
||||
|
||||
void start() {
|
||||
elapsed.start();
|
||||
report(true);
|
||||
timer = Timer.periodic(
|
||||
const Duration(milliseconds: 200),
|
||||
(_) => report(true),
|
||||
);
|
||||
}
|
||||
|
||||
addTearDown(() async {
|
||||
timer?.cancel();
|
||||
await playback.close();
|
||||
seek.dispose();
|
||||
active.dispose();
|
||||
});
|
||||
final theme = MornyeTheme.build(Brightness.dark);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
playbackStateProvider.overrideWith((ref) => playback.stream),
|
||||
musicPlayerControllerProvider.overrideWith(
|
||||
(ref) => throw StateError('Unexpected audio controller access'),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: theme,
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: active,
|
||||
builder: (_, value, _) => SyncedLyricsView(
|
||||
lyrics: _lyrics,
|
||||
seekPreview: seek,
|
||||
colorScheme: theme.colorScheme,
|
||||
isActive: value,
|
||||
showPronunciation: false,
|
||||
showTranslation: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
ScrollPosition position() =>
|
||||
tester.state<ScrollableState>(find.byType(Scrollable)).position;
|
||||
Future<void> reset() async {
|
||||
stop();
|
||||
elapsed.reset();
|
||||
active.value = true;
|
||||
seek.value = const Duration(seconds: 40);
|
||||
report(false);
|
||||
await probe.wait(const Duration(milliseconds: 500));
|
||||
seek.value = null;
|
||||
await probe.wait(const Duration(milliseconds: 150));
|
||||
}
|
||||
|
||||
await tester.runAsync(() => probe.wait(const Duration(milliseconds: 100)));
|
||||
await probe.measure('lyrics_word_highlight_and_line_progression', () async {
|
||||
await reset();
|
||||
final before = position().pixels;
|
||||
start();
|
||||
await probe.wait(const Duration(milliseconds: 2300));
|
||||
stop();
|
||||
expect(elapsed.elapsed, greaterThan(const Duration(seconds: 2)));
|
||||
expect(position().pixels, greaterThan(before + 20));
|
||||
});
|
||||
await probe.measure('lyrics_manual_scroll_during_playback', () async {
|
||||
await reset();
|
||||
final before = position().pixels;
|
||||
start();
|
||||
final touch = await tester.startGesture(
|
||||
tester.getCenter(find.byType(ListView)),
|
||||
);
|
||||
try {
|
||||
await touch.moveBy(const Offset(0, -24));
|
||||
await probe.wait(const Duration(milliseconds: 50));
|
||||
await touch.moveBy(const Offset(0, -100));
|
||||
await probe.wait(const Duration(milliseconds: 100));
|
||||
final held = position().pixels;
|
||||
expect(held, greaterThan(before + 30));
|
||||
await probe.wait(const Duration(milliseconds: 2300));
|
||||
expect(elapsed.elapsed, greaterThan(const Duration(seconds: 2)));
|
||||
expect(position().pixels, closeTo(held, 1));
|
||||
} finally {
|
||||
stop();
|
||||
await touch.up();
|
||||
}
|
||||
});
|
||||
await tester.runAsync(() async {
|
||||
stop();
|
||||
active.value = false;
|
||||
await probe.wait(const Duration(milliseconds: 700));
|
||||
});
|
||||
await probe.measure(
|
||||
'lyrics_inactive_paused_idle',
|
||||
() => probe.wait(const Duration(milliseconds: 800)),
|
||||
allowIdle: true,
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.runAsync(() => probe.wait(const Duration(milliseconds: 350)));
|
||||
await probe.finish(
|
||||
metadata: {
|
||||
'scope':
|
||||
'Production SyncedLyricsView; 200 generated five-word lines, '
|
||||
'200ms in-memory playback stream; no audio, metadata, database, '
|
||||
'preferences or network. Active samples include a 650ms paused '
|
||||
'seek reset to 40s. Idle is inactive and paused.',
|
||||
'lineCount': _lyrics.lines.length,
|
||||
'wordsPerLine': 5,
|
||||
'manualScrollTolerancePixels': 1,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'mornye_artwork_performance_test.dart' as artwork;
|
||||
import 'mornye_lyrics_performance_test.dart' as lyrics;
|
||||
import 'mornye_rendering_performance_test.dart' as rendering;
|
||||
import 'mornye_screens_performance_test.dart' as screens;
|
||||
import 'performance_probe.dart';
|
||||
|
||||
void main() {
|
||||
PerformanceTestBinding.ensureInitialized();
|
||||
rendering.main();
|
||||
screens.main();
|
||||
artwork.main();
|
||||
lyrics.main();
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mini_player.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_liquid_backdrop.dart';
|
||||
|
||||
import 'performance_probe.dart';
|
||||
|
||||
void main() {
|
||||
final binding = PerformanceTestBinding.ensureInitialized();
|
||||
for (final clarity in [0.0, 0.1, 0.6, 1.0]) {
|
||||
testWidgets('Mornye real rendering at clarity $clarity', (tester) async {
|
||||
final probe = PerformanceProbe(
|
||||
binding,
|
||||
tester,
|
||||
suite: 'mornye_chrome_${(clarity * 100).round()}',
|
||||
);
|
||||
final fixture = (await tester.runAsync(_Fixture.create))!;
|
||||
addTearDown(() async {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.runAsync(() => binding.endOfFrame);
|
||||
await fixture.dispose();
|
||||
});
|
||||
await tester.pumpWidget(fixture.app(clarity));
|
||||
await tester.runAsync(() async {
|
||||
await LiquidGlassShaders.ensureLoaded();
|
||||
await ui.FragmentProgram.fromAsset(
|
||||
'assets/shaders/mornye_selection_mask.frag',
|
||||
);
|
||||
await probe.wait(const Duration(milliseconds: 600));
|
||||
});
|
||||
expect(find.byType(MornyeBottomBar), findsOneWidget);
|
||||
expect(find.byType(MiniPlayer), findsOneWidget);
|
||||
final player = tester.state(find.byType(MiniPlayer));
|
||||
|
||||
for (final compact in [false, true]) {
|
||||
// Returning to the top expands production chrome, including during a
|
||||
// programmatic scroll. Keep both scenarios above that boundary.
|
||||
fixture.scroll.jumpTo(300);
|
||||
fixture.chrome.value = compact;
|
||||
await probe.wait(const Duration(milliseconds: 450));
|
||||
await probe.measure(
|
||||
compact ? 'compact_scroll' : 'full_scroll',
|
||||
() async {
|
||||
await probe.scroll(fixture.scroll, distance: 1800);
|
||||
await probe.scroll(fixture.scroll, distance: -1800);
|
||||
},
|
||||
);
|
||||
expect(fixture.chrome.value, compact);
|
||||
expect(fixture.scroll.offset, closeTo(300, 0.1));
|
||||
}
|
||||
|
||||
await probe.measure('collapse_expand', () async {
|
||||
fixture.chrome.value = true;
|
||||
await probe.wait(const Duration(milliseconds: 450));
|
||||
fixture.chrome.expand();
|
||||
await probe.wait(const Duration(milliseconds: 450));
|
||||
});
|
||||
expect(tester.state(find.byType(MiniPlayer)), same(player));
|
||||
expect(fixture.chrome.value, isFalse);
|
||||
|
||||
await probe.measure('tab_changes', () async {
|
||||
for (final label in ['Library', 'Repo', 'Search', 'Home']) {
|
||||
final tab = find.descendant(
|
||||
of: find.byType(MornyeTabBar),
|
||||
matching: find.text(label),
|
||||
);
|
||||
await tester.tapAt(tester.getCenter(tab.first));
|
||||
await probe.wait(const Duration(milliseconds: 300));
|
||||
}
|
||||
});
|
||||
expect(fixture.activeTab.value, 0);
|
||||
|
||||
if (clarity > 0.2) {
|
||||
await probe.measure('held_lens_drag', () async {
|
||||
final tabs = find.byType(MornyeTabBar);
|
||||
final home = tester.getCenter(
|
||||
find.descendant(of: tabs, matching: find.text('Home')).first,
|
||||
);
|
||||
final search = tester.getCenter(
|
||||
find.descendant(of: tabs, matching: find.text('Search')).first,
|
||||
);
|
||||
final gesture = await tester.startGesture(home);
|
||||
await probe.wait(const Duration(milliseconds: 280));
|
||||
final lens = find.byWidgetPredicate(
|
||||
(widget) => widget is MornyeLiquidBackdrop && widget.interaction,
|
||||
);
|
||||
if (ui.ImageFilter.isShaderFilterSupported) {
|
||||
expect(tester.widget<MornyeLiquidBackdrop>(lens).progress, 1);
|
||||
}
|
||||
for (var step = 1; step <= 60; step++) {
|
||||
final fraction = (1 - math.cos(step / 60 * math.pi * 2)) / 2;
|
||||
await gesture.moveTo(Offset.lerp(home, search, fraction)!);
|
||||
await probe.wait(const Duration(milliseconds: 16));
|
||||
}
|
||||
await gesture.up();
|
||||
await probe.wait(const Duration(milliseconds: 300));
|
||||
});
|
||||
expect(fixture.activeTab.value, 0);
|
||||
}
|
||||
|
||||
fixture.setLongTitle(true);
|
||||
await probe.wait(const Duration(milliseconds: 100));
|
||||
await probe.measure('mini_player_marquee', () async {
|
||||
await probe.wait(const Duration(seconds: 4));
|
||||
});
|
||||
fixture.setLongTitle(false);
|
||||
await probe.wait(const Duration(milliseconds: 400));
|
||||
await probe.measure('short_title_idle', () async {
|
||||
await probe.wait(const Duration(milliseconds: 1500));
|
||||
}, allowIdle: true);
|
||||
expect(tester.takeException(), isNull);
|
||||
await probe.finish(
|
||||
metadata: {
|
||||
'glass_clarity': clarity,
|
||||
'glass_level': 'liquid',
|
||||
'tracks': 2000,
|
||||
'unique_local_covers': fixture.covers.length,
|
||||
'scope':
|
||||
'production Mornye chrome and MiniPlayer over a fixture grid',
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _BenchmarkSettings extends SettingsNotifier {
|
||||
@override
|
||||
AppSettings build() => const AppSettings(motionArtworkEnabled: false);
|
||||
}
|
||||
|
||||
class _Fixture {
|
||||
_Fixture(this.directory, this.covers, this.artUri);
|
||||
|
||||
final Directory directory;
|
||||
final List<Uint8List> covers;
|
||||
final Uri artUri;
|
||||
final chrome = MornyeChromeController();
|
||||
final scroll = ScrollController();
|
||||
final activeTab = ValueNotifier<int>(0);
|
||||
final media = StreamController<MediaItem?>.broadcast(sync: true);
|
||||
|
||||
static Future<_Fixture> create() async {
|
||||
final temp = await getTemporaryDirectory();
|
||||
final directory = await temp.createTemp('mornye-benchmark-art-');
|
||||
final covers = <Uint8List>[];
|
||||
for (var index = 0; index < 24; index++) {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
final hue = index * 137.5 % 360;
|
||||
canvas.drawRect(
|
||||
const Rect.fromLTWH(0, 0, 192, 192),
|
||||
Paint()
|
||||
..shader = ui.Gradient.linear(Offset.zero, const Offset(192, 192), [
|
||||
HSLColor.fromAHSL(1, hue, 0.75, 0.25).toColor(),
|
||||
HSLColor.fromAHSL(1, (hue + 80) % 360, 0.85, 0.65).toColor(),
|
||||
]),
|
||||
);
|
||||
for (var stripe = 0; stripe < 8; stripe++) {
|
||||
canvas.drawCircle(
|
||||
Offset(24.0 * stripe, (index * 13 + stripe * 23) % 192.0),
|
||||
10 + stripe * 4.0,
|
||||
Paint()..color = Colors.white.withValues(alpha: 0.12),
|
||||
);
|
||||
}
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(192, 192);
|
||||
final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!;
|
||||
covers.add(
|
||||
bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes),
|
||||
);
|
||||
image.dispose();
|
||||
picture.dispose();
|
||||
}
|
||||
final artwork = File('${directory.path}/cover.png');
|
||||
await artwork.writeAsBytes(covers.first);
|
||||
return _Fixture(directory, covers, artwork.uri);
|
||||
}
|
||||
|
||||
MediaItem _item(bool longTitle) => MediaItem(
|
||||
id: longTitle ? 'benchmark-marquee' : 'benchmark-idle',
|
||||
title: longTitle
|
||||
? 'A very long benchmark track title that keeps the production marquee moving'
|
||||
: 'Track 001',
|
||||
artist: longTitle
|
||||
? 'Benchmark artist with an overflowing display name'
|
||||
: 'Artist',
|
||||
album: 'Fixture album',
|
||||
artUri: artUri,
|
||||
duration: const Duration(minutes: 4),
|
||||
);
|
||||
|
||||
void setLongTitle(bool value) => media.add(_item(value));
|
||||
|
||||
Widget app(double clarity) => ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_BenchmarkSettings.new),
|
||||
currentMediaItemProvider.overrideWith((ref) async* {
|
||||
yield _item(false);
|
||||
yield* media.stream;
|
||||
}),
|
||||
playbackStateProvider.overrideWith((ref) => const Stream.empty()),
|
||||
musicPlayerControllerProvider.overrideWithValue(MusicPlayerController()),
|
||||
lowEndDeviceProvider.overrideWithValue(false),
|
||||
backdropBlurEnabledProvider.overrideWithValue(true),
|
||||
mornyeGlassLevelProvider.overrideWithValue(MornyeGlassLevel.liquid),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(Brightness.dark, glassClarity: clarity),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
extendBody: true,
|
||||
body: NotificationListener<ScrollNotification>(
|
||||
onNotification: chrome.handleScroll,
|
||||
child: GridView.builder(
|
||||
controller: scroll,
|
||||
padding: const EdgeInsets.fromLTRB(16, 64, 16, 184),
|
||||
itemCount: 2000,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.80,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
),
|
||||
itemBuilder: (context, index) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.memory(
|
||||
covers[index % covers.length],
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 192,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Track ${index + 1}',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
Text(
|
||||
'Artist ${index % 40}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge([chrome, activeTab]),
|
||||
builder: (context, child) => MornyeBottomBar(
|
||||
collapsed: chrome.value,
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.music_note),
|
||||
label: 'Library',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.grid_view),
|
||||
label: 'Repo',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
),
|
||||
],
|
||||
selectedIndex: activeTab.value,
|
||||
onSelected: (index) {
|
||||
activeTab.value = index;
|
||||
chrome.expand();
|
||||
},
|
||||
onHome: () {
|
||||
activeTab.value = 0;
|
||||
chrome.expand();
|
||||
},
|
||||
onSearch: () => activeTab.value = 3,
|
||||
blurEnabled: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> dispose() async {
|
||||
final imageCache = PaintingBinding.instance.imageCache;
|
||||
for (final image in [
|
||||
for (final cover in covers)
|
||||
ResizeImage.resizeIfNeeded(192, null, MemoryImage(cover)),
|
||||
ResizeImage.resizeIfNeeded(132, null, FileImage(File.fromUri(artUri))),
|
||||
]) {
|
||||
final key = await image.obtainKey(ImageConfiguration.empty);
|
||||
expect(imageCache.statusForKey(key).live, isFalse);
|
||||
imageCache.evict(key);
|
||||
expect(imageCache.statusForKey(key).untracked, isTrue);
|
||||
}
|
||||
await media.close();
|
||||
chrome.dispose();
|
||||
scroll.dispose();
|
||||
activeTab.dispose();
|
||||
await directory.delete(recursive: true);
|
||||
debugPrint(
|
||||
'Mornye fixture cache after cleanup: ${imageCache.currentSizeBytes} bytes',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
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:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/explore_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_browse_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/user_profile_provider.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||
import 'package:spotiflac_android/services/album_completeness.dart';
|
||||
import 'package:spotiflac_android/services/user_profile_store.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_search_field.dart';
|
||||
import 'package:spotiflac_android/widgets/lazy_tab_view.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
import 'performance_probe.dart';
|
||||
|
||||
final _tracks = List.generate(
|
||||
2000,
|
||||
(index) => Track(
|
||||
id: 'fixture-track-$index',
|
||||
name: 'Fixture song $index',
|
||||
artistName: 'Artist ${index ~/ 100}',
|
||||
albumName: 'Album ${(index ~/ 10).toString().padLeft(3, '0')}',
|
||||
duration: 240,
|
||||
source: 'example',
|
||||
),
|
||||
);
|
||||
|
||||
final _albums = List.generate(
|
||||
200,
|
||||
(index) => LibraryBrowseEntry(
|
||||
source: 'downloaded',
|
||||
key: 'fixture-album-$index',
|
||||
name: _tracks[index * 10].albumName,
|
||||
artist: _tracks[index * 10].artistName,
|
||||
samplePath: '',
|
||||
trackCount: 10,
|
||||
completeness: AlbumCompleteness(
|
||||
status: ['complete', 'incomplete', 'unknown'][index % 3],
|
||||
present: 10,
|
||||
expected: index % 3 == 2
|
||||
? null
|
||||
: index % 3 == 1
|
||||
? 12
|
||||
: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
final binding = PerformanceTestBinding.ensureInitialized();
|
||||
|
||||
testWidgets('Mornye production screens with deterministic 2000-track data', (
|
||||
tester,
|
||||
) async {
|
||||
final probe = PerformanceProbe(binding, tester, suite: 'mornye_screens');
|
||||
final preferences = (await tester.runAsync(SharedPreferences.getInstance))!;
|
||||
final settingsBefore = preferences.getString('app_settings');
|
||||
const databaseChannel = MethodChannel('com.tekartik.sqflite');
|
||||
final databaseCalls = <String>[];
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(databaseChannel, (call) async {
|
||||
databaseCalls.add(call.method);
|
||||
fail('Unexpected database call: ${call.method}');
|
||||
});
|
||||
addTearDown(
|
||||
() => messenger.setMockMethodCallHandler(databaseChannel, null),
|
||||
);
|
||||
final index = ValueNotifier(0);
|
||||
addTearDown(index.dispose);
|
||||
final requests = <LibraryBrowseRequest>[];
|
||||
final search = _Search();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
libraryCollectionsProvider.overrideWith(_Collections.new),
|
||||
extensionProvider.overrideWith(_Extensions.new),
|
||||
exploreProvider.overrideWith(_Explore.new),
|
||||
downloadHistoryProvider.overrideWith(_History.new),
|
||||
downloadHistoryBatchExistsProvider.overrideWith(
|
||||
(ref, request) async => <String>{},
|
||||
),
|
||||
downloadQueueProvider.overrideWith(_Queue.new),
|
||||
recentAccessProvider.overrideWith(_Recent.new),
|
||||
trackProvider.overrideWith(() => search),
|
||||
userProfileProvider.overrideWith(_Profile.new),
|
||||
currentMediaItemProvider.overrideWith((ref) => Stream.value(null)),
|
||||
libraryBrowseProvider.overrideWith((ref, request) async {
|
||||
requests.add(request);
|
||||
final rows = _albums.where((album) {
|
||||
return '${album.name} ${album.artist}'.toLowerCase().contains(
|
||||
request.search.toLowerCase(),
|
||||
) &&
|
||||
(request.completeness == null ||
|
||||
album.completeness!.status ==
|
||||
(request.completeness == incompleteAlbumFilter
|
||||
? 'incomplete'
|
||||
: 'unknown'));
|
||||
}).toList();
|
||||
if (request.sort == 'latest') {
|
||||
return rows.reversed.take(request.limit).toList();
|
||||
}
|
||||
return rows.take(request.limit).toList();
|
||||
}),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(Brightness.dark),
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: _Tabs(index: index),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.runAsync(() => probe.wait(const Duration(seconds: 1)));
|
||||
|
||||
Future<void> select(int value) async {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
index.value = value;
|
||||
await probe.wait(const Duration(milliseconds: 350));
|
||||
}
|
||||
|
||||
Future<void> scrollRoundTrip() async {
|
||||
final scrollable = find.byType(Scrollable).hitTestable().first;
|
||||
final position = tester.state<ScrollableState>(scrollable).position;
|
||||
position.jumpTo(0);
|
||||
await probe.wait(const Duration(milliseconds: 100));
|
||||
await position.animateTo(
|
||||
position.maxScrollExtent.clamp(0.0, 2400.0),
|
||||
duration: const Duration(milliseconds: 900),
|
||||
curve: Curves.linear,
|
||||
);
|
||||
await position.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
curve: Curves.linear,
|
||||
);
|
||||
await probe.wait(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
await tester.runAsync(() async {
|
||||
await select(1);
|
||||
expect(find.byType(QueueTab), findsOneWidget);
|
||||
await tester.tap(find.text('Albums').hitTestable().first);
|
||||
await probe.wait(const Duration(milliseconds: 500));
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.byType(Scrollable).hitTestable().first)
|
||||
.position;
|
||||
for (var page = 0; page < 4; page++) {
|
||||
position.jumpTo(position.maxScrollExtent);
|
||||
await probe.wait(const Duration(milliseconds: 250));
|
||||
}
|
||||
position.jumpTo(0);
|
||||
expect(requests.last.limit, 200);
|
||||
});
|
||||
await probe.measure(
|
||||
'library_album_grid_scroll_2000_tracks',
|
||||
scrollRoundTrip,
|
||||
warmup: scrollRoundTrip,
|
||||
);
|
||||
|
||||
Future<void> filterRoundTrip() async {
|
||||
for (final label in [
|
||||
'Incomplete albums',
|
||||
'Album completeness unknown',
|
||||
'All',
|
||||
]) {
|
||||
await tester.tap(find.byTooltip('Filters').hitTestable());
|
||||
await probe.wait(const Duration(milliseconds: 350));
|
||||
await tester.tap(find.text(label).hitTestable().last);
|
||||
await probe.wait(const Duration(milliseconds: 350));
|
||||
}
|
||||
expect(requests.last.completeness, isNull);
|
||||
}
|
||||
|
||||
await probe.measure(
|
||||
'library_album_completeness_filters',
|
||||
filterRoundTrip,
|
||||
warmup: filterRoundTrip,
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.byTooltip('Search your library').hitTestable());
|
||||
await probe.wait(const Duration(milliseconds: 400));
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
});
|
||||
Future<void> librarySearchRoundTrip() async {
|
||||
final field = tester.widget<AppSearchField>(
|
||||
find.byType(AppSearchField).hitTestable(),
|
||||
);
|
||||
field.controller.text = 'Album 01';
|
||||
field.onChanged?.call('Album 01');
|
||||
await probe.wait(const Duration(milliseconds: 500));
|
||||
expect(requests.last.search, 'Album 01');
|
||||
await tester.tap(find.byTooltip('Clear').hitTestable());
|
||||
await probe.wait(const Duration(milliseconds: 500));
|
||||
expect(requests.last.search, isEmpty);
|
||||
}
|
||||
|
||||
await probe.measure(
|
||||
'library_album_search_clear',
|
||||
librarySearchRoundTrip,
|
||||
warmup: librarySearchRoundTrip,
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
Navigator.of(
|
||||
tester.element(find.byType(Scrollable).hitTestable().first),
|
||||
).pop();
|
||||
await probe.wait(const Duration(milliseconds: 500));
|
||||
await select(3);
|
||||
});
|
||||
await probe.measure(
|
||||
'queue_downloads_scroll_2000_tracks',
|
||||
scrollRoundTrip,
|
||||
warmup: scrollRoundTrip,
|
||||
);
|
||||
|
||||
await tester.runAsync(() => select(2));
|
||||
Future<void> searchRoundTrip() async {
|
||||
final field = tester.widget<AppSearchField>(
|
||||
find.byType(AppSearchField).hitTestable(),
|
||||
);
|
||||
for (final query in ['Fixture', 'Fixture song', 'Fixture song 1']) {
|
||||
field.controller.text = query;
|
||||
field.onChanged?.call(query);
|
||||
await probe.wait(const Duration(milliseconds: 100));
|
||||
}
|
||||
await probe.wait(const Duration(milliseconds: 900));
|
||||
expect(search.resultCount, greaterThan(0));
|
||||
await tester.tap(find.byTooltip('Clear').hitTestable());
|
||||
await probe.wait(const Duration(milliseconds: 400));
|
||||
expect(field.controller.text, isEmpty);
|
||||
}
|
||||
|
||||
await probe.measure(
|
||||
'home_search_input_results_clear',
|
||||
searchRoundTrip,
|
||||
warmup: searchRoundTrip,
|
||||
);
|
||||
Future<void> tabRoundTrip() async {
|
||||
for (final value in [0, 1, 2, 3, 0]) {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(MornyeTabBar),
|
||||
matching: find.text(
|
||||
['Home', 'Library', 'Search', 'Downloads'][value],
|
||||
),
|
||||
),
|
||||
);
|
||||
await probe.wait(const Duration(milliseconds: 350));
|
||||
expect(index.value, value);
|
||||
}
|
||||
}
|
||||
|
||||
await probe.measure(
|
||||
'lazy_tab_view_retained_production_tabs',
|
||||
tabRoundTrip,
|
||||
warmup: tabRoundTrip,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.runAsync(() async {
|
||||
await probe.wait(const Duration(milliseconds: 350));
|
||||
await preferences.reload();
|
||||
});
|
||||
expect(preferences.getString('app_settings'), settingsBefore);
|
||||
expect(databaseCalls, isEmpty);
|
||||
await probe.finish(
|
||||
metadata: {
|
||||
'scope':
|
||||
'Production QueueTab, MornyeLibraryScreen, HomeTab, LazyTabView and MornyeTabBar; synthetic provider data, no artwork/network/database/IME.',
|
||||
'trackCount': _tracks.length,
|
||||
'albumCount': _albums.length,
|
||||
'queueCount': 2000,
|
||||
'searchResultLimit': 80,
|
||||
'browseRequests': requests.length,
|
||||
'maximumBrowseLimit': requests
|
||||
.map((request) => request.limit)
|
||||
.reduce((a, b) => a > b ? a : b),
|
||||
'searchRequests': search.requests,
|
||||
'settingsUnchanged': true,
|
||||
'databaseCalls': databaseCalls.length,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _Tabs extends StatelessWidget {
|
||||
const _Tabs({required this.index});
|
||||
final ValueNotifier<int> index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ValueListenableBuilder<int>(
|
||||
valueListenable: index,
|
||||
builder: (context, value, _) => Scaffold(
|
||||
body: LazyTabView(
|
||||
index: value,
|
||||
preloadKeys: {const ValueKey('search')},
|
||||
children: const [
|
||||
HomeTab(key: ValueKey('home'), mode: HomeTabMode.browse),
|
||||
QueueTab(key: ValueKey('library')),
|
||||
HomeTab(key: ValueKey('search'), mode: HomeTabMode.search),
|
||||
QueueTab(key: ValueKey('downloads'), librarySection: 'downloads'),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: MornyeTabBar(
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.library_music),
|
||||
label: 'Library',
|
||||
),
|
||||
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
|
||||
NavigationDestination(icon: Icon(Icons.download), label: 'Downloads'),
|
||||
],
|
||||
selectedIndex: value,
|
||||
blurEnabled: true,
|
||||
onSelected: (value) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
index.value = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Settings extends SettingsNotifier {
|
||||
@override
|
||||
AppSettings build() => const AppSettings(
|
||||
searchProvider: 'example',
|
||||
hasSearchedBefore: true,
|
||||
defaultLibraryView: 'all',
|
||||
);
|
||||
|
||||
@override
|
||||
void setHistoryFilterMode(String mode) {
|
||||
state = state.copyWith(historyFilterMode: mode);
|
||||
}
|
||||
|
||||
@override
|
||||
void setHasSearchedBefore() {
|
||||
if (!state.hasSearchedBefore) {
|
||||
state = state.copyWith(hasSearchedBefore: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void setSearchProvider(String? provider) {
|
||||
state = state.copyWith(
|
||||
searchProvider: provider,
|
||||
clearSearchProvider: provider == null || provider.isEmpty,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Collections extends LibraryCollectionsNotifier {
|
||||
@override
|
||||
LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true);
|
||||
}
|
||||
|
||||
class _Extensions extends ExtensionNotifier {
|
||||
@override
|
||||
ExtensionState build() => const ExtensionState(
|
||||
isInitialized: true,
|
||||
extensions: [
|
||||
Extension(
|
||||
id: 'example',
|
||||
name: 'example',
|
||||
displayName: 'Example',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
enabled: true,
|
||||
status: 'loaded',
|
||||
hasMetadataProvider: true,
|
||||
searchBehavior: SearchBehavior(enabled: true),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Explore extends ExploreNotifier {
|
||||
@override
|
||||
ExploreState build() => const ExploreState(
|
||||
sections: [
|
||||
ExploreSection(
|
||||
uri: 'example:featured',
|
||||
title: 'Featured albums',
|
||||
items: [],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _History extends DownloadHistoryNotifier {
|
||||
@override
|
||||
DownloadHistoryState build() => DownloadHistoryState();
|
||||
|
||||
@override
|
||||
Future<void> reloadFromStorage() async {}
|
||||
}
|
||||
|
||||
class _Queue extends DownloadQueueNotifier {
|
||||
@override
|
||||
DownloadQueueState build() => const DownloadQueueState().copyWith(
|
||||
items: [
|
||||
for (final track in _tracks)
|
||||
DownloadItem(
|
||||
id: track.id,
|
||||
track: track,
|
||||
service: 'example',
|
||||
createdAt: DateTime(2026),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Recent extends RecentAccessNotifier {
|
||||
@override
|
||||
RecentAccessState build() => const RecentAccessState(isLoaded: true);
|
||||
}
|
||||
|
||||
class _Profile extends UserProfileNotifier {
|
||||
@override
|
||||
Future<UserProfile> build() async => const UserProfile();
|
||||
}
|
||||
|
||||
class _Search extends TrackNotifier {
|
||||
int requests = 0;
|
||||
int get resultCount => state.tracks.length;
|
||||
|
||||
@override
|
||||
Future<void> customSearch(
|
||||
String extensionId,
|
||||
String query, {
|
||||
Map<String, dynamic>? options,
|
||||
String? selectedFilter,
|
||||
bool allowVerificationRetry = true,
|
||||
}) async {
|
||||
requests++;
|
||||
state = TrackState(
|
||||
hasSearchText: true,
|
||||
searchExtensionId: 'example',
|
||||
tracks: _tracks
|
||||
.where(
|
||||
(track) => track.name.toLowerCase().contains(query.toLowerCase()),
|
||||
)
|
||||
.take(80)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:integration_test/integration_test_driver.dart';
|
||||
|
||||
/// Run on dedicated emulators with --keep-app-running: `flutter drive` otherwise
|
||||
/// uninstalls the app when it finishes. Use --profile on Android and --debug on
|
||||
/// iOS simulators. SPOTIFLAC_PERFORMANCE_REPORT selects the host JSON output;
|
||||
/// the aggregate target is integration_test/mornye_performance_test.dart.
|
||||
Future<void> main() => integrationDriver(
|
||||
responseDataCallback: (data) async {
|
||||
final output = File(
|
||||
Platform.environment['SPOTIFLAC_PERFORMANCE_REPORT'] ??
|
||||
'/tmp/spotiflac-performance.json',
|
||||
);
|
||||
await output.writeAsString(
|
||||
const JsonEncoder.withIndent(' ').convert(data),
|
||||
);
|
||||
stdout.writeln('Performance report: ${output.path}');
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Live test bindings otherwise request another engine frame after every draw,
|
||||
/// even when the app is idle. Service only frames requested by the framework.
|
||||
class PerformanceTestBinding extends IntegrationTestWidgetsFlutterBinding {
|
||||
static PerformanceTestBinding? _benchmarkInstance;
|
||||
|
||||
static PerformanceTestBinding ensureInitialized() =>
|
||||
_benchmarkInstance ??= PerformanceTestBinding();
|
||||
|
||||
bool _requestedFrame = false;
|
||||
|
||||
@override
|
||||
void handleBeginFrame(Duration? rawTimeStamp) {
|
||||
_requestedFrame = hasScheduledFrame || rawTimeStamp == null;
|
||||
if (_requestedFrame) super.handleBeginFrame(rawTimeStamp);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleDrawFrame() {
|
||||
if (_requestedFrame) super.handleDrawFrame();
|
||||
_requestedFrame = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Device-only measurements: the engine drives frames, never a pumped clock.
|
||||
class PerformanceProbe {
|
||||
PerformanceProbe(this.binding, this.tester, {required this.suite}) {
|
||||
final previousPolicy = binding.framePolicy;
|
||||
binding.framePolicy =
|
||||
LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive;
|
||||
addTearDown(() => binding.framePolicy = previousPolicy);
|
||||
}
|
||||
|
||||
final IntegrationTestWidgetsFlutterBinding binding;
|
||||
final WidgetTester tester;
|
||||
final String suite;
|
||||
final List<Map<String, Object?>> _scenarios = [];
|
||||
static const _budgetUs = 1000000 / 60;
|
||||
|
||||
/// Also safe outside [measure]: integration bindings use real timers.
|
||||
Future<void> wait(Duration duration) => Future<void>.delayed(duration);
|
||||
|
||||
Future<void> scroll(
|
||||
ScrollController controller, {
|
||||
required double distance,
|
||||
Duration duration = const Duration(milliseconds: 900),
|
||||
}) async {
|
||||
final position = controller.position;
|
||||
await controller.animateTo(
|
||||
(position.pixels + distance).clamp(
|
||||
position.minScrollExtent,
|
||||
position.maxScrollExtent,
|
||||
),
|
||||
duration: duration,
|
||||
curve: Curves.linear,
|
||||
);
|
||||
await wait(const Duration(milliseconds: 150));
|
||||
}
|
||||
|
||||
Future<void> measure(
|
||||
String name,
|
||||
Future<void> Function() action, {
|
||||
Future<void> Function()? warmup,
|
||||
int repetitions = 3,
|
||||
bool allowIdle = false,
|
||||
}) async {
|
||||
assert(repetitions > 0);
|
||||
final runs = <Map<String, Object?>>[];
|
||||
final allFrames = <ui.FrameTiming>[];
|
||||
await tester.runAsync(() async {
|
||||
await (warmup ?? action)();
|
||||
await wait(const Duration(milliseconds: 300));
|
||||
for (var repetition = 0; repetition < repetitions; repetition++) {
|
||||
final frames = <ui.FrameTiming>[];
|
||||
void collect(List<ui.FrameTiming> batch) => frames.addAll(batch);
|
||||
binding.addTimingsCallback(collect);
|
||||
final startUs = binding.currentSystemFrameTimeStamp.inMicroseconds;
|
||||
final rssBefore = ProcessInfo.currentRss;
|
||||
final elapsed = Stopwatch()..start();
|
||||
late int endUs;
|
||||
late int rssAfter;
|
||||
try {
|
||||
await action();
|
||||
elapsed.stop();
|
||||
endUs = binding.currentSystemFrameTimeStamp.inMicroseconds;
|
||||
rssAfter = ProcessInfo.currentRss;
|
||||
// Engine timing callbacks can arrive in batches once per second.
|
||||
// Exclude the drain's frames using the recorded vsync boundaries.
|
||||
await wait(const Duration(milliseconds: 1100));
|
||||
} finally {
|
||||
binding.removeTimingsCallback(collect);
|
||||
}
|
||||
frames.removeWhere((frame) {
|
||||
final vsync = frame.timestampInMicroseconds(ui.FramePhase.vsyncStart);
|
||||
return vsync <= startUs || vsync > endUs;
|
||||
});
|
||||
if (!allowIdle && frames.isEmpty) {
|
||||
fail('$name produced no real engine FrameTiming samples');
|
||||
}
|
||||
allFrames.addAll(frames);
|
||||
runs.add({
|
||||
'repetition': repetition + 1,
|
||||
'elapsed_ms': elapsed.elapsedMicroseconds / 1000,
|
||||
'rss_before_bytes': rssBefore,
|
||||
'rss_after_bytes': rssAfter,
|
||||
'rss_delta_bytes': rssAfter - rssBefore,
|
||||
..._summarize(frames),
|
||||
'samples_us': [
|
||||
for (final frame in frames)
|
||||
[
|
||||
frame.buildDuration.inMicroseconds,
|
||||
frame.rasterDuration.inMicroseconds,
|
||||
frame.totalSpan.inMicroseconds,
|
||||
],
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
expect(tester.takeException(), isNull, reason: name);
|
||||
expect(runs, hasLength(repetitions), reason: name);
|
||||
final scenario = <String, Object?>{
|
||||
'name': name,
|
||||
'runs': runs,
|
||||
'summary': _summarize(allFrames),
|
||||
};
|
||||
_scenarios.add(scenario);
|
||||
debugPrint(
|
||||
'PERFORMANCE_SCENARIO: ${jsonEncode({'name': name, ..._summarize(allFrames), 'rss_delta_bytes': runs.map((run) => run['rss_delta_bytes']).toList()})}',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _summarize(List<ui.FrameTiming> frames) {
|
||||
final build = frames.map((frame) => frame.buildDuration.inMicroseconds);
|
||||
final raster = frames.map((frame) => frame.rasterDuration.inMicroseconds);
|
||||
final total = frames.map((frame) => frame.totalSpan.inMicroseconds);
|
||||
final missed = frames
|
||||
.where(
|
||||
(frame) =>
|
||||
math.max(
|
||||
frame.buildDuration.inMicroseconds,
|
||||
frame.rasterDuration.inMicroseconds,
|
||||
) >
|
||||
_budgetUs,
|
||||
)
|
||||
.length;
|
||||
return {
|
||||
'frames': frames.length,
|
||||
'build_ms': _percentiles(build),
|
||||
'raster_ms': _percentiles(raster),
|
||||
'total_span_ms': _percentiles(total),
|
||||
'over_16_67ms_frames': missed,
|
||||
'over_16_67ms_percent': frames.isEmpty ? 0 : missed * 100 / frames.length,
|
||||
'build_over_16_67ms_frames': build
|
||||
.where((time) => time > _budgetUs)
|
||||
.length,
|
||||
'raster_over_16_67ms_frames': raster
|
||||
.where((time) => time > _budgetUs)
|
||||
.length,
|
||||
'total_over_16_67ms_frames': total
|
||||
.where((time) => time > _budgetUs)
|
||||
.length,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, double?> _percentiles(Iterable<int> values) {
|
||||
final sorted = values.toList()..sort();
|
||||
double? at(double fraction) => sorted.isEmpty
|
||||
? null
|
||||
: sorted[math.max(0, (sorted.length * fraction).ceil() - 1)] / 1000;
|
||||
return {
|
||||
'p50': at(0.50),
|
||||
'p90': at(0.90),
|
||||
'p95': at(0.95),
|
||||
'p99': at(0.99),
|
||||
'max': at(1),
|
||||
};
|
||||
}
|
||||
|
||||
Future<File> finish({Map<String, Object?> metadata = const {}}) async {
|
||||
final report = <String, Object?>{
|
||||
'suite': suite,
|
||||
'utc': DateTime.now().toUtc().toIso8601String(),
|
||||
'platform': Platform.operatingSystem,
|
||||
'mode': kProfileMode
|
||||
? 'profile'
|
||||
: kDebugMode
|
||||
? 'debug'
|
||||
: 'release',
|
||||
'frame_policy': binding.framePolicy.name,
|
||||
'framework_requested_frames_only': binding is PerformanceTestBinding,
|
||||
'budget_hz': 60,
|
||||
'budget_ms': _budgetUs / 1000,
|
||||
'budget_basis': 'fixed 60 Hz reference, independent of display refresh',
|
||||
'over_budget_definition':
|
||||
'max(buildDuration, rasterDuration) > fixed budget',
|
||||
'timing_scope': 'engine FrameTiming phases, not displayed FPS',
|
||||
'sample_columns': ['build', 'raster', 'total_span'],
|
||||
'refresh_hz': tester.view.display.refreshRate,
|
||||
'physical_width': tester.view.physicalSize.width,
|
||||
'physical_height': tester.view.physicalSize.height,
|
||||
'device_pixel_ratio': tester.view.devicePixelRatio,
|
||||
'shader_filters_supported': ui.ImageFilter.isShaderFilterSupported,
|
||||
'image_cache_bytes': PaintingBinding.instance.imageCache.currentSizeBytes,
|
||||
'rss_bytes': ProcessInfo.currentRss,
|
||||
'max_rss_bytes': ProcessInfo.maxRss,
|
||||
...metadata,
|
||||
'scenarios': _scenarios,
|
||||
};
|
||||
final directory = await getTemporaryDirectory();
|
||||
final file = File('${directory.path}/$suite-performance.json');
|
||||
await file.writeAsString(
|
||||
const JsonEncoder.withIndent(' ').convert(report),
|
||||
);
|
||||
binding.reportData ??= <String, dynamic>{};
|
||||
binding.reportData![suite] = report;
|
||||
binding.reportData!['${suite}_report_path'] = file.path;
|
||||
debugPrint('PERFORMANCE_REPORT: ${file.path}');
|
||||
return file;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user