test(glass): capture native Mornye poses and layer counts

This commit is contained in:
zarzet committed 2026-10-05 20:33:49 +07:00
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':
+19 -1
View File
@@ -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),
);