From 1458b74d5b242ed00a85685901b19b875ab87d6f Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 29 Jul 2026 17:13:50 +0700 Subject: [PATCH] test(ui): cover shared design system contracts --- test/design_system_test.dart | 518 +++++++++++++++++++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 test/design_system_test.dart diff --git a/test/design_system_test.dart b/test/design_system_test.dart new file mode 100644 index 00000000..98e6b954 --- /dev/null +++ b/test/design_system_test.dart @@ -0,0 +1,518 @@ +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_sliver_header.dart'; +import 'package:spotiflac_android/widgets/collection_scaffold.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 _libSources() { + return Directory('lib') + .listSync(recursive: true) + .whereType() + .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.standard); + expect(AppTheme.dark().extension(), AppTokens.standard); + expect( + AppTheme.dark(isAmoled: true).extension(), + 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('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( + 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(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( + 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('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(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.', + ); + }); + }); + + 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 = []; + 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')); + 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); + }); + }); +}