Files
SpotiFLAC-Mobile/test/mornye_library_actions_test.dart
T
zarzet 63e1a9b9c8 feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
2026-09-19 12:10:10 +07:00

345 lines
12 KiB
Dart

import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.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/unified_library_item.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart';
import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_switch.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/destructive_selection_button.dart';
Widget _app(
Widget child, {
Brightness brightness = Brightness.dark,
TargetPlatform? platform,
}) => ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(brightness).copyWith(platform: platform),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: child),
),
);
Future<void> _tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pumpAndSettle();
await tester.tap(finder);
await tester.pumpAndSettle();
}
void _mockMetadataFile(WidgetTester tester) {
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger = tester.binding.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
channel,
(call) async => switch (call.method) {
'safStat' => jsonEncode({'exists': true, 'size': 100}),
'readAudioMetadata' ||
'readFileMetadata' => jsonEncode({'title': 'Track', 'artist': 'Artist'}),
'getLyricsLRCWithSource' => jsonEncode({'lyrics': '', 'source': ''}),
'getSafFileModTimes' => '{}',
_ => null,
},
);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
}
void main() {
setUpAll(() async {
await (FontLoader(
'Inter',
)..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load();
await (FontLoader('packages/cupertino_icons/CupertinoIcons')..addFont(
rootBundle.load('packages/cupertino_icons/assets/CupertinoIcons.ttf'),
))
.load();
});
setUp(() {
SharedPreferences.setMockInitialValues({});
FlutterSecureStorage.setMockInitialValues({});
});
testWidgets('Mornye selection actions remain accessible on narrow screens', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 780));
addTearDown(() => tester.binding.setSurfaceSize(null));
var selectedAll = false;
var closed = false;
var deleted = false;
var converted = false;
await tester.pumpWidget(
_app(
Builder(
builder: (context) => Align(
alignment: Alignment.bottomCenter,
child: SelectionBottomBar(
selectedCount: 1,
allSelected: false,
onClose: () => closed = true,
onToggleSelectAll: () => selectedAll = true,
bottomPadding: 0,
children: [
SelectionActionButton(
icon: Icons.swap_horiz,
label: 'Convert 1 track',
onPressed: () => converted = true,
colorScheme: Theme.of(context).colorScheme,
),
const SizedBox(height: 8),
DestructiveSelectionButton(
count: 1,
colorScheme: Theme.of(context).colorScheme,
onPressed: () => deleted = true,
),
],
),
),
),
),
);
await tester.pumpAndSettle();
await _tapVisible(tester, find.text('Select All'));
await _tapVisible(tester, find.text('Convert 1 track'));
await _tapVisible(tester, find.byIcon(CupertinoIcons.trash));
await _tapVisible(tester, find.byIcon(CupertinoIcons.xmark));
expect((selectedAll, converted, deleted, closed), (true, true, true, true));
expect(tester.takeException(), isNull);
});
for (final brightness in Brightness.values) {
testWidgets('Mornye conversion preserves selected options ($brightness)', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(390, 844));
addTearDown(() => tester.binding.setSurfaceSize(null));
(String, String, bool)? result;
await tester.pumpWidget(
_app(
BatchConvertSheet(
formats: const [
'ALAC',
'FLAC',
'WAV',
'AIFF',
'AAC',
'MP3',
'Opus',
],
title: 'Batch Convert',
confirmLabel: 'Convert tracks',
sourceBitDepth: 24,
sourceSampleRate: 48000,
onConvert: (format, bitrate, quality, processing, keepOriginal) {
result = (format, bitrate, keepOriginal);
},
),
brightness: brightness,
),
);
await tester.pumpAndSettle();
await _tapVisible(tester, find.text('AAC'));
await _tapVisible(tester, find.text('128k'));
await _tapVisible(tester, find.byType(AppSwitch));
await _tapVisible(tester, find.text('Convert tracks'));
expect(result, ('AAC', '128k', true));
expect(tester.takeException(), isNull);
});
}
testWidgets(
'Mornye Re-enrich retains field choices and disables empty review',
(tester) async {
await tester.binding.setSurfaceSize(const Size(390, 844));
addTearDown(() => tester.binding.setSurfaceSize(null));
ReEnrichFieldSelection? result;
await tester.pumpWidget(
_app(
Builder(
builder: (context) => CupertinoButton(
onPressed: () async {
result = await showReEnrichFieldDialog(
context,
selectedCount: 2,
);
},
child: const Text('Open'),
),
),
),
);
await _tapVisible(tester, find.text('Open'));
await _tapVisible(tester, find.text('Update selected tags'));
await _tapVisible(tester, find.text('Select All'));
final review = find.widgetWithText(AppActionButton, 'Review changes');
expect(tester.widget<AppActionButton>(review).onPressed, isNull);
await _tapVisible(tester, find.text('Cover Art'));
await _tapVisible(tester, find.text('Review changes'));
expect(result?.mode, ReEnrichBatchMode.selectedFields);
expect(result?.fields, [ReEnrichFields.cover]);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'Mornye ReplayGain cancellation restores selection without analysis',
(tester) async {
bool? confirmed;
var exitedSelection = false;
await tester.pumpWidget(
_app(
Builder(
builder: (context) => CupertinoButton(
onPressed: () => runBatchReplayGain(
context,
[
UnifiedLibraryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: '/missing/track.flac',
addedAt: DateTime(2026),
source: LibraryItemSource.local,
),
],
onExitSelectionMode: () => exitedSelection = true,
onConfirmClosed: (value) async => confirmed = value,
),
child: const Text('Open'),
),
),
),
);
await _tapVisible(tester, find.text('Open'));
expect(find.byType(AppAlertDialog), findsOneWidget);
await _tapVisible(tester, find.text('Cancel'));
expect(confirmed, isFalse);
expect(exitedSelection, isFalse);
expect(tester.takeException(), isNull);
},
);
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
testWidgets('Metadata menu scroll reaches the last action ($platform)', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(390, 540));
addTearDown(() => tester.binding.setSurfaceSize(null));
_mockMetadataFile(tester);
await tester.pumpWidget(
_app(
TrackMetadataScreen(
item: DownloadHistoryItem(
id: 'menu-track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: 'content://library/document/menu-track.flac',
service: 'provider-a',
downloadedAt: DateTime(2026),
),
),
platform: platform,
),
);
await tester.pumpAndSettle();
await _tapVisible(tester, find.byIcon(CupertinoIcons.ellipsis));
final lastAction = find.text('Remove from device');
expect(lastAction.hitTestable(), findsNothing);
expect(find.byType(MornyeContextMenu), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
for (var drag = 0; drag < 4; drag++) {
await tester.dragFrom(const Offset(195, 400), const Offset(0, -320));
await tester.pumpAndSettle();
}
expect(find.text('Share').hitTestable(), findsOneWidget);
expect(lastAction.hitTestable(), findsOneWidget);
// Device Hub / Simulator users can also scroll with a trackpad or wheel.
await tester.sendEventToBinding(
const PointerScrollEvent(
position: Offset(195, 400),
scrollDelta: Offset(0, -2000),
),
);
await tester.pumpAndSettle();
expect(find.text('Play next').hitTestable(), findsOneWidget);
await tester.sendEventToBinding(
const PointerScrollEvent(
position: Offset(195, 400),
scrollDelta: Offset(0, 2000),
),
);
await tester.pumpAndSettle();
expect(lastAction.hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
testWidgets('Mornye metadata editor keeps edited text and Save available', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(390, 844));
addTearDown(() => tester.binding.setSurfaceSize(null));
_mockMetadataFile(tester);
await tester.pumpWidget(
_app(
TrackMetadataScreen(
item: DownloadHistoryItem(
id: 'edit-track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: 'content://library/document/edit-track.flac',
service: 'provider-a',
downloadedAt: DateTime(2026),
),
),
),
);
await tester.pumpAndSettle();
await _tapVisible(tester, find.byIcon(CupertinoIcons.ellipsis));
await tester.tap(find.text('Edit Metadata'));
await tester.pump(const Duration(milliseconds: 400));
// The editor extracts its preview through real temporary-file I/O.
await tester.runAsync(() async {
for (var attempt = 0; attempt < 100; attempt++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
if (find.byType(LinearProgressIndicator).evaluate().isEmpty) break;
}
});
expect(find.byType(LinearProgressIndicator), findsNothing);
await tester.pumpAndSettle();
final title = find.byWidgetPredicate(
(widget) =>
widget is CupertinoTextField && widget.controller?.text == 'Track',
);
await tester.ensureVisible(title);
await tester.enterText(title, 'Edited track');
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
expect(find.text('Edited track'), findsOneWidget);
expect(find.widgetWithText(AppActionButton, 'Save'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}