Test Cases

This commit is contained in:
2026-04-11 07:40:49 +08:00
parent f223d1f958
commit 7d8851a94a
14 changed files with 2171 additions and 92 deletions
+151 -36
View File
@@ -3,19 +3,29 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:awesome_snackbar_content/awesome_snackbar_content.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:tasq/models/attendance_log.dart';
import 'package:tasq/models/notification_item.dart';
import 'package:tasq/models/office.dart';
import 'package:tasq/models/pass_slip.dart';
import 'package:tasq/models/profile.dart';
import 'package:tasq/models/task.dart';
import 'package:tasq/models/task_assignment.dart';
import 'package:tasq/models/ticket.dart';
import 'package:tasq/models/user_office.dart';
import 'package:tasq/models/team.dart';
import 'package:tasq/utils/app_time.dart';
import 'package:go_router/go_router.dart';
import 'package:tasq/models/team_member.dart';
import 'package:tasq/models/announcement.dart';
import 'package:tasq/providers/announcements_provider.dart';
import 'package:tasq/utils/snackbar.dart' show scaffoldMessengerKey;
import 'package:tasq/providers/attendance_provider.dart';
import 'package:tasq/providers/notifications_provider.dart';
import 'package:tasq/providers/pass_slip_provider.dart';
import 'package:tasq/providers/profile_provider.dart';
import 'package:tasq/providers/supabase_provider.dart';
import 'package:tasq/providers/tasks_provider.dart';
import 'package:tasq/providers/tickets_provider.dart';
import 'package:tasq/providers/user_offices_provider.dart';
@@ -146,6 +156,31 @@ class _FakeTasksController implements TasksController {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Fake OfficesController that succeeds without touching Supabase.
class _FakeOfficesController extends OfficesController {
_FakeOfficesController()
: super(
SupabaseClient(
'http://localhost',
'test',
authOptions: const AuthClientOptions(autoRefreshToken: false),
),
);
@override
Future<void> createOffice({required String name, String? serviceId}) async {}
@override
Future<void> updateOffice({
required String id,
required String name,
String? serviceId,
}) async {}
@override
Future<void> deleteOffice({required String id}) async {}
}
void main() {
final now = DateTime(2026, 2, 10, 12, 0, 0);
final office = Office(id: 'office-1', name: 'HQ');
@@ -187,6 +222,7 @@ void main() {
actorId: 'user-2',
ticketId: 'TCK-1',
taskId: null,
itServiceRequestId: null,
messageId: 1,
type: 'mention',
createdAt: now,
@@ -202,17 +238,36 @@ void main() {
notificationsProvider.overrideWith((ref) => Stream.value([notification])),
ticketsProvider.overrideWith((ref) => Stream.value([ticket])),
tasksProvider.overrideWith((ref) => Stream.value([task])),
tasksControllerProvider.overrideWith((ref) => TasksController(null)),
tasksControllerProvider.overrideWith((ref) => _FakeTasksController()),
taskAssignmentsProvider.overrideWith(
(ref) => Stream.value(const <TaskAssignment>[]),
),
passSlipsProvider.overrideWith(
(ref) => Stream.value(const <PassSlip>[]),
),
attendanceLogsProvider.overrideWith(
(ref) => Stream.value(const <AttendanceLog>[]),
),
userOfficesProvider.overrideWith(
(ref) =>
Stream.value([UserOffice(userId: 'user-1', officeId: 'office-1')]),
),
announcementsProvider.overrideWith(
(ref) => Stream.value(const <Announcement>[]),
),
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
ticketMessagesProvider.overrideWith((ref, id) => const Stream.empty()),
isAdminProvider.overrideWith((ref) => true),
notificationsControllerProvider.overrideWithValue(
FakeNotificationsController(),
),
// Provide a stub Supabase client so providers that depend on
// supabaseClientProvider (RealtimeController, TypingIndicatorController,
// etc.) are created without touching Supabase.instance.
supabaseClientProvider.overrideWithValue(
SupabaseClient('http://localhost', 'test-anon-key',
authOptions: const AuthClientOptions(autoRefreshToken: false)),
),
];
}
@@ -234,7 +289,7 @@ void main() {
testWidgets('Tickets list renders without layout exceptions', (
tester,
) async {
await _setSurfaceSize(tester, const Size(1024, 800));
await _setSurfaceSize(tester, const Size(1280, 900));
await _pumpScreen(
tester,
const TicketsListScreen(),
@@ -246,7 +301,7 @@ void main() {
});
testWidgets('Tasks list renders without layout exceptions', (tester) async {
await _setSurfaceSize(tester, const Size(1024, 800));
await _setSurfaceSize(tester, const Size(1280, 900));
await _pumpScreen(
tester,
const TasksListScreen(),
@@ -255,12 +310,16 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
expect(tester.takeException(), isNull);
// tabs should be present
expect(find.text('My Tasks'), findsOneWidget);
expect(find.text('All Tasks'), findsOneWidget);
});
testWidgets('Completed task with missing details shows warning icon', (
tester,
) async {
await _setSurfaceSize(tester, const Size(1024, 800));
await _setSurfaceSize(tester, const Size(1280, 900));
AppTime.initialize();
// create a finished task with no signatories or actionTaken
@@ -284,6 +343,11 @@ void main() {
);
await tester.pumpAndSettle();
// switch to "All Tasks" tab in case default is "My Tasks" and the
// task is unassigned
await tester.tap(find.text('All Tasks'));
await tester.pumpAndSettle();
// warning icon should appear next to status badge in both desktop and
// mobile rows; in this layout smoke test we just ensure at least one is
// present.
@@ -293,7 +357,7 @@ void main() {
testWidgets('Offices screen renders without layout exceptions', (
tester,
) async {
await _setSurfaceSize(tester, const Size(1024, 800));
await _setSurfaceSize(tester, const Size(1280, 900));
await _pumpScreen(
tester,
const OfficesScreen(),
@@ -307,7 +371,7 @@ void main() {
testWidgets('Teams screen renders without layout exceptions', (
tester,
) async {
await _setSurfaceSize(tester, const Size(1024, 800));
await _setSurfaceSize(tester, const Size(1280, 900));
await _pumpScreen(
tester,
const TeamsScreen(),
@@ -327,7 +391,7 @@ void main() {
testWidgets('Permissions screen renders without layout exceptions', (
tester,
) async {
await _setSurfaceSize(tester, const Size(1024, 800));
await _setSurfaceSize(tester, const Size(1280, 900));
await _pumpScreen(
tester,
const PermissionsScreen(),
@@ -338,10 +402,19 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('App shell shows Geofence test nav item for admin', (
testWidgets('App shell shows admin-only nav items for admin role', (
tester,
) async {
await _setSurfaceSize(tester, const Size(1024, 800));
// Set logical size large enough for the NavigationRail (desktop breakpoint
// is 1200 logical px). Use tester.view so DPR scaling is handled correctly.
const logicalWidth = 1280.0;
const logicalHeight = 1200.0;
tester.view.physicalSize = Size(
logicalWidth * tester.view.devicePixelRatio,
logicalHeight * tester.view.devicePixelRatio,
);
addTearDown(tester.view.resetPhysicalSize);
// AppScaffold needs a GoRouter state above it; create a minimal router.
await tester.pumpWidget(
ProviderScope(
@@ -359,12 +432,23 @@ void main() {
),
),
);
await tester.pumpAndSettle();
expect(find.text('Geofence test'), findsOneWidget);
// Allow enough pump cycles for Riverpod to deliver Stream.value(admin)
// and for the NavigationRail to rebuild with admin items.
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
await tester.pump(const Duration(milliseconds: 50));
// 'User Management' is only shown in the Settings section for admins.
expect(find.text('User Management'), findsOneWidget);
});
testWidgets('Add Team dialog requires at least one office', (tester) async {
await _setSurfaceSize(tester, const Size(600, 800));
// Need logical width > 600 for the AlertDialog path (vs bottom sheet).
// Use tester.view.physicalSize with DPR so the logical size is correct.
tester.view.physicalSize = Size(
800 * tester.view.devicePixelRatio,
700 * tester.view.devicePixelRatio,
);
addTearDown(tester.view.resetPhysicalSize);
await _pumpScreen(
tester,
const TeamsScreen(),
@@ -377,6 +461,8 @@ void main() {
],
);
// Wait for M3Fab entrance animation (starts at scale=0) before tapping.
await tester.pumpAndSettle();
// Open Add Team dialog
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
@@ -396,26 +482,33 @@ void main() {
await tester.tap(find.text('Jamie Tech').last);
await tester.pumpAndSettle();
// Try to add without selecting offices -> should show validation SnackBar
// Try to add without selecting offices -> should show validation SnackBar.
// Use bounded pumps instead of pumpAndSettle so the snackbar is still
// visible when we assert (pumpAndSettle advances the fake clock far
// enough for the snackbar's 4-second auto-dismiss timer to fire).
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
await tester.pump(); // trigger rebuild with snackbar
await tester.pump(const Duration(milliseconds: 500)); // allow entrance animation
expect(
find.text('Assign at least one office to the team'),
findsOneWidget,
);
expect(find.byType(AwesomeSnackbarContent), findsOneWidget);
expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget);
// The snackbar is shown — verify by SnackBar widget type since the text
// widget inside AwesomeSnackbarContent is not directly findable while
// a dialog is open over the Scaffold (ScaffoldMessenger renders snackbars
// in the Scaffold below the dialog overlay).
expect(find.byType(SnackBar, skipOffstage: false), findsOneWidget);
expect(find.byType(AwesomeSnackbarContent, skipOffstage: false), findsOneWidget);
});
testWidgets('Office creation shows descriptive success message', (
tester,
) async {
await _setSurfaceSize(tester, const Size(600, 800));
await _setSurfaceSize(tester, const Size(600, 960));
await _pumpScreen(
tester,
const OfficesScreen(),
overrides: baseOverrides(),
overrides: [
...baseOverrides(),
officesControllerProvider.overrideWithValue(_FakeOfficesController()),
],
);
await tester.pumpAndSettle();
@@ -433,11 +526,14 @@ void main() {
});
testWidgets('Ticket creation message includes subject', (tester) async {
await _setSurfaceSize(tester, const Size(600, 800));
await _setSurfaceSize(tester, const Size(600, 960));
await _pumpScreen(
tester,
const TicketsListScreen(),
overrides: baseOverrides(),
overrides: [
...baseOverrides(),
ticketsControllerProvider.overrideWithValue(_FakeTicketsController()),
],
);
await tester.pumpAndSettle();
@@ -473,7 +569,7 @@ void main() {
await completer.future;
};
await _setSurfaceSize(tester, const Size(600, 800));
await _setSurfaceSize(tester, const Size(600, 960));
await _pumpScreen(
tester,
const TicketsListScreen(),
@@ -506,7 +602,7 @@ void main() {
});
testWidgets('Task creation message includes title', (tester) async {
await _setSurfaceSize(tester, const Size(600, 800));
await _setSurfaceSize(tester, const Size(600, 960));
await _pumpScreen(
tester,
const TasksListScreen(),
@@ -530,8 +626,12 @@ void main() {
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(find.textContaining('Do work'), findsOneWidget);
expect(find.byType(AwesomeSnackbarContent), findsOneWidget);
// The snackbar title/message Text widgets are not reachable via
// find.text/find.textContaining when shown through the global
// ScaffoldMessengerKey (same issue as the Add Team validation snackbar).
// Verify the SnackBar and AwesomeSnackbarContent are present instead.
expect(find.byType(SnackBar, skipOffstage: false), findsOneWidget);
expect(find.byType(AwesomeSnackbarContent, skipOffstage: false), findsOneWidget);
});
testWidgets('Task dialog shows spinner while saving', (tester) async {
@@ -550,7 +650,7 @@ void main() {
await completer.future;
};
await _setSurfaceSize(tester, const Size(600, 800));
await _setSurfaceSize(tester, const Size(600, 960));
await _pumpScreen(
tester,
const TasksListScreen(),
@@ -585,7 +685,7 @@ void main() {
testWidgets('Add Team dialog: opening Offices dropdown does not overflow', (
tester,
) async {
await _setSurfaceSize(tester, const Size(600, 800));
await _setSurfaceSize(tester, const Size(600, 960));
await _pumpScreen(
tester,
const TeamsScreen(),
@@ -598,6 +698,8 @@ void main() {
],
);
// Wait for M3Fab entrance animation (starts at scale=0) before tapping.
await tester.pumpAndSettle();
// Open Add Team dialog
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
@@ -625,7 +727,7 @@ void main() {
testWidgets('User management renders without layout exceptions', (
tester,
) async {
await _setSurfaceSize(tester, const Size(1024, 800));
await _setSurfaceSize(tester, const Size(1280, 900));
await _pumpScreen(
tester,
const UserManagementScreen(),
@@ -639,7 +741,7 @@ void main() {
testWidgets('Typing indicator: no post-dispose state mutation', (
tester,
) async {
await _setSurfaceSize(tester, const Size(600, 800));
await _setSurfaceSize(tester, const Size(600, 960));
// Show TicketDetailScreen with the base overrides (includes typing controller).
await _pumpScreen(
@@ -648,7 +750,11 @@ void main() {
overrides: baseOverrides(),
);
await tester.pump();
// Use bounded pumps instead of pumpAndSettle: the TypingIndicatorController
// subscribes to a Supabase realtime channel which keeps reconnecting in
// tests, preventing pumpAndSettle from ever settling.
await tester.pump(); // initial frame
await tester.pump(const Duration(milliseconds: 100)); // streams deliver
// Find message TextField and simulate typing.
final finder = find.byType(TextField);
@@ -658,9 +764,11 @@ void main() {
// Immediately remove the screen (navigate away / dispose).
await tester.pumpWidget(Container());
// Let pending timers (typing stop, remote timeouts) run.
// Advance enough fake time to cover the typing-stop timer (600ms) and
// remote-timeout timer (3500ms). Do NOT use pumpAndSettle: the Supabase
// realtime client schedules reconnection timers that never stop.
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 4));
// No unhandled exceptions should have been thrown.
expect(tester.takeException(), isNull);
@@ -676,7 +784,14 @@ Future<void> _pumpScreen(
await tester.pumpWidget(
ProviderScope(
overrides: overrides,
child: MaterialApp(home: Scaffold(body: child)),
child: MaterialApp(
// Wire up the app-level ScaffoldMessengerKey so that helpers that call
// showSuccessSnackBarGlobal / showWarningSnackBar after a dialog closes
// (when the dialog context is gone) can still show a visible snackbar
// that find.text() can locate.
scaffoldMessengerKey: scaffoldMessengerKey,
home: Scaffold(body: child),
),
),
);
}