mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
test(glass): capture native Mornye poses and layer counts
This commit is contained in:
1 parent
8bd1a998d0
commit
a56f206c43
3 files changed
+361
-15
No files matched your search
@@ -0,0 +1,313 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_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/services/music_player_runtime.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';
|
||||
|
||||
class _Settings extends SettingsNotifier {
|
||||
@override
|
||||
AppSettings build() => const AppSettings(motionArtworkEnabled: false);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
testWidgets('Android Mornye glass visual poses and attached layers', (
|
||||
tester,
|
||||
) async {
|
||||
expect(Platform.isAndroid, isTrue);
|
||||
expect(ui.ImageFilter.isShaderFilterSupported, isTrue);
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
final directory = (await tester.runAsync(() async {
|
||||
return (await getTemporaryDirectory()).createTemp('mornye-glass-visual-');
|
||||
}))!;
|
||||
final cover = File('${directory.path}/cover.png');
|
||||
await tester.runAsync(() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
const _Pattern().paint(Canvas(recorder), const Size(192, 192));
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(192, 192);
|
||||
try {
|
||||
final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!;
|
||||
await cover.writeAsBytes(bytes.buffer.asUint8List());
|
||||
} finally {
|
||||
image.dispose();
|
||||
picture.dispose();
|
||||
}
|
||||
});
|
||||
final chrome = MornyeChromeController();
|
||||
final selected = ValueNotifier(0);
|
||||
final runtime = MusicPlayerRuntime();
|
||||
addTearDown(() async {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await runtime.dispose();
|
||||
await ResizeImage.resizeIfNeeded(132, null, FileImage(cover)).evict();
|
||||
chrome.dispose();
|
||||
selected.dispose();
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
final item = MediaItem(
|
||||
id: 'glass-visual-fixture',
|
||||
title: 'Color study',
|
||||
artist: 'Fixture artist',
|
||||
duration: const Duration(minutes: 4),
|
||||
artUri: cover.uri,
|
||||
);
|
||||
final poses = <String, Object?>{};
|
||||
Future<void> wait(int milliseconds) async {
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(Duration(milliseconds: milliseconds)),
|
||||
);
|
||||
}
|
||||
|
||||
CurvedAnimation fold() {
|
||||
final builder = tester.widget<AnimatedBuilder>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(MornyeBottomBar),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is AnimatedBuilder &&
|
||||
widget.animation is CurvedAnimation,
|
||||
),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
return builder.animation as CurvedAnimation;
|
||||
}
|
||||
|
||||
Future<void> capture(String name) async {
|
||||
await tester.pump();
|
||||
// Android can acquire the previously displayed image before the new
|
||||
// Flutter frame reaches its image view. Prime that view, then capture the
|
||||
// frozen pose; discard the priming image from the exported report.
|
||||
await wait(250);
|
||||
await tester.runAsync(() => binding.takeScreenshot('settle_$name'));
|
||||
(binding.reportData!['screenshots']! as List<dynamic>).removeLast();
|
||||
final histogram = <String, int>{};
|
||||
void visit(Layer layer) {
|
||||
histogram.update(
|
||||
layer.runtimeType.toString(),
|
||||
(count) => count + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
if (layer is ContainerLayer) {
|
||||
for (
|
||||
var child = layer.firstChild;
|
||||
child != null;
|
||||
child = child.nextSibling
|
||||
) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profile builds need the retained layer, since debugLayer is debug-only.
|
||||
// ignore: invalid_use_of_protected_member
|
||||
visit(tester.binding.renderViews.first.layer!);
|
||||
poses[name] = {
|
||||
'collapseProgress': fold().value,
|
||||
'controllerProgress': fold().parent.value,
|
||||
'shaderBackdrops': histogram['_ImpellerShaderBackdropLayer'] ?? 0,
|
||||
'stockBlurLayers': histogram['BackdropFilterLayer'] ?? 0,
|
||||
'batchBlurLayers': histogram['LiquidGlassBatchBackdropLayer'] ?? 0,
|
||||
'attachedLayerTypes': histogram,
|
||||
};
|
||||
await tester.runAsync(() => binding.takeScreenshot(name));
|
||||
}
|
||||
|
||||
Future<void> halfway(bool collapsed) async {
|
||||
chrome.value = collapsed;
|
||||
await tester.pump();
|
||||
(fold().parent as AnimationController)
|
||||
..stop()
|
||||
..value = 0.5;
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
for (final clarity in [0.6, 1.0]) {
|
||||
chrome.expand();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
currentMediaItemProvider.overrideWith((ref) => Stream.value(item)),
|
||||
playbackStateProvider.overrideWith(
|
||||
(ref) => Stream.value(PlaybackState()),
|
||||
),
|
||||
musicPlayerControllerProvider.overrideWithValue(
|
||||
MusicPlayerController(runtime: runtime),
|
||||
),
|
||||
mornyeGlassLevelProvider.overrideWithValue(MornyeGlassLevel.liquid),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(Brightness.dark, glassClarity: clarity),
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
extendBody: true,
|
||||
body: const CustomPaint(
|
||||
painter: _Pattern(),
|
||||
child: SizedBox.expand(),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge([chrome, selected]),
|
||||
builder: (_, _) => 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: selected.value,
|
||||
onSelected: (value) => selected.value = value,
|
||||
onHome: () => selected.value = 0,
|
||||
onSearch: () => selected.value = 3,
|
||||
blurEnabled: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await LiquidGlassShaders.ensureLoaded();
|
||||
await ui.FragmentProgram.fromAsset(
|
||||
'assets/shaders/mornye_selection_mask.frag',
|
||||
);
|
||||
});
|
||||
await wait(600);
|
||||
if (clarity == 0.6) {
|
||||
await binding.convertFlutterSurfaceToImage();
|
||||
await tester.pump();
|
||||
}
|
||||
expect(find.byType(MiniPlayer), findsOneWidget);
|
||||
final player = tester.state(find.byType(MiniPlayer));
|
||||
final prefix = 'clarity_${(clarity * 100).round()}';
|
||||
await capture('${prefix}_expanded');
|
||||
await halfway(true);
|
||||
await capture('${prefix}_folding_half');
|
||||
(fold().parent as AnimationController).forward();
|
||||
await wait(450);
|
||||
await capture('${prefix}_compact');
|
||||
await halfway(false);
|
||||
await capture('${prefix}_reopening_half');
|
||||
(fold().parent as AnimationController).reverse();
|
||||
await wait(450);
|
||||
Offset tab(String label) => tester.getCenter(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(MornyeTabBar),
|
||||
matching: find.text(label),
|
||||
)
|
||||
.hitTestable(),
|
||||
);
|
||||
final home = tab('Home');
|
||||
final search = tab('Search');
|
||||
final touch = await tester.startGesture(home);
|
||||
try {
|
||||
await wait(320);
|
||||
await touch.moveTo(Offset.lerp(home, search, 0.5)!);
|
||||
await wait(100);
|
||||
final lens = find.byWidgetPredicate(
|
||||
(widget) => widget is MornyeLiquidBackdrop && widget.interaction,
|
||||
);
|
||||
for (
|
||||
var attempt = 0;
|
||||
tester.widget<MornyeLiquidBackdrop>(lens).progress < 1 &&
|
||||
attempt < 20;
|
||||
attempt++
|
||||
) {
|
||||
await wait(100);
|
||||
}
|
||||
expect(tester.widget<MornyeLiquidBackdrop>(lens).progress, 1);
|
||||
await capture('${prefix}_held_halfway');
|
||||
} finally {
|
||||
await touch.cancel();
|
||||
}
|
||||
await wait(300);
|
||||
expect(tester.state(find.byType(MiniPlayer)), same(player));
|
||||
expect(tester.takeException(), isNull);
|
||||
}
|
||||
binding.reportData!['mornye_glass_visual'] = {
|
||||
'scope':
|
||||
'Production MornyeBottomBar/MiniPlayer over generated stripes/grid; '
|
||||
'paused in-memory playback and settings, no persistent data or network.',
|
||||
'captureSurface':
|
||||
'Android converted Flutter image view; visual-only, no timings.',
|
||||
'halfPose':
|
||||
'Production animation controller stopped at linear progress 0.5.',
|
||||
'physicalSize': [
|
||||
tester.view.physicalSize.width,
|
||||
tester.view.physicalSize.height,
|
||||
],
|
||||
'devicePixelRatio': tester.view.devicePixelRatio,
|
||||
'poses': poses,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
class _Pattern extends CustomPainter {
|
||||
const _Pattern();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const colors = [Colors.deepPurple, Colors.cyan, Colors.amber, Colors.pink];
|
||||
canvas.drawRect(
|
||||
Offset.zero & size,
|
||||
Paint()..color = const Color(0xff102038),
|
||||
);
|
||||
for (var y = 0.0; y < size.height; y += 48) {
|
||||
for (var x = 0.0; x < size.width; x += 48) {
|
||||
final color = colors[((x + y) / 48).toInt() % colors.length];
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(x, y, 47, 47),
|
||||
Paint()..color = color.withValues(alpha: 0.65),
|
||||
);
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(x, y + 16, 47, 4),
|
||||
Paint()..color = Colors.white.withValues(alpha: 0.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_Pattern oldDelegate) => false;
|
||||
}
|
||||
@@ -25,7 +25,8 @@ import 'performance_probe.dart';
|
||||
|
||||
void main() {
|
||||
final binding = PerformanceTestBinding.ensureInitialized();
|
||||
for (final clarity in [0.0, 0.1, 0.6, 1.0]) {
|
||||
const focused = bool.fromEnvironment('MORNYE_GLASS_FOCUS');
|
||||
for (final clarity in focused ? [0.6, 1.0] : [0.0, 0.1, 0.6, 1.0]) {
|
||||
testWidgets('Mornye real rendering at clarity $clarity', (tester) async {
|
||||
final probe = PerformanceProbe(
|
||||
binding,
|
||||
@@ -76,16 +77,18 @@ void main() {
|
||||
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));
|
||||
}
|
||||
});
|
||||
if (!focused) {
|
||||
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) {
|
||||
@@ -103,6 +106,15 @@ void main() {
|
||||
(widget) => widget is MornyeLiquidBackdrop && widget.interaction,
|
||||
);
|
||||
if (ui.ImageFilter.isShaderFilterSupported) {
|
||||
// Await the final painted pose even if the emulator delays a frame.
|
||||
for (
|
||||
var attempt = 0;
|
||||
tester.widget<MornyeLiquidBackdrop>(lens).progress < 1 &&
|
||||
attempt < 20;
|
||||
attempt++
|
||||
) {
|
||||
await probe.wait(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(tester.widget<MornyeLiquidBackdrop>(lens).progress, 1);
|
||||
}
|
||||
for (var step = 1; step <= 60; step++) {
|
||||
@@ -123,14 +135,17 @@ void main() {
|
||||
});
|
||||
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);
|
||||
if (!focused) {
|
||||
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',
|
||||
'focused': focused,
|
||||
'tracks': 2000,
|
||||
'unique_local_covers': fixture.covers.length,
|
||||
'scope':
|
||||
|
||||
@@ -6,13 +6,31 @@ 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.
|
||||
/// screenshots are PNGs beside that report. 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.parent.create(recursive: true);
|
||||
final screenshots = data?['screenshots'];
|
||||
if (screenshots is List<dynamic>) {
|
||||
final prefix = output.path.replaceFirst(RegExp(r'\.json$'), '');
|
||||
for (final entry in screenshots) {
|
||||
final shot = entry as Map<String, dynamic>;
|
||||
final name = (shot['screenshotName'] as String).replaceAll(
|
||||
RegExp(r'[^a-zA-Z0-9_-]'),
|
||||
'_',
|
||||
);
|
||||
final bytes = (shot.remove('bytes') as List<dynamic>).cast<int>();
|
||||
final png = File('$prefix-$name.png');
|
||||
await png.writeAsBytes(bytes);
|
||||
shot.addAll({'file': png.path, 'byte_count': bytes.length});
|
||||
stdout.writeln('Screenshot: ${png.path}');
|
||||
}
|
||||
}
|
||||
await output.writeAsString(
|
||||
const JsonEncoder.withIndent(' ').convert(data),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user