本次更新涵盖多个功能模块的优化与新增: 1. 新增Wi-Fi直连配对方式与协作画布模块 2. 完成设备管理重命名API与前端适配 3. 优化日签卡片空数据保护与UI细节 4. 新增剪贴板工具与每日运势会话 5. 修复应用锁恢复、语音消息录制等已知问题 6. 完善文件传输统计与配对逻辑 7. 更新安卓权限配置与iOS隐私描述 8. 新增自动化测试脚本与文档 9. 清理旧版审计报告与测试文件
593 lines
20 KiB
Dart
593 lines
20 KiB
Dart
// ============================================================
|
|
// 闲言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<SignalingMessage> messageController;
|
|
|
|
setUp(() {
|
|
mockSignaling = MockSignalingService();
|
|
messageController = StreamController<SignalingMessage>.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<SignalingMessage>()
|
|
.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<SignalingMessage>()
|
|
.having((m) => m.payload!['timestamp'], 'timestamp', isA<int>())))).called(1);
|
|
});
|
|
|
|
test('markAsDelivered sends delivered status', () async {
|
|
await service.markAsDelivered('device-B', 'msg-002');
|
|
|
|
verify(() => mockSignaling.sendCustomMessage(any(
|
|
that: isA<SignalingMessage>()
|
|
.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<SignalingMessage>()
|
|
.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 = <DeliveryReceiptEvent>[];
|
|
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<void>.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 = <DeliveryReceiptEvent>[];
|
|
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<void>.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 = <DeliveryReceiptEvent>[];
|
|
service.receiptStream.listen(events.add);
|
|
|
|
messageController.add(const SignalingMessage(
|
|
type: SignalingMessageType.textMessage,
|
|
from: 'device-B',
|
|
to: 'device-A',
|
|
payload: {'text': 'hello'},
|
|
));
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
expect(events, isEmpty);
|
|
});
|
|
|
|
test('receiptStream ignores deliveryAck with null payload', () async {
|
|
service.startListening();
|
|
|
|
final events = <DeliveryReceiptEvent>[];
|
|
service.receiptStream.listen(events.add);
|
|
|
|
messageController.add(const SignalingMessage(
|
|
type: SignalingMessageType.deliveryAck,
|
|
from: 'device-B',
|
|
to: 'device-A',
|
|
));
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
expect(events, isEmpty);
|
|
});
|
|
|
|
test('receiptStream ignores deliveryAck with empty messageId', () async {
|
|
service.startListening();
|
|
|
|
final events = <DeliveryReceiptEvent>[];
|
|
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<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
expect(events, isEmpty);
|
|
});
|
|
|
|
test('receiptStream ignores deliveryAck with empty status', () async {
|
|
service.startListening();
|
|
|
|
final events = <DeliveryReceiptEvent>[];
|
|
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<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
expect(events, isEmpty);
|
|
});
|
|
|
|
test('receiptStream handles missing timestamp gracefully', () async {
|
|
service.startListening();
|
|
|
|
final events = <DeliveryReceiptEvent>[];
|
|
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<void>.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 = <DeliveryReceiptEvent>[];
|
|
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<void>.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 = <DeliveryReceiptEvent>[];
|
|
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<void>.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 = <DeliveryReceiptEvent>[];
|
|
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<void>.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);
|
|
});
|
|
});
|
|
}
|