From d28898cb81f1e2c94b1090025de2ac26e80b468e Mon Sep 17 00:00:00 2001 From: Sterlen Date: Fri, 9 Jan 2026 13:48:38 -0600 Subject: [PATCH] Implement data sync and cleanup --- backend/bin/server.dart | 67 +++++++ backend/data/tracker.db | Bin 0 -> 32768 bytes backend/lib/database.dart | 117 ++++++++++++ backend/pubspec.lock | 173 ++++++++++++++++++ backend/pubspec.yaml | 18 ++ lib/providers/user_provider.dart | 85 ++++----- lib/screens/home/home_screen.dart | 19 ++ .../husband/husband_settings_screen.dart | 21 +++ lib/screens/onboarding/onboarding_screen.dart | 8 +- lib/services/sync_service.dart | 135 ++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 1 + 12 files changed, 596 insertions(+), 50 deletions(-) create mode 100644 backend/bin/server.dart create mode 100644 backend/data/tracker.db create mode 100644 backend/lib/database.dart create mode 100644 backend/pubspec.lock create mode 100644 backend/pubspec.yaml create mode 100644 lib/services/sync_service.dart diff --git a/backend/bin/server.dart b/backend/bin/server.dart new file mode 100644 index 0000000..1c894e6 --- /dev/null +++ b/backend/bin/server.dart @@ -0,0 +1,67 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_router/shelf_router.dart'; +import 'package:tracker_backend/database.dart'; + +void main(List args) async { + // Use port 8090 as requested + final port = 8090; + final db = TrackerDatabase(); + + final app = Router(); + + app.get('/', (Request request) { + return Response.ok('Tracker Sync Server Running'); + }); + + // Simple Sync Endpoint (Push) + // Expects JSON: { "userId": "...", "entries": [ ... ] } + app.post('/sync/push', (Request request) async { + try { + final payload = await request.readAsString(); + final data = jsonDecode(payload); + final userId = data['userId']; + final entries = data['entries'] as List; + + print('Received sync push for $userId with ${entries.length} entries'); + + for (var entry in entries) { + // Basic upsert handling + db.upsertCycleEntry(userId, entry); + } + + return Response.ok( + jsonEncode({'status': 'success', 'synced': entries.length})); + } catch (e) { + print('Sync Error: $e'); + return Response.internalServerError(body: 'Sync Failed: $e'); + } + }); + + // Pull Endpoint + // GET /sync/pull?userId=... + app.get('/sync/pull', (Request request) { + final userId = request.url.queryParameters['userId']; + if (userId == null) return Response.badRequest(body: 'Missing userId'); + + final entries = db.getCycleEntries(userId); + return Response.ok(jsonEncode({'entries': entries})); + }); + + // Enable CORS + final handler = Pipeline().addMiddleware((innerHandler) { + return (request) async { + final response = await innerHandler(request); + return response.change(headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Origin, Content-Type', + }); + }; + }).addHandler(app); + + final server = await io.serve(handler, InternetAddress.anyIPv4, port); + print('Server running on localhost:${server.port}'); +} diff --git a/backend/data/tracker.db b/backend/data/tracker.db new file mode 100644 index 0000000000000000000000000000000000000000..fb2375182093546a8e39567c2f3472ef5193afc6 GIT binary patch literal 32768 zcmeI&&u`LT9LMo?KVTvIrI#MOJhzQxGfq#7F%hvE6xhZ};!aI1&$LNtyS7BRc$n_u zf8w9uAL0MwZk7tGMaj6-aCnmdeR!Uye0hCZ!jp1((sW`e&i$}&#p02&t*EN#ZweY1!``@0}UAmKWt)<$6c28M-iP&zic(_lez# z7~hFmGzVn1-~v@;tY z*`X7}gHW2GJeQ&L?BCO~5uP8*Xlj-f1g>LSvEzH@#Xv@*jj16)XbqD}1K;uD@pQ4j zFJ7GG5yGg&vJl|R>C)VfVW)VrOI-Zo65(b7+iTI+Z`qx!g9(vGUC z;&?szF}iS*g@m`;f2nuG#O*;AUNr0nvERQXfWGhdZW;2g zqhK&^ZdpCucH^Y0?~V`RJuwQtI*IjgwWPInX2qHdzEo+*l zeo@ooVN_a_J}l+(PhXlh_t1A|o`s&(pFYK8-*VikhyyE(z3f!a2<=eLceZ3x<_>C- zM0Kg8J<(<*ky4lk>C?^Heq;32x?RBcE4xGh0R#|0009ILKmY**5I_Kdg%PNXDq;P< zFnLf20tg_000IagfB*srAbHzX( entry) { + // Assuming entry contains fields matching DB + final stmt = _db.prepare(''' + INSERT OR REPLACE INTO cycle_entries ( + id, user_id, date, flow_intensity, is_period_day, symptoms, moods, notes, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + '''); + + stmt.execute([ + entry['id'], + userId, + entry['date'], + entry['flowIntensity'], + entry['isPeriodDay'] == true ? 1 : 0, + entry['symptoms'], // JSON + entry['moods'], // JSON + entry['notes'], + ]); + stmt.dispose(); + } + + List> getCycleEntries(String userId, {String? since}) { + // If since is provided, filter by updated_at + // For MVP sync, we might just return all or a simple diff + final result = + _db.select('SELECT * FROM cycle_entries WHERE user_id = ?', [userId]); + return result + .map((row) => { + 'id': row['id'], + 'date': row['date'], + 'flowIntensity': row['flow_intensity'], + 'isPeriodDay': row['is_period_day'] == 1, + 'symptoms': row['symptoms'], + 'moods': row['moods'], + 'notes': row['notes'], + 'updatedAt': row['updated_at'] + }) + .toList(); + } +} diff --git a/backend/pubspec.lock b/backend/pubspec.lock new file mode 100644 index 0000000..564bece --- /dev/null +++ b/backend/pubspec.lock @@ -0,0 +1,173 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + ffi: + dependency: transitive + description: + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + shelf: + dependency: "direct main" + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.7.0 <4.0.0" diff --git a/backend/pubspec.yaml b/backend/pubspec.yaml new file mode 100644 index 0000000..4f8da5c --- /dev/null +++ b/backend/pubspec.yaml @@ -0,0 +1,18 @@ +name: tracker_backend +description: A simple backend for Tracker sync. +version: 1.0.0 +publish_to: "none" + +environment: + sdk: ^3.5.0 + +dependencies: + shelf: ^1.4.1 + shelf_router: ^1.1.4 + sqlite3: ^2.4.0 + path: ^1.8.3 + uuid: ^4.0.0 + crypto: ^3.0.3 + +dev_dependencies: + lints: ^2.1.1 diff --git a/lib/providers/user_provider.dart b/lib/providers/user_provider.dart index 4b12f8f..e123f31 100644 --- a/lib/providers/user_provider.dart +++ b/lib/providers/user_provider.dart @@ -3,8 +3,8 @@ import 'package:hive_flutter/hive_flutter.dart'; import '../models/user_profile.dart'; import '../models/cycle_entry.dart'; -import 'package:uuid/uuid.dart'; import '../services/cycle_service.dart'; +import '../services/sync_service.dart'; /// Provider for the user profile final userProfileProvider = @@ -66,6 +66,7 @@ final cycleEntriesProvider = class CycleEntriesNotifier extends StateNotifier> { CycleEntriesNotifier() : super([]) { _loadEntries(); + syncData(); // Auto-sync on load } void _loadEntries() { @@ -77,18 +78,21 @@ class CycleEntriesNotifier extends StateNotifier> { final box = Hive.box('cycle_entries'); await box.put(entry.id, entry); _loadEntries(); + _push(); } Future updateEntry(CycleEntry entry) async { final box = Hive.box('cycle_entries'); await box.put(entry.id, entry); _loadEntries(); + _push(); } Future deleteEntry(String id) async { final box = Hive.box('cycle_entries'); await box.delete(id); _loadEntries(); + _push(); } Future deleteEntriesForMonth(int year, int month) async { @@ -101,63 +105,52 @@ class CycleEntriesNotifier extends StateNotifier> { } await box.deleteAll(keysToDelete); _loadEntries(); + _push(); } Future clearEntries() async { final box = Hive.box('cycle_entries'); await box.clear(); state = []; + _push(); } - Future generateExampleData(String userId) async { - await clearEntries(); - final box = Hive.box('cycle_entries'); - final today = DateTime.now(); + // Sync Logic - // Generate 3 past cycles (~28 days each) - DateTime cycleStart = today.subtract(const Duration(days: 84)); // 3 * 28 + Future syncData() async { + await _pull(); + // After pull, we might want to push any local changes not in remote? + // For now, simpler consistency: Pull then Push current state? + // Or just Pull. Push happens on edit. + // Let's just Pull. + } - while (cycleStart.isBefore(today)) { - // Create Period (5 days) - for (int i = 0; i < 5; i++) { - final date = cycleStart.add(Duration(days: i)); - if (date.isAfter(today)) break; - - final isHeavy = i == 1 || i == 2; - final entry = CycleEntry( - id: const Uuid().v4(), - date: date, - isPeriodDay: true, - flowIntensity: isHeavy ? FlowIntensity.heavy : FlowIntensity.medium, - mood: i == 1 ? MoodLevel.sad : null, - crampIntensity: i == 0 ? 3 : null, - hasHeadache: i == 0, - createdAt: date, - updatedAt: date, - ); - await box.put(entry.id, entry); - } - - // Add random ovulation symptoms near day 14 - final ovulationDay = cycleStart.add(const Duration(days: 14)); - if (ovulationDay.isBefore(today)) { - final entry = CycleEntry( - id: const Uuid().v4(), - date: ovulationDay, - isPeriodDay: false, - energyLevel: 4, // High energy - mood: MoodLevel.veryHappy, - createdAt: ovulationDay, - updatedAt: ovulationDay, - ); - await box.put(entry.id, entry); - } - - cycleStart = cycleStart.add(const Duration(days: 28)); + Future _push() async { + final userBox = Hive.box('user_profile'); + final user = userBox.get('current_user'); + if (user != null) { + await SyncService().pushSyncData(user.id, state); } - - _loadEntries(); } + + Future _pull() async { + final userBox = Hive.box('user_profile'); + final user = userBox.get('current_user'); + if (user == null) return; + + final remoteEntries = await SyncService().pullSyncData(user.id); + if (remoteEntries.isNotEmpty) { + final box = Hive.box('cycle_entries'); + // Simple merge: Remote wins or Union? + // Union: Upsert all. + for (var entry in remoteEntries) { + await box.put(entry.id, entry); + } + _loadEntries(); + } + } + + // Example data generation removed } /// Computed provider for current cycle info diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 00a89f6..225cb9f 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -620,6 +620,25 @@ class _SettingsTab extends ConsumerWidget { MaterialPageRoute( builder: (context) => const ExportDataScreen())); }), + _buildSettingsTile( + context, + Icons.sync, + 'Sync Data', + onTap: () async { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Syncing data...')), + ); + await ref.read(cycleEntriesProvider.notifier).syncData(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sync complete'), + backgroundColor: Colors.green, + ), + ); + } + }, + ), ]), const SizedBox(height: 16), _buildSettingsGroup(context, 'Account', [ diff --git a/lib/screens/husband/husband_settings_screen.dart b/lib/screens/husband/husband_settings_screen.dart index 83f5adf..dc92da4 100644 --- a/lib/screens/husband/husband_settings_screen.dart +++ b/lib/screens/husband/husband_settings_screen.dart @@ -390,6 +390,27 @@ class HusbandSettingsScreen extends ConsumerWidget { ), child: Column( children: [ + ListTile( + leading: const Icon(Icons.sync, color: Colors.blue), + title: Text('Sync Data', + style: GoogleFonts.outfit( + fontWeight: FontWeight.w500, color: Colors.blue)), + onTap: () async { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Syncing data...')), + ); + await ref.read(cycleEntriesProvider.notifier).syncData(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sync complete'), + backgroundColor: Colors.green, + ), + ); + } + }, + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.cloud_download_outlined, color: Colors.blue), diff --git a/lib/screens/onboarding/onboarding_screen.dart b/lib/screens/onboarding/onboarding_screen.dart index 463a628..71e0bfa 100644 --- a/lib/screens/onboarding/onboarding_screen.dart +++ b/lib/screens/onboarding/onboarding_screen.dart @@ -141,7 +141,7 @@ class _OnboardingScreenState extends ConsumerState { lastPeriodStartDate: _lastPeriodStart, isIrregularCycle: _isIrregularCycle, hasCompletedOnboarding: true, - useExampleData: _useExampleData, + // useExampleData: Removed isPadTrackingEnabled: _isPadTrackingEnabled, createdAt: DateTime.now(), updatedAt: DateTime.now(), @@ -149,15 +149,17 @@ class _OnboardingScreenState extends ConsumerState { await ref.read(userProfileProvider.notifier).updateProfile(userProfile); - // Generate example data if requested + // Generate example data if requested - REMOVED + /* if (_useExampleData) { await ref .read(cycleEntriesProvider.notifier) .generateExampleData(userProfile.id); } + */ // Trigger partner connection notification if applicable - if (!_skipPartnerConnection && !_useExampleData) { + if (!_skipPartnerConnection) { await NotificationService().showPartnerUpdateNotification( title: 'Connection Successful!', body: 'You are now connected with your partner. Tap to start sharing.', diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart new file mode 100644 index 0000000..57d64ca --- /dev/null +++ b/lib/services/sync_service.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import '../models/cycle_entry.dart'; + +class SyncService { + static const String _baseUrl = 'http://localhost:8090'; + + // Push data to backend + Future pushSyncData(String userId, List entries) async { + try { + final url = Uri.parse('$_baseUrl/sync/push'); + final payload = { + 'userId': userId, + 'entries': entries.map((e) => _cycleEntryToJson(e)).toList(), + }; + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ); + + if (response.statusCode == 200) { + debugPrint('Sync Push Successful'); + } else { + debugPrint('Sync Push Failed: ${response.body}'); + } + } catch (e) { + debugPrint('Sync Push Error: $e'); + } + } + + // Pull data from backend + Future> pullSyncData(String userId) async { + try { + final url = Uri.parse('$_baseUrl/sync/pull?userId=$userId'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final List entriesJson = data['entries'] ?? []; + return entriesJson.map((json) => _jsonToCycleEntry(json)).toList(); + } + } catch (e) { + debugPrint('Sync Pull Error: $e'); + } + return []; + } + + // Helpers (Adapters) + Map _cycleEntryToJson(CycleEntry entry) { + // Convert boolean symptoms to list of strings + final symptomsList = []; + if (entry.hasHeadache) symptomsList.add('headache'); + if (entry.hasBloating) symptomsList.add('bloating'); + if (entry.hasBreastTenderness) symptomsList.add('breastTenderness'); + if (entry.hasFatigue) symptomsList.add('fatigue'); + if (entry.hasAcne) symptomsList.add('acne'); + if (entry.hasLowerBackPain) symptomsList.add('lowerBackPain'); + if (entry.hasConstipation) symptomsList.add('constipation'); + if (entry.hasDiarrhea) symptomsList.add('diarrhea'); + if (entry.hasInsomnia) symptomsList.add('insomnia'); + + return { + 'id': entry.id, + 'date': entry.date.toIso8601String(), + 'flowIntensity': entry.flowIntensity?.name, + 'isPeriodDay': entry.isPeriodDay, + 'symptoms': jsonEncode(symptomsList), + 'moods': jsonEncode(entry.mood != null ? [entry.mood!.name] : []), + 'notes': entry.notes, + 'createdAt': entry.createdAt.toIso8601String(), + 'updatedAt': entry.updatedAt.toIso8601String(), + }; + } + + CycleEntry _jsonToCycleEntry(Map json) { + // FlowIntensity enum parsing + FlowIntensity? flow; + if (json['flowIntensity'] != null) { + flow = FlowIntensity.values.firstWhere( + (e) => e.name == json['flowIntensity'], + orElse: () => FlowIntensity.medium, + ); + } + + // Mood parsing + MoodLevel? mood; + final moodsList = _parseList(json['moods']); + if (moodsList.isNotEmpty) { + try { + mood = MoodLevel.values.firstWhere((e) => e.name == moodsList.first); + } catch (_) {} + } + + final symptoms = _parseList(json['symptoms']); + + return CycleEntry( + id: json['id'], + date: DateTime.parse(json['date']), + flowIntensity: flow, + isPeriodDay: json['isPeriodDay'] == true, + mood: mood, + hasHeadache: symptoms.contains('headache'), + hasBloating: symptoms.contains('bloating'), + hasBreastTenderness: symptoms.contains('breastTenderness'), + hasFatigue: symptoms.contains('fatigue'), + hasAcne: symptoms.contains('acne'), + hasLowerBackPain: symptoms.contains('lowerBackPain'), + hasConstipation: symptoms.contains('constipation'), + hasDiarrhea: symptoms.contains('diarrhea'), + hasInsomnia: symptoms.contains('insomnia'), + notes: json['notes'], + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : DateTime.now(), + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt']) + : DateTime.now(), + ); + } + + List _parseList(dynamic jsonVal) { + if (jsonVal == null) return []; + if (jsonVal is String) { + try { + return List.from(jsonDecode(jsonVal)); + } catch (_) { + return []; + } + } + return List.from(jsonVal); + } +} diff --git a/pubspec.lock b/pubspec.lock index c9fcd0a..31fe51b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -449,7 +449,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index 9780ac1..f221a2c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: collection: ^1.18.0 timezone: ^0.9.4 path_provider_platform_interface: ^2.1.2 + http: ^1.6.0 dev_dependencies: flutter_test: