test(performance): benchmark Mornye on iOS and Android emulators

This commit is contained in:
zarzet committed 2026-10-05 19:41:47 +07:00
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(),
);
}
}
+21
View File
@@ -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}');
},
);
+230
View File
@@ -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;
}
}