Offline Support
This commit is contained in:
@@ -25,19 +25,12 @@ class _FakeQuery implements Future<List<Map<String, dynamic>>> {
|
||||
|
||||
Map<String, dynamic>? _insertPayload;
|
||||
Map<String, dynamic>? _updatePayload;
|
||||
String? _eqField;
|
||||
dynamic _eqValue;
|
||||
String? _inField;
|
||||
List<String>? _inValues;
|
||||
bool _selectCalled = false;
|
||||
bool _isDelete = false;
|
||||
|
||||
_TableLog get _log => _tables.putIfAbsent(_table, _TableLog.new);
|
||||
|
||||
_FakeQuery select([String? _]) {
|
||||
_selectCalled = true;
|
||||
return this;
|
||||
}
|
||||
_FakeQuery select([String? _]) => this;
|
||||
|
||||
_FakeQuery insert(Map<String, dynamic> payload) {
|
||||
_insertPayload = Map<String, dynamic>.from(payload);
|
||||
@@ -55,8 +48,6 @@ class _FakeQuery implements Future<List<Map<String, dynamic>>> {
|
||||
}
|
||||
|
||||
_FakeQuery eq(String field, dynamic value) {
|
||||
_eqField = field;
|
||||
_eqValue = value;
|
||||
if (_updatePayload != null) {
|
||||
_log.updates.add({..._updatePayload!, '__eq': {field: value}});
|
||||
_updatePayload = null;
|
||||
@@ -68,7 +59,6 @@ class _FakeQuery implements Future<List<Map<String, dynamic>>> {
|
||||
}
|
||||
|
||||
_FakeQuery inFilter(String field, List<dynamic> values) {
|
||||
_inField = field;
|
||||
_inValues = values.map((v) => v.toString()).toList();
|
||||
return this;
|
||||
}
|
||||
@@ -437,10 +427,7 @@ void main() {
|
||||
AppTime.initialize();
|
||||
fakeClient = _FakeSupabaseClient(userId: 'author-1');
|
||||
fakeNotif = _FakeNotificationsController();
|
||||
controller = AnnouncementsController(
|
||||
fakeClient as dynamic,
|
||||
fakeNotif as dynamic,
|
||||
);
|
||||
controller = AnnouncementsController(fakeClient, fakeNotif);
|
||||
});
|
||||
|
||||
// ── createAnnouncement – template guard ────────────────────────────
|
||||
|
||||
@@ -99,7 +99,7 @@ class _FakeTicketsController implements TicketsController {
|
||||
onCreate;
|
||||
|
||||
@override
|
||||
Future<void> createTicket({
|
||||
Future<Ticket?> createTicket({
|
||||
required String subject,
|
||||
required String description,
|
||||
required String officeId,
|
||||
@@ -111,6 +111,7 @@ class _FakeTicketsController implements TicketsController {
|
||||
officeId: officeId,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/providers/connectivity_provider.dart';
|
||||
import 'package:tasq/widgets/offline_banner.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline / Connectivity Tests
|
||||
//
|
||||
// Three test tiers:
|
||||
// 1. Unit — pure provider state (no Flutter, no Supabase)
|
||||
// 2. Widget — OfflineBanner visibility
|
||||
// 3. Integration stubs — AppRepository + SQLite (require Supabase setup,
|
||||
// skipped in CI; see comments for manual verification steps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Tier 1 — Provider unit tests
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
group('isOnlineProvider', () {
|
||||
test('starts as true (online by default)', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
expect(container.read(isOnlineProvider), isTrue);
|
||||
});
|
||||
|
||||
test('can be set to false (simulate going offline)', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(isOnlineProvider.notifier).state = false;
|
||||
expect(container.read(isOnlineProvider), isFalse);
|
||||
});
|
||||
|
||||
test('can transition false → true (simulate reconnect)', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(isOnlineProvider.notifier).state = false;
|
||||
expect(container.read(isOnlineProvider), isFalse);
|
||||
|
||||
container.read(isOnlineProvider.notifier).state = true;
|
||||
expect(container.read(isOnlineProvider), isTrue);
|
||||
});
|
||||
|
||||
test('listeners are notified on state change', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final states = <bool>[];
|
||||
container.listen(isOnlineProvider, (_, next) => states.add(next));
|
||||
|
||||
container.read(isOnlineProvider.notifier).state = false;
|
||||
container.read(isOnlineProvider.notifier).state = true;
|
||||
container.read(isOnlineProvider.notifier).state = false;
|
||||
|
||||
expect(states, equals([false, true, false]));
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Tier 2 — Widget tests for OfflineBanner
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
group('OfflineBanner widget', () {
|
||||
/// Builds OfflineBanner inside a ProviderScope that overrides
|
||||
/// [isOnlineProvider] to [online] and [connectivityMonitorProvider]
|
||||
/// to a no-op so no real HTTP calls are made.
|
||||
Widget buildBanner({required bool online}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
isOnlineProvider.overrideWith((ref) => online),
|
||||
// Suppress the real connectivity poll — no network in tests.
|
||||
connectivityMonitorProvider.overrideWith((_) {}),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OfflineBanner(child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('hides banner when online', (tester) async {
|
||||
await tester.pumpWidget(buildBanner(online: true));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.wifi_off), findsNothing);
|
||||
expect(
|
||||
find.text(
|
||||
'No internet \u2014 changes saved locally, will sync automatically',
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows banner when offline', (tester) async {
|
||||
await tester.pumpWidget(buildBanner(online: false));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.wifi_off), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'No internet \u2014 changes saved locally, will sync automatically',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('banner transitions from offline to online', (tester) async {
|
||||
// Use UncontrolledProviderScope so we can update provider state in-place
|
||||
// without replacing the widget tree (which would reset AnimatedSize).
|
||||
final container = ProviderContainer(
|
||||
overrides: [connectivityMonitorProvider.overrideWith((_) {})],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(isOnlineProvider.notifier).state = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(body: OfflineBanner(child: SizedBox.expand())),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Icons.wifi_off), findsOneWidget);
|
||||
|
||||
// Go online — update provider state, then let AnimatedSize animate.
|
||||
container.read(isOnlineProvider.notifier).state = true;
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byIcon(Icons.wifi_off), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('banner transitions from online to offline', (tester) async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [connectivityMonitorProvider.overrideWith((_) {})],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
// Start online (default is true).
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(body: OfflineBanner(child: SizedBox.expand())),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Icons.wifi_off), findsNothing);
|
||||
|
||||
// Go offline — update provider state, then let AnimatedSize animate.
|
||||
container.read(isOnlineProvider.notifier).state = false;
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byIcon(Icons.wifi_off), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('child widget is always rendered regardless of connectivity',
|
||||
(tester) async {
|
||||
const childKey = Key('test-child');
|
||||
|
||||
for (final online in [true, false]) {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
isOnlineProvider.overrideWith((ref) => online),
|
||||
connectivityMonitorProvider.overrideWith((_) {}),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OfflineBanner(
|
||||
child: SizedBox.expand(key: childKey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(childKey), findsOneWidget,
|
||||
reason: 'child must be visible when online=$online');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Tier 3 — Integration stubs (AppRepository + SQLite + Supabase)
|
||||
//
|
||||
// These tests are NOT run automatically because they require:
|
||||
// • a real (or local) Supabase instance
|
||||
// • the full AppRepository.configure() call with a valid client
|
||||
//
|
||||
// To run manually:
|
||||
// 1. Start a local Supabase dev stack: `supabase start`
|
||||
// 2. Set SUPABASE_URL / SUPABASE_ANON_KEY env vars
|
||||
// 3. Run: flutter test test/offline_sync_test.dart --name "integration"
|
||||
//
|
||||
// Scenario A — offline create → sync
|
||||
// 1. Set AppRepository offline (no network / airplane mode mock)
|
||||
// 2. Call AppRepository.instance.upsert<Task>(task)
|
||||
// 3. Read back with OfflineFirstGetPolicy.localOnly → expect task present
|
||||
// 4. Restore network
|
||||
// 5. Wait for Brick HTTP queue to replay
|
||||
// 6. Read with OfflineFirstGetPolicy.requireRemote → expect task synced
|
||||
// 7. Verify task_number is now server-assigned (not null)
|
||||
//
|
||||
// Scenario B — online read → go offline → read from cache
|
||||
// 1. Hydrate: AppRepository.instance.get<Task>() (pulls from Supabase)
|
||||
// 2. Simulate offline (cut network)
|
||||
// 3. AppRepository.instance.get<Task>(policy: localOnly) → expect cached
|
||||
// 4. No exception thrown
|
||||
//
|
||||
// Scenario C — task number placeholder
|
||||
// 1. Create task offline → task.taskNumber == null
|
||||
// 2. UI should display 'OFFLINE-001' (handled by displayTaskNumber helper)
|
||||
// 3. After sync → task.taskNumber != null → UI shows 'YYYY-MM-#####'
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
group('integration (manual only)', () {
|
||||
// Placeholder so the group shows up in test output with a clear note.
|
||||
test('see file comments for manual integration test instructions', () {
|
||||
// This test intentionally passes — it documents the manual steps above.
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,13 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:tasq/providers/realtime_controller.dart';
|
||||
import 'package:tasq/providers/stream_recovery.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RealtimeController only accesses `_client.auth.onAuthStateChange` during
|
||||
// `_init()`, which wraps the subscription in a try-catch. We use a minimal
|
||||
// SupabaseClient pointed at localhost — the auth stream subscription will
|
||||
// either return an empty stream or throw (caught internally), so no network
|
||||
// activity occurs during tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('RealtimeController', () {
|
||||
late RealtimeController controller;
|
||||
|
||||
setUp(() {
|
||||
// SupabaseClient constructor does not connect eagerly; _init() catches
|
||||
// any exception thrown when accessing auth.onAuthStateChange.
|
||||
controller = RealtimeController(
|
||||
SupabaseClient('http://localhost', 'test-anon-key',
|
||||
authOptions: const AuthClientOptions(autoRefreshToken: false)),
|
||||
);
|
||||
controller = RealtimeController();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
|
||||
Reference in New Issue
Block a user