import 'package:flutter_test/flutter_test.dart'; import 'package:data/data.dart'; // ── Test doubles ───────────────────────────────────────────────────────────── /// Simple domain type used across all tests. class _Item { final String id; final String name; const _Item(this.id, this.name); @override String toString() => '_Item($id, $name)'; } /// Minimal [DataMapper] implementation for tests. class _ItemMapper extends DataMapper<_Item, Map> { @override _Item fromRemote(Map remote) { final id = remote['id']; final name = remote['name']; if (id == null || name == null) { throw DataMappingException(field: 'id/name', message: 'Required fields missing'); } return _Item(id as String, name as String); } @override Map toRemote(_Item local) => {'id': local.id, 'name': local.name}; } /// A [ConflictResolver] that always merges by concatenating names. class _MergeResolver implements ConflictResolver<_Item> { const _MergeResolver(); @override ConflictResult<_Item> resolve({ required _Item local, required _Item remote, required DateTime localModifiedAt, required DateTime remoteModifiedAt, }) { return ConflictResult.merged( merged: _Item(local.id, '${local.name}+${remote.name}'), reason: 'merged names', ); } } void main() { // ── LocalCache / InMemoryCache ──────────────────────────────────────────── group('InMemoryCache', () { late InMemoryCache cache; setUp(() => cache = InMemoryCache()); tearDown(() async => cache.dispose()); test('put and get returns stored value', () async { await cache.put('k', 'hello'); expect(await cache.get('k'), 'hello'); }); test('get returns null for missing key', () async { expect(await cache.get('missing'), isNull); }); test('containsKey returns true for present key', () async { await cache.put('k', 'v'); expect(await cache.containsKey('k'), isTrue); }); test('containsKey returns false for missing key', () async { expect(await cache.containsKey('missing'), isFalse); }); test('remove deletes the entry', () async { await cache.put('k', 'v'); await cache.remove('k'); expect(await cache.get('k'), isNull); }); test('clear removes all entries', () async { await cache.put('a', '1'); await cache.put('b', '2'); await cache.clear(); expect(await cache.keys(), isEmpty); }); test('keys returns all non-expired keys', () async { await cache.put('x', '1'); await cache.put('y', '2'); final keys = await cache.keys(); expect(keys, containsAll(['x', 'y'])); }); test('entry expires after ttl', () async { await cache.put('k', 'v', ttl: const Duration(milliseconds: 1)); await Future.delayed(const Duration(milliseconds: 10)); expect(await cache.get('k'), isNull); }); test('containsKey returns false for expired entry', () async { await cache.put('k', 'v', ttl: const Duration(milliseconds: 1)); await Future.delayed(const Duration(milliseconds: 10)); expect(await cache.containsKey('k'), isFalse); }); test('entry without ttl does not expire', () async { await cache.put('k', 'persistent'); await Future.delayed(const Duration(milliseconds: 10)); expect(await cache.get('k'), 'persistent'); }); test('dispose clears all entries', () async { await cache.put('k', 'v'); await cache.dispose(); expect(await cache.keys(), isEmpty); }); test('overwriting a key replaces the value', () async { await cache.put('k', 'first'); await cache.put('k', 'second'); expect(await cache.get('k'), 'second'); }); }); // ── SyncQueue / InMemorySyncQueue ───────────────────────────────────────── group('SyncOperation', () { final op = SyncOperation( id: 'op-1', type: 'createOrder', payload: {'orderId': 42}, enqueuedAt: DateTime(2026, 7, 11), ); test('stores all fields with defaults', () { expect(op.id, 'op-1'); expect(op.type, 'createOrder'); expect(op.payload['orderId'], 42); expect(op.status, SyncOperationStatus.pending); expect(op.attemptCount, 0); expect(op.lastError, isNull); }); test('copyWith replaces specified fields', () { final updated = op.copyWith( status: SyncOperationStatus.failed, attemptCount: 2, lastError: 'timeout', ); expect(updated.status, SyncOperationStatus.failed); expect(updated.attemptCount, 2); expect(updated.lastError, 'timeout'); expect(updated.id, op.id); }); test('toString includes id, type, status, attempts', () { expect(op.toString(), contains('op-1')); expect(op.toString(), contains('createOrder')); }); }); group('SyncFlushResult', () { final op = SyncOperation( id: 'op-1', type: 'adjustInventory', payload: {}, enqueuedAt: DateTime(2026, 7, 11), ); test('success constructor sets success true and no error', () { final result = SyncFlushResult.success(operation: op); expect(result.success, isTrue); expect(result.errorMessage, isNull); }); test('failure constructor sets success false and error message', () { final result = SyncFlushResult.failure(operation: op, errorMessage: 'network error'); expect(result.success, isFalse); expect(result.errorMessage, 'network error'); }); test('toString includes operation id and success flag', () { final result = SyncFlushResult.success(operation: op); expect(result.toString(), contains('op-1')); expect(result.toString(), contains('true')); }); }); group('InMemorySyncQueue', () { late InMemorySyncQueue queue; SyncOperation op0(String id) => SyncOperation(id: id, type: 'test', payload: {}, enqueuedAt: DateTime(2026, 7, 11)); setUp(() => queue = InMemorySyncQueue()); test('enqueue adds operation and length increases', () async { await queue.enqueue(op0('a')); expect(await queue.length, 1); }); test('pendingOperations returns only pending ops', () async { await queue.enqueue(op0('a')); await queue.enqueue(op0('b')); final pending = await queue.pendingOperations(); expect(pending.length, 2); expect(pending.every((op) => op.status == SyncOperationStatus.pending), isTrue); }); test('process succeeds and marks operation completed', () async { final op = op0('a'); await queue.enqueue(op); final result = await queue.process(op, (_) async {}); expect(result.success, isTrue); expect(result.operation.status, SyncOperationStatus.completed); expect(result.operation.attemptCount, 1); }); test('process failure marks operation failed with error', () async { final op = op0('a'); await queue.enqueue(op); final result = await queue.process(op, (_) async => throw Exception('boom')); expect(result.success, isFalse); expect(result.operation.status, SyncOperationStatus.failed); expect(result.errorMessage, contains('boom')); expect(result.operation.attemptCount, 1); }); test('process returns failure for unknown operation id', () async { final op = op0('unknown'); final result = await queue.process(op, (_) async {}); expect(result.success, isFalse); expect(result.errorMessage, contains('not found')); }); test('remove deletes operation from queue', () async { await queue.enqueue(op0('a')); await queue.remove('a'); expect(await queue.length, 0); }); test('pruneCompleted removes completed and failed ops', () async { final opA = op0('a'); final opB = op0('b'); final opC = op0('c'); await queue.enqueue(opA); await queue.enqueue(opB); await queue.enqueue(opC); await queue.process(opA, (_) async {}); // completed await queue.process(opB, (_) async => throw Exception('err')); // failed // opC remains pending await queue.pruneCompleted(); expect(await queue.length, 1); final pending = await queue.pendingOperations(); expect(pending.first.id, 'c'); }); test('pendingOperations excludes completed operations', () async { final op = op0('a'); await queue.enqueue(op); await queue.process(op, (_) async {}); final pending = await queue.pendingOperations(); expect(pending, isEmpty); }); }); // ── ConflictResolver ────────────────────────────────────────────────────── group('ConflictResult', () { const item = _Item('1', 'Widget'); test('useLocal constructor sets resolution and value', () { final result = ConflictResult.useLocal(local: item, reason: 'test'); expect(result.resolution, ConflictResolution.useLocal); expect(result.resolvedValue, item); expect(result.reason, 'test'); }); test('useRemote constructor sets resolution and value', () { final result = ConflictResult.useRemote(remote: item, reason: 'test'); expect(result.resolution, ConflictResolution.useRemote); expect(result.resolvedValue, item); }); test('merged constructor sets resolution and value', () { final result = ConflictResult.merged(merged: item, reason: 'merged'); expect(result.resolution, ConflictResolution.merge); expect(result.resolvedValue, item); }); test('requiresReview constructor sets resolution and null value', () { const result = ConflictResult<_Item>.requiresReview(reason: 'conflict'); expect(result.resolution, ConflictResolution.requiresOperatorReview); expect(result.resolvedValue, isNull); }); test('toString includes resolution and reason', () { final result = ConflictResult.useLocal(local: item, reason: 'newer'); expect(result.toString(), contains('useLocal')); expect(result.toString(), contains('newer')); }); }); group('LastWriteWinsResolver', () { const resolver = LastWriteWinsResolver<_Item>(); const local = _Item('1', 'Local'); const remote = _Item('1', 'Remote'); final older = DateTime(2026, 1, 1); final newer = DateTime(2026, 7, 11); test('prefers local when local is newer', () { final result = resolver.resolve( local: local, remote: remote, localModifiedAt: newer, remoteModifiedAt: older, ); expect(result.resolution, ConflictResolution.useLocal); expect(result.resolvedValue, local); }); test('prefers remote when remote is newer', () { final result = resolver.resolve( local: local, remote: remote, localModifiedAt: older, remoteModifiedAt: newer, ); expect(result.resolution, ConflictResolution.useRemote); expect(result.resolvedValue, remote); }); test('prefers remote when timestamps are equal', () { final result = resolver.resolve( local: local, remote: remote, localModifiedAt: older, remoteModifiedAt: older, ); expect(result.resolution, ConflictResolution.useRemote); }); }); group('LocalAlwaysWinsResolver', () { const resolver = LocalAlwaysWinsResolver<_Item>(); const local = _Item('1', 'Local'); const remote = _Item('1', 'Remote'); final t = DateTime(2026, 7, 11); test('always returns local regardless of timestamps', () { final result = resolver.resolve( local: local, remote: remote, localModifiedAt: t, remoteModifiedAt: t.add(const Duration(days: 1)), ); expect(result.resolution, ConflictResolution.useLocal); expect(result.resolvedValue, local); }); }); group('RemoteAlwaysWinsResolver', () { const resolver = RemoteAlwaysWinsResolver<_Item>(); const local = _Item('1', 'Local'); const remote = _Item('1', 'Remote'); final t = DateTime(2026, 7, 11); test('always returns remote regardless of timestamps', () { final result = resolver.resolve( local: local, remote: remote, localModifiedAt: t.add(const Duration(days: 1)), remoteModifiedAt: t, ); expect(result.resolution, ConflictResolution.useRemote); expect(result.resolvedValue, remote); }); }); group('Custom ConflictResolver (merge)', () { const resolver = _MergeResolver(); const local = _Item('1', 'A'); const remote = _Item('1', 'B'); final t = DateTime(2026, 7, 11); test('merge resolver produces merged value', () { final result = resolver.resolve( local: local, remote: remote, localModifiedAt: t, remoteModifiedAt: t, ); expect(result.resolution, ConflictResolution.merge); expect((result.resolvedValue as _Item).name, 'A+B'); }); }); // ── ChangeTracker / InMemoryChangeTracker ───────────────────────────────── group('TrackedChange', () { final now = DateTime(2026, 7, 11); const item = _Item('1', 'Widget'); test('stores all fields', () { final change = TrackedChange<_Item>( id: '1', value: item, state: ChangeState.created, changedAt: now, ); expect(change.id, '1'); expect(change.value, item); expect(change.state, ChangeState.created); expect(change.changedAt, now); expect(change.syncError, isNull); }); test('copyWith replaces specified fields', () { final change = TrackedChange<_Item>( id: '1', value: item, state: ChangeState.modified, changedAt: now, ); final updated = change.copyWith(state: ChangeState.syncError, syncError: 'err'); expect(updated.state, ChangeState.syncError); expect(updated.syncError, 'err'); expect(updated.id, '1'); }); test('toString includes id, state, changedAt', () { final change = TrackedChange<_Item>( id: '1', value: item, state: ChangeState.created, changedAt: now, ); expect(change.toString(), contains('1')); expect(change.toString(), contains('created')); }); }); group('InMemoryChangeTracker', () { late InMemoryChangeTracker<_Item> tracker; const item = _Item('1', 'Widget'); const updated = _Item('1', 'Widget Pro'); setUp(() => tracker = InMemoryChangeTracker<_Item>()); test('trackCreated records created state', () async { await tracker.trackCreated('1', item); final change = await tracker.getChange('1'); expect(change?.state, ChangeState.created); expect(change?.value, item); }); test('trackModified records modified state', () async { await tracker.trackModified('1', updated); final change = await tracker.getChange('1'); expect(change?.state, ChangeState.modified); }); test('trackModified on created record preserves created state', () async { await tracker.trackCreated('1', item); await tracker.trackModified('1', updated); final change = await tracker.getChange('1'); expect(change?.state, ChangeState.created); }); test('trackDeleted records deleted state', () async { await tracker.trackCreated('1', item); await tracker.trackDeleted('1'); final change = await tracker.getChange('1'); expect(change?.state, ChangeState.deleted); expect(change?.value, isNull); }); test('markSynced removes the change', () async { await tracker.trackCreated('1', item); await tracker.markSynced('1'); expect(await tracker.getChange('1'), isNull); }); test('markSyncError updates state and error message', () async { await tracker.trackModified('1', item); await tracker.markSyncError('1', 'network timeout'); final change = await tracker.getChange('1'); expect(change?.state, ChangeState.syncError); expect(change?.syncError, 'network timeout'); }); test('pendingChanges returns all non-synced changes', () async { await tracker.trackCreated('1', item); await tracker.trackModified('2', updated); final pending = await tracker.pendingChanges(); expect(pending.length, 2); }); test('hasPendingChanges returns true when changes exist', () async { await tracker.trackCreated('1', item); expect(await tracker.hasPendingChanges, isTrue); }); test('hasPendingChanges returns false when empty', () async { expect(await tracker.hasPendingChanges, isFalse); }); test('clear removes all tracked changes', () async { await tracker.trackCreated('1', item); await tracker.trackModified('2', updated); await tracker.clear(); expect(await tracker.hasPendingChanges, isFalse); }); test('getChange returns null for untracked id', () async { expect(await tracker.getChange('unknown'), isNull); }); test('markSyncError on untracked id is a no-op', () async { // Should not throw. await tracker.markSyncError('unknown', 'error'); expect(await tracker.hasPendingChanges, isFalse); }); }); // ── DataMapper / DataMappingException ───────────────────────────────────── group('DataMapper (_ItemMapper)', () { final mapper = _ItemMapper(); test('fromRemote maps valid remote DTO to domain object', () { final item = mapper.fromRemote({'id': 'i1', 'name': 'Candle'}); expect(item.id, 'i1'); expect(item.name, 'Candle'); }); test('toRemote maps domain object to remote DTO', () { const item = _Item('i1', 'Candle'); final dto = mapper.toRemote(item); expect(dto['id'], 'i1'); expect(dto['name'], 'Candle'); }); test('fromRemoteList maps a list of DTOs', () { final items = mapper.fromRemoteList([ {'id': 'a', 'name': 'A'}, {'id': 'b', 'name': 'B'}, ]); expect(items.length, 2); expect(items[0].id, 'a'); expect(items[1].name, 'B'); }); test('toRemoteList maps a list of domain objects', () { const items = [_Item('a', 'A'), _Item('b', 'B')]; final dtos = mapper.toRemoteList(items); expect(dtos.length, 2); expect(dtos[0]['id'], 'a'); expect(dtos[1]['name'], 'B'); }); test('fromRemote throws DataMappingException for missing fields', () { expect( () => mapper.fromRemote({'id': null, 'name': null}), throwsA(isA()), ); }); }); group('DataMappingException', () { test('stores field, message, and rawValue', () { const ex = DataMappingException( field: 'status', message: 'Unknown status value', rawValue: 'bogus', ); expect(ex.field, 'status'); expect(ex.message, 'Unknown status value'); expect(ex.rawValue, 'bogus'); }); test('rawValue defaults to null', () { const ex = DataMappingException(field: 'id', message: 'Missing id'); expect(ex.rawValue, isNull); }); test('toString includes field and message', () { const ex = DataMappingException(field: 'price', message: 'Negative price'); expect(ex.toString(), contains('price')); expect(ex.toString(), contains('Negative price')); }); test('toString includes rawValue when present', () { const ex = DataMappingException(field: 'qty', message: 'Bad qty', rawValue: -5); expect(ex.toString(), contains('-5')); }); test('is an Exception', () { const ex = DataMappingException(field: 'x', message: 'y'); expect(ex, isA()); }); }); // ── Barrel smoke test ───────────────────────────────────────────────────── group('data barrel exports', () { test('all public types are accessible via package:data/data.dart', () { // LocalCache expect(InMemoryCache.new, isNotNull); // SyncQueue expect(InMemorySyncQueue.new, isNotNull); expect(SyncOperationStatus.values, isNotEmpty); // ConflictResolver expect(LastWriteWinsResolver.new, isNotNull); expect(LocalAlwaysWinsResolver.new, isNotNull); expect(RemoteAlwaysWinsResolver.new, isNotNull); expect(ConflictResolution.values, isNotEmpty); // ChangeTracker expect(InMemoryChangeTracker.new, isNotNull); expect(ChangeState.values, isNotEmpty); // DataMapper expect(DataMappingException.new, isNotNull); }); }); }