Files
oott/frontend/test/widget/notifications_list_test.dart
T
rzuastiandClaude Opus 4.8 8b8fa5ce89 Add pull-to-refresh to the notifications list
The devices list already supported pull-to-refresh; mirror that on the
notifications list by wrapping its CustomScrollView in a RefreshIndicator
with AlwaysScrollableScrollPhysics. Keep the existing list visible during
a refresh (_isLoading = _items.isEmpty) instead of flashing the skeleton,
matching the devices list behaviour.

Add widget tests covering pull-to-refresh for both lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:02:07 -04:00

151 lines
4.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/home/notifications_list.dart';
import 'package:frontend/theme/gruvbox_theme.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
testWidgets(
'marking an expanded "New" notification as read does not expand the next',
(tester) async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [
notificationJson(id: 1, title: 'First', body: 'First body'),
notificationJson(id: 2, title: 'Second', body: 'Second body'),
]),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
);
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
// Expand the first notification.
await tester.tap(find.byType(ListTile).first);
await tester.pumpAndSettle();
expect(find.text('Mark as read'), findsOneWidget);
// Mark it as read; it disappears from the "New"-filtered list.
await tester.tap(find.text('Mark as read'));
await tester.pumpAndSettle();
// The first notification is gone and the second stays collapsed: no
// card is expanded, so no action buttons are showing.
expect(find.textContaining('First'), findsNothing);
expect(find.textContaining('Second'), findsWidgets);
expect(find.text('Mark as read'), findsNothing);
// Dispose the widget so its polling timer is cancelled.
await tester.pumpWidget(const SizedBox());
},
);
testWidgets('changing pages scrolls back to the top of the list', (
tester,
) async {
tester.view.physicalSize = const Size(400, 300);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
List<Map<String, dynamic>> page(int firstId) => List.generate(
6,
(i) => notificationJson(id: firstId + i, title: 'Item ${firstId + i}'),
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, page(1)),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, page(7)),
queryParameters: {'is_new': true, 'page_offset': 5, 'page_limit': 6},
);
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
final scrollable = find.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
);
ScrollPosition position() =>
tester.state<ScrollableState>(scrollable).position;
// Scroll down to reveal the pagination bar.
await tester.drag(scrollable, const Offset(0, -2000));
await tester.pumpAndSettle();
expect(position().pixels, greaterThan(0));
// Advance a page: the list should jump back to the top.
await tester.tap(find.byTooltip('Next page'));
await tester.pumpAndSettle();
expect(find.textContaining('Item 7'), findsWidgets);
expect(position().pixels, 0);
await tester.pumpWidget(const SizedBox());
});
testWidgets('pulling the list down refetches notifications', (tester) async {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
// A single reply list whose contents are swapped before the pull, so the
// same route re-serializes fresh data on the refresh request.
final items = <Map<String, dynamic>>[
notificationJson(id: 1, title: 'Before refresh'),
];
adapter.onGet(
'/notifications',
(server) => server.reply(200, items),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
);
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Before refresh'), findsOneWidget);
// The next fetch returns fresh data; a pull-down should pick it up.
items
..clear()
..add(notificationJson(id: 2, title: 'After refresh'));
await tester.fling(
find.byType(CustomScrollView),
const Offset(0, 300),
1000,
);
await tester.pumpAndSettle();
expect(find.textContaining('After refresh'), findsOneWidget);
expect(find.textContaining('Before refresh'), findsNothing);
await tester.pumpWidget(const SizedBox());
});
}