Files
SpotiFLAC-Mobile/test/mornye_context_menu_test.dart

328 lines
11 KiB
Dart

import 'dart:ui' as ui;
import 'package:flutter/cupertino.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:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
Future<void> openMenu(
WidgetTester tester, {
required Rect anchor,
required ValueChanged<String?> onResult,
Size size = const Size(390, 844),
Brightness brightness = Brightness.light,
double textScale = 1,
bool reduceMotion = false,
bool preferAbove = false,
bool highContrast = false,
bool lowEnd = false,
Color? backgroundColor,
GlobalKey? capture,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
overrides: [
lowEndDeviceProvider.overrideWithValue(lowEnd),
backdropBlurEnabledProvider.overrideWithValue(false),
],
child: MaterialApp(
theme: MornyeTheme.build(brightness),
builder: (context, child) => RepaintBoundary(
key: capture,
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 24),
textScaler: TextScaler.linear(textScale),
disableAnimations: reduceMotion,
highContrast: highContrast,
),
child: child!,
),
),
home: Scaffold(
backgroundColor: backgroundColor,
body: Builder(
builder: (context) => Center(
child: TextButton(
onPressed: () async {
final result = await showMornyeContextMenu<String>(
context: context,
anchor: anchor,
preferAbove: preferAbove,
builder: (menuContext) {
MornyeMenuAction action(String label, IconData icon) =>
MornyeMenuAction(
icon: icon,
label: label,
onPressed: () =>
Navigator.of(menuContext).pop(label),
);
return MornyeContextMenu(
quickActions: [
action(
'Download',
CupertinoIcons.arrow_down_circle,
),
action('Favorite', CupertinoIcons.star),
action('Share', CupertinoIcons.share),
],
groups: [
[
action('Play', CupertinoIcons.play),
action('Shuffle', CupertinoIcons.shuffle),
],
[
action(
'Add to playlist',
CupertinoIcons.text_badge_plus,
),
action('Play next', CupertinoIcons.text_insert),
],
[action('Remove', CupertinoIcons.trash)],
],
);
},
);
onResult(result);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
Future<List<Color>> samplePixels(
WidgetTester tester,
GlobalKey capture,
List<Offset> positions,
) async => (await tester.runAsync(() async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(capture),
);
final image = await boundary.toImage();
try {
final pixels = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
return positions.map((position) {
final local = boundary.globalToLocal(position);
final index = (local.dy.floor() * image.width + local.dx.floor()) * 4;
return Color.fromARGB(
pixels.getUint8(index + 3),
pixels.getUint8(index),
pixels.getUint8(index + 1),
pixels.getUint8(index + 2),
);
}).toList();
} finally {
image.dispose();
}
}))!;
for (final brightness in Brightness.values) {
for (final background in [
Colors.white,
Colors.black,
const Color(0xff6c3a22),
]) {
testWidgets('menu labels stay readable over $background ($brightness)', (
tester,
) async {
final originalDisableShadows = debugDisableShadows;
debugDisableShadows = false;
try {
final capture = GlobalKey();
await openMenu(
tester,
anchor: const Rect.fromLTWH(330, 144, 44, 44),
brightness: brightness,
backgroundColor: background,
capture: capture,
onResult: (_) {},
);
final menu = tester.getRect(find.byType(MornyeContextMenu));
final pixels = await samplePixels(tester, capture, [
for (final x in [menu.left + 8, menu.right - 8])
for (final fraction in [0.25, 0.5, 0.75])
Offset(x, menu.top + menu.height * fraction),
]);
final foreground = MornyeTheme.build(
brightness,
).colorScheme.onSurface;
for (final inside in pixels) {
// Busy lyrics and bright artwork must not wash out either label.
for (final textColor in [
foreground,
Color.alphaBlend(foreground.withValues(alpha: 0.96), inside),
]) {
final luminances = [
inside.computeLuminance(),
textColor.computeLuminance(),
]..sort();
final contrast =
(luminances.last + 0.05) / (luminances.first + 0.05);
expect(contrast, greaterThanOrEqualTo(4.5));
}
// The panel remains even at both edges without an offset lens.
expect(inside.r, closeTo(pixels.first.r, 0.015));
expect(inside.g, closeTo(pixels.first.g, 0.015));
expect(inside.b, closeTo(pixels.first.b, 0.015));
}
expect(tester.takeException(), isNull);
} finally {
debugDisableShadows = originalDisableShadows;
}
});
}
testWidgets('menu stays on screen near a bottom edge ($brightness)', (
tester,
) async {
String? result;
const anchor = Rect.fromLTWH(338, 728, 36, 44);
await openMenu(
tester,
anchor: anchor,
brightness: brightness,
onResult: (value) => result = value,
);
final rect = tester.getRect(find.byType(MornyeContextMenu));
expect(rect.left, greaterThanOrEqualTo(16));
expect(rect.right, lessThanOrEqualTo(374));
expect(rect.top, greaterThanOrEqualTo(40));
expect(rect.bottom, lessThan(anchor.top));
expect(find.text('Share').hitTestable(), findsOneWidget);
await tester.tap(find.text('Share'));
await tester.pumpAndSettle();
expect(result, 'Share');
expect(find.byType(MornyeContextMenu), findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('frosted glass still carries the artwork color', (tester) async {
final colors = <Color>[];
for (final background in [
const Color(0xff804020),
const Color(0xff204080),
]) {
final capture = GlobalKey();
await openMenu(
tester,
anchor: const Rect.fromLTWH(330, 144, 44, 44),
brightness: Brightness.dark,
backgroundColor: background,
capture: capture,
onResult: (_) {},
);
final rect = tester.getRect(find.byType(MornyeContextMenu));
colors.addAll(
await samplePixels(tester, capture, [
Offset(rect.left + 8, rect.center.dy),
]),
);
await tester.pumpWidget(const SizedBox.shrink());
}
expect(colors.first.r, greaterThan(colors.last.r + 0.03));
expect(colors.last.b, greaterThan(colors.first.b + 0.03));
expect(tester.takeException(), isNull);
});
for (final highContrast in [false, true]) {
testWidgets('menu stays readable without blur (contrast: $highContrast)', (
tester,
) async {
final capture = GlobalKey();
await openMenu(
tester,
anchor: const Rect.fromLTWH(330, 144, 44, 44),
brightness: Brightness.dark,
backgroundColor: const Color(0xff6c3a22),
highContrast: highContrast,
lowEnd: !highContrast,
capture: capture,
onResult: (_) {},
);
final menu = find.byType(MornyeContextMenu);
final rect = tester.getRect(menu);
final pixels = await samplePixels(tester, capture, [
Offset(rect.left + 8, rect.center.dy),
]);
final theme = MornyeTheme.build(Brightness.dark);
expect(pixels.single, theme.colorScheme.surfaceContainerHigh);
expect(
find.descendant(of: menu, matching: find.byType(BackdropFilter)),
findsNothing,
);
await tester.tap(find.text('Share'));
await tester.pumpAndSettle();
expect(menu, findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets(
'large text in landscape keeps shortcuts and last action usable',
(tester) async {
String? result;
await openMenu(
tester,
anchor: const Rect.fromLTWH(600, 200, 24, 44),
size: const Size(640, 320),
textScale: 2.5,
preferAbove: true,
onResult: (value) => result = value,
);
final rect = tester.getRect(find.byType(MornyeContextMenu));
expect(rect.top, greaterThanOrEqualTo(40));
expect(rect.bottom, lessThanOrEqualTo(280));
expect(find.text('Download').hitTestable(), findsOneWidget);
await tester.ensureVisible(find.text('Remove'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remove'));
await tester.pumpAndSettle();
expect(result, 'Remove');
expect(tester.takeException(), isNull);
},
);
testWidgets('outside tap dismisses without selecting in reduced motion', (
tester,
) async {
final results = <String?>[];
await openMenu(
tester,
anchor: const Rect.fromLTWH(12, 40, 44, 44),
reduceMotion: true,
preferAbove: true,
onResult: results.add,
);
expect(
tester.getRect(find.byType(MornyeContextMenu)).top,
greaterThanOrEqualTo(84),
);
await tester.tapAt(const Offset(8, 800));
await tester.pump();
expect(results, [null]);
expect(find.byType(MornyeContextMenu), findsNothing);
expect(tester.takeException(), isNull);
});
}