chore: 完成v6.7.0版本迭代更新

本次更新涵盖多个功能模块的优化与新增:
1. 新增Wi-Fi直连配对方式与协作画布模块
2. 完成设备管理重命名API与前端适配
3. 优化日签卡片空数据保护与UI细节
4. 新增剪贴板工具与每日运势会话
5. 修复应用锁恢复、语音消息录制等已知问题
6. 完善文件传输统计与配对逻辑
7. 更新安卓权限配置与iOS隐私描述
8. 新增自动化测试脚本与文档
9. 清理旧版审计报告与测试文件
This commit is contained in:
Developer
2026-05-14 05:35:18 +08:00
parent 2c1a87e7c5
commit 228095f80a
208 changed files with 45933 additions and 12870 deletions

View File

@@ -0,0 +1,592 @@
// ============================================================
// 闲言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);
});
});
}

File diff suppressed because it is too large Load Diff