mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
695 lines
21 KiB
Dart
695 lines
21 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
|
import 'package:spotiflac_android/theme/app_theme.dart';
|
|
import 'package:spotiflac_android/theme/app_tokens.dart';
|
|
import 'package:spotiflac_android/theme/cover_palette.dart';
|
|
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
|
import 'package:spotiflac_android/widgets/app_search_field.dart';
|
|
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
|
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
|
|
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
|
import 'package:spotiflac_android/widgets/track_card.dart';
|
|
|
|
/// Every Dart source file under `lib/`, used by the source-level contracts
|
|
/// below. Those contracts exist because the duplication they guard against was
|
|
/// re-introduced by copy-paste several times before the shared widgets landed.
|
|
List<File> _libSources() {
|
|
return Directory('lib')
|
|
.listSync(recursive: true)
|
|
.whereType<File>()
|
|
.where((file) => file.path.endsWith('.dart'))
|
|
.toList();
|
|
}
|
|
|
|
String _basename(File file) => file.uri.pathSegments.last;
|
|
|
|
Widget _hostSliver(Widget sliver) {
|
|
return MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(body: CustomScrollView(slivers: [sliver])),
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
group('AppTokens', () {
|
|
test('is registered on both themes', () {
|
|
expect(AppTheme.light().extension<AppTokens>(), AppTokens.standard);
|
|
expect(AppTheme.dark().extension<AppTokens>(), AppTokens.standard);
|
|
expect(
|
|
AppTheme.dark(isAmoled: true).extension<AppTokens>(),
|
|
AppTokens.standard,
|
|
);
|
|
});
|
|
|
|
testWidgets('context.tokens falls back to the standard scale', (
|
|
tester,
|
|
) async {
|
|
AppTokens? seen;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
// A bare ThemeData registers no extension; widgets must still work.
|
|
theme: ThemeData(),
|
|
home: Builder(
|
|
builder: (context) {
|
|
seen = context.tokens;
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(seen, AppTokens.standard);
|
|
});
|
|
|
|
test('lerp interpolates the scale instead of snapping', () {
|
|
const other = AppTokens.standard;
|
|
final doubled = other.copyWith(radiusCard: 40);
|
|
final mid = other.lerp(doubled, 0.5);
|
|
|
|
expect(mid.radiusCard, (other.radiusCard + 40) / 2);
|
|
});
|
|
|
|
test('badge text stays legible', () {
|
|
// 9-10px badge labels were the accessibility floor violation this token
|
|
// was introduced to fix.
|
|
expect(AppTokens.standard.badgeFontSize, greaterThanOrEqualTo(11));
|
|
});
|
|
|
|
test('minimum touch target matches the Material floor', () {
|
|
expect(AppTokens.standard.minTouchTarget, 48);
|
|
});
|
|
});
|
|
|
|
group('AppSearchField', () {
|
|
testWidgets('uses the shared filled search style and clears input', (
|
|
tester,
|
|
) async {
|
|
final controller = TextEditingController(text: 'quality');
|
|
var cleared = false;
|
|
addTearDown(controller.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: AppSearchField(
|
|
controller: controller,
|
|
hintText: 'Search settings',
|
|
clearTooltip: 'Clear',
|
|
onChanged: (_) {},
|
|
onClear: () => cleared = true,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final field = tester.widget<TextField>(find.byType(TextField));
|
|
final border = field.decoration!.enabledBorder! as OutlineInputBorder;
|
|
|
|
expect(field.decoration!.filled, isTrue);
|
|
expect(border.borderRadius.topLeft.x, AppTokens.standard.radiusSheet);
|
|
expect(find.byIcon(Icons.clear), findsOneWidget);
|
|
|
|
await tester.tap(find.byIcon(Icons.clear));
|
|
await tester.pump();
|
|
|
|
expect(controller.text, isEmpty);
|
|
expect(cleared, isTrue);
|
|
});
|
|
});
|
|
|
|
group('Now Playing actions', () {
|
|
test('uses the app bottom sheet instead of a platform popup menu', () {
|
|
final source = File(
|
|
'lib/screens/now_playing_screen.dart',
|
|
).readAsStringSync();
|
|
|
|
expect(source, contains('showAppBottomSheet<String>'));
|
|
expect(source, isNot(contains('PopupMenuButton')));
|
|
});
|
|
});
|
|
|
|
group('CoverPalette', () {
|
|
test('local cache identity changes when artwork is replaced in place', () {
|
|
final directory = Directory.systemTemp.createTempSync(
|
|
'spotiflac-cover-palette-',
|
|
);
|
|
final file = File('${directory.path}/cover.jpg');
|
|
try {
|
|
file.writeAsBytesSync(const [1, 2, 3]);
|
|
file.setLastModifiedSync(DateTime.utc(2026, 1, 1));
|
|
final before = CoverPalette.cacheKeyFor(file.path, Brightness.dark);
|
|
|
|
file.writeAsBytesSync(const [4, 5, 6, 7]);
|
|
file.setLastModifiedSync(DateTime.utc(2026, 1, 2));
|
|
final after = CoverPalette.cacheKeyFor(file.path, Brightness.dark);
|
|
|
|
expect(after, isNot(before));
|
|
} finally {
|
|
directory.deleteSync(recursive: true);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('AppSliverHeader', () {
|
|
testWidgets('tab root variant shows the title without a back button', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_hostSliver(const AppSliverHeader.tabRoot(title: 'Library')),
|
|
);
|
|
|
|
expect(find.text('Library'), findsOneWidget);
|
|
expect(find.byIcon(Icons.arrow_back), findsNothing);
|
|
});
|
|
|
|
testWidgets('page variant shows a back button that pops the route', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: Builder(
|
|
builder: (context) => ElevatedButton(
|
|
onPressed: () => Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => const Scaffold(
|
|
body: CustomScrollView(
|
|
slivers: [AppSliverHeader.page(title: 'Downloads')],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
child: const Text('open'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('open'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Downloads'), findsOneWidget);
|
|
|
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Downloads'), findsNothing);
|
|
});
|
|
|
|
testWidgets('expanded title uses the shared type ramp', (tester) async {
|
|
await tester.pumpWidget(
|
|
_hostSliver(const AppSliverHeader.tabRoot(title: 'Home')),
|
|
);
|
|
|
|
final style = tester.widget<Text>(find.text('Home')).style!;
|
|
expect(style.fontSize, AppTokens.standard.headerExpandedTitleSize);
|
|
});
|
|
|
|
test('is the only collapsing header implementation left', () {
|
|
final offenders = _libSources()
|
|
.where(
|
|
(file) =>
|
|
file.readAsStringSync().contains('expandedTitleScale') &&
|
|
_basename(file) != 'app_sliver_header.dart',
|
|
)
|
|
.map(_basename)
|
|
.toList();
|
|
|
|
expect(
|
|
offenders,
|
|
isEmpty,
|
|
reason:
|
|
'Collapsing headers must go through AppSliverHeader so the type '
|
|
'ramp cannot fork again.',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('AppBottomSheet', () {
|
|
testWidgets('supplies one handle plus the title block', (tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: Builder(
|
|
builder: (context) => ElevatedButton(
|
|
onPressed: () => showAppBottomSheet<void>(
|
|
context: context,
|
|
title: 'Open on',
|
|
subtitle: 'Track - Artist',
|
|
builder: (_) => const Text('body'),
|
|
),
|
|
child: const Text('open'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('open'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(AppSheetHandle), findsOneWidget);
|
|
expect(find.text('Open on'), findsOneWidget);
|
|
expect(find.text('Track - Artist'), findsOneWidget);
|
|
expect(find.text('body'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('draggable content moves as one surface and dismisses', (
|
|
tester,
|
|
) async {
|
|
const sheetKey = ValueKey<String>('scrollable-sheet');
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: Builder(
|
|
builder: (context) => ElevatedButton(
|
|
onPressed: () => showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
// Disable the route recognizer so the draggable surface,
|
|
// rather than the modal's fallback gesture, is under test.
|
|
enableDrag: false,
|
|
builder: (_) => AppDraggableSheet(
|
|
builder: (_, scrollController) => Material(
|
|
key: sheetKey,
|
|
child: ListView(
|
|
controller: scrollController,
|
|
children: const [
|
|
SizedBox(height: 800, child: Text('sheet body')),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
child: const Text('open'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('open'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(sheetKey), findsOneWidget);
|
|
|
|
final initialTop = tester.getTopLeft(find.byKey(sheetKey)).dy;
|
|
final gesture = await tester.startGesture(
|
|
tester.getCenter(find.byKey(sheetKey)),
|
|
);
|
|
await gesture.moveBy(const Offset(0, 160));
|
|
await tester.pump();
|
|
|
|
expect(
|
|
tester.getTopLeft(find.byKey(sheetKey)).dy,
|
|
greaterThan(initialTop + 100),
|
|
);
|
|
|
|
await gesture.moveBy(const Offset(0, 260));
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(sheetKey), findsNothing);
|
|
});
|
|
|
|
testWidgets('sheet shape comes from the token scale', (tester) async {
|
|
final shape =
|
|
AppTheme.light().bottomSheetTheme.shape! as RoundedRectangleBorder;
|
|
final radius = shape.borderRadius.resolve(TextDirection.ltr).topLeft.x;
|
|
|
|
expect(radius, AppTokens.standard.radiusSheet);
|
|
});
|
|
|
|
test('no screen hand-rolls a drag handle any more', () {
|
|
final handlePattern = RegExp(
|
|
r'height:\s*4,[\s\S]{0,200}?BorderRadius\.circular\(2\)',
|
|
);
|
|
final offenders = _libSources()
|
|
.where(
|
|
(file) =>
|
|
handlePattern.hasMatch(file.readAsStringSync()) &&
|
|
_basename(file) != 'app_bottom_sheet.dart',
|
|
)
|
|
.map(_basename)
|
|
.toList();
|
|
|
|
expect(
|
|
offenders,
|
|
isEmpty,
|
|
reason: 'Use AppSheetHandle instead of rebuilding the pill.',
|
|
);
|
|
});
|
|
|
|
test('no call site overrides the modal sheet shape', () {
|
|
final shapeOverride = RegExp(
|
|
r'shape:\s*(?:const\s+)?RoundedRectangleBorder\(\s*borderRadius:\s*'
|
|
r'(?:const\s+)?BorderRadius\.vertical\(',
|
|
);
|
|
final offenders = _libSources()
|
|
.where(
|
|
(file) =>
|
|
shapeOverride.hasMatch(file.readAsStringSync()) &&
|
|
_basename(file) != 'app_theme.dart',
|
|
)
|
|
.map(_basename)
|
|
.toList();
|
|
|
|
expect(
|
|
offenders,
|
|
isEmpty,
|
|
reason:
|
|
'Sheet radius belongs to bottomSheetTheme, which reads '
|
|
'AppTokens.radiusSheet.',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('TrackCard', () {
|
|
Widget host(Widget child) => MaterialApp(
|
|
theme: AppTheme.light(),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
home: Scaffold(body: child),
|
|
);
|
|
|
|
testWidgets('lays out leading, title, subtitle and trailing', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
host(
|
|
const TrackCard(
|
|
leading: Icon(Icons.music_note),
|
|
title: 'Song',
|
|
subtitle: Text('Artist'),
|
|
trailing: Icon(Icons.play_arrow),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.text('Song'), findsOneWidget);
|
|
expect(find.text('Artist'), findsOneWidget);
|
|
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('selection mode swaps the trailing action for a tick', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
host(
|
|
const TrackCard(
|
|
leading: Icon(Icons.music_note),
|
|
title: 'Song',
|
|
trailing: Icon(Icons.play_arrow),
|
|
isSelectionMode: true,
|
|
isSelected: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.byIcon(Icons.play_arrow), findsNothing);
|
|
expect(find.byIcon(Icons.check), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('flat style keeps the row transparent', (tester) async {
|
|
await tester.pumpWidget(
|
|
host(
|
|
const TrackCard(
|
|
leading: SizedBox.shrink(),
|
|
title: 'Song',
|
|
style: TrackCardStyle.flat,
|
|
),
|
|
),
|
|
);
|
|
|
|
final card = tester.widget<Card>(find.byType(Card));
|
|
expect(card.color, Colors.transparent);
|
|
});
|
|
|
|
testWidgets('grid variant is a real button with a semantic label', (
|
|
tester,
|
|
) async {
|
|
var taps = 0;
|
|
await tester.pumpWidget(
|
|
host(
|
|
SizedBox(
|
|
width: 160,
|
|
child: TrackGridCard(
|
|
cover: const ColoredBox(color: Colors.grey),
|
|
title: 'Song',
|
|
subtitle: const Text('Artist'),
|
|
semanticLabel: 'Song by Artist',
|
|
onTap: () => taps++,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// A bare GestureDetector gave no ripple and no semantics; the shared
|
|
// card uses InkWell + Semantics instead.
|
|
expect(find.byType(InkWell), findsOneWidget);
|
|
await tester.tap(find.byType(InkWell));
|
|
expect(taps, 1);
|
|
});
|
|
});
|
|
|
|
group('selection bar', () {
|
|
test('root-overlay mounting lives in exactly one place', () {
|
|
final offenders = _libSources()
|
|
.where(
|
|
(file) =>
|
|
file.readAsStringSync().contains('OverlayEntry(') &&
|
|
_basename(file) != 'selection_bottom_bar.dart',
|
|
)
|
|
.map(_basename)
|
|
.toList();
|
|
|
|
expect(
|
|
offenders,
|
|
isEmpty,
|
|
reason:
|
|
'Selection bars must mount through SelectionOverlayController so '
|
|
'they animate identically and clear the shell navigation bar.',
|
|
);
|
|
});
|
|
|
|
testWidgets('shell host keeps modal routes above the selection bar', (
|
|
tester,
|
|
) async {
|
|
await tester.binding.setSurfaceSize(const Size(430, 900));
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
|
|
final controller = SelectionOverlayController();
|
|
addTearDown(controller.dispose);
|
|
BuildContext? pageContext;
|
|
var selectionTaps = 0;
|
|
var sheetTaps = 0;
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: SelectionOverlayHost(
|
|
child: Builder(
|
|
builder: (context) {
|
|
pageContext = context;
|
|
return const Scaffold(body: SizedBox.expand());
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
controller.show(
|
|
pageContext!,
|
|
(_) => GestureDetector(
|
|
key: const ValueKey('selection-hit-target'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => selectionTaps++,
|
|
child: const SizedBox(height: 200),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
final sheetClosed = showModalBottomSheet<void>(
|
|
context: pageContext!,
|
|
useRootNavigator: true,
|
|
enableDrag: false,
|
|
builder: (_) => GestureDetector(
|
|
key: const ValueKey('sheet-hit-target'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => sheetTaps++,
|
|
child: const SizedBox(height: 300),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(const ValueKey('sheet-hit-target')), findsOneWidget);
|
|
expect(find.byKey(const ValueKey('selection-hit-target')), findsNothing);
|
|
expect(selectionTaps, 0);
|
|
expect(sheetTaps, 0);
|
|
|
|
Navigator.of(pageContext!, rootNavigator: true).pop();
|
|
await tester.pumpAndSettle();
|
|
await sheetClosed;
|
|
|
|
expect(
|
|
find.byKey(const ValueKey('selection-hit-target')),
|
|
findsOneWidget,
|
|
);
|
|
await tester.tap(find.byKey(const ValueKey('selection-hit-target')));
|
|
await tester.pump();
|
|
expect(selectionTaps, 1);
|
|
});
|
|
});
|
|
|
|
group('CollectionScaffold', () {
|
|
testWidgets('mounts the selection bar only while selecting', (
|
|
tester,
|
|
) async {
|
|
Widget host({required bool selecting}) => MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: CollectionScaffold(
|
|
scrollController: ScrollController(),
|
|
isSelectionMode: selecting,
|
|
onExitSelectionMode: () {},
|
|
bottomInset: 0,
|
|
appBar: const SliverToBoxAdapter(child: SizedBox(height: 10)),
|
|
slivers: const [SliverToBoxAdapter(child: Text('content'))],
|
|
selectionBar: const Text('selection-bar'),
|
|
),
|
|
);
|
|
|
|
await tester.pumpWidget(host(selecting: false));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('selection-bar'), findsNothing);
|
|
|
|
await tester.pumpWidget(host(selecting: true));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('selection-bar'), findsOneWidget);
|
|
|
|
await tester.pumpWidget(host(selecting: false));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('selection-bar'), findsNothing);
|
|
});
|
|
|
|
testWidgets('reserves the measured height of a multi-row selection bar', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: CollectionScaffold(
|
|
scrollController: ScrollController(),
|
|
isSelectionMode: true,
|
|
onExitSelectionMode: () {},
|
|
bottomInset: 0,
|
|
appBar: const SliverToBoxAdapter(child: SizedBox(height: 10)),
|
|
slivers: const [SliverToBoxAdapter(child: Text('content'))],
|
|
selectionBar: const SizedBox(height: 260),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
tester
|
|
.getSize(find.byKey(const ValueKey('collection-selection-spacer')))
|
|
.height,
|
|
260,
|
|
);
|
|
});
|
|
|
|
test('every collection screen goes through the shared shell', () {
|
|
const screens = [
|
|
'album_screen.dart',
|
|
'playlist_screen.dart',
|
|
'local_album_screen.dart',
|
|
'downloaded_album_screen.dart',
|
|
'library_tracks_folder_screen.dart',
|
|
];
|
|
final offenders = <String>[];
|
|
for (final name in screens) {
|
|
final file = _libSources().firstWhere(
|
|
(candidate) => _basename(candidate) == name,
|
|
);
|
|
if (!file.readAsStringSync().contains('CollectionScaffold(')) {
|
|
offenders.add(name);
|
|
}
|
|
}
|
|
|
|
expect(
|
|
offenders,
|
|
isEmpty,
|
|
reason:
|
|
'Collection screens must share CollectionScaffold; hand-rolled '
|
|
'Scaffold + PopScope + selection plumbing is what let the four '
|
|
'album-style screens drift apart.',
|
|
);
|
|
});
|
|
|
|
test('the playlist screen supports multi-select', () {
|
|
final playlist = _libSources().firstWhere(
|
|
(file) => _basename(file) == 'playlist_screen.dart',
|
|
);
|
|
final source = playlist.readAsStringSync();
|
|
|
|
expect(source, contains('SelectionModeMixin<PlaylistScreen>'));
|
|
expect(source, contains('SelectionBottomBar('));
|
|
expect(source, contains('isSelectionMode: isSelectionMode'));
|
|
});
|
|
});
|
|
|
|
group('settings components', () {
|
|
testWidgets('choice chip meets the minimum touch target', (tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: Row(
|
|
children: [
|
|
SettingsChoiceChip(
|
|
label: 'Stable',
|
|
isSelected: true,
|
|
onTap: () {},
|
|
expand: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final size = tester.getSize(find.byType(SettingsChoiceChip));
|
|
expect(
|
|
size.height,
|
|
greaterThanOrEqualTo(AppTokens.standard.minTouchTarget),
|
|
);
|
|
});
|
|
|
|
testWidgets('info card renders title, message and action', (tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: SettingsInfoCard(
|
|
icon: Icons.warning_amber_outlined,
|
|
tone: SettingsInfoTone.error,
|
|
title: 'Folder lost',
|
|
message: 'Pick it again',
|
|
action: TextButton(onPressed: () {}, child: const Text('Fix')),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.text('Folder lost'), findsOneWidget);
|
|
expect(find.text('Pick it again'), findsOneWidget);
|
|
expect(find.text('Fix'), findsOneWidget);
|
|
});
|
|
});
|
|
}
|