// ============================================================ // 闲言APP — 送达回执集成测试 // 创建时间: 2026-05-14 // 更新时间: 2026-05-14 // 作用: 送达回执集成测试 // 上次更新: 初始创建 // ============================================================ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:xianyan/features/file_transfer/models/transfer_message.dart'; import 'package:xianyan/features/file_transfer/presentation/widgets/receipt_indicator.dart'; import 'package:xianyan/features/file_transfer/services/delivery_receipt_service.dart'; import 'package:xianyan/features/file_transfer/services/signaling_service.dart'; class MockSignalingService extends Mock implements SignalingService {} class FakeSignalingMessage extends Fake implements SignalingMessage {} void main() { setUpAll(() { registerFallbackValue(FakeSignalingMessage()); }); group('DeliveryStatus', () { test('enum values have correct id/label/emoji', () { expect(DeliveryStatus.sending.id, 'sending'); expect(DeliveryStatus.sending.label, '发送中'); expect(DeliveryStatus.sending.emoji, '📤'); expect(DeliveryStatus.sent.id, 'sent'); expect(DeliveryStatus.sent.label, '已发送'); expect(DeliveryStatus.sent.emoji, '✓'); expect(DeliveryStatus.delivered.id, 'delivered'); expect(DeliveryStatus.delivered.label, '已送达'); expect(DeliveryStatus.delivered.emoji, '✓✓'); expect(DeliveryStatus.read.id, 'read'); expect(DeliveryStatus.read.label, '已读'); expect(DeliveryStatus.read.emoji, '✓✓'); }); test('fromId returns correct status', () { expect(DeliveryStatus.fromId('sending'), DeliveryStatus.sending); expect(DeliveryStatus.fromId('sent'), DeliveryStatus.sent); expect(DeliveryStatus.fromId('delivered'), DeliveryStatus.delivered); expect(DeliveryStatus.fromId('read'), DeliveryStatus.read); }); test('fromId returns sending for unknown id', () { expect(DeliveryStatus.fromId('unknown'), DeliveryStatus.sending); expect(DeliveryStatus.fromId(''), DeliveryStatus.sending); }); test('tryFromId returns correct status', () { expect(DeliveryStatus.tryFromId('sending'), DeliveryStatus.sending); expect(DeliveryStatus.tryFromId('sent'), DeliveryStatus.sent); expect(DeliveryStatus.tryFromId('delivered'), DeliveryStatus.delivered); expect(DeliveryStatus.tryFromId('read'), DeliveryStatus.read); }); test('tryFromId returns null for null or unknown id', () { expect(DeliveryStatus.tryFromId(null), isNull); expect(DeliveryStatus.tryFromId('unknown'), isNull); expect(DeliveryStatus.tryFromId(''), isNull); }); test('isRead is true only for read status', () { expect(DeliveryStatus.sending.isRead, false); expect(DeliveryStatus.sent.isRead, false); expect(DeliveryStatus.delivered.isRead, false); expect(DeliveryStatus.read.isRead, true); }); test('isDelivered is true for delivered and read', () { expect(DeliveryStatus.sending.isDelivered, false); expect(DeliveryStatus.sent.isDelivered, false); expect(DeliveryStatus.delivered.isDelivered, true); expect(DeliveryStatus.read.isDelivered, true); }); test('values contains all four statuses', () { expect(DeliveryStatus.values.length, 4); expect(DeliveryStatus.values, containsAll([ DeliveryStatus.sending, DeliveryStatus.sent, DeliveryStatus.delivered, DeliveryStatus.read, ])); }); }); group('DeliveryReceiptEvent', () { test('fields are set correctly', () { final now = DateTime.now(); final event = DeliveryReceiptEvent( messageId: 'msg-001', status: DeliveryStatus.delivered, fromDeviceId: 'device-A', timestamp: now, ); expect(event.messageId, 'msg-001'); expect(event.status, DeliveryStatus.delivered); expect(event.fromDeviceId, 'device-A'); expect(event.timestamp, now); }); test('toString contains messageId and status id', () { final event = DeliveryReceiptEvent( messageId: 'msg-002', status: DeliveryStatus.read, fromDeviceId: 'device-B', timestamp: DateTime.now(), ); final str = event.toString(); expect(str, contains('msg-002')); expect(str, contains('read')); }); }); group('DeliveryReceiptService', () { late MockSignalingService mockSignaling; late DeliveryReceiptService service; late StreamController messageController; setUp(() { mockSignaling = MockSignalingService(); messageController = StreamController.broadcast(); when(() => mockSignaling.onMessage).thenAnswer((_) => messageController.stream); when(() => mockSignaling.deviceId).thenReturn('device-A'); service = DeliveryReceiptService(mockSignaling); }); tearDown(() async { await service.dispose(); await messageController.close(); }); test('startListening subscribes to signaling messages', () { service.startListening(); verify(() => mockSignaling.onMessage).called(1); }); test('stopListening cancels subscription', () { service.startListening(); service.stopListening(); verify(() => mockSignaling.onMessage).called(greaterThanOrEqualTo(1)); }); test('sendDeliveryAck sends correct signaling message', () { service.sendDeliveryAck( targetId: 'device-B', messageId: 'msg-001', status: DeliveryStatus.delivered, ); verify(() => mockSignaling.sendCustomMessage(any( that: isA() .having((m) => m.type, 'type', SignalingMessageType.deliveryAck) .having((m) => m.to, 'to', 'device-B') .having((m) => m.from, 'from', 'device-A') .having((m) => m.payload!['messageId'], 'messageId', 'msg-001') .having((m) => m.payload!['status'], 'status', 'delivered')))).called(1); }); test('sendDeliveryAck includes timestamp in payload', () { service.sendDeliveryAck( targetId: 'device-B', messageId: 'msg-001', status: DeliveryStatus.read, ); verify(() => mockSignaling.sendCustomMessage(any( that: isA() .having((m) => m.payload!['timestamp'], 'timestamp', isA())))).called(1); }); test('markAsDelivered sends delivered status', () async { await service.markAsDelivered('device-B', 'msg-002'); verify(() => mockSignaling.sendCustomMessage(any( that: isA() .having((m) => m.payload!['messageId'], 'messageId', 'msg-002') .having((m) => m.payload!['status'], 'status', 'delivered')))).called(1); }); test('markAsRead sends read status', () async { await service.markAsRead('device-B', 'msg-003'); verify(() => mockSignaling.sendCustomMessage(any( that: isA() .having((m) => m.payload!['messageId'], 'messageId', 'msg-003') .having((m) => m.payload!['status'], 'status', 'read')))).called(1); }); test('receiptStream emits event on valid deliveryAck message', () async { service.startListening(); final events = []; service.receiptStream.listen(events.add); final ts = DateTime.now().millisecondsSinceEpoch; messageController.add(SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', to: 'device-A', payload: { 'messageId': 'msg-100', 'status': 'delivered', 'timestamp': ts, }, )); await Future.delayed(const Duration(milliseconds: 50)); expect(events.length, 1); expect(events[0].messageId, 'msg-100'); expect(events[0].status, DeliveryStatus.delivered); expect(events[0].fromDeviceId, 'device-B'); }); test('receiptStream emits read status correctly', () async { service.startListening(); final events = []; service.receiptStream.listen(events.add); messageController.add(SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-C', to: 'device-A', payload: { 'messageId': 'msg-200', 'status': 'read', 'timestamp': DateTime.now().millisecondsSinceEpoch, }, )); await Future.delayed(const Duration(milliseconds: 50)); expect(events.length, 1); expect(events[0].status, DeliveryStatus.read); }); test('receiptStream ignores non-deliveryAck messages', () async { service.startListening(); final events = []; service.receiptStream.listen(events.add); messageController.add(const SignalingMessage( type: SignalingMessageType.textMessage, from: 'device-B', to: 'device-A', payload: {'text': 'hello'}, )); await Future.delayed(const Duration(milliseconds: 50)); expect(events, isEmpty); }); test('receiptStream ignores deliveryAck with null payload', () async { service.startListening(); final events = []; service.receiptStream.listen(events.add); messageController.add(const SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', to: 'device-A', )); await Future.delayed(const Duration(milliseconds: 50)); expect(events, isEmpty); }); test('receiptStream ignores deliveryAck with empty messageId', () async { service.startListening(); final events = []; service.receiptStream.listen(events.add); messageController.add(const SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', to: 'device-A', payload: { 'messageId': '', 'status': 'delivered', 'timestamp': 0, }, )); await Future.delayed(const Duration(milliseconds: 50)); expect(events, isEmpty); }); test('receiptStream ignores deliveryAck with empty status', () async { service.startListening(); final events = []; service.receiptStream.listen(events.add); messageController.add(const SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', to: 'device-A', payload: { 'messageId': 'msg-300', 'status': '', 'timestamp': 0, }, )); await Future.delayed(const Duration(milliseconds: 50)); expect(events, isEmpty); }); test('receiptStream handles missing timestamp gracefully', () async { service.startListening(); final events = []; service.receiptStream.listen(events.add); messageController.add(const SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', to: 'device-A', payload: { 'messageId': 'msg-400', 'status': 'sent', }, )); await Future.delayed(const Duration(milliseconds: 50)); expect(events.length, 1); expect(events[0].messageId, 'msg-400'); expect(events[0].status, DeliveryStatus.sent); expect(events[0].timestamp, DateTime.fromMillisecondsSinceEpoch(0)); }); test('receiptStream handles unknown status id as sending', () async { service.startListening(); final events = []; service.receiptStream.listen(events.add); messageController.add(SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', to: 'device-A', payload: { 'messageId': 'msg-500', 'status': 'unknown_status', 'timestamp': DateTime.now().millisecondsSinceEpoch, }, )); await Future.delayed(const Duration(milliseconds: 50)); expect(events.length, 1); expect(events[0].status, DeliveryStatus.sending); }); test('multiple receipts are emitted in order', () async { service.startListening(); final events = []; service.receiptStream.listen(events.add); final ts = DateTime.now().millisecondsSinceEpoch; messageController.add(SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', payload: {'messageId': 'msg-1', 'status': 'sent', 'timestamp': ts}, )); messageController.add(SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', payload: {'messageId': 'msg-2', 'status': 'delivered', 'timestamp': ts}, )); messageController.add(SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', payload: {'messageId': 'msg-3', 'status': 'read', 'timestamp': ts}, )); await Future.delayed(const Duration(milliseconds: 50)); expect(events.length, 3); expect(events[0].status, DeliveryStatus.sent); expect(events[1].status, DeliveryStatus.delivered); expect(events[2].status, DeliveryStatus.read); }); test('dispose closes receipt stream', () async { service.startListening(); await service.dispose(); expect(service.receiptStream.isBroadcast, isTrue); }); test('stopListening then startListening re-subscribes', () async { service.startListening(); service.stopListening(); service.startListening(); final events = []; service.receiptStream.listen(events.add); messageController.add(SignalingMessage( type: SignalingMessageType.deliveryAck, from: 'device-B', payload: { 'messageId': 'msg-restart', 'status': 'delivered', 'timestamp': DateTime.now().millisecondsSinceEpoch, }, )); await Future.delayed(const Duration(milliseconds: 50)); expect(events.length, 1); expect(events[0].messageId, 'msg-restart'); }); }); group('TransferMessage delivery status', () { TransferMessage createMessage({DeliveryStatus? status}) { return TransferMessage( id: 'msg-001', sessionId: 'session-1', type: TransferMessageType.text, content: 'Hello', isRemote: false, timestamp: DateTime.now(), deliveryStatus: status, ); } test('deliveryStatus defaults to null', () { final msg = createMessage(); expect(msg.deliveryStatus, isNull); }); test('deliveryStatus can be set to sending', () { final msg = createMessage(status: DeliveryStatus.sending); expect(msg.deliveryStatus, DeliveryStatus.sending); }); test('deliveryStatus can be set to read', () { final msg = createMessage(status: DeliveryStatus.read); expect(msg.deliveryStatus, DeliveryStatus.read); expect(msg.deliveryStatus!.isRead, true); }); test('copyWith updates deliveryStatus', () { final msg = createMessage(status: DeliveryStatus.sending); final updated = msg.copyWith(deliveryStatus: DeliveryStatus.delivered); expect(updated.deliveryStatus, DeliveryStatus.delivered); expect(msg.deliveryStatus, DeliveryStatus.sending); }); test('toJson includes deliveryStatus', () { final msg = createMessage(status: DeliveryStatus.delivered); final json = msg.toJson(); expect(json['deliveryStatus'], 'delivered'); }); test('fromJson restores deliveryStatus', () { final msg = createMessage(status: DeliveryStatus.read); final json = msg.toJson(); final restored = TransferMessage.fromJson(json); expect(restored.deliveryStatus, DeliveryStatus.read); }); test('fromJson handles null deliveryStatus', () { final json = { 'id': 'msg-1', 'sessionId': 's1', 'type': 'text', 'content': 'hi', 'isRemote': false, 'timestamp': DateTime.now().toIso8601String(), }; final restored = TransferMessage.fromJson(json); expect(restored.deliveryStatus, isNull); }); test('deliveredAt and readAt fields', () { final deliveredTime = DateTime(2026, 5, 14, 10, 30); final readTime = DateTime(2026, 5, 14, 10, 31); final msg = createMessage().copyWith( deliveryStatus: DeliveryStatus.read, deliveredAt: deliveredTime, readAt: readTime, ); expect(msg.deliveredAt, deliveredTime); expect(msg.readAt, readTime); final json = msg.toJson(); expect(json['deliveredAt'], deliveredTime.toIso8601String()); expect(json['readAt'], readTime.toIso8601String()); final restored = TransferMessage.fromJson(json); expect(restored.deliveredAt!.millisecondsSinceEpoch, deliveredTime.millisecondsSinceEpoch); expect(restored.readAt!.millisecondsSinceEpoch, readTime.millisecondsSinceEpoch); }); test('delivery status progression via copyWith', () { var msg = createMessage(status: DeliveryStatus.sending); expect(msg.deliveryStatus, DeliveryStatus.sending); msg = msg.copyWith(deliveryStatus: DeliveryStatus.sent); expect(msg.deliveryStatus, DeliveryStatus.sent); msg = msg.copyWith(deliveryStatus: DeliveryStatus.delivered); expect(msg.deliveryStatus, DeliveryStatus.delivered); expect(msg.deliveryStatus!.isDelivered, true); expect(msg.deliveryStatus!.isRead, false); msg = msg.copyWith(deliveryStatus: DeliveryStatus.read); expect(msg.deliveryStatus, DeliveryStatus.read); expect(msg.deliveryStatus!.isDelivered, true); expect(msg.deliveryStatus!.isRead, true); }); }); group('ReceiptIndicator widget', () { Widget buildWidget(DeliveryStatus? status) { return CupertinoApp( home: Center( child: ReceiptIndicator(status: status), ), ); } testWidgets('renders SizedBox.shrink when status is null', (tester) async { await tester.pumpWidget(buildWidget(null)); expect(find.byType(SizedBox), findsOneWidget); expect(find.byType(Row), findsNothing); }); testWidgets('renders sending status with emoji and label', (tester) async { await tester.pumpWidget(buildWidget(DeliveryStatus.sending)); expect(find.byType(Row), findsOneWidget); expect(find.text('⏳'), findsOneWidget); expect(find.text('发送中'), findsOneWidget); }); testWidgets('renders sent status', (tester) async { await tester.pumpWidget(buildWidget(DeliveryStatus.sent)); expect(find.text('✓'), findsOneWidget); expect(find.text('已发送'), findsOneWidget); }); testWidgets('renders delivered status', (tester) async { await tester.pumpWidget(buildWidget(DeliveryStatus.delivered)); expect(find.text('✓✓'), findsOneWidget); expect(find.text('已送达'), findsOneWidget); }); testWidgets('renders read status', (tester) async { await tester.pumpWidget(buildWidget(DeliveryStatus.read)); expect(find.text('✓✓'), findsOneWidget); expect(find.text('已读'), findsOneWidget); }); testWidgets('switching status updates display', (tester) async { await tester.pumpWidget(buildWidget(DeliveryStatus.sending)); expect(find.text('发送中'), findsOneWidget); await tester.pumpWidget(buildWidget(DeliveryStatus.read)); expect(find.text('已读'), findsOneWidget); expect(find.text('发送中'), findsNothing); }); }); }