From d40a585b5848cc5bfe788c372a2bd1c028b9308d Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 21 May 2026 23:22:57 +0300 Subject: Implement FolderBrowserScreen with browseSet endpoint and breadcrumb navigation (va) Adds a FolderBrowserScreen that calls browseSet on init and pull-to-refresh, renders subfolders first (with cover via setFolderCoverUrl) then media items, and provides a scrollable breadcrumb bar for navigating the folder hierarchy. Key changes: - player-android/lib/screens/folder_browser_screen.dart: new screen with loading/empty/error/refresh states, generation-counter cancellation, and no Dio import in the screen layer (DIP). - player-android/lib/api/player_api_client.dart: add setFolderCoverUrl() so screens never access rawDio directly for URL construction (DIP). - player-android/lib/utils/duration_formatter.dart: extract shared formatDuration() from MediaGridScreen to eliminate the DRY violation. - player-android/lib/screens/media_grid_screen.dart: delegate to formatDuration() from the shared utility. - player-android/lib/utils/error_mappers.dart: add folderErrorMessage(). - player-android/lib/app_routes.dart: add folderBrowser and folderBrowserPath(). - player-android/lib/router.dart: wire /browse/:setId GoRoute. - player-android/test/screens/folder_browser_screen_test.dart: 16 widget tests covering renders, breadcrumbs, folder/media tap navigation, empty, error, retry, and pull-to-refresh. All 298 tests pass; flutter analyze reports no issues. Co-Authored-By: Claude Sonnet 4.6 --- player-android/lib/api/player_api_client.dart | 13 + player-android/lib/app_routes.dart | 15 + player-android/lib/router.dart | 22 + .../lib/screens/folder_browser_screen.dart | 758 +++++++++++++++++++++ player-android/lib/screens/media_grid_screen.dart | 16 +- player-android/lib/utils/duration_formatter.dart | 29 + player-android/lib/utils/error_mappers.dart | 20 + .../test/screens/folder_browser_screen_test.dart | 624 +++++++++++++++++ 8 files changed, 1485 insertions(+), 12 deletions(-) create mode 100644 player-android/lib/screens/folder_browser_screen.dart create mode 100644 player-android/lib/utils/duration_formatter.dart create mode 100644 player-android/test/screens/folder_browser_screen_test.dart diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index 7f41ec2..a1f4fe9 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -153,6 +153,19 @@ class PlayerApiClient { String streamUrl(int mediaId) => '${rawDio.options.baseUrl}/api/v1/media/$mediaId/stream'; + /// Returns the URL for the cover image of a set's root or subfolder. + /// + /// Mirrors [thumbnailUrl] and [streamUrl]: the API path + /// `/api/v1/sets/{id}/cover` is kept in one place so the UI layer never + /// needs to access Dio internals or hard-code URL segments (DIP). + /// + /// [folder] is the optional subfolder path (empty or null = set root cover). + String setFolderCoverUrl(int setId, {String? folder}) { + final base = '${rawDio.options.baseUrl}/api/v1/sets/$setId/cover'; + if (folder == null || folder.isEmpty) return base; + return '$base?folder=${Uri.encodeComponent(folder)}'; + } + /// Returns the public share URL for a share [token]. /// /// Mirrors [thumbnailUrl] and [streamUrl]: the share path `/s/{token}` is diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index 32998aa..533c9b8 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -60,6 +60,21 @@ abstract final class AppRoutes { /// Opens [MySharesScreen] from Settings. static const shares = '/shares'; + /// Route for browsing folders within a set. + /// + /// The ':setId' path segment identifies the set; the optional 'path' query + /// parameter specifies the current subfolder (empty or absent = root). + static const folderBrowser = '/browse/:setId'; + + /// Returns the concrete path for the folder browser of a given [setId]. + /// + /// [path] is the optional subfolder path; omit or pass null for the root. + static String folderBrowserPath(int setId, {String? path}) { + final base = '/browse/$setId'; + if (path == null || path.isEmpty) return base; + return '$base?path=${Uri.encodeComponent(path)}'; + } + /// Returns the concrete path for the notes editor of a given [mediaId]. static String notesPath(String mediaId) => '/notes/$mediaId'; } diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 03f14bc..170cfd5 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -18,6 +18,7 @@ import 'screens/settings_screen.dart'; import 'screens/share_screen.dart'; import 'screens/my_shares_screen.dart'; import 'screens/notes_editor_screen.dart'; +import 'screens/folder_browser_screen.dart'; import 'screens/video_player_screen.dart'; // Re-export AppRoutes so existing callers that import router.dart for routes @@ -187,6 +188,27 @@ final routerProvider = Provider((ref) { path: AppRoutes.shares, builder: (context, state) => const MySharesScreen(), ), + GoRoute( + // Folder browser — shows subfolders and media at the current path + // within a set. The ':setId' path segment identifies the set; + // the optional 'path' query parameter identifies the current subfolder + // (absent or empty means root). + path: AppRoutes.folderBrowser, + builder: (context, state) { + final raw = state.pathParameters['setId']!; + final setId = int.tryParse(raw) ?? 0; + final path = state.uri.queryParameters['path']; + // setName is optionally passed as a String extra so the screen can + // show the set name in the app bar without an extra API call. + final setName = + state.extra is String ? state.extra as String : null; + return FolderBrowserScreen( + setId: setId, + path: path, + setName: setName, + ); + }, + ), ], ); }); diff --git a/player-android/lib/screens/folder_browser_screen.dart b/player-android/lib/screens/folder_browser_screen.dart new file mode 100644 index 0000000..175c66b --- /dev/null +++ b/player-android/lib/screens/folder_browser_screen.dart @@ -0,0 +1,758 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../app_routes.dart'; +import '../models/models.dart'; +import '../providers/api_client_provider.dart'; +import '../utils/duration_formatter.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Data models (file-private) +// --------------------------------------------------------------------------- + +/// Represents a subfolder returned by the browseSet API endpoint. +/// +/// [name] is the folder's display name and last path component. +/// [hasCover] indicates whether the server has a cover image for this folder. +class _BrowseFolder { + const _BrowseFolder({required this.name, required this.hasCover}); + + final String name; + final bool hasCover; +} + +/// Parsed result from the browseSet API response. +/// +/// Holds the canonical current path returned by the server along with lists +/// of subfolders and media items at that path. +class _BrowseResult { + const _BrowseResult({ + required this.currentPath, + required this.folders, + required this.media, + }); + + final String currentPath; + final List<_BrowseFolder> folders; + final List media; +} + +// --------------------------------------------------------------------------- +// Parsing helpers (file-private, pure functions — easy to unit-test) +// --------------------------------------------------------------------------- + +/// Parses the raw JSON map from [PlayerApiClient.browseSet] into a typed +/// [_BrowseResult]. +/// +/// Unknown or missing fields are handled gracefully: the result defaults to +/// empty lists rather than throwing, so partial server responses degrade +/// gracefully (resilience principle). +_BrowseResult _parseBrowseResult(Map raw) { + final currentPath = raw['current_path'] as String? ?? ''; + final rawFolders = raw['folders'] as List? ?? []; + final rawMedia = raw['media'] as List? ?? []; + + final folders = rawFolders + .whereType>() + .map( + (f) => _BrowseFolder( + name: f['name'] as String? ?? '', + hasCover: f['has_cover'] as bool? ?? false, + ), + ) + .where((f) => f.name.isNotEmpty) + .toList(); + + final media = rawMedia + .whereType>() + .map(Media.fromJson) + .toList(); + + return _BrowseResult( + currentPath: currentPath, + folders: folders, + media: media, + ); +} + +/// Splits a slash-delimited [path] into breadcrumb segments. +/// +/// Returns a list of `(label, accumulatedPath)` pairs — the first entry is +/// always the root `('Home', '')`, followed by one entry per path component. +/// This pure function makes the breadcrumb logic independently testable. +List<({String label, String path})> _buildBreadcrumbs(String path) { + final crumbs = <({String label, String path})>[ + (label: 'Home', path: ''), + ]; + if (path.isEmpty) return crumbs; + + final parts = path.split('/').where((p) => p.isNotEmpty).toList(); + var accumulated = ''; + for (final part in parts) { + accumulated = accumulated.isEmpty ? part : '$accumulated/$part'; + crumbs.add((label: part, path: accumulated)); + } + return crumbs; +} + +// --------------------------------------------------------------------------- +// FolderBrowserScreen +// --------------------------------------------------------------------------- + +/// Displays the contents of a single folder within a [MediaSet]. +/// +/// Shows subfolders first (with cover thumbnail) followed by media items. +/// A breadcrumb bar at the top lets the user navigate back up the tree. +/// +/// Design notes: +/// - Accepts [setId] and optional [path] as constructor parameters so the +/// screen is independently testable and reusable (DIP / ISP). +/// - No `dio` import: errors are mapped by [folderErrorMessage] (DIP). +/// - All async continuations guard on [mounted] to prevent setState after +/// disposal (Flutter best practice). +/// - The generation counter prevents stale responses from overwriting +/// fresher data when navigations happen in quick succession. +class FolderBrowserScreen extends ConsumerStatefulWidget { + /// The numeric identifier of the set to browse. + final int setId; + + /// The current subfolder path (empty or null = root of the set). + final String? path; + + /// Optional human-readable set name shown in the app bar. + /// + /// Passed as a route extra by callers so the bar shows the name immediately + /// without an extra API call. + final String? setName; + + const FolderBrowserScreen({ + super.key, + required this.setId, + this.path, + this.setName, + }); + + @override + ConsumerState createState() => + _FolderBrowserScreenState(); +} + +class _FolderBrowserScreenState extends ConsumerState { + // Nullable: null means "not yet loaded" (loading indicator is shown). + _BrowseResult? _result; + + // Non-null when the last load attempt failed. + String? _error; + + // True while the initial or refresh load is in flight. + bool _isLoading = false; + + // Generation counter used to discard stale async responses (cancellation- + // by-generation pattern, consistent with MediaGridScreen). + int _loadGeneration = 0; + + @override + void initState() { + super.initState(); + // Defer the first load until after the first frame so [ref] is fully + // bound and any test-environment provider overrides are applied. + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + /// Fetches subfolder and media content for [widget.setId] at [widget.path] + /// and updates local state. + /// + /// The generation counter ensures responses from superseded loads are + /// silently discarded, preventing race conditions on rapid navigations. + Future _load() async { + if (!mounted) return; + + final generation = ++_loadGeneration; + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final client = ref.read(apiClientProvider); + final raw = await client.browseSet( + widget.setId, + parent: widget.path, + ); + + if (!mounted || generation != _loadGeneration) return; + + setState(() { + _result = _parseBrowseResult(raw); + _isLoading = false; + }); + } catch (e) { + if (!mounted || generation != _loadGeneration) return; + setState(() { + _error = folderErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Navigation helpers + // --------------------------------------------------------------------------- + + /// Navigates into [folderName] by pushing a new [FolderBrowserScreen] route. + /// + /// The new path is the current path joined with [folderName]. Using + /// `context.push` (rather than `go`) keeps the back-stack intact so the + /// user can navigate back up to the parent folder. + void _openFolder(String folderName) { + final currentPath = widget.path ?? ''; + final newPath = + currentPath.isEmpty ? folderName : '$currentPath/$folderName'; + context.push( + AppRoutes.folderBrowserPath(widget.setId, path: newPath), + extra: widget.setName, + ); + } + + /// Navigates to a breadcrumb [crumbPath]. + /// + /// For the root ('') we pop until the route matching the root path; for + /// intermediate paths we push the new screen. This keeps the navigator + /// stack correct in all navigation directions. + void _navigateToBreadcrumb(String crumbPath) { + if (crumbPath == (widget.path ?? '')) return; // already here + context.push( + AppRoutes.folderBrowserPath(widget.setId, path: crumbPath), + extra: widget.setName, + ); + } + + /// Navigates to the [MediaDetailScreen] for [mediaId]. + void _openMedia(int mediaId) => + context.push(AppRoutes.mediaDetailPath(mediaId)); + + /// Returns the URL for the cover image of a folder at [folderPath] within + /// [widget.setId]. + /// + /// Delegates URL construction to [PlayerApiClient.setFolderCoverUrl] so this + /// screen never builds API paths or accesses Dio internals directly + /// (Dependency Inversion Principle, consistent with [thumbnailUrl] pattern). + String _coverUrl(String folderPath) { + final client = ref.read(apiClientProvider); + return client.setFolderCoverUrl(widget.setId, folder: folderPath); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(), + body: _buildBody(), + ); + } + + /// Builds the app bar with the set name (or a default label). + AppBar _buildAppBar() { + return AppBar( + title: Text(widget.setName ?? 'Set ${widget.setId}'), + ); + } + + /// Dispatches to the appropriate body widget based on the current state. + Widget _buildBody() { + // Full-screen spinner only on the very first load (no data yet). + if (_isLoading && _result == null) { + return const Center( + key: Key('folder_loading'), + child: CircularProgressIndicator(), + ); + } + + // Error view with a retry button. + if (_error != null) { + return _ErrorView(message: _error!, onRetry: _load); + } + + return RefreshIndicator( + onRefresh: _load, + child: _buildContent(), + ); + } + + /// Builds the scrollable content: breadcrumb bar + folder list + media list. + Widget _buildContent() { + final result = _result; + final path = widget.path ?? ''; + final crumbs = _buildBreadcrumbs(path); + + return CustomScrollView( + key: const Key('folder_browser_scroll'), + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Breadcrumb navigation bar. + SliverToBoxAdapter( + child: _BreadcrumbBar( + crumbs: crumbs, + onTap: _navigateToBreadcrumb, + ), + ), + + // Content: empty state, or folders + media list. + if (result == null || (result.folders.isEmpty && result.media.isEmpty)) + const SliverFillRemaining( + hasScrollBody: false, + child: _EmptyView(), + ) + else ...[ + // Subfolder tiles shown before media items. + // Item 0 is the section header; items 1..n are the folder tiles. + if (result.folders.isNotEmpty) + SliverList.builder( + itemCount: result.folders.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return const _SectionHeader( + key: Key('folder_section_header'), + label: 'Folders', + ); + } + final folder = result.folders[index - 1]; + final folderPath = path.isEmpty + ? folder.name + : '$path/${folder.name}'; + return _FolderTile( + key: Key('folder_tile_${folder.name}'), + folder: folder, + coverUrl: folder.hasCover ? _coverUrl(folderPath) : '', + onTap: () => _openFolder(folder.name), + ); + }, + ), + + // Media items shown below subfolders. + // Item 0 is the section header; items 1..n are the media tiles. + if (result.media.isNotEmpty) + SliverList.builder( + itemCount: result.media.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return const _SectionHeader( + key: Key('media_section_header'), + label: 'Media', + ); + } + final item = result.media[index - 1]; + return _MediaTile( + key: Key('media_tile_${item.id}'), + item: item, + thumbnailUrl: _thumbnailUrl(item.id), + onTap: () => _openMedia(item.id), + ); + }, + ), + ], + ], + ); + } + + /// Returns the thumbnail URL for a media item. + /// + /// Delegates to [PlayerApiClient.thumbnailUrl] so no API path is + /// hard-coded in the screen layer (DIP). + String _thumbnailUrl(int mediaId) { + final client = ref.read(apiClientProvider); + return client.thumbnailUrl(mediaId); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Horizontal scrollable breadcrumb bar showing the current folder hierarchy. +/// +/// Each crumb is a tappable [TextButton]; the last crumb (current folder) is +/// shown in a highlighted style and is not interactive. +class _BreadcrumbBar extends StatelessWidget { + const _BreadcrumbBar({required this.crumbs, required this.onTap}); + + /// Ordered list of `(label, path)` pairs, root-first. + final List<({String label, String path})> crumbs; + + /// Called when any non-last crumb is tapped; receives the target path. + final void Function(String path) onTap; + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('breadcrumb_bar'), + color: Theme.of(context).colorScheme.surfaceContainerLow, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _buildCrumbs(context), + ), + ), + ); + } + + /// Builds the crumb widgets interleaved with separator chevrons. + List _buildCrumbs(BuildContext context) { + final widgets = []; + for (var i = 0; i < crumbs.length; i++) { + final crumb = crumbs[i]; + final isLast = i == crumbs.length - 1; + + if (i > 0) { + // Separator between crumbs. + widgets.add( + Icon( + Icons.chevron_right, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + widgets.add( + isLast + // Current folder: styled as body text, non-tappable. + ? Text( + crumb.label, + key: Key('breadcrumb_current_${crumb.label}'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ) + // Ancestor folder: tappable TextButton. + : TextButton( + key: Key('breadcrumb_${crumb.label}'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => onTap(crumb.path), + child: Text(crumb.label), + ), + ); + } + return widgets; + } +} + +/// Section header label (e.g. "Folders", "Media"). +/// +/// Separates the folder list from the media list with a lightweight divider +/// and label so the user understands the two sections at a glance. +class _SectionHeader extends StatelessWidget { + const _SectionHeader({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +/// List tile for a single subfolder entry. +/// +/// Shows a folder cover image (if available) or a folder icon placeholder, +/// and the folder name. Tapping fires [onTap] to navigate into the folder. +class _FolderTile extends StatelessWidget { + const _FolderTile({ + super.key, + required this.folder, + required this.coverUrl, + required this.onTap, + }); + + final _BrowseFolder folder; + + /// Full URL of the folder cover image; empty string means no cover. + final String coverUrl; + + /// Called when the tile is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: _FolderCoverImage( + key: Key('folder_cover_${folder.name}'), + coverUrl: coverUrl, + ), + title: Text( + folder.name, + key: Key('folder_name_${folder.name}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ); + } +} + +/// Square cover thumbnail for a folder. +/// +/// Falls back to a folder icon placeholder when no cover URL is provided or +/// when the network request fails. Consistent with the [_ThumbnailImage] +/// pattern in [MediaGridScreen]. +class _FolderCoverImage extends StatelessWidget { + const _FolderCoverImage({super.key, required this.coverUrl}); + + final String coverUrl; + + @override + Widget build(BuildContext context) { + const size = 48.0; + + if (coverUrl.isEmpty) return _placeholder(context, size); + + return SizedBox( + width: size, + height: size, + child: CachedNetworkImage( + imageUrl: coverUrl, + fit: BoxFit.cover, + placeholder: (_, __) => + const Center(child: CircularProgressIndicator()), + errorWidget: (_, __, ___) => _placeholder(context, size), + ), + ); + } + + static Widget _placeholder(BuildContext context, double size) => SizedBox( + width: size, + height: size, + child: ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.folder_outlined, + size: size * 0.6, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); +} + +/// List tile for a single media item. +/// +/// Shows a thumbnail (or icon placeholder), the file name, type icon, and +/// formatted duration. Tapping fires [onTap] to navigate to the detail screen. +class _MediaTile extends StatelessWidget { + const _MediaTile({ + super.key, + required this.item, + required this.thumbnailUrl, + required this.onTap, + }); + + final Media item; + + /// Full URL of the media thumbnail; empty string means no thumbnail. + final String thumbnailUrl; + + /// Called when the tile is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: _MediaThumbnail( + key: Key('media_thumb_${item.id}'), + thumbnailUrl: thumbnailUrl, + type: item.type, + ), + title: Text( + item.fileName, + key: Key('media_tile_name_${item.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + Icon(_typeIcon(item.type), size: 12), + const SizedBox(width: 4), + Text( + _formatDuration(item.duration), + key: Key('media_tile_duration_${item.id}'), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + onTap: onTap, + ); + } + + /// Returns an appropriate icon for the given media [type]. + static IconData _typeIcon(String type) { + switch (type) { + case 'video': + return Icons.videocam_outlined; + case 'audio': + return Icons.headphones_outlined; + case 'image': + return Icons.image_outlined; + default: + return Icons.insert_drive_file_outlined; + } + } + + /// Formats [seconds] as `h:mm:ss` or `m:ss`, omitting leading zeros. + /// + /// Delegates to the shared [formatDuration] helper (DRY) so the formatting + /// logic lives in exactly one place across all screen widgets. + static String _formatDuration(double seconds) => formatDuration(seconds); +} + +/// Small square thumbnail for a media tile. +/// +/// Mirrors the [_ThumbnailImage] approach from [MediaGridScreen], using +/// [CachedNetworkImage] with placeholder and error fallback. +class _MediaThumbnail extends StatelessWidget { + const _MediaThumbnail({ + super.key, + required this.thumbnailUrl, + required this.type, + }); + + final String thumbnailUrl; + + /// Media type string used to pick the placeholder icon. + final String type; + + @override + Widget build(BuildContext context) { + const size = 48.0; + + if (thumbnailUrl.isEmpty) return _placeholder(context, size); + + return SizedBox( + width: size, + height: size, + child: CachedNetworkImage( + imageUrl: thumbnailUrl, + fit: BoxFit.cover, + placeholder: (_, __) => + const Center(child: CircularProgressIndicator()), + errorWidget: (_, __, ___) => _placeholder(context, size), + ), + ); + } + + static Widget _placeholder(BuildContext context, double size) => SizedBox( + width: size, + height: size, + child: ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.image_outlined, + size: size * 0.6, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); +} + +/// Full-screen empty-state view shown when the current folder is empty. +/// +/// Wrapped in a layout that fills the sliver so pull-to-refresh gestures are +/// still captured by [RefreshIndicator]. +class _EmptyView extends StatelessWidget { + const _EmptyView(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.folder_open_outlined, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'This folder is empty', + key: const Key('folder_empty'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Pull down to refresh.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} + +/// Full-screen error view with a retry button. +/// +/// Shown when [browseSet] throws (network error, server error, etc.). +/// The [message] comes from [folderErrorMessage], which maps exceptions to +/// human-readable strings without exposing Dio to the screen layer. +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 56, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + message, + key: const Key('folder_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('folder_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/player-android/lib/screens/media_grid_screen.dart b/player-android/lib/screens/media_grid_screen.dart index 9e58b15..9d75fd5 100644 --- a/player-android/lib/screens/media_grid_screen.dart +++ b/player-android/lib/screens/media_grid_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../app_routes.dart'; import '../models/models.dart'; import '../providers/api_client_provider.dart'; +import '../utils/duration_formatter.dart'; import '../utils/error_mappers.dart'; import '../widgets/search_filter_bar.dart'; @@ -599,18 +600,9 @@ class _InfoOverlay extends StatelessWidget { /// Formats [seconds] as `h:mm:ss` or `m:ss`, omitting leading zeros. /// - /// Uses integer arithmetic only — no Duration formatting dependency — to - /// keep this helper lightweight and independently testable. - static String _formatDuration(double seconds) { - final total = seconds.truncate(); - final h = total ~/ 3600; - final m = (total % 3600) ~/ 60; - final s = total % 60; - if (h > 0) { - return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; - } - return '$m:${s.toString().padLeft(2, '0')}'; - } + /// Delegates to the shared [formatDuration] helper in `duration_formatter.dart` + /// (DRY) so the formatting logic lives in exactly one place. + static String _formatDuration(double seconds) => formatDuration(seconds); } /// Full-screen empty-state view, shown when [listMedia] returns an empty list. diff --git a/player-android/lib/utils/duration_formatter.dart b/player-android/lib/utils/duration_formatter.dart new file mode 100644 index 0000000..72e810b --- /dev/null +++ b/player-android/lib/utils/duration_formatter.dart @@ -0,0 +1,29 @@ +// Duration formatting utilities shared across screen widgets. +// +// Centralises the conversion of fractional seconds to a human-readable string +// so that [MediaGridScreen] and [FolderBrowserScreen] do not each carry their +// own copy of the same logic (DRY / Single Responsibility). +// +// All functions are pure data-transformations: no widget state, no Riverpod +// reads, no BuildContext — making them easy to unit-test in isolation. + +/// Formats [seconds] as `h:mm:ss` or `m:ss`, omitting leading zeros in the +/// hours and minutes positions. +/// +/// Examples: +/// - 7320.0 → "2:02:00" +/// - 210.5 → "3:30" +/// - 30.0 → "0:30" +/// +/// Uses integer arithmetic only — no [Duration] dependency — so the helper +/// is lightweight and independently testable. +String formatDuration(double seconds) { + final total = seconds.truncate(); + final h = total ~/ 3600; + final m = (total % 3600) ~/ 60; + final s = total % 60; + if (h > 0) { + return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + return '$m:${s.toString().padLeft(2, '0')}'; +} diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index 6ce5f33..3e54ce7 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -238,3 +238,23 @@ String sharesErrorMessage(Object error) { } return 'Unexpected error. Please try again.'; } + +/// Maps any thrown object from [PlayerApiClient.browseSet] to a UI string. +/// +/// Adds a 403-specific message (permission denied) and a 404 message (set not +/// found) on top of the generic connection-error fallback, so +/// FolderBrowserScreen can surface actionable guidance rather than a raw code. +/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve +/// independently of the other mappers. +String folderErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'Folder not found. It may have been removed.'; + } + if (error.response?.statusCode == 403) { + return 'You do not have permission to browse this folder.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} diff --git a/player-android/test/screens/folder_browser_screen_test.dart b/player-android/test/screens/folder_browser_screen_test.dart new file mode 100644 index 0000000..b645253 --- /dev/null +++ b/player-android/test/screens/folder_browser_screen_test.dart @@ -0,0 +1,624 @@ +// Widget tests for FolderBrowserScreen (folder_browser_screen.dart). +// +// Tests cover: +// 1. Shows loading indicator while browseSet is in flight. +// 2. Renders folder tiles and media tiles after a successful load. +// 3. Breadcrumb bar reflects the current path (one crumb for root, multiple +// for nested paths). +// 4. Tapping a folder tile navigates deeper (pushes a new route with the +// updated path). +// 5. Tapping a media tile navigates to the media-detail route. +// 6. Shows an empty-state view when browseSet returns no folders/media. +// 7. Shows an error view when browseSet throws, with a retry button. +// 8. Pull-to-refresh calls browseSet a second time. +// 9. Breadcrumb tap navigates to the tapped ancestor path. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/folder_browser_screen_test.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:player_android/api/dio_client.dart'; +import 'package:player_android/api/player_api_client.dart'; +import 'package:player_android/providers/api_client_provider.dart'; +import 'package:player_android/screens/folder_browser_screen.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] that returns a fixed test token. +/// +/// Avoids the platform-specific OS keychain in widget tests. +class _FakeTokenStorage implements TokenStorage { + const _FakeTokenStorage(); + + @override + Future readToken() async => 'test-token'; + + @override + Future writeToken(String token) async {} + + @override + Future deleteToken() async {} +} + +/// Controllable [PlayerApiClient] stub for [FolderBrowserScreen] tests. +/// +/// Only [browseSet], [thumbnailUrl], and [setFolderCoverUrl] are implemented; +/// all other methods remain [UnimplementedError] — the screen calls only these. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When non-null, [browseSet] returns this map. + Map? browseResult; + + /// When non-null, [browseSet] throws this instead of returning. + Object? browseError; + + /// Records the number of [browseSet] calls for refresh tests. + int browseCallCount = 0; + + /// The [parent] argument passed to the most recent [browseSet] call. + String? lastParent; + + @override + Future> browseSet(int setId, {String? parent}) async { + browseCallCount++; + lastParent = parent; + if (browseError != null) throw browseError!; + return browseResult!; + } + + /// Returns an empty string so thumbnail widgets show the placeholder instead + /// of making a network request — keeps tests hermetic. + @override + String thumbnailUrl(int mediaId) => ''; + + /// Returns an empty string so folder-cover images show the placeholder + /// instead of making a network request — keeps tests hermetic. + @override + String setFolderCoverUrl(int setId, {String? folder}) => ''; +} + +/// [PlayerApiClient] stub that delays [browseSet] until [complete] is called. +/// +/// Used to inspect mid-flight loading state before the response arrives. +class _DelayedFakeApiClient extends PlayerApiClient { + _DelayedFakeApiClient() : super(dio: Dio()); + + final _completer = Completer>(); + + /// Resolves the pending [browseSet] call with [result]. + void complete(Map result) => _completer.complete(result); + + @override + Future> browseSet(int setId, {String? parent}) => + _completer.future; + + /// Returns an empty string so thumbnail widgets show the placeholder instead + /// of making a network request — keeps tests hermetic. + @override + String thumbnailUrl(int mediaId) => ''; + + /// Returns an empty string so folder-cover images show the placeholder + /// instead of making a network request — keeps tests hermetic. + @override + String setFolderCoverUrl(int setId, {String? folder}) => ''; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// A browseSet response at the root with two subfolders and one media item. +/// +/// Both folders set [has_cover] to false so tests stay hermetic — no +/// [CachedNetworkImage] network requests are made during widget pumps. +const _kRootBrowseResult = { + 'current_path': '', + 'folders': [ + {'name': 'FolderA', 'has_cover': false}, + {'name': 'FolderB', 'has_cover': false}, + ], + 'media': [ + { + 'id': 1, + 'set_id': 10, + 'rel_path': 'root_video.mp4', + 'file_name': 'root_video.mp4', + 'abs_path': '/media/set/root_video.mp4', + 'type': 'video', + 'duration': 120.0, + 'codec': 'h264', + 'resolution': '1920x1080', + 'bitrate': 4000, + 'file_size_bytes': 15000000, + 'width': 1920, + 'height': 1080, + 'thumbnail_path': '', + 'play_count': 0, + } + ], +}; + +/// A browseSet response for FolderA containing one subfolder and two media items. +const _kFolderABrowseResult = { + 'current_path': 'FolderA', + 'folders': [ + {'name': 'SubFolderA1', 'has_cover': false}, + ], + 'media': [ + { + 'id': 2, + 'set_id': 10, + 'rel_path': 'FolderA/audio.mp3', + 'file_name': 'audio.mp3', + 'abs_path': '/media/set/FolderA/audio.mp3', + 'type': 'audio', + 'duration': 210.0, + 'codec': 'mp3', + 'resolution': '', + 'bitrate': 320, + 'file_size_bytes': 8000000, + 'width': 0, + 'height': 0, + 'thumbnail_path': '', + 'play_count': 0, + }, + { + 'id': 3, + 'set_id': 10, + 'rel_path': 'FolderA/clip.mp4', + 'file_name': 'clip.mp4', + 'abs_path': '/media/set/FolderA/clip.mp4', + 'type': 'video', + 'duration': 30.0, + 'codec': 'h264', + 'resolution': '1280x720', + 'bitrate': 2000, + 'file_size_bytes': 3000000, + 'width': 1280, + 'height': 720, + 'thumbnail_path': '', + 'play_count': 0, + }, + ], +}; + +/// A browseSet response with no folders and no media (empty folder). +const _kEmptyBrowseResult = { + 'current_path': '', + 'folders': >[], + 'media': >[], +}; + +// --------------------------------------------------------------------------- +// Helper: pump FolderBrowserScreen inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Stub route shown after navigating to the media-detail screen. +const _kMediaDetailKey = Key('nav_media_detail'); + +/// Builds a [GoRouter] with [FolderBrowserScreen] and stub detail + child routes +/// so navigation tests can verify that the correct destination is reached. +GoRouter _buildRouter( + PlayerApiClient fakeClient, { + String? path, + String? setName, +}) { + return GoRouter( + initialLocation: path != null + ? '/browse/10?path=${Uri.encodeComponent(path)}' + : '/browse/10', + routes: [ + GoRoute( + path: '/browse/:setId', + builder: (context, state) { + final setId = int.tryParse(state.pathParameters['setId']!) ?? 0; + final p = state.uri.queryParameters['path']; + final name = + state.extra is String ? state.extra as String : setName; + return FolderBrowserScreen(setId: setId, path: p, setName: name); + }, + ), + GoRoute( + // Stub for media-detail navigation. + path: '/media/:id', + builder: (context, state) => Scaffold( + body: Text( + 'Media ${state.pathParameters['id']}', + key: _kMediaDetailKey, + ), + ), + ), + ], + ); +} + +/// Pumps [FolderBrowserScreen] (set 10, optional [path]) inside a +/// [ProviderScope] that overrides [apiClientProvider] and +/// [tokenStorageProvider] with fakes. +Future _pumpScreen( + WidgetTester tester, + PlayerApiClient fakeClient, { + String? path, + String? setName, +}) async { + final router = _buildRouter(fakeClient, path: path, setName: setName); + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), + apiClientProvider.overrideWithValue(fakeClient), + ], + child: MaterialApp.router( + routerConfig: router, + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('shows loading indicator while browseSet is in flight', + (tester) async { + final fakeClient = _DelayedFakeApiClient(); + + await _pumpScreen(tester, fakeClient); + + // One frame: initState fires, addPostFrameCallback enqueues the load, + // but the Future has not yet resolved. + await tester.pump(); + + expect(find.byKey(const Key('folder_loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Resolve to prevent "pending async work" warnings. + fakeClient.complete(_kRootBrowseResult); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Renders content + // -------------------------------------------------------------------------- + + group('renders folders and media', () { + testWidgets('shows folder tiles for each folder returned by browseSet', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kRootBrowseResult); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('folder_tile_FolderA')), + findsOneWidget, + ); + expect( + find.byKey(const Key('folder_tile_FolderB')), + findsOneWidget, + ); + expect(find.byKey(const Key('folder_name_FolderA')), findsOneWidget); + expect(find.byKey(const Key('folder_name_FolderB')), findsOneWidget); + }); + + testWidgets('shows media tiles for each media item returned by browseSet', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kRootBrowseResult); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('media_tile_1')), findsOneWidget); + expect( + find.byKey(const Key('media_tile_name_1')), + findsOneWidget, + ); + expect(find.text('root_video.mp4'), findsOneWidget); + }); + + testWidgets('shows section headers for folders and media', (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kRootBrowseResult); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('folder_section_header')), + findsOneWidget, + ); + expect( + find.byKey(const Key('media_section_header')), + findsOneWidget, + ); + }); + + testWidgets('renders duration formatted correctly', (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kFolderABrowseResult); + + await _pumpScreen(tester, fakeClient, path: 'FolderA'); + await tester.pumpAndSettle(); + + // audio.mp3: 210s = 3:30 + expect(find.text('3:30'), findsOneWidget); + // clip.mp4: 30s = 0:30 + expect(find.text('0:30'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Breadcrumb navigation + // -------------------------------------------------------------------------- + + group('breadcrumb navigation', () { + testWidgets('shows single "Home" crumb at root path', (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kRootBrowseResult); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // At root the only crumb is "Home" (as a non-tappable Text). + expect( + find.byKey(const Key('breadcrumb_current_Home')), + findsOneWidget, + ); + expect(find.byKey(const Key('breadcrumb_bar')), findsOneWidget); + }); + + testWidgets('shows parent crumbs and current crumb for nested path', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kFolderABrowseResult); + + await _pumpScreen(tester, fakeClient, path: 'FolderA'); + await tester.pumpAndSettle(); + + // "Home" is a tappable ancestor crumb. + expect(find.byKey(const Key('breadcrumb_Home')), findsOneWidget); + // "FolderA" is the current (non-tappable) crumb. + expect( + find.byKey(const Key('breadcrumb_current_FolderA')), + findsOneWidget, + ); + }); + + testWidgets('shows three crumbs for a two-level nested path', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = { + 'current_path': 'FolderA/SubFolderA1', + 'folders': >[], + 'media': >[], + }; + + await _pumpScreen(tester, fakeClient, path: 'FolderA/SubFolderA1'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('breadcrumb_Home')), findsOneWidget); + expect(find.byKey(const Key('breadcrumb_FolderA')), findsOneWidget); + expect( + find.byKey(const Key('breadcrumb_current_SubFolderA1')), + findsOneWidget, + ); + }); + }); + + // -------------------------------------------------------------------------- + // Folder tap navigates deeper + // -------------------------------------------------------------------------- + + group('folder tap navigates deeper', () { + testWidgets( + 'tapping a folder tile pushes a new route with the updated path', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kRootBrowseResult); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Tap FolderA — should push /browse/10?path=FolderA. + await tester.tap(find.byKey(const Key('folder_tile_FolderA'))); + + // Prepare the client for the child browse call. + fakeClient.browseResult = + Map.from(_kFolderABrowseResult); + await tester.pumpAndSettle(); + + // After navigation the new screen's breadcrumb should show FolderA as + // the current crumb. + expect( + find.byKey(const Key('breadcrumb_current_FolderA')), + findsOneWidget, + ); + }); + + testWidgets('browseSet is called with the child path after folder tap', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kRootBrowseResult); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // First call is for the root (parent == null). + expect(fakeClient.browseCallCount, equals(1)); + expect(fakeClient.lastParent, isNull); + + // Prepare for the child browse. + fakeClient.browseResult = + Map.from(_kFolderABrowseResult); + + await tester.tap(find.byKey(const Key('folder_tile_FolderA'))); + await tester.pumpAndSettle(); + + // A second browseSet call should have been made with parent='FolderA'. + expect(fakeClient.browseCallCount, greaterThanOrEqualTo(2)); + expect(fakeClient.lastParent, equals('FolderA')); + }); + }); + + // -------------------------------------------------------------------------- + // Media tap navigates to detail + // -------------------------------------------------------------------------- + + group('media tap navigates to detail', () { + testWidgets('tapping a media tile navigates to /media/:id', (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kRootBrowseResult); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('media_tile_1'))); + await tester.pumpAndSettle(); + + // Stub route at /media/:id should be on screen. + expect(find.byKey(_kMediaDetailKey), findsOneWidget); + expect(find.text('Media 1'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Empty state + // -------------------------------------------------------------------------- + + group('empty state', () { + testWidgets('shows empty-state view when browseSet returns no content', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = + Map.from(_kEmptyBrowseResult); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('folder_empty')), findsOneWidget); + expect( + find.byKey(const Key('folder_section_header')), + findsNothing, + ); + expect( + find.byKey(const Key('media_section_header')), + findsNothing, + ); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets('shows error message when browseSet throws a network error', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseError = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets/10/browse'), + type: DioExceptionType.connectionError, + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('folder_error')), findsOneWidget); + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets('retry button re-calls browseSet and shows content on success', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseError = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets/10/browse'), + type: DioExceptionType.connectionError, + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('folder_retry')), findsOneWidget); + + // Fix the error before tapping retry. + fakeClient + ..browseError = null + ..browseResult = Map.from(_kRootBrowseResult); + + await tester.tap(find.byKey(const Key('folder_retry'))); + await tester.pumpAndSettle(); + + // Grid content is now visible. + expect(find.byKey(const Key('folder_tile_FolderA')), findsOneWidget); + expect(fakeClient.browseCallCount, equals(2)); + }); + + testWidgets('shows 403 permission message for forbidden error', + (tester) async { + final fakeClient = _FakeApiClient() + ..browseError = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets/10/browse'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/api/v1/sets/10/browse'), + statusCode: 403, + ), + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect( + find.textContaining('do not have permission'), + findsOneWidget, + ); + }); + }); + + // -------------------------------------------------------------------------- + // Pull-to-refresh + // -------------------------------------------------------------------------- + + group('pull-to-refresh', () { + testWidgets('pull-to-refresh calls browseSet a second time', (tester) async { + final fakeClient = _FakeApiClient() + ..browseResult = Map.from(_kRootBrowseResult); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(fakeClient.browseCallCount, equals(1)); + + await tester.drag( + find.byKey(const Key('folder_browser_scroll')), + const Offset(0, 300), + ); + await tester.pumpAndSettle(); + + expect(fakeClient.browseCallCount, equals(2)); + }); + }); +} -- cgit v1.2.3