Files
SpotiFLAC-Mobile/test/user_profile_test.dart
zarzet d8d46108bc feat(profile): add local profiles with backup and restore
Move Mornye Settings to the Home avatar and keep search above the profile card. Center the metadata artwork and actions, and make artist names clickable in the Material player.
2026-09-21 17:28:18 +07:00

294 lines
11 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/user_profile_provider.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/backup_service.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/user_profile_store.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/profile_avatar.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory directory;
late UserProfileStore store;
setUp(() async {
SharedPreferences.setMockInitialValues({});
directory = await Directory.systemTemp.createTemp('profile-test-');
store = UserProfileStore(documents: () async => directory);
});
tearDown(() async => directory.delete(recursive: true));
test(
'profile persists locally and replaces/removes only its previous photo',
() async {
expect((await store.read()).name, isEmpty);
final first = await store.save(
name: ' Listener ',
photo: Uint8List.fromList([1, 2, 3]),
);
expect((await store.read()).name, 'Listener');
expect(await File(first.photoPath!).readAsBytes(), [1, 2, 3]);
final renamed = await store.save(name: 'New name');
expect(renamed.photoPath, first.photoPath);
final second = await store.save(
name: 'New name',
photo: Uint8List.fromList([4, 5]),
);
expect(await File(first.photoPath!).exists(), isFalse);
expect(await File(second.photoPath!).exists(), isTrue);
await store.save(name: 'New name', removePhoto: true);
final restored = await store.read();
expect(restored.name, 'New name');
expect(restored.photoPath, isNull);
expect(await File(second.photoPath!).exists(), isFalse);
},
);
test('stored photo follows relocated documents directory', () async {
final profile = await store.save(
name: 'Listener',
photo: Uint8List.fromList([1]),
);
final relocated = await Directory(
p.join(directory.path, 'new-container'),
).create();
final photoDirectory = await Directory(
p.join(relocated.path, 'profile'),
).create();
await File(
profile.photoPath!,
).copy(p.join(photoDirectory.path, p.basename(profile.photoPath!)));
final restored = await UserProfileStore(
documents: () async => relocated,
).read();
expect(restored.photoPath, startsWith(relocated.path));
expect(restored.name, 'Listener');
});
test('backup restores profile and photo into a new app directory', () async {
final sourcePreferences = await SharedPreferences.getInstance();
final sourceStore = UserProfileStore(
preferences: () async => sourcePreferences,
documents: () async => directory,
);
SharedPreferences.setMockInitialValues({});
final destinationPreferences = await SharedPreferences.getInstance();
final recorder = ui.PictureRecorder();
Canvas(recorder).drawColor(Colors.blue, BlendMode.src);
final picture = recorder.endRecording();
final image = await picture.toImage(16, 16);
final photo = (await image.toByteData(
format: ui.ImageByteFormat.png,
))!.buffer.asUint8List();
image.dispose();
picture.dispose();
final destination = Directory(p.join(directory.path, 'restored-app'));
final destinationStore = UserProfileStore(
preferences: () async => destinationPreferences,
documents: () async => destination,
);
final container = ProviderContainer(
overrides: [userProfileStoreProvider.overrideWithValue(destinationStore)],
);
addTearDown(container.dispose);
final subscription = container.listen(userProfileProvider, (_, _) {});
addTearDown(subscription.close);
await container.read(userProfileProvider.future);
for (final withPhoto in [true, false]) {
final source = await sourceStore.save(
name: 'Backed up listener',
photo: withPhoto ? photo : null,
removePhoto: !withPhoto,
);
final file = await BackupService.writeBackupArchive(
settings: {'theme': 'dark'},
profile: source,
includeHistory: false,
loadHistoryPage: (_, _) async => [],
collections: {},
playlistCoverFiles: {},
extensions: {},
outputDirectory: directory,
temporaryDirectory: directory,
);
final bundle = (await BackupService.parseFile(
file.path,
temporaryDirectory: directory,
))!;
final restoredPhoto = bundle.profile!.photoPath;
expect(restoredPhoto != null, withPhoto);
final previousPhoto = (await destinationStore.read()).photoPath;
await container
.read(userProfileProvider.notifier)
.restoreFromBackup(bundle.profile!);
await bundle.cleanup();
final restored = await destinationStore.read();
expect(restored.name, 'Backed up listener');
expect(container.read(userProfileProvider).value!.name, restored.name);
if (withPhoto) {
expect(restored.photoPath, startsWith(destination.path));
expect(await File(restored.photoPath!).readAsBytes(), photo);
expect(await File(restoredPhoto!).exists(), isFalse);
} else {
expect(restored.photoPath, isNull);
expect(await File(previousPhoto!).exists(), isFalse);
}
}
});
test('invalid backup photo leaves the current profile intact', () async {
await store.save(name: 'Keep me');
final invalid = File(p.join(directory.path, 'invalid.png'));
await invalid.writeAsBytes([0, 1]);
final container = ProviderContainer(
overrides: [userProfileStoreProvider.overrideWithValue(store)],
);
addTearDown(container.dispose);
await expectLater(
container
.read(userProfileProvider.notifier)
.restoreFromBackup(
UserProfile(name: 'Do not save', photoPath: invalid.path),
),
throwsException,
);
expect((await store.read()).name, 'Keep me');
});
test('malformed profile and foreign photo paths fall back safely', () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user_profile_v1', 'broken JSON');
expect((await store.read()).name, isEmpty);
await prefs.setString(
'user_profile_v1',
jsonEncode({'name': 'Listener', 'photo': '../music.flac'}),
);
final profile = await store.read();
expect(profile.name, 'Listener');
expect(profile.photoPath, isNull);
});
test('photo decoding bounds dimensions and preserves aspect ratio', () async {
final recorder = ui.PictureRecorder();
Canvas(recorder).drawColor(Colors.blue, BlendMode.src);
final picture = recorder.endRecording();
final input = await picture.toImage(1600, 800);
final bytes = (await input.toByteData(
format: ui.ImageByteFormat.png,
))!.buffer.asUint8List();
input.dispose();
picture.dispose();
final png = await prepareProfilePhoto(bytes);
final codec = await ui.instantiateImageCodec(png);
final image = (await codec.getNextFrame()).image;
expect(image.width, 512);
expect(image.height, 256);
image.dispose();
codec.dispose();
await expectLater(
prepareProfilePhoto(Uint8List.fromList([0, 1])),
throwsException,
);
});
testWidgets(
'profile card stays separate, saves the name, and cancel keeps it',
(tester) async {
tester.view.physicalSize = const Size(393, 852);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final container = ProviderContainer(
overrides: [userProfileStoreProvider.overrideWithValue(store)],
);
addTearDown(container.dispose);
await container.read(userProfileProvider.future);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: SettingsTab()),
),
),
);
await tester.pumpAndSettle();
final card = find.ancestor(
of: find.text('Set up your profile'),
matching: find.byType(SettingsGroup),
);
expect(card, findsOneWidget);
expect(
find.descendant(of: card, matching: find.text('Extensions')),
findsNothing,
);
await tester.tap(find.text('Set up your profile'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Listener');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Listener'), findsOneWidget);
expect(container.read(userProfileProvider).value?.name, 'Listener');
await tester.tap(find.text('Listener'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Unsaved');
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Listener'), findsOneWidget);
expect(container.read(userProfileProvider).value?.name, 'Listener');
expect(tester.takeException(), isNull);
},
);
testWidgets('Home avatar opens Settings and reflects the saved name', (
tester,
) async {
final container = ProviderContainer(
overrides: [userProfileStoreProvider.overrideWithValue(store)],
);
addTearDown(container.dispose);
await container.read(userProfileProvider.future);
final owner = Object();
ShellTab? requested;
ShellNavigationService.registerTabSelectionHandler(
owner: owner,
handler: (tab) => requested = tab,
);
addTearDown(
() => ShellNavigationService.unregisterTabSelectionHandler(owner),
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: HomeProfileButton()),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Settings'));
expect(requested, ShellTab.settings);
await container.read(userProfileProvider.notifier).save(name: 'Listener');
await tester.pumpAndSettle();
expect(find.text('L'), findsOneWidget);
});
}