Import IKAR messenger client and server
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/account.dart';
|
||||
import 'package:ikar/core/protocol/opcode_map.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:ikar/core/storage/token_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _Paths extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
_Paths(this.directory);
|
||||
final String directory;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
class _Api extends Api {
|
||||
final requests = <(int, Map<dynamic, dynamic>)>[];
|
||||
final responses = <int, Map<dynamic, dynamic>>{};
|
||||
|
||||
@override
|
||||
SessionState get state => SessionState.online;
|
||||
|
||||
@override
|
||||
Stream<SessionState> get stateStream => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<Packet> sendRequest(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
void Function()? beforeSend,
|
||||
bool silent = false,
|
||||
}) async {
|
||||
beforeSend?.call();
|
||||
requests.add((opcode, Map.of(payload)));
|
||||
return Packet(
|
||||
cmd: CmdType.ok,
|
||||
opcode: opcode,
|
||||
payload: responses[opcode] ?? <dynamic, dynamic>{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _profile = {
|
||||
'contact': {
|
||||
'id': 42,
|
||||
'names': <dynamic>[
|
||||
{'type': 'ONEME', 'firstName': 'New', 'lastName': 'User'},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const secureChannel = MethodChannel(
|
||||
'plugins.it_nomads.com/flutter_secure_storage',
|
||||
);
|
||||
final secureValues = <String, String>{};
|
||||
late _Api api;
|
||||
late AccountModule account;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
secureValues.clear();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(secureChannel, (call) async {
|
||||
final args = call.arguments as Map;
|
||||
final key = args['key'] as String;
|
||||
switch (call.method) {
|
||||
case 'write':
|
||||
secureValues[key] = args['value'] as String;
|
||||
return null;
|
||||
case 'read':
|
||||
return secureValues[key];
|
||||
case 'delete':
|
||||
secureValues.remove(key);
|
||||
return null;
|
||||
}
|
||||
throw StateError('Unexpected storage method: ${call.method}');
|
||||
});
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'qlyra_registration_test_',
|
||||
);
|
||||
final originalPaths = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _Paths(directory.path);
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
PathProviderPlatform.instance = originalPaths;
|
||||
directory.deleteSync(recursive: true);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(secureChannel, null);
|
||||
});
|
||||
await AppDatabase.init();
|
||||
api = _Api();
|
||||
addTearDown(api.dispose);
|
||||
account = AccountModule(api);
|
||||
});
|
||||
|
||||
test(
|
||||
'new account: OTP, registration, first login and restored login',
|
||||
() async {
|
||||
api.responses[Opcode.auth] = {
|
||||
'tokenAttrs': {
|
||||
'REGISTER': {'token': 'temporary-registration-token'},
|
||||
},
|
||||
};
|
||||
final verified = await account.verifyCode('123456', 'sms-token');
|
||||
expect(verified.isRegistration, isTrue);
|
||||
expect(secureValues, isEmpty);
|
||||
expect(await TokenStorage.getActiveAccountId(), isNull);
|
||||
|
||||
api.responses[Opcode.authConfirm] = {
|
||||
'userToken': 42,
|
||||
'profile': _profile,
|
||||
'tokenType': 'LOGIN',
|
||||
'token': 'registered-login-token',
|
||||
};
|
||||
final id = await account.completeRegistration(
|
||||
token: verified.registerToken!,
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
photoId: 7,
|
||||
);
|
||||
expect(id, 42);
|
||||
expect(api.requests.last.$1, Opcode.authConfirm);
|
||||
expect(api.requests.last.$2, {
|
||||
'token': 'temporary-registration-token',
|
||||
'tokenType': 'REGISTER',
|
||||
'firstName': 'New',
|
||||
'lastName': 'User',
|
||||
'photoId': 7,
|
||||
'avatarType': 'PRESET_AVATAR',
|
||||
});
|
||||
expect(await TokenStorage.readActiveToken(), 'registered-login-token');
|
||||
expect((await AppDatabase.loadProfile(id))?.firstName, 'New');
|
||||
|
||||
api.responses[Opcode.login] = {
|
||||
'profile': _profile,
|
||||
'token': 'rotated-login-token',
|
||||
'time': 1700000000000,
|
||||
};
|
||||
final loggedIn = await account.login(accountId: id);
|
||||
expect(loggedIn.profile.id, id);
|
||||
expect(account.isLoggedIn, isTrue);
|
||||
expect(
|
||||
api.requests.firstWhere((r) => r.$1 == Opcode.login).$2['token'],
|
||||
'registered-login-token',
|
||||
);
|
||||
expect(await TokenStorage.readActiveToken(), 'rotated-login-token');
|
||||
|
||||
final restored = AccountModule(api);
|
||||
await restored.login();
|
||||
expect(
|
||||
api.requests.lastWhere((r) => r.$1 == Opcode.login).$2['token'],
|
||||
'rotated-login-token',
|
||||
);
|
||||
expect(restored.isLoggedIn, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'REGISTER token with account ID does not become an active session',
|
||||
() async {
|
||||
api.responses[Opcode.auth] = {
|
||||
'profile': _profile,
|
||||
'tokenAttrs': {
|
||||
'REGISTER': {'token': 'registration-only'},
|
||||
},
|
||||
};
|
||||
final result = await account.verifyCode('123456', 'sms-token');
|
||||
expect(result.isRegistration, isTrue);
|
||||
expect(secureValues, isEmpty);
|
||||
expect(await TokenStorage.getActiveAccountId(), isNull);
|
||||
expect(await AppDatabase.loadAllProfiles(), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('existing account still persists LOGIN token after OTP', () async {
|
||||
api.responses[Opcode.auth] = {
|
||||
'profile': _profile,
|
||||
'tokenAttrs': {
|
||||
'LOGIN': {'token': 'existing-login-token'},
|
||||
},
|
||||
};
|
||||
final result = await account.verifyCode('123456', 'sms-token');
|
||||
expect(result.isRegistration, isFalse);
|
||||
expect(await TokenStorage.readActiveToken(), 'existing-login-token');
|
||||
expect((await AppDatabase.loadProfile(42))?.firstName, 'New');
|
||||
});
|
||||
|
||||
for (final invalidToken in [null, '', ' ', 123]) {
|
||||
test('registration rejects invalid login token: $invalidToken', () async {
|
||||
api.responses[Opcode.authConfirm] = {
|
||||
'profile': _profile,
|
||||
'tokenType': 'LOGIN',
|
||||
'token': invalidToken,
|
||||
};
|
||||
await expectLater(
|
||||
account.completeRegistration(token: 'register', firstName: 'New'),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(secureValues, isEmpty);
|
||||
expect(await TokenStorage.getActiveAccountId(), isNull);
|
||||
expect(await AppDatabase.loadAllProfiles(), isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'registration rejects a REGISTER token in the confirmation response',
|
||||
() async {
|
||||
api.responses[Opcode.authConfirm] = {
|
||||
'profile': _profile,
|
||||
'tokenType': 'REGISTER',
|
||||
'token': 'temporary',
|
||||
};
|
||||
await expectLater(
|
||||
account.completeRegistration(token: 'register', firstName: 'New'),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(secureValues, isEmpty);
|
||||
expect(await TokenStorage.getActiveAccountId(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'login rejects empty explicit and stored tokens before network request',
|
||||
() async {
|
||||
await expectLater(
|
||||
account.login(accountId: 42, token: ''),
|
||||
throwsStateError,
|
||||
);
|
||||
await TokenStorage.saveToken(' ', 42);
|
||||
await expectLater(account.login(accountId: 42), throwsStateError);
|
||||
expect(api.requests, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('resend requests a new OTP token from the server', () async {
|
||||
api.responses[Opcode.authRequest] = {'token': 'fresh-sms-token'};
|
||||
final result = await account.resendCode('+7 (999) 000-00-00');
|
||||
expect(result.token, 'fresh-sms-token');
|
||||
expect(api.requests.single.$1, Opcode.authRequest);
|
||||
expect(api.requests.single.$2, {
|
||||
'phone': '+79990000000',
|
||||
'type': 'RESEND',
|
||||
'language': 'ru',
|
||||
});
|
||||
});
|
||||
|
||||
test('empty LOGIN token does not hide a valid registration token', () {
|
||||
final result = VerifyCodeResult(
|
||||
payload: {
|
||||
'tokenAttrs': {
|
||||
'LOGIN': {'token': ''},
|
||||
'REGISTER': {'token': 'registration-only'},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.loginToken, isNull);
|
||||
expect(result.isRegistration, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/contacts.dart';
|
||||
import 'package:ikar/core/config/countries.dart';
|
||||
import 'package:ikar/core/protocol/opcode_map.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
import 'package:ikar/frontend/screens/auth/phone_input_formatter.dart';
|
||||
|
||||
class _ContactApi extends Api {
|
||||
_ContactApi(this.error);
|
||||
final PacketError error;
|
||||
Map<dynamic, dynamic>? sent;
|
||||
|
||||
@override
|
||||
Future<Packet> sendRequest(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
void Function()? beforeSend,
|
||||
bool silent = false,
|
||||
}) async {
|
||||
beforeSend?.call();
|
||||
expect(opcode, Opcode.contactAddByPhone);
|
||||
sent = payload;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
String formattedDigits(String raw, {String country = 'RU'}) {
|
||||
final input = FilteringTextInputFormatter.digitsOnly.formatEditUpdate(
|
||||
TextEditingValue.empty,
|
||||
TextEditingValue(text: raw),
|
||||
);
|
||||
return PhoneInputFormatter(countriesByCode[country]!)
|
||||
.formatEditUpdate(TextEditingValue.empty, input)
|
||||
.text
|
||||
.replaceAll(RegExp(r'\D'), '');
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
for (final phone in [
|
||||
'+7 (900) 000-00-01',
|
||||
'79000000001',
|
||||
'8 900 000 00 01',
|
||||
'9000000001',
|
||||
]) {
|
||||
test('contact paste preserves subscriber digits: $phone', () async {
|
||||
final digits = formattedDigits(phone);
|
||||
expect(digits, '9000000001');
|
||||
final api = _ContactApi(
|
||||
const PacketError('missing', errorKey: 'user.not.found'),
|
||||
);
|
||||
addTearDown(api.dispose);
|
||||
final result = await ContactsModule.addContactByPhone(
|
||||
api,
|
||||
phone: '+7$digits',
|
||||
firstName: 'Test',
|
||||
);
|
||||
expect(api.sent?['phone'], '+79000000001');
|
||||
expect(result.status, AddContactStatus.notFound);
|
||||
});
|
||||
}
|
||||
|
||||
test('international paste removes selected calling code', () {
|
||||
expect(formattedDigits('+44 7700 900123', country: 'GB'), '7700900123');
|
||||
});
|
||||
|
||||
for (final entry in {
|
||||
'8 (900) 000-00-01': '+79000000001',
|
||||
'9000000001': '+9000000001',
|
||||
'+44 7700 900123': '+447700900123',
|
||||
}.entries) {
|
||||
test('backend normalizes contact phone ${entry.key}', () async {
|
||||
final api = _ContactApi(
|
||||
const PacketError('missing', errorKey: 'user.not.found'),
|
||||
);
|
||||
addTearDown(api.dispose);
|
||||
final result = await ContactsModule.addContactByPhone(
|
||||
api,
|
||||
phone: entry.key,
|
||||
firstName: 'Test',
|
||||
);
|
||||
expect(api.sent?['phone'], entry.value);
|
||||
expect(result.status, AddContactStatus.notFound);
|
||||
});
|
||||
}
|
||||
|
||||
for (final phone in ['12345', '1234567890123456']) {
|
||||
test(
|
||||
'invalid contact phone is rejected before requesting $phone',
|
||||
() async {
|
||||
final api = _ContactApi(
|
||||
const PacketError('missing', errorKey: 'user.not.found'),
|
||||
);
|
||||
addTearDown(api.dispose);
|
||||
final result = await ContactsModule.addContactByPhone(
|
||||
api,
|
||||
phone: phone,
|
||||
firstName: 'Test',
|
||||
);
|
||||
expect(api.sent, isNull);
|
||||
expect(result.status, AddContactStatus.error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('replacing a selected formatted number preserves its final digit', () {
|
||||
final formatter = PhoneInputFormatter(countriesByCode['RU']!);
|
||||
final formatted = formatter.formatEditUpdate(
|
||||
TextEditingValue.empty,
|
||||
const TextEditingValue(text: '9000000001'),
|
||||
);
|
||||
final old = formatted.copyWith(
|
||||
selection: TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: formatted.text.length,
|
||||
),
|
||||
);
|
||||
final filtered = FilteringTextInputFormatter.digitsOnly.formatEditUpdate(
|
||||
old,
|
||||
const TextEditingValue(text: '+7 (900) 000-00-02'),
|
||||
);
|
||||
expect(
|
||||
formatter
|
||||
.formatEditUpdate(old, filtered)
|
||||
.text
|
||||
.replaceAll(RegExp(r'\D'), ''),
|
||||
'9000000002',
|
||||
);
|
||||
});
|
||||
|
||||
test('oversized invalid paste is rejected without truncation', () {
|
||||
expect(formattedDigits('123456789012345'), isEmpty);
|
||||
});
|
||||
|
||||
test('typing an extra digit does not reinterpret the completed number', () {
|
||||
final formatter = PhoneInputFormatter(countriesByCode['RU']!);
|
||||
final old = formatter.formatEditUpdate(
|
||||
TextEditingValue.empty,
|
||||
const TextEditingValue(text: '7900000001'),
|
||||
);
|
||||
expect(
|
||||
formatter.formatEditUpdate(old, old.copyWith(text: '${old.text}2')),
|
||||
old,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'unrelated not found error is not reported as absent ИКАР user',
|
||||
() async {
|
||||
final api = _ContactApi(
|
||||
const PacketError('endpoint not found', errorKey: 'method.not.found'),
|
||||
);
|
||||
addTearDown(api.dispose);
|
||||
final result = await ContactsModule.addContactByPhone(
|
||||
api,
|
||||
phone: '+79000000001',
|
||||
firstName: 'Test',
|
||||
);
|
||||
expect(result.status, AddContactStatus.error);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/admin_rights_screen.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/chat_admin_state.dart';
|
||||
import 'package:ikar/frontend/widgets/settings_card.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/models/chat_info.dart';
|
||||
|
||||
const _ownerId = 11;
|
||||
const _adminId = 22;
|
||||
const _followerId = 33;
|
||||
|
||||
ChatAdminState _state({
|
||||
required int myId,
|
||||
String type = 'CHANNEL',
|
||||
int adminPermissions = 1828,
|
||||
bool everyoneCanPin = false,
|
||||
}) => ChatAdminState(
|
||||
chatId: -1000,
|
||||
myId: myId,
|
||||
name: 'Synthetic chat',
|
||||
imageUrl: '',
|
||||
info: ChatInfo.fromMap({
|
||||
'id': -1000,
|
||||
'type': type,
|
||||
'owner': _ownerId,
|
||||
'adminParticipants': {
|
||||
'$_ownerId': {'id': _ownerId, 'permissions': 4095},
|
||||
'$_adminId': {'id': _adminId, 'permissions': adminPermissions},
|
||||
},
|
||||
'options': {'ALL_CAN_PIN_MESSAGE': everyoneCanPin},
|
||||
}),
|
||||
);
|
||||
|
||||
Future<void> _pump(
|
||||
WidgetTester tester, {
|
||||
required ChatAdminState state,
|
||||
required int userId,
|
||||
required bool appointing,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1080, 4000);
|
||||
tester.view.devicePixelRatio = 2.5;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AdminRightsScreen(
|
||||
state: state,
|
||||
userId: userId,
|
||||
name: 'Synthetic person',
|
||||
appointing: appointing,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
SettingsToggleTile _toggle(WidgetTester tester, String label) => tester
|
||||
.widget<SettingsToggleTile>(find.widgetWithText(SettingsToggleTile, label));
|
||||
|
||||
FilledButton _button(WidgetTester tester, String label) =>
|
||||
tester.widget<FilledButton>(find.widgetWithText(FilledButton, label));
|
||||
|
||||
void main() {
|
||||
testWidgets('appointing starts with every right granted', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
state: _state(myId: _ownerId),
|
||||
userId: _followerId,
|
||||
appointing: true,
|
||||
);
|
||||
|
||||
expect(find.text('Назначить администратора'), findsOneWidget);
|
||||
final toggles = tester.widgetList<SettingsToggleTile>(
|
||||
find.byType(SettingsToggleTile),
|
||||
);
|
||||
expect(toggles, hasLength(8));
|
||||
expect(toggles.every((toggle) => toggle.value && toggle.enabled), isTrue);
|
||||
expect(_button(tester, 'Назначить администратором').onPressed, isNotNull);
|
||||
expect(find.text('Передать права владельца'), findsNothing);
|
||||
expect(find.text('Снять с администраторов'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the owner edits an admin and can hand over the channel', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
state: _state(myId: _ownerId),
|
||||
userId: _adminId,
|
||||
appointing: false,
|
||||
);
|
||||
|
||||
expect(find.text('Права администратора'), findsOneWidget);
|
||||
expect(_toggle(tester, 'Публиковать посты').value, isTrue);
|
||||
expect(_toggle(tester, 'Изменять канал').value, isFalse);
|
||||
expect(_button(tester, 'Сохранить').onPressed, isNull);
|
||||
expect(find.text('Передать права владельца'), findsOneWidget);
|
||||
expect(find.text('Снять с администраторов'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Изменять канал'));
|
||||
await tester.pump();
|
||||
|
||||
expect(_toggle(tester, 'Изменять канал').value, isTrue);
|
||||
expect(_button(tester, 'Сохранить').onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('an admin only grants rights they hold', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
state: _state(myId: _adminId),
|
||||
userId: _followerId,
|
||||
appointing: true,
|
||||
);
|
||||
|
||||
expect(_toggle(tester, 'Публиковать посты').enabled, isTrue);
|
||||
expect(
|
||||
_toggle(tester, 'Назначать и снимать администраторов').enabled,
|
||||
isTrue,
|
||||
);
|
||||
expect(_toggle(tester, 'Публиковать посты').value, isTrue);
|
||||
expect(_toggle(tester, 'Изменять канал').enabled, isFalse);
|
||||
expect(_toggle(tester, 'Изменять канал').value, isFalse);
|
||||
expect(_toggle(tester, 'Закреплять посты').enabled, isFalse);
|
||||
expect(_toggle(tester, 'Закреплять посты').value, isFalse);
|
||||
expect(_toggle(tester, 'Добавлять и удалять подписчиков').enabled, isFalse);
|
||||
expect(_toggle(tester, 'Смотреть статистику канала').enabled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a group admin gets the group switches', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
state: _state(myId: _ownerId, type: 'CHAT'),
|
||||
userId: _followerId,
|
||||
appointing: true,
|
||||
);
|
||||
|
||||
final toggles = tester.widgetList<SettingsToggleTile>(
|
||||
find.byType(SettingsToggleTile),
|
||||
);
|
||||
expect(toggles, hasLength(6));
|
||||
expect(_toggle(tester, 'Изменять чат').value, isTrue);
|
||||
expect(_toggle(tester, 'Удалять сообщения').value, isTrue);
|
||||
expect(_toggle(tester, 'Закреплять сообщения').value, isTrue);
|
||||
expect(_toggle(tester, 'Добавлять и удалять участников').value, isTrue);
|
||||
expect(_toggle(tester, 'Обновлять ссылку на чат').value, isFalse);
|
||||
expect(
|
||||
_toggle(tester, 'Назначать и снимать администраторов').value,
|
||||
isFalse,
|
||||
);
|
||||
expect(find.text('Публиковать посты'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('pinning is locked on when every member may pin', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
state: _state(
|
||||
myId: _ownerId,
|
||||
type: 'CHAT',
|
||||
adminPermissions: 1,
|
||||
everyoneCanPin: true,
|
||||
),
|
||||
userId: _adminId,
|
||||
appointing: false,
|
||||
);
|
||||
|
||||
final pin = _toggle(tester, 'Закреплять сообщения');
|
||||
expect(pin.value, isTrue);
|
||||
expect(pin.enabled, isFalse);
|
||||
expect(_toggle(tester, 'Удалять сообщения').value, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/shared_content.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/album_layout.dart';
|
||||
import 'package:ikar/models/attachment.dart';
|
||||
|
||||
SharedMediaItem _item(String messageId, int time, int photoId) =>
|
||||
SharedMediaItem(
|
||||
messageId: messageId,
|
||||
chatId: 1,
|
||||
senderId: 2,
|
||||
time: time,
|
||||
attachment: PhotoAttachment(photoId: photoId),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('AlbumLayout.rows', () {
|
||||
test('ten square photos grow from pairs to triples', () {
|
||||
expect(AlbumLayout.rows(List.filled(10, 1.0)), [2, 2, 3, 3]);
|
||||
});
|
||||
|
||||
test('rows never hold more than four tiles', () {
|
||||
for (var count = 2; count <= AlbumLayout.maxTiles; count++) {
|
||||
final rows = AlbumLayout.rows(List.filled(count, 0.7), narrow: true);
|
||||
expect(rows.fold<int>(0, (sum, row) => sum + row), count);
|
||||
expect(rows.every((row) => row >= 1 && row <= 4), isTrue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('AlbumLayout.layout', () {
|
||||
const mixed = [1.78, 0.56, 1.0, 0.75, 1.33, 3.2, 0.5, 1.6, 0.8, 1.0];
|
||||
|
||||
Rect bounds(List<Rect> tiles) =>
|
||||
tiles.reduce((a, b) => a.expandToInclude(b));
|
||||
|
||||
double area(Rect rect) => rect.width * rect.height;
|
||||
|
||||
test('tiles cover the album without overlapping', () {
|
||||
for (var count = 2; count <= mixed.length; count++) {
|
||||
for (final ratios in [
|
||||
mixed.sublist(0, count),
|
||||
mixed.reversed.take(count).toList(),
|
||||
List.filled(count, 0.6),
|
||||
List.filled(count, 1.9),
|
||||
]) {
|
||||
final grid = AlbumLayout.layout(ratios);
|
||||
expect(grid.tiles, hasLength(count));
|
||||
expect(grid.aspectRatio, greaterThan(0));
|
||||
|
||||
final box = bounds(grid.tiles);
|
||||
expect(box.left, closeTo(0, 1e-9));
|
||||
expect(box.top, closeTo(0, 1e-9));
|
||||
expect(box.right, closeTo(1, 1e-9));
|
||||
expect(box.bottom, closeTo(1, 1e-9));
|
||||
|
||||
final covered = grid.tiles.fold<double>(0, (sum, t) => sum + area(t));
|
||||
expect(covered, closeTo(1, 1e-6), reason: '$ratios');
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
expect(grid.tiles[i].width, greaterThan(0));
|
||||
expect(grid.tiles[i].height, greaterThan(0));
|
||||
for (var j = i + 1; j < count; j++) {
|
||||
final overlap = grid.tiles[i].intersect(grid.tiles[j]);
|
||||
final overlaps = overlap.width > 1e-9 && overlap.height > 1e-9;
|
||||
expect(overlaps, isFalse, reason: '$ratios: $i and $j');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a tall photo among three spans the full height on the left', () {
|
||||
final grid = AlbumLayout.layout([0.6, 1.0, 1.0]);
|
||||
expect(grid.tiles[0].left, 0);
|
||||
expect(grid.tiles[0].top, 0);
|
||||
expect(grid.tiles[0].bottom, closeTo(1, 1e-9));
|
||||
expect(grid.tiles[1].left, grid.tiles[0].right);
|
||||
expect(grid.tiles[2].left, grid.tiles[0].right);
|
||||
expect(grid.tiles[2].top, grid.tiles[1].bottom);
|
||||
});
|
||||
|
||||
test('a wide first photo of four sits above a row of three', () {
|
||||
final grid = AlbumLayout.layout([1.6, 1.0, 1.0, 1.0]);
|
||||
expect(grid.tiles[0].width, closeTo(1, 1e-9));
|
||||
for (final tile in grid.tiles.skip(1)) {
|
||||
expect(tile.top, grid.tiles[0].bottom);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown sizes fall back to squares', () {
|
||||
final grid = AlbumLayout.layout([0, double.nan, 1]);
|
||||
expect(grid.tiles, hasLength(3));
|
||||
expect(grid.aspectRatio.isFinite, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test('media feed keeps album order when photos share a timestamp', () {
|
||||
final items = [
|
||||
for (var i = 0; i < 40; i++) _item('old$i', 1000 + i, 500 + i),
|
||||
_item('album', 5000, 1),
|
||||
_item('album', 5000, 2),
|
||||
_item('album', 5000, 3),
|
||||
];
|
||||
|
||||
sortMediaNewestFirst(items);
|
||||
|
||||
final album = items.where((item) => item.messageId == 'album').toList();
|
||||
expect(items.take(3).every((item) => item.messageId == 'album'), isTrue);
|
||||
expect(
|
||||
[for (final item in album) (item.attachment as PhotoAttachment).photoId],
|
||||
[1, 2, 3],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/view/anchored_message_list.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'a distant message can become the center without walking the list',
|
||||
(tester) async {
|
||||
final controller = ScrollController();
|
||||
final anchor = ValueNotifier<int?>(null);
|
||||
final keys = <int, GlobalKey>{};
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(anchor.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
height: 300,
|
||||
child: ValueListenableBuilder<int?>(
|
||||
valueListenable: anchor,
|
||||
builder: (context, index, _) => AnchoredMessageList(
|
||||
controller: controller,
|
||||
cacheExtent: 300,
|
||||
padding: EdgeInsets.zero,
|
||||
epoch: 0,
|
||||
itemCount: 500,
|
||||
anchorIndex: index,
|
||||
itemBuilder: (context, i) => KeyedSubtree(
|
||||
key: keys.putIfAbsent(i, GlobalKey.new),
|
||||
child: SizedBox(
|
||||
key: ValueKey('message-$i'),
|
||||
height: 60,
|
||||
child: Text('message $i'),
|
||||
),
|
||||
),
|
||||
bottomSpacer: const SizedBox.shrink(),
|
||||
loadingOlder: false,
|
||||
loadingIndicator: const SizedBox(height: 30),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('message-499')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('message-20')), findsNothing);
|
||||
|
||||
anchor.value = 20;
|
||||
controller.jumpTo(
|
||||
AnchoredMessageList.anchoredPixels(
|
||||
alignment: 0.3,
|
||||
viewport: controller.position.viewportDimension,
|
||||
anchorHeight: 0,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const ValueKey('message-20')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-20')).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(controller.position.pixels.isFinite, isTrue);
|
||||
expect(controller.position.minScrollExtent, lessThan(0));
|
||||
expect(
|
||||
AnchoredMessageList.distanceFromBottom(controller.position),
|
||||
greaterThan(120),
|
||||
);
|
||||
controller.jumpTo(controller.position.minScrollExtent);
|
||||
await tester.pump();
|
||||
expect(
|
||||
AnchoredMessageList.distanceFromBottom(controller.position),
|
||||
lessThanOrEqualTo(120),
|
||||
);
|
||||
anchor.value = null;
|
||||
await tester.pump();
|
||||
controller.jumpTo(controller.position.minScrollExtent);
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-499')).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('older loading indicator remains on the history edge', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
height: 300,
|
||||
child: AnchoredMessageList(
|
||||
controller: controller,
|
||||
cacheExtent: 300,
|
||||
padding: EdgeInsets.zero,
|
||||
epoch: 0,
|
||||
itemCount: 3,
|
||||
anchorIndex: 1,
|
||||
itemBuilder: (context, i) =>
|
||||
SizedBox(height: 60, child: Text('message $i')),
|
||||
bottomSpacer: const SizedBox.shrink(),
|
||||
loadingOlder: true,
|
||||
loadingIndicator: const Text('loading older'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('loading older'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final manifest = File('android/app/src/main/AndroidManifest.xml');
|
||||
final matrix = File('tool/android_permissions.json');
|
||||
|
||||
test('every Android manifest permission is present in the policy matrix', () {
|
||||
final declared = RegExp(r'<uses-permission\s+android:name="([^"]+)"')
|
||||
.allMatches(manifest.readAsStringSync())
|
||||
.map((match) => match.group(1)!)
|
||||
.toSet();
|
||||
final entries = (jsonDecode(matrix.readAsStringSync()) as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
final documented = entries
|
||||
.map((entry) => entry['permission'] as String)
|
||||
.toSet();
|
||||
|
||||
expect(entries.length, documented.length);
|
||||
expect(documented, declared);
|
||||
for (final entry in entries) {
|
||||
expect((entry['owner'] as String).trim(), isNotEmpty);
|
||||
expect((entry['feature'] as String).trim(), isNotEmpty);
|
||||
expect((entry['trigger'] as String).trim(), isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime-sensitive permissions require a user action', () {
|
||||
final entries = (jsonDecode(matrix.readAsStringSync()) as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
final byPermission = {
|
||||
for (final entry in entries) entry['permission'] as String: entry,
|
||||
};
|
||||
final sensitive = {
|
||||
'android.permission.REQUEST_INSTALL_PACKAGES',
|
||||
'android.permission.CAMERA',
|
||||
'android.permission.ACCESS_FINE_LOCATION',
|
||||
'android.permission.ACCESS_COARSE_LOCATION',
|
||||
'android.permission.RECORD_AUDIO',
|
||||
'android.permission.BLUETOOTH_CONNECT',
|
||||
'android.permission.BLUETOOTH_ADVERTISE',
|
||||
'android.permission.BLUETOOTH_SCAN',
|
||||
'android.permission.POST_NOTIFICATIONS',
|
||||
'android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS',
|
||||
'android.permission.USE_FULL_SCREEN_INTENT',
|
||||
'android.permission.READ_EXTERNAL_STORAGE',
|
||||
'android.permission.WRITE_EXTERNAL_STORAGE',
|
||||
'android.permission.READ_MEDIA_IMAGES',
|
||||
'android.permission.READ_MEDIA_VIDEO',
|
||||
'android.permission.READ_MEDIA_VISUAL_USER_SELECTED',
|
||||
'android.permission.READ_CONTACTS',
|
||||
};
|
||||
|
||||
for (final permission in sensitive) {
|
||||
expect(byPermission[permission]?['trigger'], 'user_action');
|
||||
}
|
||||
});
|
||||
|
||||
test('startup paths do not request notification or full-screen access', () {
|
||||
final pushSource = File(
|
||||
'lib/core/push/push_service.dart',
|
||||
).readAsStringSync();
|
||||
final mainSource = File('lib/main.dart').readAsStringSync();
|
||||
|
||||
final initBody = RegExp(
|
||||
r'Future<void> init\([\s\S]*?Future<bool> requestPermissionFromUser',
|
||||
).firstMatch(pushSource)?.group(0);
|
||||
expect(initBody, isNotNull);
|
||||
expect(initBody, isNot(contains('requestPermission()')));
|
||||
expect(mainSource, isNot(contains('_ensureFullScreenIntentPermission')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/widgets/animated_slash_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
Widget _host({required bool slashed}) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AnimatedSlashIcon(
|
||||
icon: Symbols.mic,
|
||||
slashedIcon: Symbols.mic_off,
|
||||
slashed: slashed,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('в покое рисуется ровно одна исходная иконка', (tester) async {
|
||||
await tester.pumpWidget(_host(slashed: false));
|
||||
|
||||
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic_off), findsNothing);
|
||||
expect(find.byType(ClipPath), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('в перечёркнутом покое рисуется ровно off-иконка', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(slashed: true));
|
||||
|
||||
expect(find.byIcon(Symbols.mic_off), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic), findsNothing);
|
||||
expect(find.byType(ClipPath), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('переключение проходит через клип обеих иконок', (tester) async {
|
||||
await tester.pumpWidget(_host(slashed: false));
|
||||
await tester.pumpWidget(_host(slashed: true));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
|
||||
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic_off), findsOneWidget);
|
||||
expect(find.byType(ClipPath), findsNWidgets(2));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Symbols.mic_off), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('обратное переключение возвращает исходную иконку', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(slashed: true));
|
||||
await tester.pumpWidget(_host(slashed: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic_off), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('размер и цвет прокидываются в обе иконки', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AnimatedSlashIcon(
|
||||
icon: Symbols.visibility,
|
||||
slashedIcon: Symbols.visibility_off,
|
||||
slashed: false,
|
||||
size: 14,
|
||||
color: const Color(0xFF00FF00),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final icon = tester.widget<Icon>(find.byType(Icon));
|
||||
expect(icon.size, 14);
|
||||
expect(icon.color, const Color(0xFF00FF00));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/widgets/animated_text_swap.dart';
|
||||
|
||||
Widget _host(int value) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AnimatedValueSwap<int>(
|
||||
value: value,
|
||||
builder: (context, v) => Text('$v'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final Finder _slidingParts = find.descendant(
|
||||
of: find.byType(AnimatedValueSwap<int>),
|
||||
matching: find.byType(FractionalTranslation),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('первое значение показывается без анимации', (tester) async {
|
||||
await tester.pumpWidget(_host(3));
|
||||
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(_slidingParts, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('смена значения перелистывает старое и новое', (tester) async {
|
||||
await tester.pumpWidget(_host(1));
|
||||
await tester.pumpWidget(_host(2));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
expect(_slidingParts, findsNWidgets(2));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('старое значение уезжает вверх, новое приходит снизу', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(1));
|
||||
await tester.pumpWidget(_host(2));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
|
||||
final outgoing = tester.widget<FractionalTranslation>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('1'),
|
||||
matching: find.byType(FractionalTranslation),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final incoming = tester.widget<FractionalTranslation>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('2'),
|
||||
matching: find.byType(FractionalTranslation),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
|
||||
expect(outgoing.translation.dy, lessThan(0));
|
||||
expect(incoming.translation.dy, greaterThan(0));
|
||||
});
|
||||
|
||||
testWidgets('тот же самый номер не запускает анимацию', (tester) async {
|
||||
await tester.pumpWidget(_host(7));
|
||||
await tester.pumpWidget(_host(7));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
|
||||
expect(find.text('7'), findsOneWidget);
|
||||
expect(_slidingParts, findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:ikar/core/utils/text_format.dart';
|
||||
|
||||
FormatRange _animoji(int start, int length, String url) => FormatRange(
|
||||
format: TextFormat.animoji,
|
||||
start: start,
|
||||
length: length,
|
||||
attributes: {'animojiLottieUrl': url},
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('single animoji-only message is jumbo', () {
|
||||
expect(animojiOnlyLottieUrls('❤️', [_animoji(0, 2, 'L1')]), ['L1']);
|
||||
});
|
||||
|
||||
test('several animoji with no other text are jumbo, in order', () {
|
||||
final urls = animojiOnlyLottieUrls('❤️🔥', [
|
||||
_animoji(2, 2, 'L2'),
|
||||
_animoji(0, 2, 'L1'),
|
||||
]);
|
||||
expect(urls, ['L1', 'L2']);
|
||||
});
|
||||
|
||||
test('animoji mixed with real text is NOT jumbo', () {
|
||||
expect(
|
||||
animojiOnlyLottieUrls('animoji message🤣', [_animoji(15, 2, 'L1')]),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('plain emoji without an ANIMOJI element is NOT jumbo', () {
|
||||
expect(animojiOnlyLottieUrls('😀', const []), isNull);
|
||||
});
|
||||
|
||||
test('more than the limit is NOT jumbo', () {
|
||||
final ranges = [for (var i = 0; i < 5; i++) _animoji(i * 2, 2, 'L$i')];
|
||||
expect(animojiOnlyLottieUrls('❤️❤️❤️❤️❤️', ranges), isNull);
|
||||
});
|
||||
|
||||
test('whitespace between animoji is allowed', () {
|
||||
expect(
|
||||
animojiOnlyLottieUrls('❤️ ❤️', [
|
||||
_animoji(0, 2, 'L1'),
|
||||
_animoji(3, 2, 'L2'),
|
||||
]),
|
||||
['L1', 'L2'],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/core/protocol/opcode_map.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
Future<Object?> _failureOf(Future<Object?> request) async {
|
||||
try {
|
||||
await request;
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('без подключения запрос отклоняется сразу', () async {
|
||||
final api = Api();
|
||||
addTearDown(api.dispose);
|
||||
|
||||
final failure = await _failureOf(
|
||||
api.sendRequest(Opcode.contactPhotos, const {}),
|
||||
);
|
||||
|
||||
expect(failure, isA<StateError>());
|
||||
expect((failure as StateError).message, contains('CONTACT_PHOTOS'));
|
||||
});
|
||||
|
||||
test('запрос во время подключения ждёт сессию, а не падает', () async {
|
||||
final api = Api();
|
||||
addTearDown(api.dispose);
|
||||
|
||||
unawaited(api.connect());
|
||||
expect(api.state, SessionState.connecting);
|
||||
|
||||
var settled = false;
|
||||
final failure = _failureOf(
|
||||
api.sendRequest(Opcode.contactPhotos, const {}),
|
||||
).whenComplete(() => settled = true);
|
||||
|
||||
await Future<void>.microtask(() {});
|
||||
expect(settled, isFalse);
|
||||
|
||||
await api.disconnect();
|
||||
|
||||
final error = await failure;
|
||||
expect(error, isA<StateError>());
|
||||
expect((error as StateError).message, isNot(contains('CONTACT_PHOTOS')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/ikar_transport.dart';
|
||||
import 'package:ikar/core/protocol/opcode_map.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test(
|
||||
'repeated failed connections stay quiet and recover to online',
|
||||
() async {
|
||||
var available = false;
|
||||
final api = Api(
|
||||
transportFactory: (uri) => IkarTransport(
|
||||
uri,
|
||||
client: MockClient((request) async {
|
||||
if (!available) return http.Response('Gateway unavailable', 503);
|
||||
return http.Response(jsonEncode({'cmd': 1, 'payload': {}}), 200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(api.dispose);
|
||||
final errors = <String>[];
|
||||
final states = <SessionState>[];
|
||||
final errorsSub = api.errorStream.listen(errors.add);
|
||||
final statesSub = api.stateStream.listen(states.add);
|
||||
addTearDown(errorsSub.cancel);
|
||||
addTearDown(statesSub.cancel);
|
||||
await api.connect();
|
||||
await api.connect();
|
||||
expect(api.state, SessionState.disconnected);
|
||||
available = true;
|
||||
await api.connect();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(errors, isEmpty);
|
||||
expect(states.where((s) => s == SessionState.disconnected), hasLength(2));
|
||||
expect(api.state, SessionState.online);
|
||||
expect(states.last, SessionState.online);
|
||||
},
|
||||
);
|
||||
|
||||
for (final failure in <Object>[
|
||||
const SocketException('Server unavailable'),
|
||||
TimeoutException('Server unavailable'),
|
||||
const HttpException('Server unavailable'),
|
||||
http.ClientException('Server unavailable'),
|
||||
]) {
|
||||
test(
|
||||
'a ${failure.runtimeType} request immediately changes connection state without a toast',
|
||||
() async {
|
||||
var fail = false;
|
||||
final api = Api(
|
||||
transportFactory: (uri) => IkarTransport(
|
||||
uri,
|
||||
client: MockClient((request) async {
|
||||
if (fail) throw failure;
|
||||
return http.Response(jsonEncode({'cmd': 1, 'payload': {}}), 200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(api.dispose);
|
||||
final errors = <String>[];
|
||||
final sub = api.errorStream.listen(errors.add);
|
||||
addTearDown(sub.cancel);
|
||||
await api.connect();
|
||||
fail = true;
|
||||
await expectLater(
|
||||
api.sendRequest(Opcode.contactsGet, {}),
|
||||
throwsA(same(failure)),
|
||||
);
|
||||
expect(api.state, SessionState.disconnected);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(errors, isEmpty);
|
||||
fail = false;
|
||||
await api.connect();
|
||||
expect(api.state, SessionState.online);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('operation errors on an online server are still reported', () async {
|
||||
final api = Api(
|
||||
transportFactory: (uri) => IkarTransport(
|
||||
uri,
|
||||
client: MockClient((request) async {
|
||||
final opcode = int.parse(request.url.pathSegments.last);
|
||||
return http.Response(
|
||||
jsonEncode(
|
||||
opcode == Opcode.ping
|
||||
? {'cmd': 1, 'payload': {}}
|
||||
: {
|
||||
'cmd': 3,
|
||||
'payload': {
|
||||
'error': 'user.not.found',
|
||||
'message': 'Пользователь не найден',
|
||||
},
|
||||
},
|
||||
),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(api.dispose);
|
||||
final errors = <String>[];
|
||||
final sub = api.errorStream.listen(errors.add);
|
||||
addTearDown(sub.cancel);
|
||||
await api.connect();
|
||||
await expectLater(
|
||||
api.sendRequest(Opcode.contactInfoByPhone, {'phone': '+79000000000'}),
|
||||
throwsA(isA<PacketError>()),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(api.state, SessionState.online);
|
||||
expect(errors, ['Пользователь не найден']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/login_gate.dart';
|
||||
|
||||
class _ReconnectApi extends Api {
|
||||
SessionState current = SessionState.online;
|
||||
final calls = <String>[];
|
||||
bool connectionSucceeds = true;
|
||||
|
||||
@override
|
||||
SessionState get state => current;
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
calls.add('disconnect');
|
||||
current = SessionState.disconnected;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> connect({bool allowAutomaticLogin = true}) async {
|
||||
calls.add('connect:$allowAutomaticLogin');
|
||||
if (connectionSucceeds) current = SessionState.online;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'new authorization drops the old session and skips automatic login',
|
||||
() async {
|
||||
final api = _ReconnectApi();
|
||||
addTearDown(api.dispose);
|
||||
await api.reconnectPreLogin();
|
||||
expect(api.calls, ['disconnect', 'connect:false']);
|
||||
expect(api.state, SessionState.online);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'failed pre-login reconnect cannot continue with a stale session',
|
||||
() async {
|
||||
final api = _ReconnectApi()..connectionSucceeds = false;
|
||||
addTearDown(api.dispose);
|
||||
await expectLater(api.reconnectPreLogin(), throwsStateError);
|
||||
expect(api.state, SessionState.disconnected);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'normal reconnects still restore automatic login after pre-login',
|
||||
() async {
|
||||
final api = _ReconnectApi();
|
||||
addTearDown(api.dispose);
|
||||
await api.reconnectPreLogin();
|
||||
await api.disconnect();
|
||||
await api.connect();
|
||||
expect(api.calls.last, 'connect:true');
|
||||
},
|
||||
);
|
||||
|
||||
test('a successful or rejected login is not a fresh pre-login session', () {
|
||||
final gate = LoginGate()..close();
|
||||
expect(gate.loginAttempted, isFalse);
|
||||
gate.noteLoginSent();
|
||||
gate.noteLoginAnswer(ok: true);
|
||||
expect(gate.loginAttempted, isTrue);
|
||||
gate.close();
|
||||
expect(gate.loginAttempted, isFalse);
|
||||
gate.noteLoginSent();
|
||||
gate.noteLoginAnswer(ok: false);
|
||||
expect(gate.loginAttempted, isTrue);
|
||||
gate.fail();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/config/app_icon.dart';
|
||||
|
||||
Set<String> _iosAlternateIcons() {
|
||||
final plist = File('ios/Runner/Info.plist').readAsStringSync();
|
||||
final anchor = plist.indexOf('<key>CFBundleAlternateIcons</key>');
|
||||
expect(
|
||||
anchor,
|
||||
isNonNegative,
|
||||
reason: 'в Info.plist нет альтернативных иконок',
|
||||
);
|
||||
final tokens = RegExp(
|
||||
r'<dict>|</dict>|<key>([^<]*)</key>',
|
||||
).allMatches(plist.substring(anchor));
|
||||
|
||||
final names = <String>{};
|
||||
var depth = 0;
|
||||
var entered = false;
|
||||
for (final token in tokens) {
|
||||
final text = token.group(0)!;
|
||||
if (text == '<dict>') {
|
||||
depth++;
|
||||
entered = true;
|
||||
} else if (text == '</dict>') {
|
||||
depth--;
|
||||
if (entered && depth == 0) break;
|
||||
} else if (entered && depth == 1) {
|
||||
names.add(token.group(1)!);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
Set<String> _androidComponents() {
|
||||
final manifest = File(
|
||||
'android/app/src/main/AndroidManifest.xml',
|
||||
).readAsStringSync();
|
||||
return RegExp(
|
||||
r'android:name="ru\.ikar\.app\.(\w+)"',
|
||||
).allMatches(manifest).map((m) => m.group(1)!).toSet();
|
||||
}
|
||||
|
||||
Set<String> _androidIconKeys() {
|
||||
final source = File(
|
||||
'android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt',
|
||||
).readAsStringSync();
|
||||
final start = source.indexOf('iconComponents = mapOf(');
|
||||
expect(start, isNonNegative, reason: 'в MainActivity нет карты иконок');
|
||||
final body = source.substring(start, source.indexOf(')', start));
|
||||
return RegExp(r'"(\w+)" to').allMatches(body).map((m) => m.group(1)!).toSet();
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('дефолтная иконка на iOS — это primary, а не альтернативная', () {
|
||||
expect(
|
||||
AppIcon.defaultIcon.iosAlternateName,
|
||||
isNull,
|
||||
reason: 'setAlternateIconName ждёт nil, любое имя тут — APPLY_FAILED',
|
||||
);
|
||||
});
|
||||
|
||||
test('каждая альтернативная иконка объявлена в Info.plist', () {
|
||||
final declared = _iosAlternateIcons();
|
||||
for (final icon in AppIcon.values) {
|
||||
final name = icon.iosAlternateName;
|
||||
if (name == null) continue;
|
||||
expect(
|
||||
declared,
|
||||
contains(name),
|
||||
reason: 'иконка ${icon.id} шлёт в iOS имя $name',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('каждый android alias объявлен в манифесте и в MainActivity', () {
|
||||
final components = _androidComponents();
|
||||
final keys = _androidIconKeys();
|
||||
for (final icon in AppIcon.values) {
|
||||
expect(
|
||||
components,
|
||||
contains(icon.androidAlias),
|
||||
reason: 'иконка ${icon.id} шлёт в Android имя ${icon.androidAlias}',
|
||||
);
|
||||
expect(keys, contains(icon.androidAlias));
|
||||
}
|
||||
});
|
||||
|
||||
test('имена иконок не пересекаются между платформами по смыслу', () {
|
||||
final ids = AppIcon.values.map((i) => i.id).toSet();
|
||||
expect(ids.length, AppIcon.values.length);
|
||||
final aliases = AppIcon.values.map((i) => i.androidAlias).toSet();
|
||||
expect(aliases.length, AppIcon.values.length);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/security/app_lock.dart';
|
||||
import 'package:ikar/frontend/screens/lock/app_lock_layer.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
Future<void> _pumpApp(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
builder: (context, child) => AppLockLayer(child: child!),
|
||||
home: const Scaffold(body: Center(child: Text('synthetic chats'))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _enter(WidgetTester tester, String pin) async {
|
||||
for (final digit in pin.split('')) {
|
||||
await tester.tap(find.text(digit));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 400)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
final lock = AppLock.instance;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
await lock.load();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await lock.disable();
|
||||
});
|
||||
|
||||
testWidgets('cold start hides chats on the very first locked frame', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({'app_lock_enabled': true});
|
||||
await tester.runAsync(lock.load);
|
||||
await _pumpApp(tester);
|
||||
expect(find.text('Введите код-пароль'), findsOneWidget);
|
||||
expect(find.text('synthetic chats'), findsNothing);
|
||||
expect(find.text('synthetic chats', skipOffstage: false), findsOneWidget);
|
||||
expect(lock.locked.value, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('locking covers the app and the passcode opens it again', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.runAsync(() => lock.setPin('1357'));
|
||||
await _pumpApp(tester);
|
||||
expect(find.text('Введите код-пароль'), findsNothing);
|
||||
|
||||
lock.lock(origin: const Rect.fromLTWH(300, 20, 24, 24));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Введите код-пароль'), findsOneWidget);
|
||||
expect(find.text('synthetic chats'), findsNothing);
|
||||
expect(find.text('synthetic chats', skipOffstage: false), findsOneWidget);
|
||||
|
||||
await _enter(tester, '1111');
|
||||
expect(lock.locked.value, isTrue);
|
||||
expect(find.textContaining('Осталось попыток: 4'), findsOneWidget);
|
||||
|
||||
await _enter(tester, '1357');
|
||||
expect(lock.locked.value, isFalse);
|
||||
expect(find.text('Введите код-пароль'), findsNothing);
|
||||
expect(find.text('synthetic chats'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/security/app_lock.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final lock = AppLock.instance;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
await lock.load();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await lock.disable();
|
||||
});
|
||||
|
||||
test('a fresh install is not locked', () {
|
||||
expect(lock.enabled.value, isFalse);
|
||||
expect(lock.locked.value, isFalse);
|
||||
lock.lock();
|
||||
expect(lock.locked.value, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'a missing secure key never silently disables a configured lock',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({'app_lock_enabled': true});
|
||||
await lock.load();
|
||||
expect(lock.enabled.value, isTrue);
|
||||
expect(lock.locked.value, isTrue);
|
||||
expect(await lock.unlockWithPin('1234'), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('overlapping PIN attempts cannot race a successful unlock', () async {
|
||||
await lock.setPin('2468');
|
||||
lock.lock();
|
||||
final wrong = lock.unlockWithPin('0000');
|
||||
expect(await lock.unlockWithPin('2468'), isFalse);
|
||||
expect(await wrong, isFalse);
|
||||
expect(lock.locked.value, isTrue);
|
||||
expect(await lock.unlockWithPin('2468'), isTrue);
|
||||
});
|
||||
|
||||
test('a malformed secure key fails closed', () async {
|
||||
SharedPreferences.setMockInitialValues({'app_lock_enabled': true});
|
||||
FlutterSecureStorage.setMockInitialValues({'app_lock_pin': 'v1:!:!'});
|
||||
await lock.load();
|
||||
expect(await lock.unlockWithPin('2468'), isFalse);
|
||||
expect(lock.locked.value, isTrue);
|
||||
});
|
||||
|
||||
test('the right passcode unlocks and a wrong one does not', () async {
|
||||
await lock.setPin('2468');
|
||||
lock.lock();
|
||||
expect(lock.locked.value, isTrue);
|
||||
|
||||
expect(await lock.unlockWithPin('1111'), isFalse);
|
||||
expect(lock.locked.value, isTrue);
|
||||
expect(lock.attemptsLeft, AppLock.maxAttempts - 1);
|
||||
|
||||
expect(await lock.unlockWithPin('2468'), isTrue);
|
||||
expect(lock.locked.value, isFalse);
|
||||
expect(lock.attemptsLeft, AppLock.maxAttempts);
|
||||
});
|
||||
|
||||
test('five wrong attempts block input even for the right code', () async {
|
||||
await lock.setPin('2468');
|
||||
lock.lock();
|
||||
for (var i = 0; i < AppLock.maxAttempts; i++) {
|
||||
expect(await lock.unlockWithPin('0000'), isFalse);
|
||||
}
|
||||
|
||||
expect(lock.blockedUntil.value, isNotNull);
|
||||
expect(
|
||||
lock.blockedUntil.value!.difference(DateTime.now()),
|
||||
greaterThan(AppLock.lockout - const Duration(seconds: 5)),
|
||||
);
|
||||
expect(await lock.unlockWithPin('2468'), isFalse);
|
||||
expect(lock.locked.value, isTrue);
|
||||
});
|
||||
|
||||
test('the lockout survives a restart', () async {
|
||||
await lock.setPin('2468');
|
||||
for (var i = 0; i < AppLock.maxAttempts; i++) {
|
||||
await lock.unlockWithPin('0000');
|
||||
}
|
||||
|
||||
await lock.load();
|
||||
|
||||
expect(lock.locked.value, isTrue);
|
||||
expect(lock.blockedUntil.value, isNotNull);
|
||||
});
|
||||
|
||||
test('a restart with a passcode starts locked', () async {
|
||||
await lock.setPin('2468');
|
||||
lock.unlock();
|
||||
|
||||
await lock.load();
|
||||
|
||||
expect(lock.enabled.value, isTrue);
|
||||
expect(lock.locked.value, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'going to the background locks, an app-launched picker does not',
|
||||
() async {
|
||||
await lock.setPin('2468');
|
||||
lock.unlock();
|
||||
|
||||
await lock.external(() async {
|
||||
lock.onLifecycle(AppLifecycleState.paused);
|
||||
});
|
||||
expect(lock.locked.value, isFalse);
|
||||
|
||||
lock.onLifecycle(AppLifecycleState.inactive);
|
||||
expect(lock.locked.value, isFalse);
|
||||
|
||||
lock.onLifecycle(AppLifecycleState.paused);
|
||||
expect(lock.locked.value, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('turning the passcode off forgets it', () async {
|
||||
await lock.setPin('2468');
|
||||
await lock.disable();
|
||||
|
||||
expect(lock.enabled.value, isFalse);
|
||||
expect(await lock.checkPin('2468'), isFalse);
|
||||
await lock.load();
|
||||
expect(lock.locked.value, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:ikar/core/media/gallery_source.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/attachment_sheet.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
|
||||
class FakeGallery implements GallerySource {
|
||||
FakeGallery({
|
||||
this.permission = GalleryPermission.granted,
|
||||
this.fail = false,
|
||||
this.count = 8,
|
||||
this.hang = false,
|
||||
});
|
||||
GalleryPermission permission;
|
||||
int requests = 0;
|
||||
int settingsOpened = 0;
|
||||
final bool fail;
|
||||
final int count;
|
||||
final bool hang;
|
||||
@override
|
||||
Future<GalleryPermission> ensurePermission() async {
|
||||
requests++;
|
||||
return permission;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GalleryPermission> currentPermission() async => permission;
|
||||
@override
|
||||
Future<GalleryPage> load({int offset = 0, int limit = 120}) async {
|
||||
if (hang) return Completer<GalleryPage>().future;
|
||||
if (fail) throw StateError('gallery unavailable');
|
||||
return GalleryPage(
|
||||
items: List.generate(
|
||||
count,
|
||||
(i) => GalleryItem.fromFile(File('missing-$i.jpg')),
|
||||
),
|
||||
hasMore: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async {
|
||||
settingsOpened++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> manageAccess() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(
|
||||
() => SharedPreferences.setMockInitialValues({
|
||||
'attachment_gallery_access_explained_v1': true,
|
||||
}),
|
||||
);
|
||||
Future<void> openSheet(WidgetTester tester, FakeGallery source) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: AttachmentSheet(gallerySource: source)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
for (final allow in [true, false]) {
|
||||
testWidgets('first gallery opening explains access, allow=$allow', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final source = FakeGallery(permission: GalleryPermission.denied);
|
||||
await openSheet(tester, source);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(source.requests, 0);
|
||||
final dialog = find.byType(AlertDialog);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: dialog,
|
||||
matching: find.byType(allow ? FilledButton : TextButton),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(source.requests, allow ? 1 : 0);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(
|
||||
(await SharedPreferences.getInstance()).getBool(
|
||||
'attachment_gallery_access_explained_v1',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
testWidgets('full access skips introduction and permission request', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final source = FakeGallery();
|
||||
await openSheet(tester, source);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(source.requests, 0);
|
||||
});
|
||||
testWidgets(
|
||||
'empty limited gallery retains settings action and refreshes on return',
|
||||
(tester) async {
|
||||
final source = FakeGallery(
|
||||
permission: GalleryPermission.limited,
|
||||
count: 0,
|
||||
);
|
||||
await openSheet(tester, source);
|
||||
expect(
|
||||
find.textContaining('Доступны только выбранные фото'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Настройки'));
|
||||
expect(source.settingsOpened, 1);
|
||||
source.permission = GalleryPermission.granted;
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.textContaining('Доступны только выбранные фото'),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
testWidgets(
|
||||
'system picker sends chosen photos when gallery access is denied',
|
||||
(tester) async {
|
||||
final original = ImagePickerPlatform.instance;
|
||||
final picker = FakePicker()..files = [XFile('one.jpg'), XFile('two.jpg')];
|
||||
ImagePickerPlatform.instance = picker;
|
||||
addTearDown(() => ImagePickerPlatform.instance = original);
|
||||
List<PickedPhoto>? sent;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => AttachmentSheet(
|
||||
gallerySource: FakeGallery(
|
||||
permission: GalleryPermission.denied,
|
||||
),
|
||||
onSend: (photos, caption) => sent = photos,
|
||||
),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Камера'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(picker.source, ImageSource.camera);
|
||||
expect(find.byType(AttachmentSheet), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Галерея'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.byIcon(Symbols.send));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sent?.map((photo) => photo.item.localFile?.path), [
|
||||
'one.jpg',
|
||||
'two.jpg',
|
||||
]);
|
||||
expect(find.byType(AttachmentSheet), findsNothing);
|
||||
},
|
||||
);
|
||||
for (final scenario in ['photos', 'empty', 'denied', 'error', 'timeout']) {
|
||||
testWidgets(
|
||||
'attachment gallery $scenario keeps camera visible and finite',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 850));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: AttachmentSheet(
|
||||
gallerySource: FakeGallery(
|
||||
permission: scenario == 'denied'
|
||||
? GalleryPermission.denied
|
||||
: GalleryPermission.granted,
|
||||
fail: scenario == 'error',
|
||||
hang: scenario == 'timeout',
|
||||
count: scenario == 'empty' ? 0 : 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
if (scenario == 'timeout') {
|
||||
expect(find.widgetWithText(TextButton, 'Камера'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 21));
|
||||
await tester.pump();
|
||||
expect(find.text('Не удалось загрузить галерею'), findsOneWidget);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.widgetWithText(TextButton, 'Камера'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextButton, 'Галерея'), findsOneWidget);
|
||||
expect(find.byType(CustomScrollView), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FakePicker extends ImagePickerPlatform {
|
||||
List<XFile> files = [];
|
||||
ImageSource? source;
|
||||
@override
|
||||
Future<List<XFile>> getMedia({required MediaOptions options}) async => files;
|
||||
@override
|
||||
Future<XFile?> getImageFromSource({
|
||||
required ImageSource source,
|
||||
ImagePickerOptions options = const ImagePickerOptions(),
|
||||
}) async {
|
||||
this.source = source;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/utils/media_cache.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/models/attachment.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
final String directory;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('скачанный аудиофайл показывает кнопку воспроизведения', (
|
||||
tester,
|
||||
) async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_audio_file_bubble',
|
||||
);
|
||||
addTearDown(() {
|
||||
MediaCache.resetForTesting();
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
MediaCache.resetForTesting();
|
||||
|
||||
await tester.runAsync(() async {
|
||||
final file = await MediaCache.fileFor('42_sample.mp3');
|
||||
await file.writeAsBytes(List<int>.filled(128, 1));
|
||||
await MediaCache.existing('42_sample.mp3');
|
||||
});
|
||||
|
||||
final message = CachedMessage(
|
||||
id: 'synthetic-message',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 7,
|
||||
time: DateTime(2026, 1, 1).millisecondsSinceEpoch,
|
||||
status: 'sent',
|
||||
attachments: const [
|
||||
FileAttachment(fileId: 42, name: 'sample.mp3', size: 128),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: MessageBubble(
|
||||
message: message,
|
||||
isMe: false,
|
||||
myId: 1,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byIcon(Symbols.audio_file), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.play_arrow), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.download), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('нескачанный аудиофайл показывает кнопку загрузки', (
|
||||
tester,
|
||||
) async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_audio_file_bubble_missing',
|
||||
);
|
||||
addTearDown(() {
|
||||
MediaCache.resetForTesting();
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
MediaCache.resetForTesting();
|
||||
|
||||
final message = CachedMessage(
|
||||
id: 'synthetic-message',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 7,
|
||||
time: DateTime(2026, 1, 1).millisecondsSinceEpoch,
|
||||
status: 'sent',
|
||||
attachments: const [
|
||||
FileAttachment(fileId: 43, name: 'missing.mp3', size: 128),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: MessageBubble(
|
||||
message: message,
|
||||
isMe: false,
|
||||
myId: 1,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byIcon(Symbols.audio_file), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.download), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.play_arrow), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
|
||||
void main() {
|
||||
group('обрыв связи не должен стоить пользователю нового кода', () {
|
||||
test('клиентская ошибка "сессия не онлайн" — токен переиспользуем', () {
|
||||
final error = StateError(
|
||||
'AccountModule: сессия не онлайн (текущее состояние: connecting)',
|
||||
);
|
||||
|
||||
expect(isSessionStateError(error), isTrue);
|
||||
expect(isAuthSessionLostError(error), isFalse);
|
||||
});
|
||||
|
||||
test('таймаут запроса — токен переиспользуем', () {
|
||||
const error = PacketError('AUTH таймаут');
|
||||
|
||||
expect(isAuthSessionLostError(error), isFalse);
|
||||
expect(isSessionStateError(error), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('мёртвая авторизационная сессия требует нового кода', () {
|
||||
test('FAIL_LOGIN_TOKEN приходит как SessionExpiredException', () {
|
||||
const error = SessionExpiredException(
|
||||
'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.',
|
||||
);
|
||||
|
||||
expect(isAuthSessionLostError(error), isTrue);
|
||||
});
|
||||
|
||||
test('сервер сообщает, что авторизационная сессия потеряна', () {
|
||||
const lost = PacketError('Авторизационная сессия не найдена');
|
||||
const missing = PacketError('Сессия не найдена');
|
||||
|
||||
expect(isAuthSessionLostError(lost), isTrue);
|
||||
expect(isAuthSessionLostError(missing), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('ошибка самого кода не трогает токен', () {
|
||||
test('неверный код — ни восстановление, ни перезапрос', () {
|
||||
const error = PacketError('Неверный код');
|
||||
|
||||
expect(isAuthSessionLostError(error), isFalse);
|
||||
expect(isSessionStateError(error), isFalse);
|
||||
});
|
||||
|
||||
test('исчерпаны попытки — ни восстановление, ни перезапрос', () {
|
||||
const error = PacketError('Слишком много попыток, попробуйте позже');
|
||||
|
||||
expect(isAuthSessionLostError(error), isFalse);
|
||||
expect(isSessionStateError(error), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/screens/profile/avatar_carousel.dart';
|
||||
|
||||
void main() {
|
||||
const historyUrls = ['https://example.test/new', 'https://example.test/old'];
|
||||
const historyIds = <int?>[22, 11];
|
||||
|
||||
test('history already holding the main photo is shown as is', () {
|
||||
final photos = buildAvatarPhotos(
|
||||
urls: historyUrls,
|
||||
ids: historyIds,
|
||||
baseUrl: 'https://example.test/main-link',
|
||||
mainPhotoId: 22,
|
||||
);
|
||||
|
||||
expect(photos.length, 2);
|
||||
expect(indexOfMainAvatar(photos, 22), 0);
|
||||
});
|
||||
|
||||
test('a main photo missing from history is added, not dropped', () {
|
||||
final photos = buildAvatarPhotos(
|
||||
urls: historyUrls,
|
||||
ids: historyIds,
|
||||
baseUrl: 'https://example.test/fresh',
|
||||
mainPhotoId: 33,
|
||||
);
|
||||
|
||||
expect(photos.length, 3);
|
||||
expect(photos.first, const AvatarPhoto('https://example.test/fresh', 33));
|
||||
expect(indexOfMainAvatar(photos, 33), 0);
|
||||
expect(photos.last.id, 11);
|
||||
});
|
||||
|
||||
test('an older main photo keeps its place in the list', () {
|
||||
final photos = buildAvatarPhotos(
|
||||
urls: historyUrls,
|
||||
ids: historyIds,
|
||||
baseUrl: 'https://example.test/main-link',
|
||||
mainPhotoId: 11,
|
||||
);
|
||||
|
||||
expect(photos.length, 2);
|
||||
expect(indexOfMainAvatar(photos, 11), 1);
|
||||
});
|
||||
|
||||
test('without ids the photo is matched by url instead of duplicated', () {
|
||||
final photos = buildAvatarPhotos(
|
||||
urls: historyUrls,
|
||||
ids: const [],
|
||||
baseUrl: historyUrls.first,
|
||||
mainPhotoId: null,
|
||||
);
|
||||
|
||||
expect(photos.length, 2);
|
||||
expect(photos.every((p) => p.id == null), isTrue);
|
||||
});
|
||||
|
||||
test('an empty history still shows the current avatar', () {
|
||||
final photos = buildAvatarPhotos(
|
||||
urls: const [],
|
||||
ids: const [],
|
||||
baseUrl: 'https://example.test/only',
|
||||
mainPhotoId: 7,
|
||||
);
|
||||
|
||||
expect(photos, [const AvatarPhoto('https://example.test/only', 7)]);
|
||||
expect(indexOfMainAvatar(photos, 7), 0);
|
||||
});
|
||||
|
||||
test('no avatar at all gives an empty carousel', () {
|
||||
expect(
|
||||
buildAvatarPhotos(urls: const [], ids: const [], baseUrl: ''),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/attachment_sheet.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/avatar_editor.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/editor_common.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
const _bounds = Rect.fromLTWH(0, 0, 300, 400);
|
||||
const _square = Rect.fromLTWH(50, 100, 200, 200);
|
||||
|
||||
Widget _app(Widget home) => MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: home,
|
||||
);
|
||||
|
||||
Future<File> _syntheticImage() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
ui.Canvas(recorder).drawRect(
|
||||
const Rect.fromLTWH(0, 0, 64, 48),
|
||||
ui.Paint()..color = const Color(0xFF3366CC),
|
||||
);
|
||||
final image = await recorder.endRecording().toImage(64, 48);
|
||||
final png = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
image.dispose();
|
||||
final file = File(
|
||||
'${Directory.systemTemp.createTempSync('synthetic_avatar').path}/a.png',
|
||||
);
|
||||
await file.writeAsBytes(png!.buffer.asUint8List());
|
||||
return file;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('square crop handles', () {
|
||||
test('dragging a corner keeps the crop square', () {
|
||||
final grown = moveSquareCropHandle(
|
||||
_square,
|
||||
2,
|
||||
const Offset(30, 10),
|
||||
_bounds,
|
||||
);
|
||||
expect(grown.width, grown.height);
|
||||
expect(grown.topLeft, _square.topLeft);
|
||||
expect(grown.width, 220);
|
||||
|
||||
final shrunk = moveSquareCropHandle(
|
||||
_square,
|
||||
0,
|
||||
const Offset(20, 40),
|
||||
_bounds,
|
||||
);
|
||||
expect(shrunk.width, shrunk.height);
|
||||
expect(shrunk.bottomRight, _square.bottomRight);
|
||||
expect(shrunk.width, 170);
|
||||
});
|
||||
|
||||
test('a corner never pushes the square out of the image', () {
|
||||
final grown = moveSquareCropHandle(
|
||||
_square,
|
||||
1,
|
||||
const Offset(400, -400),
|
||||
_bounds,
|
||||
);
|
||||
expect(grown.width, grown.height);
|
||||
expect(_bounds.contains(grown.topLeft), isTrue);
|
||||
expect(grown.right, lessThanOrEqualTo(_bounds.right));
|
||||
expect(grown.top, greaterThanOrEqualTo(_bounds.top));
|
||||
});
|
||||
|
||||
test('the square never collapses below the minimum', () {
|
||||
final tiny = moveSquareCropHandle(
|
||||
_square,
|
||||
3,
|
||||
const Offset(500, -500),
|
||||
_bounds,
|
||||
);
|
||||
expect(tiny.width, tiny.height);
|
||||
expect(tiny.width, 64);
|
||||
});
|
||||
|
||||
test('dragging inside moves the square without resizing', () {
|
||||
final moved = moveSquareCropHandle(
|
||||
_square,
|
||||
8,
|
||||
const Offset(10, 20),
|
||||
_bounds,
|
||||
);
|
||||
expect(moved.size, _square.size);
|
||||
expect(moved.topLeft, const Offset(60, 120));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the avatar picker only offers photos and files', (tester) async {
|
||||
var filesTapped = false;
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => showAttachmentSheet(
|
||||
context,
|
||||
onPickPhoto: (_, _) async {},
|
||||
onPickFile: () => filesTapped = true,
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
// ИКАР also keeps a system gallery button above the local photo grid.
|
||||
expect(find.text('Галерея'), findsNWidgets(2));
|
||||
expect(find.text('Файл'), findsOneWidget);
|
||||
expect(find.text('Геопозиция'), findsNothing);
|
||||
expect(find.text('Опрос'), findsNothing);
|
||||
expect(find.text('Контакт'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Файл'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(find.text('Выбрать фото из файлов'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Выбрать файл'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(filesTapped, isTrue);
|
||||
await tester.pump(const Duration(minutes: 1));
|
||||
});
|
||||
|
||||
testWidgets('the avatar editor offers the round crop actions', (
|
||||
tester,
|
||||
) async {
|
||||
final source = (await tester.runAsync(_syntheticImage))!;
|
||||
addTearDown(() => source.parent.deleteSync(recursive: true));
|
||||
await tester.pumpWidget(_app(AvatarEditor(source: source)));
|
||||
for (
|
||||
var i = 0;
|
||||
i < 10 && find.byType(CropWorkspace).evaluate().isEmpty;
|
||||
i++
|
||||
) {
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.byType(CropWorkspace), findsOneWidget);
|
||||
expect(find.text('Отмена'), findsOneWidget);
|
||||
expect(find.text('Установить фото'), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.flip), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.rotate_90_degrees_ccw), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.brush), findsOneWidget);
|
||||
expect(find.text('СБРОС'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/frontend/widgets/avatar_gallery.dart';
|
||||
import 'package:ikar/frontend/widgets/photo_viewer.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
String _url(int i) => 'https://example.test/avatar/$i';
|
||||
|
||||
class _HistoryApi extends Api {
|
||||
int count = 0;
|
||||
final List<int> requested = [];
|
||||
|
||||
@override
|
||||
Future<Map<dynamic, dynamic>?> sendRequestMap(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
bool silent = false,
|
||||
}) async {
|
||||
final from = payload['from'] as int;
|
||||
final end = math.min(from + (payload['count'] as int), count);
|
||||
requested.add(from);
|
||||
return {
|
||||
'total': count,
|
||||
'urls': [for (var i = from; i < end; i++) _url(i)],
|
||||
'ids': [for (var i = from; i < end; i++) 1000 + i],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final _ru = lookupAppLocalizations(const Locale('ru'));
|
||||
|
||||
void main() {
|
||||
late _HistoryApi history;
|
||||
setUpAll(() => history = _HistoryApi());
|
||||
setUp(() => history.requested.clear());
|
||||
|
||||
_HistoryApi withHistory(int count) => history..count = count;
|
||||
|
||||
group('avatar feed', () {
|
||||
test('the main photo already in history is not repeated', () async {
|
||||
final feed = AvatarFeed(
|
||||
const AvatarGallery(
|
||||
contactId: 601,
|
||||
name: 'Synthetic',
|
||||
currentUrl: 'https://example.test/main-link',
|
||||
mainPhotoId: 1000,
|
||||
initialPhotoId: 1002,
|
||||
),
|
||||
client: withHistory(3),
|
||||
);
|
||||
await feed.load();
|
||||
|
||||
expect(feed.photos.map((p) => p.id), [1000, 1001, 1002]);
|
||||
expect(feed.total, 3);
|
||||
expect(feed.initialIndex, 2);
|
||||
expect(feed.reachedEnd, isTrue);
|
||||
});
|
||||
|
||||
test('a main photo missing from history leads the list', () async {
|
||||
final feed = AvatarFeed(
|
||||
const AvatarGallery(
|
||||
contactId: 602,
|
||||
name: 'Synthetic',
|
||||
currentUrl: 'https://example.test/fresh',
|
||||
mainPhotoId: 77,
|
||||
),
|
||||
client: withHistory(2),
|
||||
);
|
||||
await feed.load();
|
||||
|
||||
expect(feed.photos.first.url, 'https://example.test/fresh');
|
||||
expect(feed.photos.length, 3);
|
||||
expect(feed.total, 3);
|
||||
expect(feed.initialIndex, 0);
|
||||
});
|
||||
|
||||
test('history is paged until the server runs out', () async {
|
||||
final api = withHistory(AvatarFeed.pageSize + 10);
|
||||
final feed = AvatarFeed(
|
||||
AvatarGallery(
|
||||
contactId: 603,
|
||||
name: 'Synthetic',
|
||||
currentUrl: _url(0),
|
||||
mainPhotoId: 1000,
|
||||
),
|
||||
client: api,
|
||||
);
|
||||
await feed.load();
|
||||
expect(feed.photos.length, AvatarFeed.pageSize);
|
||||
expect(feed.reachedEnd, isFalse);
|
||||
|
||||
await feed.loadMore();
|
||||
expect(feed.photos.length, AvatarFeed.pageSize + 10);
|
||||
expect(feed.reachedEnd, isTrue);
|
||||
expect(api.requested, [0, AvatarFeed.pageSize]);
|
||||
});
|
||||
|
||||
test('a chat without a contact has only its current photo', () async {
|
||||
final api = withHistory(5);
|
||||
final feed = AvatarFeed(
|
||||
const AvatarGallery(
|
||||
contactId: 0,
|
||||
name: 'Synthetic group',
|
||||
currentUrl: 'https://example.test/group',
|
||||
),
|
||||
client: api,
|
||||
);
|
||||
await feed.load();
|
||||
|
||||
expect(feed.photos.length, 1);
|
||||
expect(feed.reachedEnd, isTrue);
|
||||
expect(api.requested, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
Future<void> openViewer(WidgetTester tester, AvatarFeed feed) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PhotoViewerScreen.avatars(avatars: feed),
|
||||
),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
testWidgets('avatars open in the media viewer, older ones to the right', (
|
||||
tester,
|
||||
) async {
|
||||
await openViewer(
|
||||
tester,
|
||||
AvatarFeed(
|
||||
AvatarGallery(
|
||||
contactId: 701,
|
||||
name: 'Synthetic Name',
|
||||
currentUrl: _url(0),
|
||||
mainPhotoId: 1000,
|
||||
),
|
||||
client: withHistory(3),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text(_ru.mediaViewerCounter(1, 3)), findsOneWidget);
|
||||
expect(find.text('Synthetic Name'), findsOneWidget);
|
||||
expect(find.byType(InteractiveViewer), findsWidgets);
|
||||
expect(find.byIcon(Symbols.chevron_left), findsNothing);
|
||||
expect(find.byIcon(Symbols.chevron_right), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Symbols.chevron_right));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.text(_ru.mediaViewerCounter(2, 3)), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.chevron_left), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'deleting an avatar confirms, closes the viewer, reports the id',
|
||||
(tester) async {
|
||||
final deleted = <int>[];
|
||||
await openViewer(
|
||||
tester,
|
||||
AvatarFeed(
|
||||
AvatarGallery(
|
||||
contactId: 702,
|
||||
name: 'Synthetic Name',
|
||||
currentUrl: _url(0),
|
||||
mainPhotoId: 1000,
|
||||
initialPhotoId: 1001,
|
||||
onDelete: deleted.add,
|
||||
),
|
||||
client: withHistory(3),
|
||||
),
|
||||
);
|
||||
expect(find.text(_ru.mediaViewerCounter(2, 3)), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Symbols.more_vert));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.tap(find.text(_ru.msgActionsDelete).last);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, _ru.msgActionsDelete));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
|
||||
expect(deleted, [1001]);
|
||||
expect(find.byType(PhotoViewerScreen), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('someone else\'s avatars offer no delete', (tester) async {
|
||||
await openViewer(
|
||||
tester,
|
||||
AvatarFeed(
|
||||
AvatarGallery(
|
||||
contactId: 703,
|
||||
name: 'Synthetic Name',
|
||||
currentUrl: _url(0),
|
||||
mainPhotoId: 1000,
|
||||
),
|
||||
client: withHistory(2),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Symbols.more_vert));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.text(_ru.photoViewerSaveAs), findsOneWidget);
|
||||
expect(find.text(_ru.msgActionsDelete), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/banners.dart';
|
||||
import 'package:ikar/models/informer_banner.dart';
|
||||
|
||||
void main() {
|
||||
test('a pinned banner records one presentation', () async {
|
||||
final api = Api();
|
||||
addTearDown(api.dispose);
|
||||
final module = BannersModule(api);
|
||||
const banner = InformerBanner(
|
||||
id: 'synthetic-banner',
|
||||
title: 'Synthetic title',
|
||||
repeat: 3,
|
||||
);
|
||||
|
||||
await module.markShown(banner);
|
||||
await module.markShown(banner);
|
||||
|
||||
expect(module.stateOf(banner.id).showCounter, 1);
|
||||
|
||||
await module.close(banner);
|
||||
await module.markShown(banner);
|
||||
|
||||
expect(module.stateOf(banner.id).showCounter, 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
|
||||
const int _me = 1;
|
||||
|
||||
CachedMessage _botStart({String? payload}) =>
|
||||
CachedMessage.fromPushPayload(_me, 2, {
|
||||
'id': '5005',
|
||||
'time': DateTime(2026, 1, 1, 18, 6).millisecondsSinceEpoch,
|
||||
'type': 'USER',
|
||||
'sender': _me,
|
||||
'text': payload ?? '',
|
||||
'attaches': [
|
||||
{'_type': 'CONTROL', 'event': 'botStarted'},
|
||||
],
|
||||
});
|
||||
|
||||
Future<void> _pump(WidgetTester tester, CachedMessage message) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MessageBubble(
|
||||
message: message,
|
||||
isMe: true,
|
||||
myId: _me,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a start with a payload shows a service line', (tester) async {
|
||||
await _pump(tester, _botStart(payload: 'abc123'));
|
||||
|
||||
expect(find.text('Бот запущен: abc123'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a start without a payload takes no space', (tester) async {
|
||||
await _pump(tester, _botStart());
|
||||
|
||||
expect(find.textContaining('Бот запущен'), findsNothing);
|
||||
expect(tester.getSize(find.byType(MessageBubble)), Size.zero);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/models/attachment.dart';
|
||||
import 'package:ikar/models/bot_info.dart';
|
||||
|
||||
void main() {
|
||||
group('botStarted control message', () {
|
||||
CachedMessage parse(Map<String, dynamic> message) =>
|
||||
CachedMessage.fromPushPayload(1001, 2002, message);
|
||||
|
||||
test('is control and shows the payload the server put into text', () {
|
||||
final message = parse({
|
||||
'id': '3003',
|
||||
'time': 1700000000000,
|
||||
'type': 'USER',
|
||||
'sender': 1001,
|
||||
'text': 'abc123',
|
||||
'attaches': [
|
||||
{'_type': 'CONTROL', 'event': 'botStarted'},
|
||||
],
|
||||
});
|
||||
|
||||
expect(message.isControl, isTrue);
|
||||
expect(message.botStartPayload, 'abc123');
|
||||
expect(message.isSilentBotStart, isFalse);
|
||||
});
|
||||
|
||||
test('reads the payload off the attach when it is there', () {
|
||||
final message = parse({
|
||||
'id': '3006',
|
||||
'time': 1700000000000,
|
||||
'type': 'USER',
|
||||
'sender': 1001,
|
||||
'attaches': [
|
||||
{'_type': 'CONTROL', 'event': 'botStarted', 'startPayload': 'abc123'},
|
||||
],
|
||||
});
|
||||
|
||||
expect(message.botStartPayload, 'abc123');
|
||||
expect(message.isSilentBotStart, isFalse);
|
||||
});
|
||||
|
||||
test('a start without a payload stays hidden', () {
|
||||
final message = parse({
|
||||
'id': '3007',
|
||||
'time': 1700000000000,
|
||||
'type': 'USER',
|
||||
'sender': 1001,
|
||||
'text': '',
|
||||
'attaches': [
|
||||
{'_type': 'CONTROL', 'event': 'botStarted'},
|
||||
],
|
||||
});
|
||||
|
||||
expect(message.isControl, isTrue);
|
||||
expect(message.botStartPayload, isNull);
|
||||
expect(message.isSilentBotStart, isTrue);
|
||||
});
|
||||
|
||||
test('other control events are untouched', () {
|
||||
final message = parse({
|
||||
'id': '3004',
|
||||
'time': 1700000000000,
|
||||
'type': 'USER',
|
||||
'sender': 1001,
|
||||
'attaches': [
|
||||
{
|
||||
'_type': 'CONTROL',
|
||||
'event': 'add',
|
||||
'userIds': [1002],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(message.isControl, isTrue);
|
||||
expect(message.botStartPayload, isNull);
|
||||
expect(message.isSilentBotStart, isFalse);
|
||||
});
|
||||
|
||||
test('a plain message is not a start at all', () {
|
||||
final message = parse({
|
||||
'id': '3005',
|
||||
'time': 1700000000000,
|
||||
'type': 'USER',
|
||||
'sender': 1001,
|
||||
'text': 'привет',
|
||||
'attaches': const [],
|
||||
});
|
||||
|
||||
expect(message.isControl, isFalse);
|
||||
expect(message.botStartPayload, isNull);
|
||||
expect(message.isSilentBotStart, isFalse);
|
||||
});
|
||||
|
||||
test('the event name used on the wire stays stable', () {
|
||||
expect(ControlAttachment.botStartedEvent, 'botStarted');
|
||||
});
|
||||
});
|
||||
|
||||
group('BotInfo', () {
|
||||
test('parses commands and the bot contact', () {
|
||||
final info = BotInfo.fromPayload(4004, {
|
||||
'commands': [
|
||||
{'botId': 4004, 'name': 'start', 'description': 'Главное меню'},
|
||||
{'botId': 4004, 'name': 'stats', 'description': ' '},
|
||||
{'botId': 4004, 'description': 'без имени'},
|
||||
],
|
||||
'contact': {
|
||||
'id': 4004,
|
||||
'names': [
|
||||
{'name': 'Тестовый бот', 'type': 'ONEME'},
|
||||
],
|
||||
'options': ['BOT'],
|
||||
'description': 'Описание бота',
|
||||
'link': 'https://ikar.kusoft.xyz/id100000000001_bot',
|
||||
},
|
||||
});
|
||||
|
||||
expect(info.botId, 4004);
|
||||
expect(info.commands.map((c) => c.name), ['start', 'stats']);
|
||||
expect(info.commands.first.slash, '/start');
|
||||
expect(info.commands.first.description, 'Главное меню');
|
||||
expect(info.commands.last.description, isNull);
|
||||
expect(info.contact?.isBot, isTrue);
|
||||
expect(info.contact?.displayName, 'Тестовый бот');
|
||||
expect(info.description, 'Описание бота');
|
||||
expect(info.link, 'https://ikar.kusoft.xyz/id100000000001_bot');
|
||||
});
|
||||
|
||||
test('reads the start message and splits off its heading', () {
|
||||
const heading = 'Синтетический бот для проверки';
|
||||
const body = 'Нажимая Начать, вы принимаете правила';
|
||||
final info = BotInfo.fromPayload(4006, {
|
||||
'startMessage': {
|
||||
'text': {
|
||||
'text': '$heading$body',
|
||||
'elements': [
|
||||
{'type': 'HEADING', 'length': heading.length},
|
||||
{
|
||||
'type': 'LINK',
|
||||
'from': heading.length + body.indexOf('правила'),
|
||||
'length': 7,
|
||||
'attributes': {'url': 'https://example.test/rules'},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
final sections = info.startMessage!.sections;
|
||||
expect(sections.heading, heading);
|
||||
expect(sections.body, body);
|
||||
final link = sections.bodyRanges.single;
|
||||
expect(link.url, 'https://example.test/rules');
|
||||
expect(body.substring(link.start, link.end), 'правила');
|
||||
});
|
||||
|
||||
test('a start message without a heading stays one paragraph', () {
|
||||
final info = BotInfo.fromPayload(4007, {
|
||||
'startMessage': {
|
||||
'text': {'text': 'Просто приветствие'},
|
||||
},
|
||||
});
|
||||
|
||||
final sections = info.startMessage!.sections;
|
||||
expect(sections.heading, isEmpty);
|
||||
expect(sections.body, 'Просто приветствие');
|
||||
expect(BotInfo.fromPayload(4008, const {}).startMessage, isNull);
|
||||
});
|
||||
|
||||
test('tolerates a payload without commands or contact', () {
|
||||
final info = BotInfo.fromPayload(4005, const {});
|
||||
|
||||
expect(info.commands, isEmpty);
|
||||
expect(info.contact, isNull);
|
||||
expect(info.link, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/screens/calls/call_controls.dart';
|
||||
|
||||
const _labels = ['Динамик', 'Видео', 'Экран', 'Выкл. звук', 'Завершить'];
|
||||
|
||||
Widget _controls({required double textScale}) => MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(
|
||||
size: const Size(320, 640),
|
||||
textScaler: TextScaler.linear(textScale),
|
||||
),
|
||||
child: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: CallControlBar(
|
||||
children: [
|
||||
for (final label in _labels)
|
||||
CallButton(
|
||||
key: ValueKey(label),
|
||||
icon: Icons.call_end,
|
||||
label: label,
|
||||
background: Colors.red,
|
||||
foreground: Colors.white,
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _pumpNarrow(WidgetTester tester, double textScale) async {
|
||||
tester.view.physicalSize = const Size(960, 1920);
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(_controls(textScale: textScale));
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final scale in [1.0, 1.6]) {
|
||||
testWidgets('все кнопки звонка влезают в 320dp при шрифте ×$scale', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpNarrow(tester, scale);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
for (final label in _labels) {
|
||||
final rect = tester.getRect(find.byKey(ValueKey(label)));
|
||||
expect(rect.left, greaterThanOrEqualTo(0), reason: label);
|
||||
expect(rect.right, lessThanOrEqualTo(320), reason: label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('кнопка вне панели сохраняет полный размер', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
for (final label in ['Отклонить', 'Принять'])
|
||||
CallButton(
|
||||
key: ValueKey(label),
|
||||
icon: Icons.call,
|
||||
label: label,
|
||||
background: Colors.green,
|
||||
foreground: Colors.white,
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
final circle = find.descendant(
|
||||
of: find.byKey(const ValueKey('Принять')),
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is SizedBox && w.width == CallButton.maxDiameter,
|
||||
),
|
||||
);
|
||||
expect(circle, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('кнопка «Завершить» нажимается на узком экране', (tester) async {
|
||||
tester.view.physicalSize = const Size(960, 1920);
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
var hungUp = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(
|
||||
size: Size(320, 640),
|
||||
textScaler: TextScaler.linear(1.6),
|
||||
),
|
||||
child: Scaffold(
|
||||
body: CallControlBar(
|
||||
children: [
|
||||
for (final label in _labels.take(4))
|
||||
CallButton(
|
||||
icon: Icons.mic,
|
||||
label: label,
|
||||
background: Colors.grey,
|
||||
foreground: Colors.white,
|
||||
onTap: () {},
|
||||
),
|
||||
CallButton(
|
||||
icon: Icons.call_end,
|
||||
label: 'Завершить',
|
||||
background: Colors.red,
|
||||
foreground: Colors.white,
|
||||
onTap: () => hungUp = true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.call_end));
|
||||
expect(hungUp, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/calls/audio_devices.dart';
|
||||
import 'package:ikar/core/calls/pulse_audio.dart';
|
||||
import 'package:ikar/core/config/call_no_mute.dart';
|
||||
|
||||
const _deviceId = 'mic-test-0';
|
||||
|
||||
const _sourcesJson = '''
|
||||
[
|
||||
{
|
||||
"name": "alsa_input.test-device",
|
||||
"description": "(null)",
|
||||
"monitor_source": "",
|
||||
"properties": {"device.description": "Тестовая звуковая карта"}
|
||||
},
|
||||
{
|
||||
"name": "alsa_output.test-device.monitor",
|
||||
"description": "(null)",
|
||||
"monitor_source": "alsa_output.test-device",
|
||||
"properties": {"device.description": "Тестовая звуковая карта"}
|
||||
},
|
||||
{
|
||||
"name": "virtual_sink.monitor",
|
||||
"description": "Monitor of Virtual Sink",
|
||||
"monitor_source": "virtual_sink",
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"name": "qlyra_capture_4242",
|
||||
"description": "qlyra_capture_4242",
|
||||
"monitor_source": "",
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
CallNoMute.enabled = false;
|
||||
});
|
||||
|
||||
test('--no-mute включается только своим флагом', () {
|
||||
CallNoMute.enabled = false;
|
||||
CallNoMute.parse(const ['--debug-test']);
|
||||
expect(CallNoMute.enabled, isFalse);
|
||||
|
||||
CallNoMute.parse(const ['--debug-test', '--no-mute']);
|
||||
expect(CallNoMute.enabled, isTrue);
|
||||
});
|
||||
|
||||
test('desktop выбирает вход через sourceId', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
expect(AudioDevices.switchesInsideEngine, isFalse);
|
||||
expect(AudioDevices.micConstraints(_deviceId), <String, dynamic>{
|
||||
'optional': [
|
||||
{'sourceId': _deviceId},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('мобильные платформы переключают вход внутри движка', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
expect(AudioDevices.switchesInsideEngine, isTrue);
|
||||
expect(AudioDevices.micConstraints(_deviceId), isTrue);
|
||||
});
|
||||
|
||||
test('без выбранного устройства ограничений нет', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
expect(AudioDevices.micConstraints(null), isTrue);
|
||||
expect(AudioDevices.micConstraints(''), isTrue);
|
||||
});
|
||||
|
||||
test('захват монитора глушит шумодав и АРУ, но оставляет эхоподавление', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
final constraints =
|
||||
AudioDevices.micConstraints(_deviceId, monitorCapture: true)
|
||||
as Map<String, dynamic>;
|
||||
expect(constraints['optional'], [
|
||||
{'sourceId': _deviceId},
|
||||
]);
|
||||
expect(constraints['echoCancellation'], isTrue);
|
||||
expect(constraints['noiseSuppression'], isFalse);
|
||||
expect(constraints['autoGainControl'], isFalse);
|
||||
expect(constraints['highpassFilter'], isFalse);
|
||||
});
|
||||
|
||||
test('источники pulse разбираются вместе с мониторами', () {
|
||||
final sources = PulseAudio.parseSources(_sourcesJson);
|
||||
expect(sources.map((s) => s.name), [
|
||||
'alsa_input.test-device',
|
||||
'alsa_output.test-device.monitor',
|
||||
'virtual_sink.monitor',
|
||||
]);
|
||||
expect(sources[0].isMonitor, isFalse);
|
||||
expect(sources[0].label, 'Тестовая звуковая карта');
|
||||
expect(sources[1].isMonitor, isTrue);
|
||||
expect(sources[1].label, 'Monitor of Тестовая звуковая карта');
|
||||
expect(sources[2].label, 'Monitor of Virtual Sink');
|
||||
});
|
||||
|
||||
test('битый вывод pactl не роняет разбор', () {
|
||||
expect(PulseAudio.parseSources('не json'), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/calls/camera_devices.dart';
|
||||
|
||||
void main() {
|
||||
test('camera facing is read from platform labels', () {
|
||||
expect(
|
||||
CameraDevices.facingOf('Camera 1, Facing front, Orientation 270'),
|
||||
CameraFacing.front,
|
||||
);
|
||||
expect(
|
||||
CameraDevices.facingOf('Camera 0, Facing back, Orientation 90'),
|
||||
CameraFacing.back,
|
||||
);
|
||||
expect(CameraDevices.facingOf('Rear Camera'), CameraFacing.back);
|
||||
expect(CameraDevices.facingOf('Synthetic USB Cam'), CameraFacing.external);
|
||||
});
|
||||
|
||||
test('the system camera leaves the choice to WebRTC', () {
|
||||
expect(CameraDevices.constraints(null), isTrue);
|
||||
expect(CameraDevices.constraints(''), isTrue);
|
||||
expect(CameraDevices.constraints('2'), {
|
||||
'optional': [
|
||||
{'sourceId': '2'},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/utils/channel_comments.dart';
|
||||
|
||||
void main() {
|
||||
group('commentsInfoBatches', () {
|
||||
test('splits long histories into bounded requests', () {
|
||||
final ids = [for (var i = 0; i < 120; i++) '$i'];
|
||||
final batches = commentsInfoBatches(ids);
|
||||
expect(batches.map((b) => b.length), [50, 50, 20]);
|
||||
expect(batches.expand((b) => b).toList(), ids);
|
||||
});
|
||||
|
||||
test('short lists stay in one request', () {
|
||||
expect(commentsInfoBatches(['1', '2']), [
|
||||
['1', '2'],
|
||||
]);
|
||||
expect(commentsInfoBatches(const []), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/admin_section.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/chat_admin_state.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/admins_screen.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/channel_followers_screen.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/channel_type_link_screen.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/chat_settings_screen.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/member_actions.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/member_permissions_screen.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/ownership_transfer.dart';
|
||||
import 'package:ikar/frontend/widgets/settings_card.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/main.dart' show api;
|
||||
import 'package:ikar/models/chat_info.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
const _ownerId = 11;
|
||||
const _adminId = 22;
|
||||
|
||||
ChatAdminState _state({
|
||||
required int myId,
|
||||
int adminPermissions = 1828,
|
||||
String type = 'CHANNEL',
|
||||
Map<String, bool> options = const {'JOIN_REQUEST': false},
|
||||
}) => ChatAdminState(
|
||||
chatId: -1000,
|
||||
myId: myId,
|
||||
name: 'Synthetic channel',
|
||||
imageUrl: '',
|
||||
info: ChatInfo.fromMap({
|
||||
'id': -1000,
|
||||
'type': type,
|
||||
'owner': _ownerId,
|
||||
'access': 'PRIVATE',
|
||||
'link': 'https://example.test/join/synthetic',
|
||||
'participantsCount': 3,
|
||||
'adminParticipants': {
|
||||
'$_ownerId': {'id': _ownerId, 'permissions': 4095},
|
||||
'$_adminId': {'id': _adminId, 'permissions': adminPermissions},
|
||||
},
|
||||
'options': options,
|
||||
}),
|
||||
);
|
||||
|
||||
Future<void> _pump(WidgetTester tester, Widget home) async {
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2.5;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: home,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() => api.state);
|
||||
|
||||
testWidgets('the info section shows both counters and statistics', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
Scaffold(
|
||||
body: AdminSection(
|
||||
state: _state(myId: _ownerId),
|
||||
onLeave: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.widgetWithText(SettingsNavTile, 'Администраторы'), findsOne);
|
||||
expect(find.widgetWithText(SettingsNavTile, '2'), findsOne);
|
||||
expect(find.widgetWithText(SettingsNavTile, 'Подписчики'), findsOne);
|
||||
expect(find.widgetWithText(SettingsNavTile, '3'), findsOne);
|
||||
expect(find.text('Статистика канала'), findsOne);
|
||||
|
||||
await tester.tap(find.text('Статистика канала'));
|
||||
await tester.pump();
|
||||
expect(find.text('Статистика канала пока недоступна'), findsOne);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
|
||||
testWidgets('the owner sees every admin and can add more', (tester) async {
|
||||
await _pump(tester, AdminsScreen(state: _state(myId: _ownerId)));
|
||||
|
||||
expect(find.text('Добавить администратора'), findsOne);
|
||||
expect(find.text('Владелец (вы)'), findsOne);
|
||||
expect(find.text('Админ'), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('an admin without the right cannot add admins', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
AdminsScreen(state: _state(myId: _adminId, adminPermissions: 16)),
|
||||
);
|
||||
|
||||
expect(find.text('Добавить администратора'), findsNothing);
|
||||
expect(find.text('Владелец'), findsOne);
|
||||
expect(find.text('Админ (вы)'), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('the invite link screen offers every link action', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, ChannelTypeLinkScreen(state: _state(myId: _ownerId)));
|
||||
|
||||
expect(find.text('example.test/join/synthetic'), findsOne);
|
||||
expect(find.text('Отправить в ИКАР'), findsOne);
|
||||
expect(find.text('Показать QR-код'), findsOne);
|
||||
expect(find.text('Заявки на вступление'), findsOne);
|
||||
expect(find.byIcon(Symbols.more_horiz), findsOne);
|
||||
|
||||
await tester.tap(find.byIcon(Symbols.more_horiz));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Перевыпустить ссылку'), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('an admin without follower rights only shares the link', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
ChannelTypeLinkScreen(
|
||||
state: _state(myId: _adminId, adminPermissions: 32),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Отправить в ИКАР'), findsOne);
|
||||
expect(find.text('Заявки на вступление'), findsNothing);
|
||||
expect(find.byIcon(Symbols.more_horiz), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the followers screen offers adding and inviting', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, ChannelFollowersScreen(state: _state(myId: _ownerId)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Найти по имени'), findsOne);
|
||||
expect(find.text('Добавить подписчиков'), findsOne);
|
||||
expect(find.text('Пригласить по ссылке'), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('followers without the right are read-only', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
ChannelFollowersScreen(
|
||||
state: _state(myId: _adminId, adminPermissions: 32),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Добавить подписчиков'), findsNothing);
|
||||
expect(find.text('Пригласить по ссылке'), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('a group shows admins and settings in one card', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
Scaffold(
|
||||
body: AdminSection(
|
||||
state: _state(myId: _ownerId, type: 'CHAT'),
|
||||
onLeave: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.widgetWithText(SettingsNavTile, 'Администраторы'), findsOne);
|
||||
expect(find.widgetWithText(SettingsNavTile, 'Настройки группы'), findsOne);
|
||||
expect(find.text('Подписчики'), findsNothing);
|
||||
expect(find.text('Статистика канала'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the owner sees every group setting', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
ChatSettingsScreen(
|
||||
state: _state(myId: _ownerId, type: 'CHAT'),
|
||||
onLeave: () {},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Synthetic channel'), findsOne);
|
||||
expect(find.text('НАЗВАНИЕ ЧАТА'), findsOne);
|
||||
expect(find.text('ОПИСАНИЕ ЧАТА'), findsOne);
|
||||
expect(find.text('Реакции'), findsOne);
|
||||
expect(find.text('Передать права владельца'), findsOne);
|
||||
expect(find.text('Покинуть чат'), findsOne);
|
||||
expect(find.text('Разрешения участников'), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('saving waits for a real change', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
ChatSettingsScreen(
|
||||
state: _state(myId: _ownerId, type: 'CHAT'),
|
||||
onLeave: () {},
|
||||
),
|
||||
);
|
||||
|
||||
IconButton save() => tester.widget<IconButton>(
|
||||
find.widgetWithIcon(IconButton, Symbols.check),
|
||||
);
|
||||
expect(save().onPressed, isNull);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Synthetic renamed');
|
||||
await tester.pump();
|
||||
expect(save().onPressed, isNotNull);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, ' ');
|
||||
await tester.pump();
|
||||
expect(save().onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets('a member with edit rights only edits the basics', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
ChatSettingsScreen(
|
||||
state: _state(
|
||||
myId: 99,
|
||||
type: 'CHAT',
|
||||
options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': false},
|
||||
),
|
||||
onLeave: () {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Реакции'), findsNothing);
|
||||
expect(find.text('Передать права владельца'), findsNothing);
|
||||
expect(find.text('Разрешения участников'), findsNothing);
|
||||
expect(find.text('Покинуть чат'), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('member permissions mirror the chat options', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
MemberPermissionsScreen(
|
||||
state: _state(
|
||||
myId: _ownerId,
|
||||
type: 'CHAT',
|
||||
options: {
|
||||
'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true,
|
||||
'ONLY_ADMIN_CAN_ADD_MEMBER': false,
|
||||
'ALL_CAN_PIN_MESSAGE': true,
|
||||
'MEMBERS_CAN_SEE_PRIVATE_LINK': false,
|
||||
'ONLY_ADMIN_CAN_CALL': false,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
bool value(String label) => tester
|
||||
.widget<SettingsToggleTile>(
|
||||
find.widgetWithText(SettingsToggleTile, label),
|
||||
)
|
||||
.value;
|
||||
expect(value('Изменять название, фото и описание чата'), isFalse);
|
||||
expect(value('Добавлять участников'), isTrue);
|
||||
expect(value('Закреплять сообщения'), isTrue);
|
||||
expect(value('Приглашать по ссылке'), isFalse);
|
||||
expect(value('Звонить в чате'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('the owner must hand over the chat before leaving', (
|
||||
tester,
|
||||
) async {
|
||||
final owner = _state(myId: _ownerId, type: 'CHAT');
|
||||
late BuildContext context;
|
||||
await _pump(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (built) {
|
||||
context = built;
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final result = transferBeforeLeaving(context, owner);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Вы владелец'), findsOne);
|
||||
expect(
|
||||
find.text(
|
||||
'Чтобы покинуть группу, сначала передайте права владельца '
|
||||
'другому участнику.',
|
||||
),
|
||||
findsOne,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Отмена'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await result, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('anyone else leaves without the ownership step', (tester) async {
|
||||
late BuildContext context;
|
||||
await _pump(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (built) {
|
||||
context = built;
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await transferBeforeLeaving(
|
||||
context,
|
||||
_state(myId: _adminId, type: 'CHAT'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a sole owner leaves without handing anything over', (
|
||||
tester,
|
||||
) async {
|
||||
late BuildContext context;
|
||||
await _pump(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (built) {
|
||||
context = built;
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
final alone = ChatAdminState(
|
||||
chatId: -2000,
|
||||
myId: _ownerId,
|
||||
name: 'Synthetic alone',
|
||||
imageUrl: '',
|
||||
info: ChatInfo.fromMap({
|
||||
'id': -2000,
|
||||
'type': 'CHAT',
|
||||
'owner': _ownerId,
|
||||
'participantsCount': 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await transferBeforeLeaving(context, alone), isTrue);
|
||||
expect(find.text('Вы владелец'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('group restrictions show the server values', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
ChatSettingsScreen(
|
||||
state: _state(
|
||||
myId: _ownerId,
|
||||
type: 'CHAT',
|
||||
options: {
|
||||
'DISABLE_FORWARD': true,
|
||||
'MESSAGE_COPY_NOT_ALLOWED': false,
|
||||
'CONFIRM_BEFORE_SEND': true,
|
||||
},
|
||||
),
|
||||
onLeave: () {},
|
||||
),
|
||||
);
|
||||
|
||||
bool value(String label) => tester
|
||||
.widget<SettingsToggleTile>(
|
||||
find.widgetWithText(SettingsToggleTile, label),
|
||||
)
|
||||
.value;
|
||||
expect(find.text('ОГРАНИЧЕНИЯ'), findsOne);
|
||||
expect(value('Запретить пересылку'), isTrue);
|
||||
expect(value('Запретить копирование'), isFalse);
|
||||
expect(value('Подтверждать отправку'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('members do not see group restrictions', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
ChatSettingsScreen(
|
||||
state: _state(
|
||||
myId: 99,
|
||||
type: 'CHAT',
|
||||
options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': false},
|
||||
),
|
||||
onLeave: () {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('ОГРАНИЧЕНИЯ'), findsNothing);
|
||||
expect(find.text('Запретить пересылку'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a new channel greets with its type and link', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
ChannelTypeLinkScreen(state: _state(myId: _ownerId), justCreated: true),
|
||||
);
|
||||
|
||||
expect(find.text('Приватный канал создан'), findsOne);
|
||||
expect(find.text('Публичный для бизнеса'), findsOne);
|
||||
expect(find.text('Ссылка-приглашение в ваш канал'), findsOne);
|
||||
expect(find.text('Копировать ссылку'), findsOne);
|
||||
expect(find.text('Заявки на вступление'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Публичный'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.text('Публичные каналы пока недоступны'), findsOne);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('the channel owner sees every channel setting', (tester) async {
|
||||
var deleted = false;
|
||||
await _pump(
|
||||
tester,
|
||||
ChatSettingsScreen(
|
||||
state: _state(myId: _ownerId),
|
||||
onLeave: () {},
|
||||
onClearHistory: () {},
|
||||
onDelete: () => deleted = true,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Настройки канала'), findsOne);
|
||||
expect(find.text('НАЗВАНИЕ КАНАЛА'), findsOne);
|
||||
expect(find.text('Подтверждать публикацию'), findsOne);
|
||||
expect(
|
||||
find.widgetWithText(SettingsNavTile, 'Тип канала и ссылка'),
|
||||
findsOne,
|
||||
);
|
||||
expect(find.widgetWithText(SettingsNavTile, 'Приватный'), findsOne);
|
||||
expect(find.text('Комментарии'), findsOne);
|
||||
expect(find.text('Передать права владельца'), findsOne);
|
||||
expect(find.text('Очистить историю'), findsOne);
|
||||
expect(find.text('Покинуть канал'), findsOne);
|
||||
expect(find.text('Разрешения участников'), findsNothing);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Удалить канал'),
|
||||
200,
|
||||
scrollable: find
|
||||
.descendant(
|
||||
of: find.byType(ListView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
await tester.tap(find.text('Удалить канал'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Удалить канал?'), findsOne);
|
||||
expect(find.text('Передать права и выйти'), findsOne);
|
||||
|
||||
await tester.tap(find.text('Удалить канал').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(deleted, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('comments ask before they are switched on', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
ChatSettingsScreen(
|
||||
state: _state(myId: _ownerId),
|
||||
onLeave: () {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.widgetWithText(SettingsToggleTile, 'Комментарии'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Комментарии — часть вашего канала'), findsOne);
|
||||
|
||||
await tester.tap(find.text('Не включать'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Комментарии — часть вашего канала'), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<SettingsToggleTile>(
|
||||
find.widgetWithText(SettingsToggleTile, 'Комментарии'),
|
||||
)
|
||||
.value,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a channel admin opens settings from the info section', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
Scaffold(
|
||||
body: AdminSection(
|
||||
state: _state(myId: _ownerId),
|
||||
onLeave: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.widgetWithText(SettingsNavTile, 'Настройки канала'), findsOne);
|
||||
});
|
||||
|
||||
test('member actions follow the admin rights', () {
|
||||
final owner = _state(myId: _ownerId, type: 'CHAT');
|
||||
expect(memberActionsFor(owner, userId: 44, isContact: false), [
|
||||
MemberAction.addContact,
|
||||
MemberAction.appointAdmin,
|
||||
MemberAction.remove,
|
||||
]);
|
||||
expect(memberActionsFor(owner, userId: 44, isContact: true), [
|
||||
MemberAction.appointAdmin,
|
||||
MemberAction.remove,
|
||||
]);
|
||||
expect(memberActionsFor(owner, userId: _adminId, isContact: true), isEmpty);
|
||||
expect(
|
||||
memberActionsFor(owner, userId: _ownerId, isContact: false),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
final plainMember = _state(myId: 99, type: 'CHAT');
|
||||
expect(memberActionsFor(plainMember, userId: 44, isContact: false), [
|
||||
MemberAction.addContact,
|
||||
]);
|
||||
|
||||
final followerManager = _state(myId: _adminId, adminPermissions: 2 | 128);
|
||||
expect(memberActionsFor(followerManager, userId: 44, isContact: true), [
|
||||
MemberAction.remove,
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('a group member menu asks before removing', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
Scaffold(
|
||||
body: Center(
|
||||
child: MemberActionsButton(
|
||||
state: _state(myId: _ownerId, type: 'CHAT'),
|
||||
userId: 44,
|
||||
name: 'Synthetic member',
|
||||
isContact: false,
|
||||
onDone: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Symbols.more_horiz));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Добавить в контакты'), findsOne);
|
||||
expect(find.text('Назначить администратором'), findsOne);
|
||||
|
||||
await tester.tap(find.text('Удалить'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Удалить участника'), findsOne);
|
||||
expect(find.text('Synthetic member будет удалён из группы.'), findsOne);
|
||||
|
||||
await tester.tap(find.text('Отмена'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/chat_admin.dart';
|
||||
import 'package:ikar/core/cache/info_cache.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
|
||||
class _PendingApi extends Api {
|
||||
int epoch = 1;
|
||||
final started = Completer<void>();
|
||||
final reply = Completer<Packet>();
|
||||
@override
|
||||
int get sessionEpoch => epoch;
|
||||
@override
|
||||
Future<Packet> sendRequest(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
bool silent = false,
|
||||
void Function()? beforeSend,
|
||||
}) {
|
||||
beforeSend?.call();
|
||||
started.complete();
|
||||
return reply.future;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ChatInfoFetch.clear();
|
||||
});
|
||||
test('late chat update cannot populate the next session cache', () async {
|
||||
final api = _PendingApi();
|
||||
final module = ChatAdminModule(api);
|
||||
final pending = module.setOptions(10, {'ALL_CAN_PIN_MESSAGE': true});
|
||||
final check = expectLater(pending, throwsStateError);
|
||||
await api.started.future;
|
||||
api.epoch++;
|
||||
api.reply.complete(
|
||||
Packet(
|
||||
cmd: CmdType.ok,
|
||||
opcode: 0,
|
||||
payload: {
|
||||
'chat': {'id': 10, 'type': 'CHAT', 'owner': 1},
|
||||
},
|
||||
),
|
||||
);
|
||||
await check;
|
||||
expect(ChatInfoFetch.peek(10), isNull);
|
||||
});
|
||||
test(
|
||||
'reaction fetch does not return settings from a departed session',
|
||||
() async {
|
||||
final api = _PendingApi();
|
||||
final module = ChatAdminModule(api);
|
||||
final pending = module.reactionSettingsFor(10);
|
||||
await api.started.future;
|
||||
api.epoch++;
|
||||
api.reply.complete(
|
||||
Packet(
|
||||
cmd: CmdType.ok,
|
||||
opcode: 0,
|
||||
payload: {
|
||||
'chatReactionsSettings': [
|
||||
{'chatId': 10, 'count': 3, 'value': true},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(await pending, isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/chat_admin.dart';
|
||||
import 'package:ikar/core/cache/info_cache.dart';
|
||||
import 'package:ikar/core/protocol/opcode_map.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/chat_admin_state.dart';
|
||||
import 'package:ikar/models/admin_rights.dart';
|
||||
import 'package:ikar/models/chat_info.dart';
|
||||
import 'package:ikar/models/chat_reaction_settings.dart';
|
||||
import 'package:ikar/models/member_permission.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _chatId = -1000;
|
||||
const _ownerId = 11;
|
||||
const _adminId = 22;
|
||||
const _followerId = 33;
|
||||
|
||||
Map<String, dynamic> _chat({
|
||||
String type = 'CHANNEL',
|
||||
Map<String, dynamic> admins = const {},
|
||||
Map<String, bool> options = const {},
|
||||
String link = 'https://example.test/join/synthetic',
|
||||
String? title,
|
||||
}) => {
|
||||
'id': _chatId,
|
||||
'type': type,
|
||||
'owner': _ownerId,
|
||||
'access': 'PRIVATE',
|
||||
'link': link,
|
||||
'title': ?title,
|
||||
'participantsCount': 3,
|
||||
'adminParticipants': {
|
||||
'$_ownerId': {'id': _ownerId, 'permissions': 4095},
|
||||
...admins,
|
||||
},
|
||||
'options': options,
|
||||
};
|
||||
|
||||
class _RecordingApi extends Api {
|
||||
final Map<String, dynamic> Function() reply;
|
||||
final List<(int, Map<dynamic, dynamic>, bool)> requests = [];
|
||||
|
||||
_RecordingApi(this.reply);
|
||||
|
||||
@override
|
||||
Future<Packet> sendRequest(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
bool silent = false,
|
||||
void Function()? beforeSend,
|
||||
}) async {
|
||||
beforeSend?.call();
|
||||
requests.add((opcode, payload, silent));
|
||||
return Packet(cmd: CmdType.ok, opcode: opcode, payload: reply());
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('channel admin rights', () {
|
||||
test('read the captured appoint request', () {
|
||||
const rights = AdminRights(1958);
|
||||
|
||||
expect(rights.has(AdminRight.createPosts), isTrue);
|
||||
expect(rights.has(AdminRight.editPosts), isTrue);
|
||||
expect(rights.has(AdminRight.deletePosts), isTrue);
|
||||
expect(rights.has(AdminRight.manageFollowers), isTrue);
|
||||
expect(rights.has(AdminRight.manageAdmins), isTrue);
|
||||
expect(rights.has(AdminRight.editInfo), isFalse);
|
||||
expect(rights.has(AdminRight.pinMessages), isFalse);
|
||||
expect(rights.has(AdminRight.viewStats), isFalse);
|
||||
});
|
||||
|
||||
test('dropping followers reproduces the captured edit request', () {
|
||||
final edited = const AdminRights(
|
||||
1958,
|
||||
).withRight(AdminRight.manageFollowers, false);
|
||||
|
||||
expect(edited.bits, 1828);
|
||||
});
|
||||
|
||||
test('a new channel admin starts with every switch on', () {
|
||||
final defaults = AdminRights.appointDefaults(AdminChatKind.channel);
|
||||
|
||||
for (final right in AdminRights.layoutOf(
|
||||
AdminChatKind.channel,
|
||||
).expand((group) => group)) {
|
||||
expect(defaults.has(right), isTrue, reason: right.name);
|
||||
}
|
||||
expect(defaults.bits & (1 | 64), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('group admin rights', () {
|
||||
test('a new group admin matches the plain captured appoint', () {
|
||||
expect(AdminRights.appointDefaults(AdminChatKind.group).bits, 123);
|
||||
});
|
||||
|
||||
test('granting admins on top gives the captured 127', () {
|
||||
final rights = AdminRights.appointDefaults(
|
||||
AdminChatKind.group,
|
||||
).withRight(AdminRight.manageAdmins, true);
|
||||
|
||||
expect(rights.bits, 127);
|
||||
});
|
||||
|
||||
test('dropping edit chat and then deleting reproduces 119 and 118', () {
|
||||
final withoutEdit = const AdminRights(
|
||||
127,
|
||||
).withRight(AdminRight.editInfo, false);
|
||||
final withoutDelete = withoutEdit.withRight(
|
||||
AdminRight.deleteMessages,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(withoutEdit.bits, 119);
|
||||
expect(withoutDelete.bits, 118);
|
||||
});
|
||||
|
||||
test('the link switch is separate from members', () {
|
||||
const rights = AdminRights(127);
|
||||
|
||||
expect(rights.has(AdminRight.manageMembers), isTrue);
|
||||
expect(rights.has(AdminRight.editLink), isFalse);
|
||||
});
|
||||
|
||||
test('is empty only when no group switch is on', () {
|
||||
expect(
|
||||
const AdminRights(32 | 64).isEmptyFor(AdminChatKind.group),
|
||||
isTrue,
|
||||
);
|
||||
expect(const AdminRights(1).isEmptyFor(AdminChatKind.group), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('rights toggling', () {
|
||||
test('keeps bits no switch controls', () {
|
||||
final rights = const AdminRights(
|
||||
1 | 64,
|
||||
).withRight(AdminRight.pinMessages, true);
|
||||
|
||||
expect(rights.bits, 1 | 64 | 16);
|
||||
expect(rights.withRight(AdminRight.pinMessages, false).bits, 1 | 64);
|
||||
});
|
||||
});
|
||||
|
||||
group('ChatInfo rights', () {
|
||||
final info = ChatInfo.fromMap(
|
||||
_chat(
|
||||
admins: {
|
||||
'$_adminId': {'id': _adminId, 'permissions': 1828},
|
||||
},
|
||||
options: {'JOIN_REQUEST': true},
|
||||
),
|
||||
);
|
||||
|
||||
test('the owner holds every right', () {
|
||||
expect(info.rightsOf(_ownerId).bits, AdminRights.ownerBits);
|
||||
expect(info.can(_ownerId, AdminRight.manageAdmins), isTrue);
|
||||
});
|
||||
|
||||
test('an admin holds what the server granted', () {
|
||||
expect(info.rightsOf(_adminId).bits, 1828);
|
||||
expect(info.can(_adminId, AdminRight.manageAdmins), isTrue);
|
||||
expect(info.can(_adminId, AdminRight.manageFollowers), isFalse);
|
||||
});
|
||||
|
||||
test('a follower holds nothing', () {
|
||||
expect(info.rightsOf(_followerId).bits, 0);
|
||||
expect(info.can(_followerId, AdminRight.createPosts), isFalse);
|
||||
});
|
||||
|
||||
test('reads join requests and the chat kind', () {
|
||||
expect(info.joinRequests, isTrue);
|
||||
expect(info.adminKind, AdminChatKind.channel);
|
||||
expect(
|
||||
ChatInfo.fromMap(_chat(type: 'CHAT')).adminKind,
|
||||
AdminChatKind.group,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('MemberPermission', () {
|
||||
test('reads inverted and direct options', () {
|
||||
final info = ChatInfo.fromMap(
|
||||
_chat(
|
||||
type: 'CHAT',
|
||||
options: {
|
||||
'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true,
|
||||
'ONLY_ADMIN_CAN_ADD_MEMBER': false,
|
||||
'ALL_CAN_PIN_MESSAGE': true,
|
||||
'MEMBERS_CAN_SEE_PRIVATE_LINK': false,
|
||||
'ONLY_ADMIN_CAN_CALL': true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(MemberPermission.editInfo.allowedIn(info), isFalse);
|
||||
expect(MemberPermission.addMembers.allowedIn(info), isTrue);
|
||||
expect(MemberPermission.pinMessages.allowedIn(info), isTrue);
|
||||
expect(MemberPermission.inviteByLink.allowedIn(info), isFalse);
|
||||
expect(MemberPermission.call.allowedIn(info), isFalse);
|
||||
});
|
||||
|
||||
test('writes the captured option values', () {
|
||||
expect(MemberPermission.editInfo.optionFor(false), {
|
||||
'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true,
|
||||
});
|
||||
expect(MemberPermission.pinMessages.optionFor(false), {
|
||||
'ALL_CAN_PIN_MESSAGE': false,
|
||||
});
|
||||
expect(MemberPermission.call.optionFor(false), {
|
||||
'ONLY_ADMIN_CAN_CALL': true,
|
||||
});
|
||||
expect(MemberPermission.addMembers.optionFor(false), {
|
||||
'ONLY_ADMIN_CAN_ADD_MEMBER': true,
|
||||
});
|
||||
expect(MemberPermission.pinMessages.optionFor(true), {
|
||||
'ALL_CAN_PIN_MESSAGE': true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ChatReactionSettings', () {
|
||||
const catalog = ['👍', '❤️', '⚡️', '😴'];
|
||||
|
||||
test('treats listed reactions as forbidden by default', () {
|
||||
final settings = ChatReactionSettings.fromMap({
|
||||
'isActive': true,
|
||||
'count': 8,
|
||||
'included': false,
|
||||
'reactionIds': ['😴', '⚡'],
|
||||
})!;
|
||||
|
||||
expect(settings.allowedOf(catalog), {'👍', '❤️'});
|
||||
expect(settings.restricted, isTrue);
|
||||
});
|
||||
|
||||
test('treats listed reactions as the only allowed when included', () {
|
||||
final settings = ChatReactionSettings.fromMap({
|
||||
'isActive': true,
|
||||
'count': 4,
|
||||
'included': true,
|
||||
'reactionIds': ['👍'],
|
||||
})!;
|
||||
|
||||
expect(settings.allowedOf(catalog), {'👍'});
|
||||
});
|
||||
|
||||
test('an empty forbidden list allows everything', () {
|
||||
final settings = ChatReactionSettings.fromMap({
|
||||
'isActive': true,
|
||||
'count': 4,
|
||||
'included': false,
|
||||
'reactionIds': const [],
|
||||
})!;
|
||||
|
||||
expect(settings.allowedOf(catalog), catalog.toSet());
|
||||
expect(settings.restricted, isFalse);
|
||||
});
|
||||
|
||||
test('forbids whatever is not allowed', () {
|
||||
expect(ChatReactionSettings.forbiddenOf(catalog, {'👍', '❤️'}), [
|
||||
'⚡️',
|
||||
'😴',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('ChatAdminState', () {
|
||||
ChatAdminState state(
|
||||
int myId, {
|
||||
String type = 'CHANNEL',
|
||||
int adminPermissions = 1828,
|
||||
Map<String, bool> options = const {},
|
||||
}) => ChatAdminState(
|
||||
chatId: _chatId,
|
||||
myId: myId,
|
||||
name: 'Synthetic chat',
|
||||
imageUrl: '',
|
||||
info: ChatInfo.fromMap(
|
||||
_chat(
|
||||
type: type,
|
||||
options: options,
|
||||
admins: {
|
||||
'$_adminId': {'id': _adminId, 'permissions': adminPermissions},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
test('lists the owner first', () {
|
||||
expect(state(_ownerId).adminIds, [_ownerId, _adminId]);
|
||||
});
|
||||
|
||||
test('the owner manages everyone but themselves', () {
|
||||
final owner = state(_ownerId);
|
||||
|
||||
expect(owner.isOwner, isTrue);
|
||||
expect(owner.canEditAdmin(_adminId), isTrue);
|
||||
expect(owner.canEditAdmin(_ownerId), isFalse);
|
||||
expect(owner.canManageFollowers, isTrue);
|
||||
});
|
||||
|
||||
test('an admin follows the rights the owner granted', () {
|
||||
final admin = state(_adminId);
|
||||
|
||||
expect(admin.isAdmin, isTrue);
|
||||
expect(admin.canManageAdmins, isTrue);
|
||||
expect(admin.canManageFollowers, isFalse);
|
||||
expect(admin.canEditAdmin(_ownerId), isFalse);
|
||||
expect(admin.canEditAdmin(_adminId), isFalse);
|
||||
});
|
||||
|
||||
test('only channel admins get the channel settings', () {
|
||||
expect(state(_ownerId).hasSettings, isTrue);
|
||||
expect(state(_followerId).hasSettings, isFalse);
|
||||
});
|
||||
|
||||
test('a group admin with edit rights gets the settings', () {
|
||||
final admin = state(_adminId, type: 'CHAT', adminPermissions: 123);
|
||||
|
||||
expect(admin.hasSettings, isTrue);
|
||||
expect(admin.canManageChat, isTrue);
|
||||
expect(admin.canEditInfo, isTrue);
|
||||
});
|
||||
|
||||
test('a member edits the group only when members may', () {
|
||||
final locked = state(
|
||||
_followerId,
|
||||
type: 'CHAT',
|
||||
options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true},
|
||||
);
|
||||
final open = state(
|
||||
_followerId,
|
||||
type: 'CHAT',
|
||||
options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': false},
|
||||
);
|
||||
|
||||
expect(locked.hasSettings, isFalse);
|
||||
expect(open.hasSettings, isTrue);
|
||||
expect(open.canEditInfo, isTrue);
|
||||
expect(open.canManageChat, isFalse);
|
||||
});
|
||||
|
||||
test('keeps the opened avatar until the server changes it', () {
|
||||
final opened = ChatAdminState(
|
||||
chatId: _chatId,
|
||||
myId: _ownerId,
|
||||
name: 'Synthetic chat',
|
||||
imageUrl: 'https://example.test/list.jpg',
|
||||
info: ChatInfo.fromMap({
|
||||
..._chat(type: 'CHAT', title: 'Synthetic chat'),
|
||||
'baseIconUrl': 'https://example.test/server.jpg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(opened.imageUrl, 'https://example.test/list.jpg');
|
||||
expect(opened.name, 'Synthetic chat');
|
||||
|
||||
opened.adopt(
|
||||
ChatInfo.fromMap({
|
||||
..._chat(type: 'CHAT', title: 'Synthetic chat'),
|
||||
'baseIconUrl': 'https://example.test/fresh.jpg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(opened.imageUrl, 'https://example.test/fresh.jpg');
|
||||
});
|
||||
|
||||
test('takes the name from the server answer', () {
|
||||
final owner = state(_ownerId);
|
||||
var notified = 0;
|
||||
owner.addListener(() => notified++);
|
||||
|
||||
owner.adopt(ChatInfo.fromMap(_chat(title: 'Synthetic renamed')));
|
||||
|
||||
expect(notified, 1);
|
||||
expect(owner.name, 'Synthetic renamed');
|
||||
expect(owner.adminIds, [_ownerId]);
|
||||
});
|
||||
});
|
||||
|
||||
group('ChatAdminModule', () {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
tearDown(ChatInfoFetch.clear);
|
||||
|
||||
test('appoints an admin with the chosen permissions', () async {
|
||||
final api = _RecordingApi(
|
||||
() => {
|
||||
'chat': _chat(
|
||||
type: 'CHAT',
|
||||
admins: {
|
||||
'$_followerId': {'id': _followerId, 'permissions': 123},
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
final info = await ChatAdminModule(
|
||||
api,
|
||||
).setAdmin(_chatId, _followerId, const AdminRights(123));
|
||||
|
||||
final (opcode, payload, silent) = api.requests.single;
|
||||
expect(opcode, Opcode.chatMembersUpdate);
|
||||
expect(payload, {
|
||||
'chatId': _chatId,
|
||||
'userIds': [_followerId],
|
||||
'type': 'ADMIN',
|
||||
'operation': 'add',
|
||||
'permissions': 123,
|
||||
});
|
||||
expect(silent, isTrue);
|
||||
expect(info.isAdmin(_followerId), isTrue);
|
||||
expect(ChatInfoFetch.peek(_chatId)?.isAdmin(_followerId), isTrue);
|
||||
});
|
||||
|
||||
test('removes an admin', () async {
|
||||
final api = _RecordingApi(() => {'chat': _chat()});
|
||||
|
||||
final info = await ChatAdminModule(api).removeAdmin(_chatId, _adminId);
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.chatMembersUpdate);
|
||||
expect(payload, {
|
||||
'chatId': _chatId,
|
||||
'userIds': [_adminId],
|
||||
'type': 'ADMIN',
|
||||
'operation': 'remove',
|
||||
});
|
||||
expect(info.isAdmin(_adminId), isFalse);
|
||||
});
|
||||
|
||||
test('removes a follower like the captured request', () async {
|
||||
final api = _RecordingApi(() => {'chat': _chat()});
|
||||
|
||||
await ChatAdminModule(api).removeMember(_chatId, _followerId);
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.chatMembersUpdate);
|
||||
expect(payload, {
|
||||
'chatId': _chatId,
|
||||
'userIds': [_followerId],
|
||||
'operation': 'remove',
|
||||
'cleanMsgPeriod': 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('turns channel comments on through the options', () async {
|
||||
final api = _RecordingApi(
|
||||
() => {
|
||||
'chat': _chat(options: {'COMMENTS': true}),
|
||||
},
|
||||
);
|
||||
|
||||
final info = await ChatAdminModule(
|
||||
api,
|
||||
).setOptions(_chatId, {'COMMENTS': true});
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.chatUpdate);
|
||||
expect(payload, {
|
||||
'chatId': _chatId,
|
||||
'options': {'COMMENTS': true},
|
||||
});
|
||||
expect(info.commentsEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('transfers ownership', () async {
|
||||
final api = _RecordingApi(() => {'chat': _chat()});
|
||||
|
||||
await ChatAdminModule(api).transferOwnership(_chatId, _adminId);
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.chatUpdate);
|
||||
expect(payload, {'chatId': _chatId, 'changeOwnerId': _adminId});
|
||||
});
|
||||
|
||||
test('revokes the invite link and adopts the new one', () async {
|
||||
final api = _RecordingApi(
|
||||
() => {'chat': _chat(link: 'https://example.test/join/fresh')},
|
||||
);
|
||||
|
||||
final info = await ChatAdminModule(api).revokeInviteLink(_chatId);
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.chatUpdate);
|
||||
expect(payload, {'chatId': _chatId, 'revokePrivateLink': true});
|
||||
expect(info.link, 'https://example.test/join/fresh');
|
||||
});
|
||||
|
||||
test('switches chat options', () async {
|
||||
final api = _RecordingApi(
|
||||
() => {
|
||||
'chat': _chat(type: 'CHAT', options: {'ALL_CAN_PIN_MESSAGE': false}),
|
||||
},
|
||||
);
|
||||
|
||||
final info = await ChatAdminModule(
|
||||
api,
|
||||
).setOptions(_chatId, {'ALL_CAN_PIN_MESSAGE': false});
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.chatUpdate);
|
||||
expect(payload, {
|
||||
'chatId': _chatId,
|
||||
'options': {'ALL_CAN_PIN_MESSAGE': false},
|
||||
});
|
||||
expect(MemberPermission.pinMessages.allowedIn(info), isFalse);
|
||||
});
|
||||
|
||||
test('renames the chat with its description', () async {
|
||||
final api = _RecordingApi(
|
||||
() => {'chat': _chat(type: 'CHAT', title: 'Synthetic title')},
|
||||
);
|
||||
|
||||
final info = await ChatAdminModule(api).updateInfo(
|
||||
_chatId,
|
||||
title: 'Synthetic title',
|
||||
description: 'Synthetic about',
|
||||
);
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.chatUpdate);
|
||||
expect(payload, {
|
||||
'chatId': _chatId,
|
||||
'theme': 'Synthetic title',
|
||||
'description': 'Synthetic about',
|
||||
});
|
||||
expect(info.title, 'Synthetic title');
|
||||
});
|
||||
|
||||
test('sets the chat photo by token', () async {
|
||||
final api = _RecordingApi(() => {'chat': _chat(type: 'CHAT')});
|
||||
|
||||
await ChatAdminModule(api).setPhoto(_chatId, 'synthetic-photo-token');
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.chatUpdate);
|
||||
expect(payload, {
|
||||
'chatId': _chatId,
|
||||
'photoToken': 'synthetic-photo-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('reads reaction settings for the chat', () async {
|
||||
final api = _RecordingApi(
|
||||
() => {
|
||||
'chatReactionsSettings': [
|
||||
{
|
||||
'chatId': _chatId,
|
||||
'isActive': true,
|
||||
'count': 8,
|
||||
'included': false,
|
||||
'reactionIds': ['😴'],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final settings = await ChatAdminModule(api).reactionSettingsFor(_chatId);
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.reactionsSettingsGetByChatId);
|
||||
expect(payload, {
|
||||
'chatIds': [_chatId],
|
||||
});
|
||||
expect(settings?.count, 8);
|
||||
expect(settings?.reactionIds, ['😴']);
|
||||
});
|
||||
|
||||
test('forbids reactions in the captured shape', () async {
|
||||
final api = _RecordingApi(
|
||||
() => {
|
||||
'chatReactionsSettings': {
|
||||
'chatId': _chatId,
|
||||
'isActive': true,
|
||||
'count': 8,
|
||||
'included': false,
|
||||
'reactionIds': ['😴', '😈'],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
final settings = await ChatAdminModule(
|
||||
api,
|
||||
).setReactions(_chatId, count: 8, forbidden: ['😴', '😈']);
|
||||
|
||||
final (opcode, payload, _) = api.requests.single;
|
||||
expect(opcode, Opcode.chatReactionsSettingsSet);
|
||||
expect(payload, {
|
||||
'chatId': _chatId,
|
||||
'value': true,
|
||||
'count': 8,
|
||||
'reactionIds': ['😴', '😈'],
|
||||
'included': false,
|
||||
});
|
||||
expect(settings.reactionIds, ['😴', '😈']);
|
||||
});
|
||||
|
||||
test('turns reactions off', () async {
|
||||
final api = _RecordingApi(
|
||||
() => {
|
||||
'chatReactionsSettings': {
|
||||
'chatId': _chatId,
|
||||
'isActive': false,
|
||||
'count': 4,
|
||||
'included': false,
|
||||
'reactionIds': const [],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
final settings = await ChatAdminModule(api).disableReactions(_chatId);
|
||||
|
||||
final (_, payload, _) = api.requests.single;
|
||||
expect(payload, {'chatId': _chatId, 'value': false});
|
||||
expect(settings.isActive, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/config/app_chat_chrome.dart';
|
||||
import 'package:ikar/core/config/app_composer_background.dart';
|
||||
import 'package:ikar/core/config/app_composer_style.dart';
|
||||
import 'package:ikar/backend/modules/forward_sender.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/upload_status.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/view/chat_header.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/video_note_controller.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/view/composer_input.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/voice_record_controller.dart';
|
||||
import 'package:ikar/frontend/widgets/composer_morph_icon.dart';
|
||||
import 'package:ikar/frontend/widgets/rich_message_controller.dart';
|
||||
|
||||
void main() {
|
||||
late RichMessageController messageController;
|
||||
late FocusNode focusNode;
|
||||
late AnimationController attachAnim;
|
||||
late VoiceRecordController voiceRec;
|
||||
late VideoNoteController note;
|
||||
late ValueNotifier<CachedMessage?> replyTo;
|
||||
late ValueNotifier<ForwardRequest?> forwards;
|
||||
late ValueNotifier<bool> hasText;
|
||||
late ValueNotifier<UploadStatus> uploadStatus;
|
||||
|
||||
setUp(() {
|
||||
messageController = RichMessageController();
|
||||
focusNode = FocusNode();
|
||||
attachAnim = AnimationController(
|
||||
vsync: const TestVSync(),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
voiceRec = VoiceRecordController(
|
||||
contextOf: () => throw UnimplementedError(),
|
||||
isMounted: () => true,
|
||||
myId: () => 1,
|
||||
onRecorded: (File file, int durationMs, List<double> amps) async {},
|
||||
);
|
||||
note = VideoNoteController(
|
||||
contextOf: () => throw UnimplementedError(),
|
||||
isMounted: () => true,
|
||||
onRecorded: (File file, int durationMs) async {},
|
||||
formatElapsed: (ms) => '0:00',
|
||||
bottomInset: () => 0,
|
||||
);
|
||||
replyTo = ValueNotifier(null);
|
||||
forwards = ValueNotifier(null);
|
||||
hasText = ValueNotifier(false);
|
||||
uploadStatus = ValueNotifier(const UploadStatus());
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
messageController.dispose();
|
||||
focusNode.dispose();
|
||||
attachAnim.dispose();
|
||||
replyTo.dispose();
|
||||
forwards.dispose();
|
||||
hasText.dispose();
|
||||
uploadStatus.dispose();
|
||||
});
|
||||
|
||||
Future<double> pumpBar(WidgetTester tester, ComposerStyle style) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(),
|
||||
child: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ComposerInputBar(
|
||||
chatType: 'DIALOG',
|
||||
chrome: ChatChromeStyle.none,
|
||||
vignette: true,
|
||||
style: style,
|
||||
background: ComposerBackground.standard,
|
||||
attachAnim: attachAnim,
|
||||
replyTo: replyTo,
|
||||
forward: forwards,
|
||||
myId: 1,
|
||||
hasText: hasText,
|
||||
uploadStatus: uploadStatus,
|
||||
messageController: messageController,
|
||||
messageFocusNode: focusNode,
|
||||
voiceRec: voiceRec,
|
||||
note: note,
|
||||
onToggleStickerPanel: () {},
|
||||
onSendText: () {},
|
||||
onScheduleMessage: () {},
|
||||
onOpenAttach: () {},
|
||||
onOpenAttachScheduled: () {},
|
||||
onSendHistory: (entry) async {},
|
||||
onCancelReply: () {},
|
||||
onCancelForward: () {},
|
||||
onToggleForwardSender: () {},
|
||||
formatElapsed: (ms) => '0:00',
|
||||
contextMenuBuilder: (context, state) => const SizedBox.shrink(),
|
||||
isMuted: false,
|
||||
onToggleMute: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return tester.getSize(find.byType(ComposerInputBar)).height;
|
||||
}
|
||||
|
||||
Future<void> pumpHeader(WidgetTester tester) async {
|
||||
final status = ValueNotifier<String>('online');
|
||||
final scheduled = ValueNotifier<int>(0);
|
||||
final unread = ValueNotifier<int>(0);
|
||||
addTearDown(status.dispose);
|
||||
addTearDown(scheduled.dispose);
|
||||
addTearDown(unread.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: ChatHeaderRow(
|
||||
glossy: false,
|
||||
frosted: false,
|
||||
cs: Theme.of(context).colorScheme,
|
||||
embedded: false,
|
||||
chatId: 0,
|
||||
heroTag: 'header',
|
||||
name: 'Chat',
|
||||
imageUrl: '',
|
||||
chatType: 'CHAT',
|
||||
isOfficial: false,
|
||||
myId: 1,
|
||||
headerStatus: status,
|
||||
scheduledCount: scheduled,
|
||||
otherUnread: unread,
|
||||
showCall: true,
|
||||
onClose: null,
|
||||
onOpenInfo: () {},
|
||||
onOpenScheduled: () {},
|
||||
onCall: () {},
|
||||
onMenu: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('the material composer is exactly as tall as the app bar', (
|
||||
tester,
|
||||
) async {
|
||||
expect(await pumpBar(tester, ComposerStyle.materialYou), kToolbarHeight);
|
||||
});
|
||||
|
||||
testWidgets('the glossy composer keeps its taller pill layout', (
|
||||
tester,
|
||||
) async {
|
||||
expect(
|
||||
await pumpBar(tester, ComposerStyle.glossy),
|
||||
greaterThan(kToolbarHeight),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('material actions sit on the app bar icon columns', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHeader(tester);
|
||||
final headerWidth = tester.getSize(find.byType(ChatHeaderRow)).width;
|
||||
final back = tester.getCenter(find.byIcon(Symbols.arrow_back)).dx;
|
||||
final call = headerWidth - tester.getCenter(find.byIcon(Symbols.call)).dx;
|
||||
final menu =
|
||||
headerWidth - tester.getCenter(find.byIcon(Symbols.more_vert)).dx;
|
||||
|
||||
await pumpBar(tester, ComposerStyle.materialYou);
|
||||
final width = tester.getSize(find.byType(ComposerInputBar)).width;
|
||||
|
||||
expect(tester.getCenter(find.byIcon(Symbols.face)).dx, back);
|
||||
expect(width - tester.getCenter(find.byIcon(Symbols.attachment)).dx, call);
|
||||
expect(width - tester.getCenter(find.byType(ComposerMorphIcon)).dx, menu);
|
||||
});
|
||||
|
||||
testWidgets('glossy action geometry is untouched', (tester) async {
|
||||
await pumpBar(tester, ComposerStyle.glossy);
|
||||
final width = tester.getSize(find.byType(ComposerInputBar)).width;
|
||||
|
||||
expect(tester.getCenter(find.byType(ComposerMorphIcon)).dx, width - 39);
|
||||
expect(tester.getCenter(find.byIcon(Symbols.face)).dx, 38);
|
||||
expect(tester.getCenter(find.byIcon(Symbols.attachment)).dx, width - 100);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/protocol/chat_cache_fingerprint.dart';
|
||||
|
||||
void main() {
|
||||
test('fingerprint selects the handshake architecture and keeps 96 bytes', () {
|
||||
final values = <String>{};
|
||||
for (final arch in ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']) {
|
||||
final result = ChatCacheFingerprint.compute(123, 'synthetic', arch: arch);
|
||||
expect(result.length, 96);
|
||||
values.add(result.toString());
|
||||
expect(result, ChatCacheFingerprint.compute(123, 'synthetic', arch: arch));
|
||||
}
|
||||
expect(values.length, 4);
|
||||
expect(
|
||||
ChatCacheFingerprint.compute(123, 'synthetic', arch: 'unknown'),
|
||||
ChatCacheFingerprint.compute(123, 'synthetic', arch: 'arm64-v8a'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/view/chat_call_banner.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/models/chat_call.dart';
|
||||
|
||||
void main() {
|
||||
for (final floating in [false, true]) {
|
||||
testWidgets('call banner joins once, floating=$floating', (tester) async {
|
||||
var joined = 0;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: ChatCallBanner(
|
||||
call: const ChatCall(
|
||||
joinLink: 'synthetic-call',
|
||||
isVideo: true,
|
||||
participantsCount: 3,
|
||||
),
|
||||
floating: floating,
|
||||
onJoin: () => joined++,
|
||||
)),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(FilledButton), findsOneWidget);
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
expect(joined, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chat_parsing.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:ikar/models/chat_call.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
final String directory;
|
||||
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
const _accountId = 1;
|
||||
|
||||
Map<String, dynamic> _ongoingCall({String callType = 'AUDIO'}) => {
|
||||
'conversationId': 'synthetic-conversation',
|
||||
'joinLink': 'synthetic-join-token',
|
||||
'type': 1,
|
||||
'approxParticipantsCount': 2,
|
||||
'previewParticipantIds': [11, 12],
|
||||
'callType': callType,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _endedCall() => {
|
||||
'conversationId': 'synthetic-conversation',
|
||||
'joinLink': 'synthetic-join-token',
|
||||
'type': 1,
|
||||
'previewParticipantIds': const [],
|
||||
'callType': 'AUDIO',
|
||||
};
|
||||
|
||||
Map<String, dynamic> _groupChat(int id, {Map<String, dynamic>? call}) => {
|
||||
'id': id,
|
||||
'type': 'CHAT',
|
||||
'status': 'ACTIVE',
|
||||
'title': 'Synthetic group',
|
||||
'participants': {'$_accountId': 0, '11': 0, '12': 0},
|
||||
'videoConversation': ?call,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('ChatCall', () {
|
||||
test('reads an ongoing call', () {
|
||||
final call = ChatCall.fromServer(_ongoingCall())!;
|
||||
|
||||
expect(call.joinLink, 'synthetic-join-token');
|
||||
expect(call.participantsCount, 2);
|
||||
expect(call.participantIds, [11, 12]);
|
||||
expect(call.isVideo, isFalse);
|
||||
expect(
|
||||
ChatCall.fromServer(_ongoingCall(callType: 'VIDEO'))!.isVideo,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('treats a call without participants as ended', () {
|
||||
expect(ChatCall.fromServer(_endedCall()), isNull);
|
||||
expect(ChatCall.fromServer(null), isNull);
|
||||
});
|
||||
|
||||
test('survives a round trip through its stored form', () {
|
||||
final call = ChatCall.fromServer(_ongoingCall(callType: 'VIDEO'))!;
|
||||
final restored = ChatCall.decode(call.encode())!;
|
||||
|
||||
expect(restored.joinLink, call.joinLink);
|
||||
expect(restored.isVideo, isTrue);
|
||||
expect(restored.participantsCount, 2);
|
||||
expect(restored.participantIds, [11, 12]);
|
||||
});
|
||||
|
||||
test('a chat row carries its call and notices when it ends', () {
|
||||
final ongoing = parseChatRow(
|
||||
_groupChat(-500, call: _ongoingCall()),
|
||||
_accountId,
|
||||
_accountId,
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
0,
|
||||
)!;
|
||||
final ended = parseChatRow(
|
||||
_groupChat(-500, call: _endedCall()),
|
||||
_accountId,
|
||||
_accountId,
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
0,
|
||||
)!;
|
||||
|
||||
expect(ongoing.activeCall?.joinLink, 'synthetic-join-token');
|
||||
expect(CachedChat.fromDbRow(ongoing.toDbRow()).activeCall, isNotNull);
|
||||
expect(ended.activeCall, isNull);
|
||||
expect(sameChatContent(ongoing, ended), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('stored chat', () {
|
||||
setUp(() async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_chat_call_test',
|
||||
);
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
await AppDatabase.init();
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: _accountId,
|
||||
firstName: 'Synthetic owner',
|
||||
phone: 100000,
|
||||
country: 'ZZ',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps a call until the server reports it ended', () async {
|
||||
const chatId = -600;
|
||||
await chats.cacheServerChat(
|
||||
_groupChat(chatId, call: _ongoingCall()),
|
||||
_accountId,
|
||||
);
|
||||
final during = (await chats.getChat(_accountId, chatId)).single;
|
||||
expect(during.activeCall?.participantsCount, 2);
|
||||
|
||||
await chats.cacheServerChat(
|
||||
_groupChat(chatId, call: _endedCall()),
|
||||
_accountId,
|
||||
);
|
||||
final after = (await chats.getChat(_accountId, chatId)).single;
|
||||
expect(after.activeCall, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/crypto/chat_crypto_key_cache.dart';
|
||||
|
||||
void _wipe(Uint8List key) => key.fillRange(0, key.length, 0);
|
||||
|
||||
void main() {
|
||||
test('rejects a derivation completed after keys are cleared', () async {
|
||||
final oldDerivation = Completer<Uint8List?>();
|
||||
final newDerivation = Completer<Uint8List?>();
|
||||
var derivations = 0;
|
||||
final cache = ChatCryptoKeyCache((accountId, chatId) {
|
||||
derivations++;
|
||||
return derivations == 1 ? oldDerivation.future : newDerivation.future;
|
||||
}, wipe: _wipe);
|
||||
|
||||
final oldKey = cache.keyFor(101, 202);
|
||||
cache.clear();
|
||||
final newKey = cache.keyFor(101, 202);
|
||||
newDerivation.complete(Uint8List.fromList([2, 2, 2]));
|
||||
expect(await newKey, [2, 2, 2]);
|
||||
|
||||
oldDerivation.complete(Uint8List.fromList([1, 1, 1]));
|
||||
expect(await oldKey, isNull);
|
||||
expect(await cache.keyFor(101, 202), [2, 2, 2]);
|
||||
expect(derivations, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
'obsolete success preserves the replacement pending derivation',
|
||||
() async {
|
||||
final oldDerivation = Completer<Uint8List?>();
|
||||
final newDerivation = Completer<Uint8List?>();
|
||||
var derivations = 0;
|
||||
final cache = ChatCryptoKeyCache((accountId, chatId) {
|
||||
derivations++;
|
||||
return derivations == 1 ? oldDerivation.future : newDerivation.future;
|
||||
}, wipe: _wipe);
|
||||
|
||||
final oldKey = cache.keyFor(101, 202);
|
||||
cache.clear();
|
||||
final newKey = cache.keyFor(101, 202);
|
||||
oldDerivation.complete(Uint8List.fromList([1, 1, 1]));
|
||||
expect(await oldKey, isNull);
|
||||
|
||||
final sharedKey = cache.keyFor(101, 202);
|
||||
expect(identical(sharedKey, newKey), isTrue);
|
||||
expect(derivations, 2);
|
||||
newDerivation.complete(Uint8List.fromList([2, 2, 2]));
|
||||
expect(await newKey, [2, 2, 2]);
|
||||
expect(await sharedKey, [2, 2, 2]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'obsolete failure preserves the replacement pending derivation',
|
||||
() async {
|
||||
final oldDerivation = Completer<Uint8List?>();
|
||||
final newDerivation = Completer<Uint8List?>();
|
||||
var derivations = 0;
|
||||
final cache = ChatCryptoKeyCache((accountId, chatId) {
|
||||
derivations++;
|
||||
return derivations == 1 ? oldDerivation.future : newDerivation.future;
|
||||
}, wipe: _wipe);
|
||||
|
||||
final oldKey = cache.keyFor(101, 202);
|
||||
final failure = expectLater(oldKey, throwsStateError);
|
||||
cache.clear();
|
||||
final newKey = cache.keyFor(101, 202);
|
||||
oldDerivation.completeError(StateError('synthetic derivation failure'));
|
||||
await failure;
|
||||
|
||||
final sharedKey = cache.keyFor(101, 202);
|
||||
expect(identical(sharedKey, newKey), isTrue);
|
||||
expect(derivations, 2);
|
||||
newDerivation.complete(Uint8List.fromList([2, 2, 2]));
|
||||
expect(await newKey, [2, 2, 2]);
|
||||
expect(await sharedKey, [2, 2, 2]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'shares pending keys and reuses completed keys by chat and account',
|
||||
() async {
|
||||
final derivation = Completer<Uint8List?>();
|
||||
var derivations = 0;
|
||||
final cache = ChatCryptoKeyCache((accountId, chatId) {
|
||||
derivations++;
|
||||
if (derivations == 1) return derivation.future;
|
||||
return Future.value(Uint8List.fromList([accountId, chatId]));
|
||||
}, wipe: _wipe);
|
||||
|
||||
final key = cache.keyFor(101, 202);
|
||||
expect(identical(cache.keyFor(101, 202), key), isTrue);
|
||||
derivation.complete(Uint8List.fromList([3, 3, 3]));
|
||||
expect(await key, [3, 3, 3]);
|
||||
expect(await cache.keyFor(101, 202), [3, 3, 3]);
|
||||
expect(derivations, 1);
|
||||
expect(await cache.keyFor(102, 202), [102, 202]);
|
||||
expect(await cache.keyFor(101, 203), [101, 203]);
|
||||
expect(derivations, 3);
|
||||
},
|
||||
);
|
||||
|
||||
test('retries a missing key instead of caching it', () async {
|
||||
var derivations = 0;
|
||||
final cache = ChatCryptoKeyCache((accountId, chatId) async {
|
||||
derivations++;
|
||||
return derivations == 1 ? null : Uint8List.fromList([4, 4, 4]);
|
||||
}, wipe: _wipe);
|
||||
|
||||
expect(await cache.keyFor(101, 202), isNull);
|
||||
expect(await cache.keyFor(101, 202), [4, 4, 4]);
|
||||
expect(derivations, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
'clear wipes cached keys and a derivation that finished too late',
|
||||
() async {
|
||||
final lateDerivation = Completer<Uint8List?>();
|
||||
final cached = Uint8List.fromList([5, 5, 5]);
|
||||
final late = Uint8List.fromList([6, 6, 6]);
|
||||
final cache = ChatCryptoKeyCache(
|
||||
(accountId, chatId) =>
|
||||
chatId == 202 ? Future.value(cached) : lateDerivation.future,
|
||||
wipe: _wipe,
|
||||
);
|
||||
|
||||
expect(await cache.keyFor(101, 202), [5, 5, 5]);
|
||||
final pending = cache.keyFor(101, 203);
|
||||
cache.clear();
|
||||
lateDerivation.complete(late);
|
||||
|
||||
expect(await pending, isNull);
|
||||
expect(cached, [0, 0, 0]);
|
||||
expect(late, [0, 0, 0]);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:qlyra_crypto/qlyra_crypto.dart' as kc;
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/backend/modules/outgoing_text.dart';
|
||||
import 'package:ikar/backend/modules/outbox_queue.dart';
|
||||
import 'package:komet_crypto/komet_crypto.dart';
|
||||
import 'package:ikar/core/crypto/chat_crypto_service.dart';
|
||||
import 'package:ikar/core/crypto/noise_png.dart';
|
||||
|
||||
final _libPath =
|
||||
Platform.environment['QLYRA_CRYPTO_LIBRARY'] ??
|
||||
'native/qlyra_crypto/rust/target/debug/${Platform.isWindows
|
||||
? 'qlyra_crypto.dll'
|
||||
: Platform.isMacOS
|
||||
? 'libqlyra_crypto.dylib'
|
||||
: 'libqlyra_crypto.so'}';
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
expect(
|
||||
File(_libPath).existsSync(),
|
||||
isTrue,
|
||||
reason:
|
||||
'Build native crypto with cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml',
|
||||
);
|
||||
await kc.RustLib.init(externalLibrary: ExternalLibrary.open(_libPath));
|
||||
});
|
||||
|
||||
test('round-trips through the native bridge', () async {
|
||||
final key = await kc.deriveKey(password: 'общий ключ');
|
||||
expect(key.length, 32);
|
||||
|
||||
const plaintext = 'встречаемся в 19:00 у метро';
|
||||
final encrypted = await kc.encryptMessage(plaintext: plaintext, key: key);
|
||||
|
||||
expect(encrypted, isNot(contains(RegExp(r'[a-zA-Z0-9]'))));
|
||||
expect(encrypted, contains(' '));
|
||||
expect(await kc.decryptMessage(text: encrypted, key: key), plaintext);
|
||||
});
|
||||
|
||||
test('derives the same key from the same password', () async {
|
||||
final a = await kc.deriveKey(password: 'один ключ');
|
||||
final b = await kc.deriveKey(password: 'один ключ');
|
||||
expect(a, b);
|
||||
});
|
||||
|
||||
test(
|
||||
'C message and photo envelopes remain compatible with ИКАР Rust legacy',
|
||||
() async {
|
||||
KometCrypto.libraryPath =
|
||||
Platform.environment['QLYRA_E2EE_LIBRARY'] ??
|
||||
'${Directory.current.path}/build/native-tests/crypto-core/Debug/komet_crypto.dll';
|
||||
expect(KometCrypto.isAvailable, isTrue);
|
||||
final key = await kc.deriveKey(password: 'legacy qlyra key');
|
||||
final old = await kc.encryptMessage(plaintext: 'old history', key: key);
|
||||
expect(KometCrypto.decryptMessage(old, key), 'old history');
|
||||
expect(
|
||||
await ChatCryptoService.instance.decryptWithPassword(
|
||||
old,
|
||||
'legacy qlyra key',
|
||||
),
|
||||
'old history',
|
||||
);
|
||||
final current = KometCrypto.encryptMessage('new legacy message', key);
|
||||
expect(
|
||||
await kc.decryptMessage(text: current, key: key),
|
||||
'new legacy message',
|
||||
);
|
||||
final temp = await Directory.systemTemp.createTemp('qlyra-legacy-photo-');
|
||||
try {
|
||||
final source = File('${temp.path}/source');
|
||||
await source.writeAsBytes([1, 2, 3, 4]);
|
||||
final cipher = File('${temp.path}/old.png');
|
||||
await kc.encryptImageFile(
|
||||
sourcePath: source.path,
|
||||
destPath: cipher.path,
|
||||
key: key,
|
||||
);
|
||||
expect(
|
||||
KometCrypto.decryptImageBlob(
|
||||
unwrapNoisePng(await cipher.readAsBytes())!,
|
||||
key,
|
||||
),
|
||||
[1, 2, 3, 4],
|
||||
);
|
||||
final wrapped = wrapNoisePng(
|
||||
KometCrypto.encryptImageBlob(await source.readAsBytes(), key),
|
||||
)!;
|
||||
await cipher.writeAsBytes(wrapped);
|
||||
final restored = File('${temp.path}/restored');
|
||||
await kc.decryptImageFile(
|
||||
sourcePath: cipher.path,
|
||||
destPath: restored.path,
|
||||
key: key,
|
||||
);
|
||||
expect(await restored.readAsBytes(), [1, 2, 3, 4]);
|
||||
} finally {
|
||||
await temp.delete(recursive: true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('native ciphertext survives queued retry after restart', () async {
|
||||
final key = await kc.deriveKey(password: 'synthetic test key');
|
||||
const plaintext = 'synthetic private message';
|
||||
final cipher = await kc.encryptMessage(plaintext: plaintext, key: key);
|
||||
final original = OutgoingText.prepare(
|
||||
accountId: 42,
|
||||
chatId: 7,
|
||||
wireText: cipher,
|
||||
encrypted: true,
|
||||
);
|
||||
CachedMessage? stored;
|
||||
var fail = true;
|
||||
var clock = DateTime(2026);
|
||||
final requests = <(String, int)>[];
|
||||
OutboxQueue create() => OutboxQueue(
|
||||
activeAccount: () async => 42,
|
||||
isOnline: () => true,
|
||||
sessionEpoch: () => 1,
|
||||
now: () => clock,
|
||||
load: (_) async => stored?.status == 'pending' ? [stored!] : [],
|
||||
save: (message) async {
|
||||
stored = CachedMessage.fromDbRow(message.toDbRow());
|
||||
},
|
||||
updatePending: (m) async {
|
||||
stored = CachedMessage.fromDbRow(m.toDbRow());
|
||||
return true;
|
||||
},
|
||||
replace: (_, sent) async {
|
||||
stored = sent;
|
||||
},
|
||||
send: (message, cid) async {
|
||||
requests.add((message.text!, cid));
|
||||
if (fail) throw TimeoutException('Response lost');
|
||||
return 'confirmed';
|
||||
},
|
||||
onChanged: (_, _) {},
|
||||
onError: (_, _) {},
|
||||
);
|
||||
final first = create();
|
||||
await first.enqueue(original);
|
||||
await first.flush();
|
||||
first.dispose();
|
||||
expect(stored!.text, isNot(plaintext));
|
||||
fail = false;
|
||||
clock = clock.add(const Duration(minutes: 1));
|
||||
final restarted = create();
|
||||
addTearDown(restarted.dispose);
|
||||
await restarted.flush();
|
||||
expect(requests, hasLength(2));
|
||||
expect(requests.first, requests.last);
|
||||
expect(
|
||||
await kc.decryptMessage(text: requests.last.$1, key: key),
|
||||
plaintext,
|
||||
);
|
||||
expect(stored!.status, 'sent');
|
||||
});
|
||||
|
||||
test('rejects a wrong key', () async {
|
||||
final key = await kc.deriveKey(password: 'правильный');
|
||||
final wrong = await kc.deriveKey(password: 'неправильный');
|
||||
final encrypted = await kc.encryptMessage(plaintext: 'секрет', key: key);
|
||||
|
||||
expect(
|
||||
() => kc.decryptMessage(text: encrypted, key: wrong),
|
||||
throwsA(predicate((e) => e.toString().contains('wrong_key'))),
|
||||
);
|
||||
});
|
||||
|
||||
test('reports plain text as not encrypted', () async {
|
||||
final key = await kc.deriveKey(password: 'ключ');
|
||||
expect(await kc.looksEncrypted(text: 'привет как дела'), isFalse);
|
||||
expect(
|
||||
() => kc.decryptMessage(text: 'привет как дела', key: key),
|
||||
throwsA(predicate((e) => e.toString().contains('not_encrypted'))),
|
||||
);
|
||||
});
|
||||
|
||||
group('images', _imageTests);
|
||||
|
||||
test('survives whitespace mangling', () async {
|
||||
final key = await kc.deriveKey(password: 'ключ');
|
||||
final encrypted = await kc.encryptMessage(
|
||||
plaintext: 'пробелы декоративные',
|
||||
key: key,
|
||||
);
|
||||
final mangled = ' ${encrypted.replaceAll(' ', ' ')}\n';
|
||||
expect(
|
||||
await kc.decryptMessage(text: mangled, key: key),
|
||||
'пробелы декоративные',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const List<int> _tinyPng = [
|
||||
137,
|
||||
80,
|
||||
78,
|
||||
71,
|
||||
13,
|
||||
10,
|
||||
26,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
73,
|
||||
72,
|
||||
68,
|
||||
82,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
8,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
38,
|
||||
147,
|
||||
9,
|
||||
41,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
63,
|
||||
73,
|
||||
68,
|
||||
65,
|
||||
84,
|
||||
120,
|
||||
156,
|
||||
1,
|
||||
52,
|
||||
0,
|
||||
203,
|
||||
255,
|
||||
0,
|
||||
0,
|
||||
40,
|
||||
80,
|
||||
120,
|
||||
160,
|
||||
200,
|
||||
240,
|
||||
24,
|
||||
64,
|
||||
104,
|
||||
144,
|
||||
184,
|
||||
0,
|
||||
17,
|
||||
57,
|
||||
97,
|
||||
137,
|
||||
177,
|
||||
217,
|
||||
1,
|
||||
41,
|
||||
81,
|
||||
121,
|
||||
161,
|
||||
201,
|
||||
0,
|
||||
34,
|
||||
74,
|
||||
114,
|
||||
154,
|
||||
194,
|
||||
234,
|
||||
18,
|
||||
58,
|
||||
98,
|
||||
138,
|
||||
178,
|
||||
218,
|
||||
0,
|
||||
51,
|
||||
91,
|
||||
131,
|
||||
171,
|
||||
211,
|
||||
251,
|
||||
35,
|
||||
75,
|
||||
115,
|
||||
155,
|
||||
195,
|
||||
235,
|
||||
36,
|
||||
246,
|
||||
23,
|
||||
9,
|
||||
123,
|
||||
15,
|
||||
58,
|
||||
142,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
73,
|
||||
69,
|
||||
78,
|
||||
68,
|
||||
174,
|
||||
66,
|
||||
96,
|
||||
130,
|
||||
];
|
||||
|
||||
void _imageTests() {
|
||||
late Directory tmp;
|
||||
|
||||
setUp(() => tmp = Directory.systemTemp.createTempSync('qlyra_img'));
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
File writePlain() =>
|
||||
File('${tmp.path}/plain.png')..writeAsBytesSync(_tinyPng);
|
||||
|
||||
test('round-trips a photo through the native bridge', () async {
|
||||
final key = await kc.deriveKey(password: 'фото-ключ');
|
||||
final plain = writePlain();
|
||||
final enc = '${tmp.path}/enc.png';
|
||||
final out = '${tmp.path}/out.png';
|
||||
|
||||
await kc.encryptImageFile(sourcePath: plain.path, destPath: enc, key: key);
|
||||
|
||||
final encBytes = File(enc).readAsBytesSync();
|
||||
expect(encBytes.sublist(1, 4), 'PNG'.codeUnits);
|
||||
expect(encBytes, isNot(_tinyPng));
|
||||
expect(await kc.looksEncryptedImageFile(path: enc), isTrue);
|
||||
expect(await kc.looksEncryptedImageFile(path: plain.path), isFalse);
|
||||
|
||||
await kc.decryptImageFile(sourcePath: enc, destPath: out, key: key);
|
||||
expect(File(out).readAsBytesSync(), _tinyPng);
|
||||
});
|
||||
|
||||
test('rejects a photo decrypted with a wrong key', () async {
|
||||
final key = await kc.deriveKey(password: 'правильный');
|
||||
final wrong = await kc.deriveKey(password: 'неправильный');
|
||||
final enc = '${tmp.path}/enc.png';
|
||||
|
||||
await kc.encryptImageFile(
|
||||
sourcePath: writePlain().path,
|
||||
destPath: enc,
|
||||
key: key,
|
||||
);
|
||||
expect(
|
||||
() => kc.decryptImageFile(
|
||||
sourcePath: enc,
|
||||
destPath: '${tmp.path}/out.png',
|
||||
key: wrong,
|
||||
),
|
||||
throwsA(predicate((e) => e.toString().contains('wrong_key'))),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chat_parsing.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
import 'package:ikar/models/chat_info.dart';
|
||||
|
||||
void main() {
|
||||
const myId = 7;
|
||||
|
||||
ChatInfo channel({
|
||||
required String access,
|
||||
Map<String, dynamic> admins = const {},
|
||||
Map<String, dynamic> options = const {},
|
||||
}) {
|
||||
return ChatInfo.fromMap({
|
||||
'id': -100,
|
||||
'type': 'CHANNEL',
|
||||
'owner': 0,
|
||||
'participants': {'$myId': 1},
|
||||
'adminParticipants': admins,
|
||||
'link': 'https://example.test/synthetic_channel',
|
||||
'access': access,
|
||||
'options': options,
|
||||
});
|
||||
}
|
||||
|
||||
group('ChatInfo.canSeeLink', () {
|
||||
test('shows the public link to a plain subscriber', () {
|
||||
final info = channel(access: 'PUBLIC');
|
||||
|
||||
expect(info.isPublic, isTrue);
|
||||
expect(info.canSeeInviteLink(myId), isFalse);
|
||||
expect(info.canSeeLink(myId), isTrue);
|
||||
});
|
||||
|
||||
test('hides the private invite link from a plain subscriber', () {
|
||||
final info = channel(access: 'PRIVATE');
|
||||
|
||||
expect(info.isPublic, isFalse);
|
||||
expect(info.canSeeLink(myId), isFalse);
|
||||
});
|
||||
|
||||
test('shows the private invite link to an admin', () {
|
||||
final info = channel(
|
||||
access: 'PRIVATE',
|
||||
admins: {
|
||||
'$myId': {'permissions': 0},
|
||||
},
|
||||
);
|
||||
|
||||
expect(info.canSeeLink(myId), isTrue);
|
||||
});
|
||||
|
||||
test('shows the private invite link when members may see it', () {
|
||||
final info = channel(
|
||||
access: 'PRIVATE',
|
||||
options: {'MEMBERS_CAN_SEE_PRIVATE_LINK': true},
|
||||
);
|
||||
|
||||
expect(info.canSeeLink(myId), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('CachedChat.publicLink', () {
|
||||
CachedChat parse(Map<String, dynamic> chat, {CachedChat? previous}) =>
|
||||
parseChatRow(
|
||||
chat,
|
||||
myId,
|
||||
myId,
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
previous == null ? const {} : {previous.id: previous},
|
||||
0,
|
||||
)!;
|
||||
|
||||
Map<String, dynamic> channelPayload({String? access}) => {
|
||||
'id': -100,
|
||||
'type': 'CHANNEL',
|
||||
'title': 'Synthetic channel',
|
||||
'participants': {'$myId': 1},
|
||||
'link': 'https://example.test/synthetic_channel',
|
||||
'access': ?access,
|
||||
};
|
||||
|
||||
test('keeps the link of a public chat', () {
|
||||
expect(
|
||||
parse(channelPayload(access: 'PUBLIC')).publicLink,
|
||||
'https://example.test/synthetic_channel',
|
||||
);
|
||||
});
|
||||
|
||||
test('never treats a private invite link as public', () {
|
||||
expect(parse(channelPayload(access: 'PRIVATE')).publicLink, isNull);
|
||||
});
|
||||
|
||||
test('keeps the known link when the payload says nothing', () {
|
||||
final previous = parse(channelPayload(access: 'PUBLIC'));
|
||||
|
||||
expect(
|
||||
parse(channelPayload(), previous: previous).publicLink,
|
||||
'https://example.test/synthetic_channel',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/models/attachment.dart';
|
||||
import 'package:ikar/models/animoji.dart';
|
||||
|
||||
void main() {
|
||||
for (final attachment in <MessageAttachment>[
|
||||
const AudioAttachment(duration: 11000),
|
||||
const VideoAttachment(width: 720, height: 1280),
|
||||
const VideoAttachment(videoType: 1),
|
||||
const PhotoAttachment(width: 0, height: 0),
|
||||
const ForwardedMessageAttachment(
|
||||
originalSenderId: 4,
|
||||
originalAttachments: [AudioAttachment(duration: 11000)],
|
||||
),
|
||||
const ForwardedMessageAttachment(
|
||||
originalSenderId: 4,
|
||||
originalAttachments: [VideoAttachment(width: 720, height: 1280)],
|
||||
),
|
||||
]) {
|
||||
for (final count
|
||||
in attachment is PhotoAttachment ||
|
||||
(attachment is VideoAttachment && !attachment.isNote)
|
||||
? [1, 2, 3, 4]
|
||||
: [1]) {
|
||||
for (final reacted in [false, true]) {
|
||||
testWidgets(
|
||||
'media remain finite, count=$count reactions=$reacted type=${attachment.runtimeType} note=${attachment is VideoAttachment ? attachment.videoType : null}',
|
||||
(tester) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ListView.builder(
|
||||
controller: controller,
|
||||
reverse: true,
|
||||
scrollCacheExtent: const ScrollCacheExtent.pixels(5000),
|
||||
itemCount: 30,
|
||||
itemBuilder: (_, index) => MessageBubble(
|
||||
message: CachedMessage(
|
||||
id: '$index',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 3,
|
||||
time: 1000,
|
||||
status: 'sent',
|
||||
attachments: List.filled(count, attachment),
|
||||
payload: reacted
|
||||
? {
|
||||
'reactionInfo': {
|
||||
'totalCount': 1,
|
||||
'counters': [
|
||||
{'reaction': '🔥', 'count': 1},
|
||||
],
|
||||
},
|
||||
}
|
||||
: null,
|
||||
),
|
||||
isMe: false,
|
||||
myId: 1,
|
||||
chatType: 'DIALOG',
|
||||
reactionAnimojiResolver: (emoji) => Animoji(
|
||||
id: 1,
|
||||
emoji: emoji,
|
||||
iconUrl: 'https://example.com/a.png',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(controller.position.maxScrollExtent.isFinite, isTrue);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 1200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(controller.position.pixels.isFinite, isTrue);
|
||||
expect(find.byType(MessageBubble), findsWidgets);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:ikar/core/storage/chat_activity_store.dart';
|
||||
import 'package:ikar/core/storage/chat_members_store.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/typing_label.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
|
||||
const int _chatId = 900001;
|
||||
const int _alice = 900101;
|
||||
const int _bob = 900102;
|
||||
const int _carol = 900103;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
setUp(() {
|
||||
ChatMembersStore.instance.clear();
|
||||
ChatActivityStore.instance.clearChat(_chatId);
|
||||
ContactCache.clear();
|
||||
});
|
||||
|
||||
group('ChatMembersStore', () {
|
||||
test('счётчик читается из одного места и уведомляет слушателей', () {
|
||||
final seen = <int?>[];
|
||||
final listenable = ChatMembersStore.instance.listenable(_chatId);
|
||||
listenable.addListener(() => seen.add(listenable.value));
|
||||
|
||||
ChatMembersStore.instance.setCount(_chatId, 5);
|
||||
ChatMembersStore.instance.setCount(_chatId, 5);
|
||||
ChatMembersStore.instance.adjust(_chatId, 2);
|
||||
|
||||
expect(ChatMembersStore.instance.count(_chatId), 7);
|
||||
expect(seen, [5, 7]);
|
||||
});
|
||||
|
||||
test('adjust не опускает счётчик ниже нуля', () {
|
||||
ChatMembersStore.instance.setCount(_chatId, 1);
|
||||
ChatMembersStore.instance.adjust(_chatId, -5);
|
||||
expect(ChatMembersStore.instance.count(_chatId), 0);
|
||||
});
|
||||
|
||||
test('adjust без известного значения ничего не выдумывает', () {
|
||||
ChatMembersStore.instance.adjust(_chatId, 3);
|
||||
expect(ChatMembersStore.instance.count(_chatId), isNull);
|
||||
});
|
||||
|
||||
test('payload чата с сервера заполняет счётчик', () {
|
||||
ChatMembersStore.instance.applyChatPayload({
|
||||
'id': _chatId,
|
||||
'participantsCount': 12,
|
||||
});
|
||||
expect(ChatMembersStore.instance.count(_chatId), 12);
|
||||
});
|
||||
});
|
||||
|
||||
group('Подпись «печатает»', () {
|
||||
final ru = lookupAppLocalizations(const Locale('ru'));
|
||||
|
||||
ChatActivitySnapshot snapshot(List<int> ids) {
|
||||
for (final id in ids) {
|
||||
ChatActivityStore.instance.mark(_chatId, id, ChatActivity.typing);
|
||||
}
|
||||
return ChatActivityStore.instance.snapshot(_chatId)!;
|
||||
}
|
||||
|
||||
test('в диалоге остаётся безымянная подпись', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
expect(chatActivityLabel(ru, snapshot([_alice])), 'Печатает...');
|
||||
});
|
||||
|
||||
test('в группе показывает имя печатающего', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
expect(
|
||||
chatActivityLabel(ru, snapshot([_alice]), withNames: true),
|
||||
'Алиса печатает...',
|
||||
);
|
||||
});
|
||||
|
||||
test('двое печатающих перечисляются', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
ContactCache.put(_bob, 'Борис');
|
||||
expect(
|
||||
chatActivityLabel(ru, snapshot([_alice, _bob]), withNames: true),
|
||||
'Алиса и Борис печатают...',
|
||||
);
|
||||
});
|
||||
|
||||
test('трое и больше сворачиваются в «и ещё N»', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
ContactCache.put(_bob, 'Борис');
|
||||
ContactCache.put(_carol, 'Вера');
|
||||
expect(
|
||||
chatActivityLabel(
|
||||
ru,
|
||||
snapshot([_alice, _bob, _carol]),
|
||||
withNames: true,
|
||||
),
|
||||
'Алиса и ещё 2 печатают...',
|
||||
);
|
||||
});
|
||||
|
||||
test('без известного имени откатывается к общей подписи', () {
|
||||
expect(
|
||||
chatActivityLabel(ru, snapshot([_alice]), withNames: true),
|
||||
'Печатает...',
|
||||
);
|
||||
});
|
||||
|
||||
test('стикеры получают свой глагол', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
ChatActivityStore.instance.mark(_chatId, _alice, ChatActivity.sticker);
|
||||
final snap = ChatActivityStore.instance.snapshot(_chatId)!;
|
||||
expect(
|
||||
chatActivityLabel(ru, snap, withNames: true),
|
||||
'Алиса выбирает стикер...',
|
||||
);
|
||||
});
|
||||
|
||||
test('снимок отдаёт только пользователей ведущей активности', () {
|
||||
ChatActivityStore.instance.mark(_chatId, _alice, ChatActivity.sticker);
|
||||
ChatActivityStore.instance.mark(_chatId, _bob, ChatActivity.typing);
|
||||
final snap = ChatActivityStore.instance.snapshot(_chatId)!;
|
||||
expect(snap.activity, ChatActivity.typing);
|
||||
expect(snap.userIds, [_bob]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
|
||||
void main() {
|
||||
test('chat list state distinguishes previews from memberships', () {
|
||||
expect(AppDatabase.chatRowIsInList({'in_list': 0}), isFalse);
|
||||
expect(AppDatabase.chatRowIsInList({'in_list': 1}), isTrue);
|
||||
expect(AppDatabase.chatRowIsInList({'in_list': 2}), isTrue);
|
||||
expect(AppDatabase.chatRowIsInList({}), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chat_parsing.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
|
||||
const int _me = 4242;
|
||||
const int _peer = 7331;
|
||||
const int _chatId = -1000000000001;
|
||||
|
||||
const int _readMark = 1700000000000;
|
||||
const int _mentionTime = 1700000005000;
|
||||
const int _lastMsgTime = 1700000010000;
|
||||
|
||||
const int _mentionId = 111411200327680777;
|
||||
const int _lastMsgId = 111411200655360012;
|
||||
const int _oldMentionId = 111411196067840005;
|
||||
|
||||
CachedChat _chat({int? mentionId, int readMark = _readMark, int unread = 2}) =>
|
||||
CachedChat(
|
||||
id: _chatId,
|
||||
accountId: _me,
|
||||
type: 'CHAT',
|
||||
unreadCount: unread,
|
||||
lastEventTime: _lastMsgTime,
|
||||
cachedAt: 0,
|
||||
dontDisturbUntil: 0,
|
||||
isOnline: false,
|
||||
seenTime: 0,
|
||||
participants: {_me: readMark, _peer: _lastMsgTime},
|
||||
lastMentionMsgId: mentionId,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('message id', () {
|
||||
test('carries the timestamp in its high bits', () {
|
||||
expect(messageIdToTime(_mentionId), _mentionTime);
|
||||
expect(messageIdToTime(_lastMsgId), _lastMsgTime);
|
||||
expect(messageIdToTime(_oldMentionId), _readMark - 60000);
|
||||
});
|
||||
});
|
||||
|
||||
group('hasUnreadMention', () {
|
||||
test('is set when the mention is newer than my read mark', () {
|
||||
expect(_chat(mentionId: _mentionId).hasUnreadMention, isTrue);
|
||||
});
|
||||
|
||||
test('stays off for a mention I have already read', () {
|
||||
expect(_chat(mentionId: _oldMentionId).hasUnreadMention, isFalse);
|
||||
});
|
||||
|
||||
test('clears once the read mark passes the mention', () {
|
||||
final read = _chat(
|
||||
mentionId: _mentionId,
|
||||
readMark: _lastMsgTime,
|
||||
unread: 0,
|
||||
);
|
||||
expect(read.hasUnreadMention, isFalse);
|
||||
});
|
||||
|
||||
test('is false without a mention id', () {
|
||||
expect(_chat().hasUnreadMention, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseChatRow', () {
|
||||
CachedChat parse(Map<String, dynamic> chat) => parseChatRow(
|
||||
chat,
|
||||
_me,
|
||||
_me,
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
0,
|
||||
)!;
|
||||
|
||||
test('reads lastMentionMessageId from the server chat', () {
|
||||
final chat = parse({
|
||||
'id': _chatId,
|
||||
'type': 'CHAT',
|
||||
'title': 'test mention',
|
||||
'newMessages': 2,
|
||||
'lastEventTime': _lastMsgTime,
|
||||
'participants': {'$_me': _readMark},
|
||||
'lastMentionMessageId': '$_mentionId',
|
||||
});
|
||||
|
||||
expect(chat.lastMentionMsgId, _mentionId);
|
||||
expect(chat.hasUnreadMention, isTrue);
|
||||
});
|
||||
|
||||
test('a chat without mentions keeps the badge off', () {
|
||||
final chat = parse({
|
||||
'id': _chatId,
|
||||
'type': 'CHAT',
|
||||
'title': 'test',
|
||||
'lastEventTime': _lastMsgTime,
|
||||
'participants': {'$_me': _readMark},
|
||||
});
|
||||
|
||||
expect(chat.lastMentionMsgId, isNull);
|
||||
expect(chat.hasUnreadMention, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('messageMentionsUser', () {
|
||||
test('matches a USER_MENTION addressed to me', () {
|
||||
expect(
|
||||
messageMentionsUser(const {
|
||||
'elements': [
|
||||
{'type': 'USER_MENTION', 'entityId': _me, 'length': 15},
|
||||
],
|
||||
}, _me),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores a mention of somebody else', () {
|
||||
expect(
|
||||
messageMentionsUser(const {
|
||||
'elements': [
|
||||
{'type': 'USER_MENTION', 'entityId': _peer, 'length': 15},
|
||||
],
|
||||
}, _me),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores messages without elements', () {
|
||||
expect(messageMentionsUser(const {'text': 'privet'}, _me), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/widgets/chat_menu_overlay.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('a disabled menu item is shown but does not fire', (
|
||||
tester,
|
||||
) async {
|
||||
var disabledTaps = 0;
|
||||
var enabledTaps = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => Center(
|
||||
child: TextButton(
|
||||
onPressed: () => showChatMenu(
|
||||
context: context,
|
||||
anchorRect: const Rect.fromLTWH(100, 100, 40, 40),
|
||||
compact: true,
|
||||
items: [
|
||||
ChatMenuItem(
|
||||
icon: Symbols.motion_photos_on,
|
||||
label: 'Synthetic disabled',
|
||||
enabled: false,
|
||||
onTap: () => disabledTaps++,
|
||||
),
|
||||
ChatMenuItem(
|
||||
icon: Symbols.arrow_split,
|
||||
label: 'Synthetic enabled',
|
||||
onTap: () => enabledTaps++,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Synthetic disabled'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Synthetic disabled'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(disabledTaps, 0);
|
||||
expect(find.text('Synthetic disabled'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Synthetic enabled'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(enabledTaps, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
final String directory;
|
||||
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
const _accountId = 1;
|
||||
|
||||
Map<String, dynamic> _dialogCard(
|
||||
int chatId,
|
||||
int peerId, {
|
||||
String status = 'ACTIVE',
|
||||
}) => {
|
||||
'id': chatId,
|
||||
'type': 'DIALOG',
|
||||
'status': status,
|
||||
'participants': {'$_accountId': 0, '$peerId': 0},
|
||||
};
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_outgoing_preview_test',
|
||||
);
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
await AppDatabase.init();
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: _accountId,
|
||||
firstName: 'Synthetic owner',
|
||||
phone: 100000,
|
||||
country: 'ZZ',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> openNewDialog(int chatId, int peerId) async {
|
||||
await chats.cacheServerChat(
|
||||
_dialogCard(chatId, peerId),
|
||||
_accountId,
|
||||
inList: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<CachedChat> chatRow(int chatId) async =>
|
||||
(await chats.getChat(_accountId, chatId)).single;
|
||||
|
||||
test(
|
||||
'a confirmation dated before the phone clock still clears the clock',
|
||||
() async {
|
||||
const chatId = 900101;
|
||||
await openNewDialog(chatId, 40);
|
||||
|
||||
await chats.applyOutgoing(
|
||||
_accountId,
|
||||
chatId,
|
||||
messageId: 'temp_1',
|
||||
time: 2000,
|
||||
text: 'Synthetic ping',
|
||||
status: 'sending',
|
||||
);
|
||||
await chats.applyOutgoing(
|
||||
_accountId,
|
||||
chatId,
|
||||
messageId: '601',
|
||||
time: 1990,
|
||||
text: 'Synthetic ping',
|
||||
status: 'sent',
|
||||
replacesTime: 2000,
|
||||
);
|
||||
|
||||
final chat = await chatRow(chatId);
|
||||
expect(chat.lastMsgStatus, 'sent');
|
||||
expect(chat.lastMsgId, 601);
|
||||
expect(chat.lastMsgTime, 1990);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'confirming an earlier message keeps a newer one still sending',
|
||||
() async {
|
||||
const chatId = 900102;
|
||||
await openNewDialog(chatId, 41);
|
||||
|
||||
await chats.applyOutgoing(
|
||||
_accountId,
|
||||
chatId,
|
||||
messageId: 'temp_1',
|
||||
time: 2000,
|
||||
text: 'Synthetic first',
|
||||
status: 'sending',
|
||||
);
|
||||
await chats.applyOutgoing(
|
||||
_accountId,
|
||||
chatId,
|
||||
messageId: 'temp_2',
|
||||
time: 2005,
|
||||
text: 'Synthetic second',
|
||||
status: 'sending',
|
||||
);
|
||||
await chats.applyOutgoing(
|
||||
_accountId,
|
||||
chatId,
|
||||
messageId: '602',
|
||||
time: 1995,
|
||||
text: 'Synthetic first',
|
||||
status: 'sent',
|
||||
replacesTime: 2000,
|
||||
);
|
||||
|
||||
final chat = await chatRow(chatId);
|
||||
expect(chat.lastMsgText, 'Synthetic second');
|
||||
expect(chat.lastMsgStatus, 'sending');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chat_parsing.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
|
||||
const int _me = 4242;
|
||||
const int _chatId = -1000000000001;
|
||||
|
||||
CachedChat _cached({int? favIndex}) => CachedChat(
|
||||
id: _chatId,
|
||||
accountId: _me,
|
||||
type: 'CHAT',
|
||||
title: 'pinned chat',
|
||||
unreadCount: 0,
|
||||
lastEventTime: 1700000000000,
|
||||
cachedAt: 0,
|
||||
favIndex: favIndex,
|
||||
dontDisturbUntil: 0,
|
||||
isOnline: false,
|
||||
seenTime: 0,
|
||||
participants: {_me: 1700000000000},
|
||||
);
|
||||
|
||||
CachedChat _parse({
|
||||
Map<dynamic, dynamic> chatsConfig = const {},
|
||||
CachedChat? existing,
|
||||
}) => parseChatRow(
|
||||
{
|
||||
'id': _chatId,
|
||||
'type': 'CHAT',
|
||||
'title': 'pinned chat',
|
||||
'lastEventTime': 1700000000000,
|
||||
'participants': {'$_me': 1700000000000},
|
||||
},
|
||||
_me,
|
||||
_me,
|
||||
const {},
|
||||
chatsConfig,
|
||||
const {},
|
||||
existing == null ? const {} : {_chatId: existing},
|
||||
0,
|
||||
)!;
|
||||
|
||||
void main() {
|
||||
group('login sync keeps pins', () {
|
||||
test('a zero favIndex in the config does not unpin a cached chat', () {
|
||||
final parsed = _parse(
|
||||
chatsConfig: {
|
||||
'$_chatId': {'favIndex': 0, 'dontDisturbUntil': 0},
|
||||
},
|
||||
existing: _cached(favIndex: 3),
|
||||
);
|
||||
expect(parsed.favIndex, 3);
|
||||
});
|
||||
|
||||
test('a real favIndex from the config wins', () {
|
||||
final parsed = _parse(
|
||||
chatsConfig: {
|
||||
'$_chatId': {'favIndex': 2, 'dontDisturbUntil': 0},
|
||||
},
|
||||
existing: _cached(favIndex: 3),
|
||||
);
|
||||
expect(parsed.favIndex, 2);
|
||||
});
|
||||
|
||||
test('mute settings still come from the config', () {
|
||||
final parsed = _parse(
|
||||
chatsConfig: {
|
||||
'$_chatId': {'favIndex': 0, 'dontDisturbUntil': -1},
|
||||
},
|
||||
existing: _cached(favIndex: 3),
|
||||
);
|
||||
expect(parsed.dontDisturbUntil, -1);
|
||||
expect(parsed.isMuted, isTrue);
|
||||
});
|
||||
|
||||
test('a chat with no cached pin stays unpinned', () {
|
||||
final parsed = _parse(
|
||||
chatsConfig: {
|
||||
'$_chatId': {'favIndex': 0, 'dontDisturbUntil': 0},
|
||||
},
|
||||
);
|
||||
expect(parsed.favIndex, isNull);
|
||||
});
|
||||
|
||||
test('without a config entry the cached pin survives', () {
|
||||
expect(_parse(existing: _cached(favIndex: 5)).favIndex, 5);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/view/chat_preview_line.dart';
|
||||
import 'package:ikar/models/chat_preview_media.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
const String _pixel =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'
|
||||
'AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
const TextStyle _style = TextStyle(fontSize: 14, height: 1.2);
|
||||
|
||||
Widget _host(Widget child) => MaterialApp(
|
||||
home: Scaffold(body: Center(child: child)),
|
||||
);
|
||||
|
||||
String _plainText(WidgetTester tester) {
|
||||
final span = tester.widget<Text>(find.byType(Text).first).textSpan!;
|
||||
final buffer = StringBuffer();
|
||||
span.visitChildren((child) {
|
||||
if (child is TextSpan && child.text != null) buffer.write(child.text);
|
||||
return true;
|
||||
});
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
List<TextSpan> _spans(WidgetTester tester) {
|
||||
final span = tester.widget<Text>(find.byType(Text).first).textSpan!;
|
||||
final result = <TextSpan>[];
|
||||
span.visitChildren((child) {
|
||||
if (child is TextSpan && child.text != null) result.add(child);
|
||||
return true;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('фото без подписи: миниатюра и курсивная подпись', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const ChatPreviewLine(
|
||||
text: 'Изображение',
|
||||
style: _style,
|
||||
media: ChatPreviewMedia(
|
||||
kind: ChatPreviewKind.photo,
|
||||
thumbs: [ChatPreviewThumb(source: _pixel)],
|
||||
label: 'Изображение',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.play_arrow), findsNothing);
|
||||
expect(_plainText(tester), 'Изображение');
|
||||
expect(_spans(tester).single.style?.fontStyle, FontStyle.italic);
|
||||
});
|
||||
|
||||
testWidgets('фото с подписью: миниатюра и обычный текст', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const ChatPreviewLine(
|
||||
prefix: 'Кто-то: ',
|
||||
text: 'смотри',
|
||||
style: _style,
|
||||
media: ChatPreviewMedia(
|
||||
kind: ChatPreviewKind.photo,
|
||||
thumbs: [ChatPreviewThumb(source: _pixel)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
expect(_plainText(tester), 'Кто-то: смотри');
|
||||
expect(_spans(tester).last.style?.fontStyle, isNot(FontStyle.italic));
|
||||
});
|
||||
|
||||
testWidgets('видео помечается иконкой проигрывания на миниатюре', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const ChatPreviewLine(
|
||||
text: 'Видео',
|
||||
style: _style,
|
||||
media: ChatPreviewMedia(
|
||||
kind: ChatPreviewKind.video,
|
||||
thumbs: [
|
||||
ChatPreviewThumb(source: _pixel, video: true),
|
||||
ChatPreviewThumb(source: _pixel),
|
||||
],
|
||||
label: 'Видео',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(Image), findsNWidgets(2));
|
||||
expect(find.byIcon(Symbols.play_arrow), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('файл: иконка вместо слова и имя после двоеточия', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const ChatPreviewLine(
|
||||
text: 'Файл: notes.pdf',
|
||||
style: _style,
|
||||
media: ChatPreviewMedia(
|
||||
kind: ChatPreviewKind.file,
|
||||
label: 'Файл',
|
||||
detail: 'notes.pdf',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Symbols.description), findsOneWidget);
|
||||
expect(_plainText(tester), ': notes.pdf');
|
||||
});
|
||||
|
||||
testWidgets('специфичная подпись идёт с иконкой и курсивом', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const ChatPreviewLine(
|
||||
text: 'Пропущенный звонок',
|
||||
style: _style,
|
||||
media: ChatPreviewMedia(
|
||||
kind: ChatPreviewKind.missedCall,
|
||||
label: 'Пропущенный звонок',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Symbols.call_missed), findsOneWidget);
|
||||
expect(_plainText(tester), 'Пропущенный звонок');
|
||||
expect(_spans(tester).single.style?.fontStyle, FontStyle.italic);
|
||||
});
|
||||
|
||||
testWidgets('метка пересылки остаётся перед иконкой', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const ChatPreviewLine(
|
||||
text: '↪ Контакт',
|
||||
style: _style,
|
||||
media: ChatPreviewMedia(
|
||||
kind: ChatPreviewKind.contact,
|
||||
label: '↪ Контакт',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Symbols.person), findsOneWidget);
|
||||
expect(_plainText(tester), '↪ Контакт');
|
||||
});
|
||||
|
||||
testWidgets('ссылка с текстом сообщения не тащит иконку', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const ChatPreviewLine(
|
||||
text: 'глянь qlyra.ru',
|
||||
style: _style,
|
||||
media: ChatPreviewMedia(kind: ChatPreviewKind.share),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Symbols.link), findsNothing);
|
||||
expect(_plainText(tester), 'глянь qlyra.ru');
|
||||
});
|
||||
|
||||
testWidgets('без описания вложения строка остаётся обычным текстом', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(const ChatPreviewLine(text: 'привет', style: _style)),
|
||||
);
|
||||
|
||||
expect(find.byType(Image), findsNothing);
|
||||
expect(_plainText(tester), 'привет');
|
||||
});
|
||||
|
||||
test('иконка типа чата зависит от вида чата', () {
|
||||
expect(chatKindIcon('CHANNEL', isBot: false), Symbols.campaign);
|
||||
expect(chatKindIcon('CHAT', isBot: false), Symbols.group);
|
||||
expect(chatKindIcon('GROUP', isBot: false), Symbols.group);
|
||||
expect(chatKindIcon('DIALOG', isBot: true), Symbols.smart_toy);
|
||||
expect(chatKindIcon('DIALOG', isBot: false), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chat_preview.dart';
|
||||
import 'package:ikar/models/chat_preview_media.dart';
|
||||
|
||||
const String _thumbA = 'data:image/webp;base64,AAAA';
|
||||
const String _thumbB = 'data:image/webp;base64,BBBB';
|
||||
const String _thumbC = 'data:image/webp;base64,CCCC';
|
||||
const String _thumbD = 'data:image/webp;base64,DDDD';
|
||||
|
||||
Map<String, dynamic> _photo(String preview) => {
|
||||
'_type': 'PHOTO',
|
||||
'previewData': preview,
|
||||
'photoId': 1,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _video(String preview) => {
|
||||
'_type': 'VIDEO',
|
||||
'previewData': preview,
|
||||
'videoId': 2,
|
||||
};
|
||||
|
||||
ChatPreviewMedia _media(Map<String, dynamic> msg) {
|
||||
final encoded = messagePreviewMedia(msg);
|
||||
expect(encoded, isNotNull);
|
||||
final decoded = ChatPreviewMedia.decode(encoded);
|
||||
expect(decoded, isNotNull);
|
||||
return decoded!;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('превью вложений', () {
|
||||
test('одиночное фото без подписи даёт миниатюру и словесную подпись', () {
|
||||
final msg = {
|
||||
'text': '',
|
||||
'attaches': [_photo(_thumbA)],
|
||||
};
|
||||
final media = _media(msg);
|
||||
expect(media.kind, ChatPreviewKind.photo);
|
||||
expect(media.captioned, isFalse);
|
||||
expect(media.label, 'Изображение');
|
||||
expect(media.detail, isNull);
|
||||
expect(media.thumbs.map((t) => t.source), [_thumbA]);
|
||||
expect(media.thumbs.single.video, isFalse);
|
||||
expect(messagePreviewText(msg), 'Изображение');
|
||||
});
|
||||
|
||||
test('фото с подписью оставляет текст сообщения', () {
|
||||
final msg = {
|
||||
'text': 'подпись',
|
||||
'attaches': [_photo(_thumbA)],
|
||||
};
|
||||
final media = _media(msg);
|
||||
expect(media.captioned, isTrue);
|
||||
expect(media.label, isNull);
|
||||
expect(media.thumbs, hasLength(1));
|
||||
expect(messagePreviewText(msg), 'подпись');
|
||||
});
|
||||
|
||||
test('альбом отдаёт не больше трёх миниатюр и помечает видео', () {
|
||||
final msg = {
|
||||
'text': '',
|
||||
'attaches': [
|
||||
_photo(_thumbA),
|
||||
_video(_thumbB),
|
||||
_photo(_thumbC),
|
||||
_photo(_thumbD),
|
||||
],
|
||||
};
|
||||
final media = _media(msg);
|
||||
expect(media.label, 'Изображения');
|
||||
expect(media.thumbs.map((t) => t.source), [_thumbA, _thumbB, _thumbC]);
|
||||
expect(media.thumbs.map((t) => t.video), [false, true, false]);
|
||||
});
|
||||
|
||||
test('кружок не считается обычным видео', () {
|
||||
final msg = {
|
||||
'text': '',
|
||||
'attaches': [
|
||||
{'_type': 'VIDEO', 'videoType': 1, 'previewData': _thumbA},
|
||||
],
|
||||
};
|
||||
final media = _media(msg);
|
||||
expect(media.kind, ChatPreviewKind.videoNote);
|
||||
expect(media.label, 'Видео-сообщение');
|
||||
});
|
||||
|
||||
test('файл отдаёт имя отдельно от подписи', () {
|
||||
final msg = {
|
||||
'text': '',
|
||||
'attaches': [
|
||||
{'_type': 'FILE', 'name': 'notes.pdf', 'fileId': 3},
|
||||
],
|
||||
};
|
||||
final media = _media(msg);
|
||||
expect(media.kind, ChatPreviewKind.file);
|
||||
expect(media.label, 'Файл');
|
||||
expect(media.detail, 'notes.pdf');
|
||||
expect(media.thumbs, isEmpty);
|
||||
expect(messagePreviewText(msg), 'Файл: notes.pdf');
|
||||
});
|
||||
|
||||
test('пропущенный звонок отличается от состоявшегося', () {
|
||||
final missed = _media({
|
||||
'text': '',
|
||||
'attaches': [
|
||||
{'_type': 'CALL', 'callType': 'AUDIO', 'duration': 0},
|
||||
],
|
||||
});
|
||||
expect(missed.kind, ChatPreviewKind.missedCall);
|
||||
expect(missed.label, 'Пропущенный звонок');
|
||||
|
||||
final answered = _media({
|
||||
'text': '',
|
||||
'attaches': [
|
||||
{'_type': 'CALL', 'callType': 'VIDEO', 'duration': 42},
|
||||
],
|
||||
});
|
||||
expect(answered.kind, ChatPreviewKind.videoCall);
|
||||
expect(answered.label, 'Видеозвонок');
|
||||
});
|
||||
|
||||
test('пересланное вложение сохраняет метку пересылки', () {
|
||||
final msg = {
|
||||
'text': '',
|
||||
'link': {
|
||||
'type': 'FORWARD',
|
||||
'message': {
|
||||
'text': '',
|
||||
'attaches': [_photo(_thumbA)],
|
||||
},
|
||||
},
|
||||
};
|
||||
final media = _media(msg);
|
||||
expect(media.kind, ChatPreviewKind.photo);
|
||||
expect(media.label, '↪ Изображение');
|
||||
expect(media.thumbs, hasLength(1));
|
||||
expect(messagePreviewText(msg), '↪ Изображение');
|
||||
});
|
||||
|
||||
test('клавиатура бота не считается вложением', () {
|
||||
final encoded = messagePreviewMedia({
|
||||
'text': 'выбери вариант',
|
||||
'attaches': [
|
||||
{'_type': 'INLINE_KEYBOARD'},
|
||||
],
|
||||
});
|
||||
expect(encoded, isNull);
|
||||
});
|
||||
|
||||
test('без вложений описания нет', () {
|
||||
expect(messagePreviewMedia({'text': 'привет'}), isNull);
|
||||
});
|
||||
|
||||
test('слишком тяжёлая миниатюра не попадает в кеш чатов', () {
|
||||
final heavy = 'data:image/webp;base64,${'A' * 30000}';
|
||||
final media = _media({
|
||||
'text': '',
|
||||
'attaches': [_photo(heavy)],
|
||||
});
|
||||
expect(media.thumbs, isEmpty);
|
||||
});
|
||||
|
||||
test('описание переживает сериализацию', () {
|
||||
const media = ChatPreviewMedia(
|
||||
kind: ChatPreviewKind.video,
|
||||
thumbs: [
|
||||
ChatPreviewThumb(source: _thumbA, video: true),
|
||||
ChatPreviewThumb(source: _thumbB),
|
||||
],
|
||||
label: 'Видео',
|
||||
);
|
||||
final restored = ChatPreviewMedia.decode(media.encode())!;
|
||||
expect(restored.kind, ChatPreviewKind.video);
|
||||
expect(restored.label, 'Видео');
|
||||
expect(restored.detail, isNull);
|
||||
expect(restored.thumbs.map((t) => t.source), [_thumbA, _thumbB]);
|
||||
expect(restored.thumbs.map((t) => t.video), [true, false]);
|
||||
});
|
||||
|
||||
test('битое описание не роняет разбор', () {
|
||||
expect(ChatPreviewMedia.decode('{'), isNull);
|
||||
expect(ChatPreviewMedia.decode('{"k":"чтоэто"}'), isNull);
|
||||
expect(ChatPreviewMedia.decode(null), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/models/chat_reaction_settings.dart';
|
||||
|
||||
ChatReactionSettings _settings({
|
||||
bool active = true,
|
||||
int count = 8,
|
||||
bool included = false,
|
||||
List<String> ids = const [],
|
||||
}) => ChatReactionSettings(
|
||||
isActive: active,
|
||||
count: count,
|
||||
included: included,
|
||||
reactionIds: ids,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('nothing is allowed while reactions are off', () {
|
||||
expect(_settings(active: false).allows('👍'), isFalse);
|
||||
});
|
||||
|
||||
test('forbidden reactions are refused, others pass', () {
|
||||
final settings = _settings(ids: ['😴', '⚡']);
|
||||
|
||||
expect(settings.allows('😴'), isFalse);
|
||||
expect(settings.allows('⚡️'), isFalse);
|
||||
expect(settings.allows('👍'), isTrue);
|
||||
});
|
||||
|
||||
test('an allow list only lets its own reactions through', () {
|
||||
final settings = _settings(included: true, ids: ['👍']);
|
||||
|
||||
expect(settings.allows('👍'), isTrue);
|
||||
expect(settings.allows('❤️'), isFalse);
|
||||
});
|
||||
|
||||
test('a full message only takes reactions it already has', () {
|
||||
final settings = _settings(count: 2);
|
||||
|
||||
expect(settings.allowsOn('🔥', ['👍', '❤️']), isFalse);
|
||||
expect(settings.allowsOn('❤', ['👍', '❤️']), isTrue);
|
||||
expect(settings.allowsOn('🔥', ['👍']), isTrue);
|
||||
});
|
||||
|
||||
test('switching my own reaction does not count it as taken', () {
|
||||
final settings = _settings(count: 2);
|
||||
final info = {
|
||||
'yourReaction': '👍',
|
||||
'counters': [
|
||||
{'reaction': '👍', 'count': 1},
|
||||
{'reaction': '❤️', 'count': 3},
|
||||
],
|
||||
};
|
||||
|
||||
final present = ChatReactionSettings.presentWithoutMine(info);
|
||||
|
||||
expect(present, ['❤️']);
|
||||
expect(settings.allowsOn('🔥', present), isTrue);
|
||||
});
|
||||
|
||||
test('a reaction others also chose still counts', () {
|
||||
final present = ChatReactionSettings.presentWithoutMine({
|
||||
'yourReaction': '👍',
|
||||
'counters': [
|
||||
{'reaction': '👍', 'count': 2},
|
||||
{'reaction': '❤️', 'count': 1},
|
||||
],
|
||||
});
|
||||
|
||||
expect(present, ['👍', '❤️']);
|
||||
expect(_settings(count: 2).allowsOn('🔥', present), isFalse);
|
||||
});
|
||||
|
||||
test('a disabled filter refuses everything', () {
|
||||
final allows = _settings(active: false).filterFor(const []);
|
||||
|
||||
expect(allows('👍'), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chat_parsing.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
import 'package:kusoft/kusoft.dart';
|
||||
|
||||
const int _me = 501;
|
||||
const int _peer = 777;
|
||||
const int _chatId = 4242;
|
||||
const int _lastMsgId = 9000000;
|
||||
|
||||
CachedChat _cached({int? senderId, String? status, int? lastMsgId}) =>
|
||||
CachedChat(
|
||||
id: _chatId,
|
||||
accountId: _me,
|
||||
type: 'DIALOG',
|
||||
title: 'Диалог',
|
||||
lastMsgId: lastMsgId ?? _lastMsgId,
|
||||
lastMsgTime: 1700000000000,
|
||||
lastMsgText: 'привет',
|
||||
lastMsgSenderId: senderId,
|
||||
lastMsgStatus: status,
|
||||
unreadCount: 0,
|
||||
lastEventTime: 1700000000000,
|
||||
cachedAt: 0,
|
||||
dontDisturbUntil: ChatsModule.muteOff,
|
||||
isOnline: false,
|
||||
seenTime: 0,
|
||||
participants: {_me: 1700000000000, _peer: 0},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _serverChat({Object? sender}) => {
|
||||
'id': _chatId,
|
||||
'type': 'DIALOG',
|
||||
'participants': {'$_me': 1700000000000, '$_peer': 0},
|
||||
'lastMessage': {
|
||||
'id': _lastMsgId,
|
||||
'time': 1700000000000,
|
||||
'text': 'привет',
|
||||
if (sender != null) 'sender': sender,
|
||||
},
|
||||
};
|
||||
|
||||
CachedChat _parse(
|
||||
Map<String, dynamic> chat, {
|
||||
Map<int, CachedChat> existing = const {},
|
||||
}) {
|
||||
final parsed = parseChatRow(
|
||||
chat,
|
||||
_me,
|
||||
_me,
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
existing,
|
||||
0,
|
||||
);
|
||||
expect(parsed, isNotNull);
|
||||
return parsed!;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('разбор чата из ответа сервера', () {
|
||||
test('числовые ключи участников выбирают имя собеседника', () {
|
||||
final decoded = decodeKusoftPayload(
|
||||
r'{"participants":{"$int:501":10,"$int:777":20}}',
|
||||
);
|
||||
final chat = _serverChat()..['participants'] = decoded['participants'];
|
||||
final parsed = parseChatRow(
|
||||
chat,
|
||||
_me,
|
||||
_me,
|
||||
buildContactsMap([
|
||||
{
|
||||
'id': _me,
|
||||
'names': [
|
||||
{'type': 'ONEME', 'name': 'Владелец'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': _peer,
|
||||
'names': [
|
||||
{'type': 'ONEME', 'name': 'Собеседник'},
|
||||
],
|
||||
},
|
||||
]),
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
0,
|
||||
);
|
||||
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.title, 'Собеседник');
|
||||
expect(parsed.participants.keys, containsAll([_me, _peer]));
|
||||
});
|
||||
|
||||
test('отправитель последнего сообщения берётся из payload', () {
|
||||
final parsed = _parse(_serverChat(sender: _me));
|
||||
expect(parsed.lastMsgSenderId, _me);
|
||||
});
|
||||
|
||||
test('отправитель читается и когда сервер прислал его строкой', () {
|
||||
final parsed = _parse(_serverChat(sender: '$_me'));
|
||||
expect(parsed.lastMsgSenderId, _me);
|
||||
expect(parsed.lastMsgId, _lastMsgId);
|
||||
});
|
||||
|
||||
test('без sender в payload отправитель берётся из кэша', () {
|
||||
final parsed = _parse(
|
||||
_serverChat(),
|
||||
existing: {_chatId: _cached(senderId: _me, status: 'read')},
|
||||
);
|
||||
expect(parsed.lastMsgSenderId, _me);
|
||||
expect(parsed.lastMsgStatus, 'read');
|
||||
});
|
||||
|
||||
test('на новом последнем сообщении кэш не подмешивается', () {
|
||||
final parsed = _parse(
|
||||
_serverChat(sender: _peer),
|
||||
existing: {
|
||||
_chatId: _cached(
|
||||
senderId: _me,
|
||||
status: 'read',
|
||||
lastMsgId: _lastMsgId - 100,
|
||||
),
|
||||
},
|
||||
);
|
||||
expect(parsed.lastMsgSenderId, _peer);
|
||||
expect(parsed.lastMsgStatus, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/chat_controller.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/retain_offset_physics.dart';
|
||||
|
||||
const double _itemHeight = 60;
|
||||
const double _newestHeight = 84;
|
||||
const double _viewportHeight = 300;
|
||||
|
||||
double? _offsetInList(GlobalKey listKey, GlobalKey itemKey) {
|
||||
final listBox = listKey.currentContext?.findRenderObject();
|
||||
final box = itemKey.currentContext?.findRenderObject();
|
||||
if (listBox is! RenderBox || box is! RenderBox || !box.attached) return null;
|
||||
return box.localToGlobal(Offset.zero, ancestor: listBox).dy;
|
||||
}
|
||||
|
||||
class _Harness {
|
||||
_Harness(this.tester, {required this.physics});
|
||||
|
||||
final WidgetTester tester;
|
||||
final ScrollPhysics? physics;
|
||||
final GlobalKey listKey = GlobalKey();
|
||||
final ScrollController controller = ScrollController();
|
||||
final List<String> items = [for (var i = 0; i < 200; i++) 'm$i'];
|
||||
final Map<String, GlobalKey> keys = {};
|
||||
|
||||
GlobalKey keyFor(String id) => keys.putIfAbsent(id, GlobalKey.new);
|
||||
|
||||
Future<void> pump() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
key: listKey,
|
||||
height: _viewportHeight,
|
||||
child: CustomScrollView(
|
||||
controller: controller,
|
||||
reverse: true,
|
||||
physics: physics,
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index == 0) return const SizedBox(height: 0);
|
||||
final id = items[items.length - index];
|
||||
return SizedBox(
|
||||
key: keyFor(id),
|
||||
height: id == 'newest' ? _newestHeight : _itemHeight,
|
||||
child: Text(id),
|
||||
);
|
||||
}, childCount: items.length + 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
String anchorId() => items.firstWhere((id) {
|
||||
final dy = _offsetInList(listKey, keyFor(id));
|
||||
return dy != null && dy >= 0 && dy <= _viewportHeight;
|
||||
});
|
||||
|
||||
double dyOf(String id) => _offsetInList(listKey, keyFor(id))!;
|
||||
|
||||
double? dyOrNull(String id) => _offsetInList(listKey, keyFor(id));
|
||||
|
||||
double contentOffsetOf(String id) => dyOf(id) - controller.position.pixels;
|
||||
|
||||
double alignmentOf(String id) => dyOf(id) / _viewportHeight;
|
||||
|
||||
void insertAt(int index, int count) {
|
||||
items.insertAll(index, [for (var i = 0; i < count; i++) 'gap$index-$i']);
|
||||
}
|
||||
|
||||
bool restore(String id, double before) {
|
||||
final dy = dyOrNull(id);
|
||||
if (dy == null) return false;
|
||||
final delta = before - (dy - controller.position.pixels);
|
||||
if (delta.abs() <= 0.5) return true;
|
||||
final pos = controller.position;
|
||||
final target = (pos.pixels + delta).clamp(
|
||||
pos.minScrollExtent,
|
||||
pos.maxScrollExtent,
|
||||
);
|
||||
if ((target - pos.pixels).abs() <= 0.5) return true;
|
||||
controller.jumpTo(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
int visibleOldestIndex() {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
final dy = dyOrNull(items[i]);
|
||||
if (dy != null && dy + _itemHeight > 0 && dy < _viewportHeight) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool jumpNear(String id) {
|
||||
final index = items.indexOf(id);
|
||||
final oldest = visibleOldestIndex();
|
||||
if (index == -1 || oldest == -1) return false;
|
||||
final perScreen = (_viewportHeight / _itemHeight).floor();
|
||||
final away = (oldest - index).abs();
|
||||
final screens = (away / perScreen).clamp(1.0, 4.0);
|
||||
final pos = controller.position;
|
||||
final step = pos.viewportDimension * screens;
|
||||
final next = index < oldest ? pos.pixels + step : pos.pixels - step;
|
||||
final clamped = next.clamp(pos.minScrollExtent, pos.maxScrollExtent);
|
||||
if ((clamped - pos.pixels).abs() < 0.5) return false;
|
||||
controller.jumpTo(clamped);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<int> align(String id, double alignment) async {
|
||||
for (var frame = 0; frame < 40; frame++) {
|
||||
final dy = dyOrNull(id);
|
||||
if (dy == null) {
|
||||
if (!jumpNear(id)) return frame;
|
||||
await tester.pump();
|
||||
continue;
|
||||
}
|
||||
final delta = alignment * _viewportHeight - dy;
|
||||
if (delta.abs() <= 0.5) return frame;
|
||||
final pos = controller.position;
|
||||
final target = (pos.pixels + delta).clamp(
|
||||
pos.minScrollExtent,
|
||||
pos.maxScrollExtent,
|
||||
);
|
||||
if ((target - pos.pixels).abs() <= 0.5) return frame;
|
||||
controller.jumpTo(target);
|
||||
await tester.pump();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('appending to a reversed list drags the view toward the newest '
|
||||
'message', (tester) async {
|
||||
final h = _Harness(tester, physics: null);
|
||||
addTearDown(h.controller.dispose);
|
||||
|
||||
await h.pump();
|
||||
h.controller.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
final anchor = h.anchorId();
|
||||
final beforeDy = h.dyOf(anchor);
|
||||
|
||||
h.items.add('newest');
|
||||
await h.pump();
|
||||
|
||||
expect(h.dyOf(anchor), lessThan(beforeDy - 1));
|
||||
expect(h.controller.position.pixels, 600);
|
||||
});
|
||||
|
||||
testWidgets('RetainOffsetScrollPhysics holds the view in place when a '
|
||||
'message is appended', (tester) async {
|
||||
var retainOnce = false;
|
||||
final h = _Harness(
|
||||
tester,
|
||||
physics: RetainOffsetScrollPhysics(
|
||||
retain: () {
|
||||
if (!retainOnce) return false;
|
||||
retainOnce = false;
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
addTearDown(h.controller.dispose);
|
||||
|
||||
await h.pump();
|
||||
h.controller.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
final anchor = h.anchorId();
|
||||
final beforeDy = h.dyOf(anchor);
|
||||
|
||||
retainOnce = true;
|
||||
h.items.add('newest');
|
||||
await h.pump();
|
||||
|
||||
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
|
||||
expect(h.controller.position.pixels, greaterThan(600));
|
||||
});
|
||||
|
||||
testWidgets('RetainOffsetScrollPhysics stays inert while the flag is unset', (
|
||||
tester,
|
||||
) async {
|
||||
final h = _Harness(
|
||||
tester,
|
||||
physics: RetainOffsetScrollPhysics(retain: () => false),
|
||||
);
|
||||
addTearDown(h.controller.dispose);
|
||||
|
||||
await h.pump();
|
||||
h.controller.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
final anchor = h.anchorId();
|
||||
final beforeDy = h.dyOf(anchor);
|
||||
|
||||
h.items.add('newest');
|
||||
await h.pump();
|
||||
|
||||
expect(h.dyOf(anchor), lessThan(beforeDy - 1));
|
||||
expect(h.controller.position.pixels, 600);
|
||||
});
|
||||
|
||||
testWidgets('вставка старее вьюпорта не двигает его вообще', (tester) async {
|
||||
final h = _Harness(tester, physics: null);
|
||||
addTearDown(h.controller.dispose);
|
||||
|
||||
await h.pump();
|
||||
h.controller.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
final anchor = h.anchorId();
|
||||
final beforeDy = h.dyOf(anchor);
|
||||
|
||||
h.insertAt(10, 60);
|
||||
await h.pump();
|
||||
|
||||
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
|
||||
expect(h.controller.position.pixels, 600);
|
||||
});
|
||||
|
||||
testWidgets('заполнение дыры не двигает то, что новее её', (tester) async {
|
||||
final h = _Harness(tester, physics: null);
|
||||
addTearDown(h.controller.dispose);
|
||||
|
||||
await h.pump();
|
||||
h.controller.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
final anchor = h.anchorId();
|
||||
final before = h.contentOffsetOf(anchor);
|
||||
final beforeDy = h.dyOf(anchor);
|
||||
|
||||
h.insertAt(10, 5);
|
||||
await h.pump();
|
||||
h.restore(anchor, before);
|
||||
await tester.pump();
|
||||
|
||||
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
|
||||
expect(h.controller.position.pixels, 600);
|
||||
});
|
||||
|
||||
testWidgets('заполнение дыры удерживает то, что старее её', (tester) async {
|
||||
final h = _Harness(tester, physics: null);
|
||||
addTearDown(h.controller.dispose);
|
||||
|
||||
await h.pump();
|
||||
h.controller.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
final anchor = h.anchorId();
|
||||
final before = h.contentOffsetOf(anchor);
|
||||
final beforeDy = h.dyOf(anchor);
|
||||
|
||||
h.insertAt(195, 5);
|
||||
await h.pump();
|
||||
expect(h.dyOf(anchor), lessThan(beforeDy - 1));
|
||||
|
||||
h.restore(anchor, before);
|
||||
await tester.pump();
|
||||
|
||||
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
|
||||
expect(h.controller.position.pixels, 600 + 5 * _itemHeight);
|
||||
});
|
||||
|
||||
testWidgets('скролл пользователя во время дозагрузки не отменяется', (
|
||||
tester,
|
||||
) async {
|
||||
final h = _Harness(tester, physics: null);
|
||||
addTearDown(h.controller.dispose);
|
||||
|
||||
await h.pump();
|
||||
h.controller.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
final anchor = h.anchorId();
|
||||
final before = h.contentOffsetOf(anchor);
|
||||
final beforeDy = h.dyOf(anchor);
|
||||
|
||||
h.insertAt(195, 5);
|
||||
await h.pump();
|
||||
h.controller.jumpTo(h.controller.position.pixels + 120);
|
||||
await tester.pump();
|
||||
|
||||
h.restore(anchor, before);
|
||||
await tester.pump();
|
||||
|
||||
expect(h.dyOf(anchor), closeTo(beforeDy + 120, 0.5));
|
||||
expect(h.controller.position.pixels, 600 + 120 + 5 * _itemHeight);
|
||||
});
|
||||
|
||||
testWidgets('большой блок уносит якорь за пределы отрисованного окна', (
|
||||
tester,
|
||||
) async {
|
||||
final h = _Harness(tester, physics: null);
|
||||
addTearDown(h.controller.dispose);
|
||||
|
||||
await h.pump();
|
||||
h.controller.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
final anchor = h.anchorId();
|
||||
final before = h.contentOffsetOf(anchor);
|
||||
|
||||
h.insertAt(195, 60);
|
||||
await h.pump();
|
||||
|
||||
expect(h.dyOrNull(anchor), isNull);
|
||||
expect(h.restore(anchor, before), isFalse);
|
||||
expect(h.controller.position.pixels, 600);
|
||||
});
|
||||
|
||||
testWidgets('после большого блока выравнивание возвращает якорь на место', (
|
||||
tester,
|
||||
) async {
|
||||
final h = _Harness(tester, physics: null);
|
||||
addTearDown(h.controller.dispose);
|
||||
|
||||
await h.pump();
|
||||
h.controller.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
final anchor = h.anchorId();
|
||||
final before = h.contentOffsetOf(anchor);
|
||||
final beforeDy = h.dyOf(anchor);
|
||||
final alignment = h.alignmentOf(anchor);
|
||||
|
||||
h.insertAt(195, 60);
|
||||
await h.pump();
|
||||
expect(h.restore(anchor, before), isFalse);
|
||||
|
||||
final frames = await h.align(anchor, alignment);
|
||||
|
||||
expect(frames, greaterThanOrEqualTo(0));
|
||||
expect(frames, lessThan(10));
|
||||
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
|
||||
expect(h.controller.position.pixels, 600 + 60 * _itemHeight);
|
||||
});
|
||||
|
||||
group('дыру можно заполнять только с новой стороны', () {
|
||||
final gap = HistoryGap(edgeId: 'e', edgeTime: 1100, tailTime: 9000);
|
||||
|
||||
test('пользователь в хвосте — вставка ляжет выше вьюпорта', () {
|
||||
expect(ChatController.gapFillLeavesViewportInPlace(gap, 9000), isTrue);
|
||||
expect(ChatController.gapFillLeavesViewportInPlace(gap, 9050), isTrue);
|
||||
});
|
||||
|
||||
test('пользователь у закрепа — вставка утащила бы вьюпорт', () {
|
||||
expect(ChatController.gapFillLeavesViewportInPlace(gap, 1050), isFalse);
|
||||
expect(ChatController.gapFillLeavesViewportInPlace(gap, 8999), isFalse);
|
||||
});
|
||||
|
||||
test('без отрисованных сообщений заполнять нечего', () {
|
||||
expect(ChatController.gapFillLeavesViewportInPlace(gap, null), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_unread_count.dart';
|
||||
|
||||
CachedChat chat(int id, int unread) => CachedChat(
|
||||
id: id,
|
||||
accountId: 1,
|
||||
type: 'DIALOG',
|
||||
unreadCount: unread,
|
||||
lastEventTime: 0,
|
||||
cachedAt: 0,
|
||||
dontDisturbUntil: ChatsModule.muteOff,
|
||||
isOnline: false,
|
||||
seenTime: 0,
|
||||
participants: const {},
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('one chat with multiple unread messages counts as one', () {
|
||||
expect(countUnreadChats([chat(1, 20)]), 1);
|
||||
});
|
||||
test('each unread chat contributes once', () {
|
||||
expect(countUnreadChats([chat(1, 1), chat(2, 8), chat(3, 99)]), 3);
|
||||
});
|
||||
test('read chats do not contribute and an empty list is zero', () {
|
||||
expect(countUnreadChats([chat(1, 0), chat(2, 7), chat(3, 0)]), 1);
|
||||
expect(countUnreadChats([chat(1, 0), chat(2, 0)]), 0);
|
||||
expect(countUnreadChats([]), 0);
|
||||
});
|
||||
test('reading a chat subtracts one, regardless of messages read', () {
|
||||
final chats = [chat(1, 50), chat(2, 4)];
|
||||
expect(countUnreadChats(chats), 2);
|
||||
chats[0] = chats[0].copyWith(unreadCount: 0);
|
||||
expect(countUnreadChats(chats), 1);
|
||||
chats[1] = chats[1].copyWith(unreadCount: 0);
|
||||
expect(countUnreadChats(chats), 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/chat_controller.dart';
|
||||
import 'package:ikar/models/attachment.dart';
|
||||
|
||||
CachedMessage videoMessage({String? thumbnail, bool payload = true}) {
|
||||
final attachment = VideoAttachment(
|
||||
videoId: 121,
|
||||
videoToken: 'synthetic-video-token',
|
||||
thumbnail: thumbnail,
|
||||
baseUrl: thumbnail,
|
||||
duration: thumbnail == null ? null : 5178,
|
||||
width: thumbnail == null ? null : 2160,
|
||||
height: thumbnail == null ? null : 3840,
|
||||
);
|
||||
return CachedMessage(
|
||||
id: '119',
|
||||
accountId: 1,
|
||||
chatId: 3,
|
||||
senderId: 1,
|
||||
text: '',
|
||||
time: 1000,
|
||||
status: 'sent',
|
||||
attachments: [attachment],
|
||||
payload: payload
|
||||
? {
|
||||
'id': 119,
|
||||
'attaches': [attachment.toMap()],
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('existing video is redrawn when its preview and metadata arrive', () {
|
||||
final controller = ChatController()..messages = [videoMessage()];
|
||||
addTearDown(controller.dispose);
|
||||
final refreshed = videoMessage(
|
||||
thumbnail: 'https://media.example.test/thumbnail?access_token=new',
|
||||
);
|
||||
expect(controller.mergeMessages([refreshed]), isTrue);
|
||||
expect(controller.messages.single, same(refreshed));
|
||||
expect(
|
||||
(controller.messages.single.attachments!.single as VideoAttachment)
|
||||
.duration,
|
||||
5178,
|
||||
);
|
||||
expect(controller.messagesRev.value, 1);
|
||||
expect(
|
||||
controller.mergeMessages([
|
||||
videoMessage(
|
||||
thumbnail: 'https://media.example.test/thumbnail?access_token=new',
|
||||
),
|
||||
]),
|
||||
isFalse,
|
||||
);
|
||||
expect(controller.messagesRev.value, 1);
|
||||
});
|
||||
|
||||
test('refreshed signed preview URL replaces an expired URL', () {
|
||||
final controller = ChatController()
|
||||
..messages = [
|
||||
videoMessage(
|
||||
thumbnail:
|
||||
'https://media.example.test/thumbnail?access_token=expired',
|
||||
),
|
||||
];
|
||||
addTearDown(controller.dispose);
|
||||
final refreshed = videoMessage(
|
||||
thumbnail: 'https://media.example.test/thumbnail?access_token=fresh',
|
||||
);
|
||||
expect(controller.mergeMessages([refreshed]), isTrue);
|
||||
expect(controller.messages.single.time, 1000);
|
||||
expect(controller.messages.single.id, '119');
|
||||
expect(controller.messages.single, same(refreshed));
|
||||
});
|
||||
|
||||
test(
|
||||
'attachment updates without a raw server payload also redraw the video',
|
||||
() {
|
||||
final controller = ChatController()
|
||||
..messages = [videoMessage(payload: false)];
|
||||
addTearDown(controller.dispose);
|
||||
final refreshed = videoMessage(
|
||||
payload: false,
|
||||
thumbnail: 'https://media.example.test/thumbnail',
|
||||
);
|
||||
expect(controller.mergeMessages([refreshed]), isTrue);
|
||||
expect(controller.messages.single, same(refreshed));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const qualityWorkflows = [
|
||||
'.github/workflows/flutter-dev.yml',
|
||||
'.github/workflows/flutter-main.yml',
|
||||
'.github/workflows/build-android.yml',
|
||||
'.github/workflows/build-android-fcm.yml',
|
||||
];
|
||||
|
||||
test('build workflows run formatting, analysis and tests', () {
|
||||
for (final path in qualityWorkflows) {
|
||||
final workflow = File(path).readAsStringSync();
|
||||
expect(
|
||||
workflow,
|
||||
contains('dart format --output=none --set-exit-if-changed lib test'),
|
||||
);
|
||||
expect(workflow, contains('flutter analyze --no-fatal-infos lib test'));
|
||||
expect(workflow, contains('flutter test'));
|
||||
}
|
||||
});
|
||||
|
||||
test('pull request CI produces coverage', () {
|
||||
for (final path in [
|
||||
'.github/workflows/flutter-dev.yml',
|
||||
'.github/workflows/flutter-main.yml',
|
||||
]) {
|
||||
final workflow = File(path).readAsStringSync();
|
||||
expect(workflow, contains('flutter test --coverage'));
|
||||
expect(workflow, contains('coverage/lcov.info'));
|
||||
}
|
||||
});
|
||||
|
||||
test('dependency and Android smoke workflows stay enabled', () {
|
||||
final security = File(
|
||||
'.github/workflows/osv-scanner.yml',
|
||||
).readAsStringSync();
|
||||
final smoke = File(
|
||||
'.github/workflows/android-smoke.yml',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(security, contains('osv-scanner-reusable.yml@v2.5.0'));
|
||||
expect(security, contains('--lockfile=./pubspec.lock'));
|
||||
expect(smoke, contains('android-emulator-runner@v2'));
|
||||
expect(smoke, contains('adb shell pidof ru.ikar.app'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/core/plugins/plugin_manifest.dart';
|
||||
import 'package:ikar/frontend/commands/slash_command.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/view/command_arguments_form.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
void main() {
|
||||
const chance = PluginCommandArgumentManifest(
|
||||
name: 'chance',
|
||||
description: 'Шанс от 1 до 100',
|
||||
required: false,
|
||||
rest: false,
|
||||
);
|
||||
const text = PluginCommandArgumentManifest(
|
||||
name: 'text',
|
||||
description: 'Текст команды',
|
||||
required: true,
|
||||
rest: true,
|
||||
);
|
||||
const command = SlashCommand(
|
||||
'/synthetic',
|
||||
'Синтетическая команда',
|
||||
arguments: [chance, text],
|
||||
);
|
||||
|
||||
testWidgets('shows command arguments as separate interface fields', (
|
||||
tester,
|
||||
) async {
|
||||
final controllers = {
|
||||
'chance': TextEditingController(),
|
||||
'text': TextEditingController(),
|
||||
};
|
||||
final focusNodes = {'chance': FocusNode(), 'text': FocusNode()};
|
||||
var cancelled = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: CommandArgumentsForm(
|
||||
command: command,
|
||||
controllers: controllers,
|
||||
focusNodes: focusNodes,
|
||||
onCancel: () => cancelled = true,
|
||||
onSubmit: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('/synthetic'), findsOneWidget);
|
||||
expect(find.text('Синтетическая команда'), findsOneWidget);
|
||||
expect(find.text('chance · необязательно'), findsOneWidget);
|
||||
expect(find.text('text'), findsOneWidget);
|
||||
expect(find.byType(TextField), findsNWidgets(2));
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('command_argument_chance')),
|
||||
'80',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('command_argument_text')),
|
||||
'alpha beta',
|
||||
);
|
||||
|
||||
expect(controllers['chance']!.text, '80');
|
||||
expect(controllers['text']!.text, 'alpha beta');
|
||||
|
||||
await tester.tap(find.byIcon(Symbols.close));
|
||||
expect(cancelled, isTrue);
|
||||
|
||||
for (final controller in controllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final node in focusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('moves focus to the next argument on submit', (tester) async {
|
||||
final controllers = {
|
||||
'chance': TextEditingController(),
|
||||
'text': TextEditingController(),
|
||||
};
|
||||
final focusNodes = {'chance': FocusNode(), 'text': FocusNode()};
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: CommandArgumentsForm(
|
||||
command: command,
|
||||
controllers: controllers,
|
||||
focusNodes: focusNodes,
|
||||
onCancel: () {},
|
||||
onSubmit: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
focusNodes['chance']!.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.testTextInput.receiveAction(TextInputAction.next);
|
||||
await tester.pump();
|
||||
|
||||
expect(focusNodes['text']!.hasFocus, isTrue);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
for (final controller in controllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final node in focusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/plugins/plugin_manifest.dart';
|
||||
import 'package:ikar/frontend/commands/slash_command.dart';
|
||||
import 'package:ikar/frontend/commands/plugin_commands.dart';
|
||||
|
||||
void main() {
|
||||
const optional = PluginCommandArgumentManifest(
|
||||
name: 'optional',
|
||||
description: '',
|
||||
required: false,
|
||||
rest: false,
|
||||
);
|
||||
const text = PluginCommandArgumentManifest(
|
||||
name: 'text',
|
||||
description: '',
|
||||
required: true,
|
||||
rest: true,
|
||||
);
|
||||
test('empty optional field does not consume the required text', () {
|
||||
final raw = serializeCommandArguments(
|
||||
[optional, text],
|
||||
{'optional': '', 'text': 'alpha beta'},
|
||||
);
|
||||
expect(parseCommandArguments(raw, [optional, text]), {
|
||||
'optional': '',
|
||||
'text': 'alpha beta',
|
||||
});
|
||||
});
|
||||
test('quoted arguments round trip through the form', () {
|
||||
const values = {
|
||||
'optional': 'with "quotes" and spaces',
|
||||
'text': 'alpha beta',
|
||||
};
|
||||
final raw = serializeCommandArguments([optional, text], values);
|
||||
expect(parseCommandArguments(raw, [optional, text]), values);
|
||||
});
|
||||
test('usage includes optional and rest fields', () {
|
||||
const command = SlashCommand(
|
||||
'/synthetic',
|
||||
'Synthetic',
|
||||
arguments: [optional, text],
|
||||
);
|
||||
expect(command.usage, '/synthetic [optional] <text...>');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/config/app_chat_chrome.dart';
|
||||
import 'package:ikar/core/config/app_composer_background.dart';
|
||||
import 'package:ikar/core/config/app_composer_style.dart';
|
||||
import 'package:ikar/backend/modules/forward_sender.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/upload_status.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/video_note_controller.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/view/composer_input.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat/voice_record_controller.dart';
|
||||
import 'package:ikar/frontend/widgets/lottie_slash_icon.dart';
|
||||
import 'package:ikar/frontend/widgets/rich_message_controller.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
CachedMessage _message() => const CachedMessage(
|
||||
id: '101',
|
||||
accountId: 7,
|
||||
chatId: 70,
|
||||
senderId: 7,
|
||||
text: 'Synthetic forwarded text',
|
||||
time: 1000,
|
||||
status: 'sent',
|
||||
);
|
||||
|
||||
ForwardRequest _request() => ForwardRequest(
|
||||
sourceChatId: 70,
|
||||
sourceChatName: 'Synthetic chat',
|
||||
sourceChatIconUrl: '',
|
||||
sourceChatType: 'CHAT',
|
||||
messages: [_message()],
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'forward preview forces send mode, hides the sender and can be cancelled',
|
||||
(tester) async {
|
||||
final forwards = ValueNotifier<ForwardRequest?>(_request());
|
||||
final reply = ValueNotifier<CachedMessage?>(null);
|
||||
final hasText = ValueNotifier(false);
|
||||
final uploadStatus = ValueNotifier(const UploadStatus());
|
||||
final messageController = RichMessageController();
|
||||
final focusNode = FocusNode();
|
||||
final attachAnimation = AnimationController(
|
||||
vsync: const TestVSync(),
|
||||
duration: const Duration(milliseconds: 1),
|
||||
);
|
||||
late BuildContext composerContext;
|
||||
var sendCount = 0;
|
||||
var cancelCount = 0;
|
||||
var toggleCount = 0;
|
||||
final voice = VoiceRecordController(
|
||||
contextOf: () => composerContext,
|
||||
isMounted: () => true,
|
||||
myId: () => 7,
|
||||
onRecorded:
|
||||
(File file, int durationMs, List<double> amplitudes) async {},
|
||||
);
|
||||
final note = VideoNoteController(
|
||||
contextOf: () => composerContext,
|
||||
isMounted: () => true,
|
||||
onRecorded: (File file, int durationMs) async {},
|
||||
formatElapsed: (milliseconds) => '$milliseconds',
|
||||
bottomInset: () => 0,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
composerContext = context;
|
||||
return Scaffold(
|
||||
bottomNavigationBar: ComposerInputBar(
|
||||
chatType: 'CHAT',
|
||||
chrome: ChatChromeStyle.color,
|
||||
style: ComposerStyle.materialYou,
|
||||
background: ComposerBackground.standard,
|
||||
attachAnim: attachAnimation,
|
||||
replyTo: reply,
|
||||
forward: forwards,
|
||||
myId: 7,
|
||||
hasText: hasText,
|
||||
uploadStatus: uploadStatus,
|
||||
messageController: messageController,
|
||||
messageFocusNode: focusNode,
|
||||
voiceRec: voice,
|
||||
note: note,
|
||||
onToggleStickerPanel: () {},
|
||||
onSendText: () => sendCount++,
|
||||
onScheduleMessage: () {},
|
||||
onOpenAttach: () {},
|
||||
onOpenAttachScheduled: () {},
|
||||
onSendHistory: (_) async {},
|
||||
onCancelReply: () {},
|
||||
onCancelForward: () {
|
||||
cancelCount++;
|
||||
forwards.value = null;
|
||||
},
|
||||
onToggleForwardSender: () {
|
||||
toggleCount++;
|
||||
final request = forwards.value!;
|
||||
forwards.value = request.withHideSender(
|
||||
!request.hideSender,
|
||||
);
|
||||
},
|
||||
formatElapsed: (milliseconds) => '$milliseconds',
|
||||
contextMenuBuilder: (context, state) =>
|
||||
const SizedBox.shrink(),
|
||||
isMuted: false,
|
||||
onToggleMute: () {},
|
||||
showStickerButton: false,
|
||||
showAttachButton: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Пересылка от вас'), findsOneWidget);
|
||||
expect(find.text('Synthetic forwarded text'), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.forward), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.send), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic), findsNothing);
|
||||
|
||||
await tester.tap(find.byIcon(Symbols.send));
|
||||
expect(sendCount, 1);
|
||||
|
||||
expect(
|
||||
tester.widget<LottieSlashIcon>(find.byType(LottieSlashIcon)).slashed,
|
||||
isFalse,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Скрыть имя отправителя'));
|
||||
await tester.pump();
|
||||
|
||||
expect(toggleCount, 1);
|
||||
expect(find.text('Пересылка без автора'), findsOneWidget);
|
||||
expect(find.text('Пересылка от вас'), findsNothing);
|
||||
expect(find.text('Synthetic forwarded text'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<LottieSlashIcon>(find.byType(LottieSlashIcon)).slashed,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Показать имя отправителя'));
|
||||
await tester.pump();
|
||||
|
||||
expect(toggleCount, 2);
|
||||
expect(find.text('Пересылка от вас'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Symbols.close));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
|
||||
expect(cancelCount, 1);
|
||||
expect(find.text('Пересылка от вас'), findsNothing);
|
||||
expect(find.byIcon(Symbols.send), findsNothing);
|
||||
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
forwards.dispose();
|
||||
reply.dispose();
|
||||
hasText.dispose();
|
||||
uploadStatus.dispose();
|
||||
messageController.dispose();
|
||||
focusNode.dispose();
|
||||
attachAnimation.dispose();
|
||||
voice.dispose();
|
||||
note.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/widgets/composer_morph_icon.dart';
|
||||
import 'package:ikar/frontend/widgets/glossy_pill.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
const _assets = [
|
||||
'assets/lottie/ic_mic_to_videocam.json',
|
||||
'assets/lottie/ic_videocam_to_mic.json',
|
||||
'assets/lottie/ic_mic_to_send.json',
|
||||
'assets/lottie/ic_videocam_to_send.json',
|
||||
'assets/lottie/ic_send_to_mic.json',
|
||||
'assets/lottie/ic_send_to_videocam.json',
|
||||
];
|
||||
|
||||
Widget _host(ComposerAction action) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: ComposerMorphIcon(action: action, color: const Color(0xFFFFFFFF)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
List<Map<String, dynamic>> _paths(Map<String, dynamic> doc) {
|
||||
final layer = (doc['layers'] as List).first as Map<String, dynamic>;
|
||||
final group = (layer['shapes'] as List).first as Map<String, dynamic>;
|
||||
return (group['it'] as List)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.where((item) => item['ty'] == 'sh')
|
||||
.toList();
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Ассеты морфинга', () {
|
||||
test('файлы существуют и разбираются', () {
|
||||
for (final path in _assets) {
|
||||
final file = File(path);
|
||||
expect(file.existsSync(), isTrue, reason: '$path отсутствует');
|
||||
final doc = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
||||
expect(doc['w'], doc['h'], reason: '$path должен быть квадратным');
|
||||
expect(doc['op'], greaterThan(0));
|
||||
expect(_paths(doc), isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('обе ключевые точки контура имеют одинаковое число вершин', () {
|
||||
for (final path in _assets) {
|
||||
final doc =
|
||||
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
|
||||
for (final shape in _paths(doc)) {
|
||||
final frames = (shape['ks'] as Map)['k'] as List;
|
||||
expect(
|
||||
frames.length,
|
||||
2,
|
||||
reason: '$path: ожидались две ключевые точки',
|
||||
);
|
||||
final from = ((frames.first as Map)['s'] as List).first as Map;
|
||||
final to = ((frames.last as Map)['s'] as List).first as Map;
|
||||
for (final key in ['v', 'i', 'o']) {
|
||||
expect(
|
||||
(to[key] as List).length,
|
||||
(from[key] as List).length,
|
||||
reason: '$path: «$key» разной длины — морф не построится',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('анимации не начинаются и не заканчиваются смещением', () {
|
||||
for (final path in _assets) {
|
||||
final doc =
|
||||
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
|
||||
final layer = (doc['layers'] as List).first as Map<String, dynamic>;
|
||||
final transform = layer['ks'] as Map<String, dynamic>;
|
||||
for (final key in ['r', 's', 'p']) {
|
||||
final prop = transform[key] as Map<String, dynamic>;
|
||||
if (prop['a'] != 1) continue;
|
||||
final frames = (prop['k'] as List).cast<Map<String, dynamic>>();
|
||||
expect(
|
||||
frames.first['s'],
|
||||
frames.last['s'],
|
||||
reason: '$path: «$key» должен возвращаться в исходное значение',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('ComposerMorphIcon', () {
|
||||
testWidgets('в покое рисует обычную иконку', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.mic));
|
||||
|
||||
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||
expect(find.byType(Lottie), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('на смене состояния запускает lottie', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.mic));
|
||||
await tester.pumpWidget(_host(ComposerAction.videocam));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(Lottie), findsOneWidget);
|
||||
expect(find.byType(Icon), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('после анимации возвращает обычную иконку', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.mic));
|
||||
await tester.pumpWidget(_host(ComposerAction.send));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Symbols.send), findsOneWidget);
|
||||
expect(find.byType(Lottie), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('каждая следующая смена тоже анимируется', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.mic));
|
||||
|
||||
const sequence = [
|
||||
ComposerAction.videocam,
|
||||
ComposerAction.send,
|
||||
ComposerAction.videocam,
|
||||
ComposerAction.mic,
|
||||
ComposerAction.send,
|
||||
ComposerAction.mic,
|
||||
];
|
||||
|
||||
for (final action in sequence) {
|
||||
await tester.pumpWidget(_host(action));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(
|
||||
find.byType(Lottie),
|
||||
findsOneWidget,
|
||||
reason: 'переход в $action должен проигрываться',
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byType(Lottie),
|
||||
findsNothing,
|
||||
reason: 'переход в $action должен завершаться статикой',
|
||||
);
|
||||
expect(find.byIcon(composerActionIcon(action)), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('морф переживает появление обработчика нажатия', (
|
||||
tester,
|
||||
) async {
|
||||
Widget host(bool sendMode) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: GlossyPill(
|
||||
onTap: sendMode ? () {} : null,
|
||||
keepInkLayer: true,
|
||||
child: ComposerMorphIcon(
|
||||
action: sendMode ? ComposerAction.send : ComposerAction.mic,
|
||||
color: const Color(0xFFFFFFFF),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(host(false));
|
||||
await tester.pumpWidget(host(true));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(Lottie), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Symbols.send), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('обратный переход тоже завершается статикой', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.send));
|
||||
await tester.pumpWidget(_host(ComposerAction.videocam));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Symbols.videocam), findsOneWidget);
|
||||
expect(find.byType(Lottie), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/contacts.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/models/attachment.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
final String directory;
|
||||
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
CachedMessage _contactMessage({
|
||||
required String id,
|
||||
required int contactId,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
}) {
|
||||
return CachedMessage(
|
||||
id: id,
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 3,
|
||||
text: '',
|
||||
time: DateTime(2026, 1, 2, 5, 46).millisecondsSinceEpoch,
|
||||
attachments: [
|
||||
ContactAttachment(
|
||||
contactId: contactId,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
name: '$firstName $lastName',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('contact bubble shows profile and contextual contact action', (
|
||||
tester,
|
||||
) async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_contact_bubble_test',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
await AppDatabase.init();
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: 1,
|
||||
firstName: 'Synthetic owner',
|
||||
phone: 100000,
|
||||
country: 'ZZ',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
);
|
||||
await AppDatabase.saveContacts([
|
||||
{
|
||||
'id': 77,
|
||||
'account_id': 1,
|
||||
'first_name': 'Existing',
|
||||
'last_name': 'Contact',
|
||||
'phone': 100001,
|
||||
'photo_id': null,
|
||||
'base_url': null,
|
||||
'base_raw_url': null,
|
||||
'update_time': 1,
|
||||
'options': '',
|
||||
},
|
||||
]);
|
||||
expect(await ContactsModule.getContact(1, 77), isNotNull);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MessageBubble(
|
||||
message: _contactMessage(
|
||||
id: 'synthetic-existing-message',
|
||||
contactId: 77,
|
||||
firstName: 'Existing',
|
||||
lastName: 'Contact',
|
||||
),
|
||||
isMe: false,
|
||||
myId: 1,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
MessageBubble(
|
||||
message: _contactMessage(
|
||||
id: 'synthetic-new-message',
|
||||
contactId: 88,
|
||||
firstName: 'New',
|
||||
lastName: 'Contact',
|
||||
),
|
||||
isMe: false,
|
||||
myId: 1,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 100)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Existing Contact'), findsOneWidget);
|
||||
expect(find.text('Уже твой контакт'), findsOneWidget);
|
||||
expect(find.text('New Contact'), findsOneWidget);
|
||||
expect(find.text('Новый контакт'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('contact-add-button')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('contact-profile-button')),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
expect(find.text('05:46'), findsNWidgets(2));
|
||||
expect(
|
||||
tester.getSize(find.byKey(const ValueKey('contact-card')).first).width,
|
||||
320,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/contacts.dart';
|
||||
import 'package:ikar/core/protocol/opcode_map.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
class _PathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_PathProvider(this.directory);
|
||||
final String directory;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
class _ContactApi extends Api {
|
||||
_ContactApi(this.responses);
|
||||
final List<Object> responses;
|
||||
final List<({int opcode, Map<dynamic, dynamic> payload, bool silent})> calls =
|
||||
[];
|
||||
Future<void> Function(int)? onRequest;
|
||||
Future<void> Function(int)? beforeDispatch;
|
||||
int attempts = 0;
|
||||
int epoch = 0;
|
||||
|
||||
@override
|
||||
int get sessionEpoch => epoch;
|
||||
|
||||
@override
|
||||
Future<Packet> sendRequest(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
void Function()? beforeSend,
|
||||
bool silent = false,
|
||||
}) async {
|
||||
await beforeDispatch?.call(++attempts);
|
||||
beforeSend?.call();
|
||||
calls.add((opcode: opcode, payload: Map.of(payload), silent: silent));
|
||||
await onRequest?.call(calls.length);
|
||||
final result = responses.removeAt(0);
|
||||
if (result is Packet) return result;
|
||||
throw result;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _contact(String first, {String last = '', int id = 11}) =>
|
||||
{
|
||||
'id': id,
|
||||
'phone': 79000000011,
|
||||
'names': [
|
||||
{'type': 'CUSTOM', 'firstName': first, 'lastName': last},
|
||||
],
|
||||
};
|
||||
|
||||
Packet _success(String first, {String last = '', int id = 11}) => Packet(
|
||||
cmd: CmdType.ok,
|
||||
payload: {'contact': _contact(first, last: last, id: id)},
|
||||
);
|
||||
|
||||
Future<void> _saveOtherProfile() => AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: 2,
|
||||
firstName: 'Other owner',
|
||||
phone: 79000000002,
|
||||
country: 'RU',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
isActive: false,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
final directory = Directory.systemTemp.createTempSync('ikar_contacts_test');
|
||||
PathProviderPlatform.instance = _PathProvider(directory.path);
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
await AppDatabase.init();
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: 1,
|
||||
firstName: 'Owner',
|
||||
phone: 79000000001,
|
||||
country: 'RU',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
);
|
||||
await ContactsModule.syncFromLoginPayload({
|
||||
'contacts': [_contact('Stale name')],
|
||||
}, 1);
|
||||
});
|
||||
|
||||
test('adding an ID sends ADD and persists the confirmed contact', () async {
|
||||
final api = _ContactApi([_success('Alice')]);
|
||||
addTearDown(api.dispose);
|
||||
final contact = await ContactsModule.addContact(api, 11, 'Alice');
|
||||
expect(api.calls.single.opcode, Opcode.contactUpdate);
|
||||
expect(api.calls.single.payload['action'], 'ADD');
|
||||
expect(api.calls.single.silent, isTrue);
|
||||
expect(contact?.firstName, 'Alice');
|
||||
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Alice');
|
||||
});
|
||||
|
||||
test(
|
||||
'saving a stale local contact recreates the relation with its name',
|
||||
() async {
|
||||
final api = _ContactApi([
|
||||
const PacketError(
|
||||
'Contact was not found.',
|
||||
errorKey: 'contact.not.found',
|
||||
),
|
||||
_success('Alice', last: 'Local'),
|
||||
]);
|
||||
addTearDown(api.dispose);
|
||||
final contact = await ContactsModule.updateContact(
|
||||
api,
|
||||
contactId: 11,
|
||||
firstName: 'Alice',
|
||||
lastName: 'Local',
|
||||
);
|
||||
expect(api.calls.map((c) => c.payload['action']), ['UPDATE', 'ADD']);
|
||||
expect(api.calls.every((c) => c.silent), isTrue);
|
||||
expect(api.calls.last.payload['contactId'], 11);
|
||||
expect(api.calls.last.payload['firstName'], 'Alice');
|
||||
expect(api.calls.last.payload['lastName'], 'Local');
|
||||
expect(contact?.lastName, 'Local');
|
||||
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Alice');
|
||||
},
|
||||
);
|
||||
|
||||
for (final errorKey in ['user.not.found', 'CONTACT_NAME_INVALID', 'limit']) {
|
||||
test('saving does not retry an unrelated $errorKey failure', () async {
|
||||
final api = _ContactApi([PacketError('failed', errorKey: errorKey)]);
|
||||
addTearDown(api.dispose);
|
||||
expect(
|
||||
await ContactsModule.updateContact(
|
||||
api,
|
||||
contactId: 11,
|
||||
firstName: 'Alice',
|
||||
lastName: '',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(api.calls, hasLength(1));
|
||||
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Stale name');
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'account switching prevents restoring a contact to another account',
|
||||
() async {
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: 2,
|
||||
firstName: 'Other owner',
|
||||
phone: 79000000002,
|
||||
country: 'RU',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
isActive: false,
|
||||
);
|
||||
final api = _ContactApi([
|
||||
const PacketError(
|
||||
'Contact was not found.',
|
||||
errorKey: 'contact.not.found',
|
||||
),
|
||||
])..onRequest = (_) => AppDatabase.setActiveAccount(2);
|
||||
addTearDown(api.dispose);
|
||||
expect(
|
||||
await ContactsModule.updateContact(
|
||||
api,
|
||||
contactId: 11,
|
||||
firstName: 'Alice',
|
||||
lastName: '',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(api.calls, hasLength(1));
|
||||
expect(await ContactsModule.getContact(2, 11), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('a rejected add does not create a fake saved contact', () async {
|
||||
final api = _ContactApi([Packet(cmd: CmdType.error)]);
|
||||
addTearDown(api.dispose);
|
||||
expect(await ContactsModule.addContact(api, 12, 'Absent'), isNull);
|
||||
expect(await ContactsModule.getContact(1, 12), isNull);
|
||||
});
|
||||
|
||||
test('switching sessions while UPDATE waits prevents dispatch', () async {
|
||||
final api = _ContactApi([_success('Alice')]);
|
||||
api.beforeDispatch = (_) async => api.epoch++;
|
||||
addTearDown(api.dispose);
|
||||
expect(
|
||||
await ContactsModule.updateContact(
|
||||
api,
|
||||
contactId: 11,
|
||||
firstName: 'Alice',
|
||||
lastName: '',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(api.calls, isEmpty);
|
||||
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Stale name');
|
||||
});
|
||||
|
||||
test(
|
||||
'switching sessions while the ADD retry waits prevents dispatch',
|
||||
() async {
|
||||
final api = _ContactApi([
|
||||
const PacketError(
|
||||
'Contact was not found.',
|
||||
errorKey: 'contact.not.found',
|
||||
),
|
||||
_success('Alice'),
|
||||
]);
|
||||
api.beforeDispatch = (attempt) async {
|
||||
if (attempt == 2) api.epoch++;
|
||||
};
|
||||
addTearDown(api.dispose);
|
||||
expect(
|
||||
await ContactsModule.updateContact(
|
||||
api,
|
||||
contactId: 11,
|
||||
firstName: 'Alice',
|
||||
lastName: '',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(api.attempts, 2);
|
||||
expect(api.calls.map((call) => call.payload['action']), ['UPDATE']);
|
||||
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Stale name');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'full sync for an inactive account does not request or delete contacts',
|
||||
() async {
|
||||
await _saveOtherProfile();
|
||||
final api = _ContactApi([]);
|
||||
addTearDown(api.dispose);
|
||||
await ContactsModule.syncFromServer(api, 2);
|
||||
expect(api.calls, isEmpty);
|
||||
expect(
|
||||
(await ContactsModule.getContacts(1)).map((contact) => contact.id),
|
||||
[11],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'switching sessions before a full sync dispatch cancels the request',
|
||||
() async {
|
||||
final api = _ContactApi([
|
||||
Packet(cmd: CmdType.ok, payload: {'contacts': []}),
|
||||
]);
|
||||
api.beforeDispatch = (_) async => api.epoch++;
|
||||
addTearDown(api.dispose);
|
||||
await expectLater(
|
||||
ContactsModule.syncFromServer(api, 1),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(api.calls, isEmpty);
|
||||
expect(
|
||||
(await ContactsModule.getContacts(1)).map((contact) => contact.id),
|
||||
[11],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (final changeAccount in [true, false]) {
|
||||
test(
|
||||
'an in-flight full sync is ignored after ${changeAccount ? 'account' : 'session'} switching',
|
||||
() async {
|
||||
await _saveOtherProfile();
|
||||
final requested = Completer<void>();
|
||||
final response = Completer<void>();
|
||||
final api =
|
||||
_ContactApi([
|
||||
Packet(cmd: CmdType.ok, payload: {'contacts': []}),
|
||||
])
|
||||
..onRequest = (_) {
|
||||
requested.complete();
|
||||
return response.future;
|
||||
};
|
||||
addTearDown(api.dispose);
|
||||
final revision = ContactsModule.revision.value;
|
||||
final sync = ContactsModule.syncFromServer(api, 1);
|
||||
await requested.future;
|
||||
if (changeAccount) {
|
||||
await AppDatabase.setActiveAccount(2);
|
||||
} else {
|
||||
api.epoch++;
|
||||
}
|
||||
response.complete();
|
||||
await sync;
|
||||
expect(
|
||||
(await ContactsModule.getContacts(1)).map((contact) => contact.id),
|
||||
[11],
|
||||
);
|
||||
expect(ContactsModule.revision.value, revision);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'an older full snapshot does not erase a contact added during sync',
|
||||
() async {
|
||||
final requested = Completer<void>();
|
||||
final response = Completer<void>();
|
||||
final api =
|
||||
_ContactApi([
|
||||
Packet(cmd: CmdType.ok, payload: {'contacts': []}),
|
||||
])
|
||||
..onRequest = (_) {
|
||||
requested.complete();
|
||||
return response.future;
|
||||
};
|
||||
final addingApi = _ContactApi([_success('New contact', id: 12)]);
|
||||
addTearDown(api.dispose);
|
||||
addTearDown(addingApi.dispose);
|
||||
final sync = ContactsModule.syncFromServer(api, 1);
|
||||
await requested.future;
|
||||
await ContactsModule.addContact(addingApi, 12, 'New contact');
|
||||
response.complete();
|
||||
await sync;
|
||||
expect(
|
||||
(await ContactsModule.getContacts(
|
||||
1,
|
||||
)).map((contact) => contact.id).toSet(),
|
||||
{11, 12},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a current full snapshot still removes stale contacts', () async {
|
||||
final api = _ContactApi([
|
||||
Packet(cmd: CmdType.ok, payload: {'contacts': []}),
|
||||
]);
|
||||
addTearDown(api.dispose);
|
||||
await ContactsModule.syncFromServer(api, 1);
|
||||
expect(api.calls.single.opcode, Opcode.contactsGet);
|
||||
expect(await ContactsModule.getContacts(1), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/contacts.dart';
|
||||
|
||||
class _PhotosApi extends Api {
|
||||
_PhotosApi(this.response);
|
||||
|
||||
final Map<dynamic, dynamic>? response;
|
||||
|
||||
@override
|
||||
Future<Map<dynamic, dynamic>?> sendRequestMap(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
bool silent = false,
|
||||
}) async => response;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('photo ids come back aligned with urls', () async {
|
||||
final api = _PhotosApi({
|
||||
'total': 3,
|
||||
'urls': <dynamic>['https://example.test/a', 'https://example.test/b'],
|
||||
'ids': <dynamic>[111, 222],
|
||||
});
|
||||
|
||||
final photos = await ContactsModule.fetchPhotos(api, 501);
|
||||
|
||||
expect(photos.urls, ['https://example.test/a', 'https://example.test/b']);
|
||||
expect(photos.ids, [111, 222]);
|
||||
expect(photos.idAt(1), 222);
|
||||
expect(photos.idAt(5), isNull);
|
||||
expect(photos.total, 3);
|
||||
});
|
||||
|
||||
test('misaligned ids are dropped instead of shifting photos', () async {
|
||||
final api = _PhotosApi({
|
||||
'total': 2,
|
||||
'urls': <dynamic>['https://example.test/a', 'https://example.test/b'],
|
||||
'ids': <dynamic>[111],
|
||||
});
|
||||
|
||||
final photos = await ContactsModule.fetchPhotos(api, 502);
|
||||
|
||||
expect(photos.urls.length, 2);
|
||||
expect(photos.ids, isEmpty);
|
||||
expect(photos.idAt(0), isNull);
|
||||
});
|
||||
|
||||
test('a response without ids still lists photos', () async {
|
||||
final api = _PhotosApi({
|
||||
'total': 1,
|
||||
'urls': <dynamic>['https://example.test/a'],
|
||||
});
|
||||
|
||||
final photos = await ContactsModule.fetchPhotos(api, 503);
|
||||
|
||||
expect(photos.urls.length, 1);
|
||||
expect(photos.ids, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/contacts/contact_presence.dart';
|
||||
|
||||
void main() {
|
||||
final now = DateTime.utc(2026, 9, 5, 12);
|
||||
final seconds = now.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
test('missing or hidden last seen is unknown', () {
|
||||
for (final value in [
|
||||
null,
|
||||
<String, dynamic>{},
|
||||
{'status': 0},
|
||||
{'seen': 0},
|
||||
]) {
|
||||
expect(contactPresence(value, now), ContactPresence.unknown);
|
||||
}
|
||||
});
|
||||
|
||||
test('server online and approximate statuses take precedence', () {
|
||||
expect(contactPresence({'status': 1}, now), ContactPresence.online);
|
||||
for (final status in [2, 3]) {
|
||||
expect(
|
||||
contactPresence({'status': status, 'seen': 1}, now),
|
||||
ContactPresence.recently,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('last seen uses seconds and a three day recent boundary', () {
|
||||
expect(
|
||||
contactPresence({
|
||||
'seen': seconds - const Duration(days: 3).inSeconds,
|
||||
}, now),
|
||||
ContactPresence.recently,
|
||||
);
|
||||
expect(
|
||||
contactPresence({
|
||||
'seen': seconds - const Duration(days: 3).inSeconds - 1,
|
||||
}, now),
|
||||
ContactPresence.longAgo,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/contacts.dart';
|
||||
import 'package:ikar/core/contacts/contact_search.dart';
|
||||
|
||||
void main() {
|
||||
test('phonebook includes numbers without ИКАР accounts and sorts by name', () {
|
||||
const phonebook = {
|
||||
'+79000000002': 'Борис',
|
||||
'+79000000001': 'Алёна Тестовая',
|
||||
};
|
||||
expect(
|
||||
ContactSearch.filterPhonebook(phonebook, '').map((entry) => entry.key),
|
||||
['+79000000001', '+79000000002'],
|
||||
);
|
||||
expect(
|
||||
ContactSearch.filterPhonebook(phonebook, 'алена').single.value,
|
||||
'Алёна Тестовая',
|
||||
);
|
||||
expect(
|
||||
ContactSearch.filterPhonebook(phonebook, '8 (900) 000-00-02').single.key,
|
||||
'+79000000002',
|
||||
);
|
||||
expect(ContactSearch.filterPhonebook(phonebook, 'unknown'), isEmpty);
|
||||
});
|
||||
|
||||
const contacts = [
|
||||
CachedContact(
|
||||
id: 101,
|
||||
accountId: 1,
|
||||
firstName: 'Алёна',
|
||||
lastName: 'Тестовая',
|
||||
phone: 79000000001,
|
||||
updateTime: 0,
|
||||
),
|
||||
CachedContact(
|
||||
id: 202,
|
||||
accountId: 1,
|
||||
firstName: 'Борис',
|
||||
phone: 79000000002,
|
||||
updateTime: 0,
|
||||
),
|
||||
];
|
||||
|
||||
test('empty search preserves every contact and order', () {
|
||||
expect(ContactSearch.filter(contacts, ' '), contacts);
|
||||
});
|
||||
|
||||
test('matches name fragments ignoring case, whitespace and yo', () {
|
||||
for (final query in ['АЛЕН', 'тест', ' ТЕСТОВАЯ алёна ']) {
|
||||
expect(ContactSearch.filter(contacts, query).map((c) => c.id), [101]);
|
||||
}
|
||||
});
|
||||
|
||||
test('matches formatted phone, Russian prefix and partial phone', () {
|
||||
for (final query in ['+7 (900) 000-00-02', '8 (900) 000-00-02', '00002']) {
|
||||
expect(ContactSearch.filter(contacts, query).map((c) => c.id), [202]);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not search by contact ID', () {
|
||||
expect(ContactSearch.filter(contacts, '101'), isEmpty);
|
||||
});
|
||||
|
||||
test('unmatched text and bare punctuation return empty results', () {
|
||||
for (final query in ['Нет такого имени', '+', '---', 'Борис 00001']) {
|
||||
expect(ContactSearch.filter(contacts, query), isEmpty);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/contacts.dart';
|
||||
import 'package:ikar/core/contacts/contact_labels.dart';
|
||||
import 'package:ikar/core/storage/local_contact_avatars.dart';
|
||||
import 'package:ikar/core/utils/image_utils.dart';
|
||||
import 'package:ikar/frontend/widgets/qlyra_avatar.dart';
|
||||
import 'package:ikar/frontend/widgets/lost_account_dialog.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _BlockedApi extends Api {
|
||||
final List<Completer<Map<dynamic, dynamic>?>> calls = [];
|
||||
|
||||
@override
|
||||
Future<Map<dynamic, dynamic>?> sendRequestMap(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
bool silent = false,
|
||||
}) {
|
||||
final call = Completer<Map<dynamic, dynamic>?>();
|
||||
calls.add(call);
|
||||
return call.future;
|
||||
}
|
||||
}
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
final String directory;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => directory;
|
||||
}
|
||||
|
||||
Map<String, Object> _blockedPage(List<int> ids) => {
|
||||
'contacts': [
|
||||
for (final id in ids) {'id': id},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('contactMatchesQuery', () {
|
||||
test('matches names regardless of case', () {
|
||||
expect(
|
||||
contactMatchesQuery(
|
||||
'алис',
|
||||
title: 'Алиса Синтетическая',
|
||||
firstName: 'Алиса',
|
||||
lastName: 'Синтетическая',
|
||||
phone: 70000000001,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches phone digits only from three digits on', () {
|
||||
bool matches(String query) => contactMatchesQuery(
|
||||
query,
|
||||
title: 'Синтетический контакт',
|
||||
phone: 70000012345,
|
||||
);
|
||||
|
||||
expect(matches('+7 000 001'), isTrue);
|
||||
expect(matches('12'), isFalse);
|
||||
expect(matches('999'), isFalse);
|
||||
});
|
||||
|
||||
test('an empty query keeps everyone', () {
|
||||
expect(contactMatchesQuery(' ', title: 'Кто угодно'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('blocked contacts', () {
|
||||
tearDown(ContactsModule.clearBlockedCache);
|
||||
|
||||
test('load once and announce the change', () async {
|
||||
final api = _BlockedApi();
|
||||
final revision = ContactsModule.revision.value;
|
||||
|
||||
final first = ContactsModule.ensureBlockedLoaded(api);
|
||||
final second = ContactsModule.ensureBlockedLoaded(api);
|
||||
expect(api.calls, hasLength(1));
|
||||
api.calls.single.complete(_blockedPage([7, 9]));
|
||||
await Future.wait([first, second]);
|
||||
|
||||
expect(ContactsModule.blockedIds, {7, 9});
|
||||
expect(ContactsModule.revision.value, revision + 1);
|
||||
await ContactsModule.ensureBlockedLoaded(api);
|
||||
expect(api.calls, hasLength(1));
|
||||
});
|
||||
|
||||
test('a list fetched for the previous account is dropped', () async {
|
||||
final api = _BlockedApi();
|
||||
|
||||
final pending = ContactsModule.ensureBlockedLoaded(api);
|
||||
ContactsModule.clearBlockedCache();
|
||||
api.calls.single.complete(_blockedPage([5]));
|
||||
await pending;
|
||||
|
||||
expect(ContactsModule.blockedIds, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('local contact avatars', () {
|
||||
late Directory directory;
|
||||
|
||||
setUp(() {
|
||||
directory = Directory.systemTemp.createTempSync('synthetic_avatars');
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await LocalContactAvatars.instance.clear(42);
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('replacing a photo removes the previous file', () async {
|
||||
final store = LocalContactAvatars.instance;
|
||||
final revision = store.revision.value;
|
||||
|
||||
await store.set(42, Uint8List.fromList([1, 2, 3]));
|
||||
final first = (store.imageFor(42)! as FileImage).file;
|
||||
await store.set(42, Uint8List.fromList([4, 5, 6]));
|
||||
final second = (store.imageFor(42)! as FileImage).file;
|
||||
|
||||
expect(first.existsSync(), isFalse);
|
||||
expect(second.readAsBytesSync(), [4, 5, 6]);
|
||||
expect(store.revision.value, revision + 2);
|
||||
|
||||
await store.clear(42);
|
||||
expect(store.has(42), isFalse);
|
||||
expect(second.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('the avatar prefers the local photo', (tester) async {
|
||||
await tester.runAsync(
|
||||
() => LocalContactAvatars.instance.set(42, Uint8List(4)),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: QlyraAvatar(name: 'Синтетика', size: 40, userId: 42),
|
||||
),
|
||||
);
|
||||
|
||||
final image = tester.widget<Image>(find.byType(Image));
|
||||
expect((image.image as ResizeImage).imageProvider, isA<FileImage>());
|
||||
});
|
||||
});
|
||||
|
||||
test('photos are cropped to a centred square', () {
|
||||
final wide = img.Image(width: 300, height: 200);
|
||||
final huge = img.Image(width: 1200, height: 900);
|
||||
|
||||
final small = img.decodeJpg(encodeSquareAvatar(img.encodePng(wide))!)!;
|
||||
final big = img.decodeJpg(encodeSquareAvatar(img.encodePng(huge))!)!;
|
||||
|
||||
expect([small.width, small.height], [200, 200]);
|
||||
expect([big.width, big.height], [512, 512]);
|
||||
});
|
||||
|
||||
testWidgets('a lost account offers to sign in or remove it', (tester) async {
|
||||
LostAccountChoice? choice;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () async {
|
||||
choice = await showLostAccountDialog(context, name: 'Синтетика');
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('«Синтетика»'), findsOne);
|
||||
|
||||
await tester.tap(find.text('Удалить с устройства'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(choice, LostAccountChoice.remove);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/contacts.dart';
|
||||
import 'package:ikar/core/contacts/contact_search.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
final String directory;
|
||||
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic> _serverContact({
|
||||
required int id,
|
||||
required String firstName,
|
||||
required int phone,
|
||||
int accountStatus = 0,
|
||||
}) {
|
||||
return {
|
||||
'id': id,
|
||||
'phone': phone,
|
||||
'updateTime': 1,
|
||||
'accountStatus': accountStatus,
|
||||
'names': [
|
||||
{'type': 'ONEME', 'firstName': firstName, 'lastName': 'Synthetic'},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const accountId = 1;
|
||||
|
||||
setUp(() async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_contacts_deleted_test',
|
||||
);
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
await AppDatabase.init();
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: accountId,
|
||||
firstName: 'Synthetic owner',
|
||||
phone: 100000,
|
||||
country: 'ZZ',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
);
|
||||
await ContactsModule.syncFromLoginPayload({
|
||||
'contacts': [
|
||||
_serverContact(id: 11, firstName: 'Alive', phone: 700000011),
|
||||
_serverContact(
|
||||
id: 12,
|
||||
firstName: 'Gone',
|
||||
phone: 700000012,
|
||||
accountStatus: 2,
|
||||
),
|
||||
],
|
||||
}, accountId);
|
||||
});
|
||||
|
||||
test('deleted accounts are hidden from the contact list', () async {
|
||||
final visible = await ContactsModule.getContacts(accountId);
|
||||
|
||||
expect(visible.map((c) => c.id), [11]);
|
||||
expect(visible.single.isDeleted, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'ИКАР contacts remain searchable without a phonebook or visible phone',
|
||||
() async {
|
||||
await ContactsModule.syncFromLoginPayload({
|
||||
'contacts': [_serverContact(id: 13, firstName: 'Remote', phone: 0)],
|
||||
}, accountId);
|
||||
final contacts = await ContactsModule.getContacts(accountId);
|
||||
expect(ContactSearch.filter(contacts, 'remote').single.id, 13);
|
||||
expect(ContactSearch.filter(contacts, '700000011').single.id, 11);
|
||||
expect(ContactSearch.filter(contacts, 'gone'), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('deleted accounts stay available when explicitly requested', () async {
|
||||
final all = await ContactsModule.getContacts(
|
||||
accountId,
|
||||
includeDeleted: true,
|
||||
);
|
||||
|
||||
expect(all.map((c) => c.id).toSet(), {11, 12});
|
||||
expect(all.firstWhere((c) => c.id == 12).isDeleted, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'a re-synced deleted account does not come back into the list',
|
||||
() async {
|
||||
await ContactsModule.syncFromLoginPayload({
|
||||
'contacts': [
|
||||
_serverContact(
|
||||
id: 12,
|
||||
firstName: 'Gone',
|
||||
phone: 700000012,
|
||||
accountStatus: 2,
|
||||
),
|
||||
],
|
||||
}, accountId);
|
||||
|
||||
final visible = await ContactsModule.getContacts(accountId);
|
||||
|
||||
expect(visible.map((c) => c.id), [11]);
|
||||
},
|
||||
);
|
||||
|
||||
test('a contact without accountStatus is treated as alive', () async {
|
||||
await ContactsModule.syncFromLoginPayload({
|
||||
'contacts': [
|
||||
{
|
||||
'id': 13,
|
||||
'phone': 700000013,
|
||||
'updateTime': 1,
|
||||
'names': [
|
||||
{'type': 'ONEME', 'firstName': 'Legacy', 'lastName': 'Synthetic'},
|
||||
],
|
||||
},
|
||||
],
|
||||
}, accountId);
|
||||
|
||||
final visible = await ContactsModule.getContacts(accountId);
|
||||
|
||||
expect(visible.map((c) => c.id).toSet(), {11, 13});
|
||||
});
|
||||
|
||||
test('a login delta keeps contacts it does not mention', () async {
|
||||
await ContactsModule.syncFromLoginPayload({
|
||||
'contacts': [
|
||||
_serverContact(id: 13, firstName: 'Fresh', phone: 700000013),
|
||||
],
|
||||
}, accountId);
|
||||
|
||||
final all = await ContactsModule.getContacts(
|
||||
accountId,
|
||||
includeDeleted: true,
|
||||
);
|
||||
|
||||
expect(all.map((c) => c.id).toSet(), {11, 12, 13});
|
||||
});
|
||||
|
||||
test('a login delta drops contacts marked as removed', () async {
|
||||
await ContactsModule.syncFromLoginPayload({
|
||||
'contacts': [
|
||||
{
|
||||
..._serverContact(id: 11, firstName: 'Alive', phone: 700000011),
|
||||
'status': 'REMOVED',
|
||||
},
|
||||
],
|
||||
}, accountId);
|
||||
|
||||
final all = await ContactsModule.getContacts(
|
||||
accountId,
|
||||
includeDeleted: true,
|
||||
);
|
||||
|
||||
expect(all.map((c) => c.id).toSet(), {12});
|
||||
});
|
||||
|
||||
test('a full list drops contacts removed elsewhere', () async {
|
||||
await ContactsModule.applyFullContactList({
|
||||
'contacts': [
|
||||
_serverContact(
|
||||
id: 12,
|
||||
firstName: 'Gone',
|
||||
phone: 700000012,
|
||||
accountStatus: 2,
|
||||
),
|
||||
_serverContact(id: 13, firstName: 'Fresh', phone: 700000013),
|
||||
],
|
||||
}, accountId);
|
||||
|
||||
final all = await ContactsModule.getContacts(
|
||||
accountId,
|
||||
includeDeleted: true,
|
||||
);
|
||||
|
||||
expect(all.map((c) => c.id).toSet(), {12, 13});
|
||||
});
|
||||
|
||||
test('an empty full list drops contacts removed elsewhere', () async {
|
||||
final revision = ContactsModule.revision.value;
|
||||
await ContactsModule.applyFullContactList({'contacts': []}, accountId);
|
||||
|
||||
final all = await ContactsModule.getContacts(
|
||||
accountId,
|
||||
includeDeleted: true,
|
||||
);
|
||||
|
||||
expect(all, isEmpty);
|
||||
expect(ContactsModule.revision.value, revision + 1);
|
||||
});
|
||||
|
||||
test('an empty login delta keeps contacts it does not mention', () async {
|
||||
await ContactsModule.syncFromLoginPayload({'contacts': []}, accountId);
|
||||
final all = await ContactsModule.getContacts(
|
||||
accountId,
|
||||
includeDeleted: true,
|
||||
);
|
||||
expect(all.map((c) => c.id).toSet(), {11, 12});
|
||||
});
|
||||
|
||||
test('a missing full list does not discard local contacts', () async {
|
||||
await ContactsModule.applyFullContactList({}, accountId);
|
||||
final all = await ContactsModule.getContacts(
|
||||
accountId,
|
||||
includeDeleted: true,
|
||||
);
|
||||
expect(all.map((c) => c.id).toSet(), {11, 12});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/config/ios_release.dart';
|
||||
import 'package:ikar/frontend/screens/contacts/contacts_tab.dart';
|
||||
|
||||
Future<void> _pump(WidgetTester tester, VoidCallback onPressed) =>
|
||||
tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ContactsNfcExchangeButton(
|
||||
tooltip: 'Synthetic exchange',
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
tearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
testWidgets('iOS hides the contact exchange button', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
expect(IosRelease.nfcContactExchange, isFalse);
|
||||
await _pump(tester, () {});
|
||||
expect(find.byKey(const ValueKey('contacts-nfc-exchange')), findsNothing);
|
||||
expect(find.byType(IconButton), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('Android keeps the contact exchange button', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
expect(IosRelease.nfcContactExchange, isTrue);
|
||||
var taps = 0;
|
||||
await _pump(tester, () => taps++);
|
||||
await tester.tap(find.byKey(const ValueKey('contacts-nfc-exchange')));
|
||||
expect(taps, 1);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chat_preview.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
|
||||
const int _me = 1;
|
||||
const int _other = 2;
|
||||
|
||||
Map<String, dynamic> _control(int sender, Map<String, dynamic> attach) => {
|
||||
'id': '6001',
|
||||
'time': DateTime(2026, 1, 1, 12).millisecondsSinceEpoch,
|
||||
'type': 'USER',
|
||||
'sender': sender,
|
||||
'text': '',
|
||||
'attaches': [
|
||||
{'_type': 'CONTROL', ...attach},
|
||||
],
|
||||
};
|
||||
|
||||
Future<void> _pump(WidgetTester tester, Map<String, dynamic> raw) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: MessageBubble(
|
||||
message: CachedMessage.fromPushPayload(_me, 3, raw),
|
||||
isMe: raw['sender'] == _me,
|
||||
myId: _me,
|
||||
chatType: 'CHAT',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() => ContactCache.put(_other, 'Синтетик'));
|
||||
|
||||
testWidgets('my rename speaks in the second person', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
_control(_me, {'event': 'title', 'title': 'Synthetic title'}),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.text('Вы изменили название чата на «Synthetic title»'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('someone else renaming is named', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
_control(_other, {'event': 'title', 'title': 'Synthetic title'}),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.text('Синтетик изменил(а) название чата на «Synthetic title»'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a new photo shows the photo under the line', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
_control(_me, {
|
||||
'event': 'icon',
|
||||
'url': 'https://example.test/icon.jpg',
|
||||
'fullUrl': 'https://example.test/icon-full.jpg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(find.text('Вы изменили фото чата'), findsOneWidget);
|
||||
final image = tester.widget<CachedNetworkImage>(
|
||||
find.byType(CachedNetworkImage),
|
||||
);
|
||||
expect(image.imageUrl, 'https://example.test/icon.jpg');
|
||||
});
|
||||
|
||||
testWidgets('creating a chat quotes its name', (tester) async {
|
||||
await _pump(tester, _control(_me, {'event': 'new', 'title': 'Synthetic'}));
|
||||
|
||||
expect(find.text('Вы создали чат «Synthetic»'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('chat list preview', () {
|
||||
test('a rename is spelled out instead of the bare name', () {
|
||||
expect(
|
||||
messagePreviewText(
|
||||
_control(_me, {'event': 'title', 'title': 'Synthetic title'}),
|
||||
),
|
||||
'Название чата изменено на «Synthetic title»',
|
||||
);
|
||||
});
|
||||
|
||||
test('a new photo and a new chat get their own labels', () {
|
||||
expect(
|
||||
messagePreviewText(
|
||||
_control(_me, {'event': 'icon', 'url': 'https://example.test/i'}),
|
||||
),
|
||||
'Фото чата обновлено',
|
||||
);
|
||||
expect(
|
||||
messagePreviewText(
|
||||
_control(_me, {'event': 'new', 'title': 'Synthetic'}),
|
||||
),
|
||||
'Чат создан',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:ikar/frontend/widgets/attachment/editor_common.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
|
||||
Future<ui.Image> _solidImage(int width, int height) {
|
||||
final recorder = ui.PictureRecorder();
|
||||
Canvas(recorder).drawRect(
|
||||
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
|
||||
Paint()..color = const Color(0xFF3366AA),
|
||||
);
|
||||
return recorder.endRecording().toImage(width, height);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('CropView', () {
|
||||
const vp = Size(400, 800);
|
||||
|
||||
test('рамка во весь вьюпорт даёт единичный масштаб', () {
|
||||
final crop = Rect.fromCenter(
|
||||
center: const Offset(200, 400),
|
||||
width: 360,
|
||||
height: 720,
|
||||
);
|
||||
final view = CropView.fit(crop, vp);
|
||||
expect(view.scale, closeTo(1, 0.001));
|
||||
expect(view.focus, crop.center);
|
||||
});
|
||||
|
||||
test('маленькая рамка приближает и центрирует', () {
|
||||
final crop = Rect.fromLTWH(40, 80, 90, 180);
|
||||
final view = CropView.fit(crop, vp);
|
||||
expect(view.scale, closeTo(4, 0.001));
|
||||
final display = view.rect(crop, vp);
|
||||
expect(display.center.dx, closeTo(200, 0.001));
|
||||
expect(display.center.dy, closeTo(400, 0.001));
|
||||
expect(display.width, closeTo(360, 0.001));
|
||||
});
|
||||
|
||||
test('перевод координат обратим', () {
|
||||
final view = CropView.fit(Rect.fromLTWH(40, 80, 90, 180), vp);
|
||||
const point = Offset(123, 456);
|
||||
final round = view.toLogical(view.toDisplay(point, vp), vp);
|
||||
expect(round.dx, closeTo(point.dx, 0.001));
|
||||
expect(round.dy, closeTo(point.dy, 0.001));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('после зума жест по рамке двигает её в масштабе', (tester) async {
|
||||
final image = await _solidImage(400, 400);
|
||||
addTearDown(image.dispose);
|
||||
|
||||
CropState? applied;
|
||||
Size? viewport;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: CropWorkspace(
|
||||
imageSize: const Size(400, 400),
|
||||
imageBuilder: (context, matrix) =>
|
||||
CustomPaint(painter: MatrixImagePainter(image, matrix)),
|
||||
onApply: (state, vp, changed, identity) async {
|
||||
applied = state;
|
||||
viewport = vp;
|
||||
return 'ok';
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final area = tester.getRect(find.byType(LayoutBuilder).first);
|
||||
final geometry = const CropGeometry(source: Size(400, 400));
|
||||
final fitted = geometry.fittedRect(area.size);
|
||||
final corner = area.topLeft + fitted.topLeft;
|
||||
|
||||
Future<void> dragCorner(Offset from, Offset delta) async {
|
||||
final gesture = await tester.startGesture(from);
|
||||
await tester.pump();
|
||||
await gesture.moveBy(delta);
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await dragCorner(corner, const Offset(40, 40));
|
||||
|
||||
final zoomed = Rect.fromLTRB(
|
||||
fitted.left + 40,
|
||||
fitted.top + 40,
|
||||
fitted.right,
|
||||
fitted.bottom,
|
||||
);
|
||||
final view = CropView.fit(zoomed, area.size);
|
||||
expect(view.scale, greaterThan(1));
|
||||
|
||||
await dragCorner(
|
||||
area.topLeft + view.rect(zoomed, area.size).topLeft,
|
||||
const Offset(40, 40),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('ГОТОВО'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(applied, isNotNull);
|
||||
final left = applied!.cropNorm.left * viewport!.width;
|
||||
expect(left, closeTo(fitted.left + 40 + 40 / view.scale, 1.5));
|
||||
expect(left, lessThan(fitted.left + 80));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/utils/debug_session_log.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
final String directory;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Directory sessions;
|
||||
late File staleSession;
|
||||
|
||||
File currentSession() => sessions
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.where((file) => file.path.endsWith('.jsonl'))
|
||||
.reduce((a, b) => a.path.compareTo(b.path) > 0 ? a : b);
|
||||
|
||||
setUpAll(() async {
|
||||
final root = Directory.systemTemp.createTempSync('synthetic_debug_log');
|
||||
addTearDown(() => root.deleteSync(recursive: true));
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(root.path);
|
||||
sessions = Directory('${root.path}/debug_sessions')..createSync();
|
||||
|
||||
final now = DateTime.now();
|
||||
final previous = now.subtract(const Duration(hours: 1));
|
||||
File(
|
||||
'${sessions.path}/session_${previous.millisecondsSinceEpoch}.jsonl',
|
||||
).writeAsStringSync(
|
||||
[
|
||||
{
|
||||
'k': 'q',
|
||||
'op': 1,
|
||||
'seq': 5,
|
||||
't': previous.toIso8601String(),
|
||||
'p': {'probe': 'synthetic-request'},
|
||||
},
|
||||
{'k': 'l', 'v': 'synthetic log line'},
|
||||
{
|
||||
'k': 'r',
|
||||
'seq': 5,
|
||||
'cmd': 1,
|
||||
't': previous.toIso8601String(),
|
||||
'p': {'probe': 'synthetic-response'},
|
||||
},
|
||||
{'k': 'q', 'op': 2, 'seq': 6, 't': previous.toIso8601String(), 'p': {}},
|
||||
{
|
||||
'k': 'e',
|
||||
'seq': 6,
|
||||
't': previous.toIso8601String(),
|
||||
'err': 'synthetic failure',
|
||||
},
|
||||
].map(jsonEncode).join('\n'),
|
||||
);
|
||||
|
||||
final legacy = now.subtract(const Duration(hours: 2));
|
||||
File(
|
||||
'${sessions.path}/session_${legacy.millisecondsSinceEpoch}.json',
|
||||
).writeAsStringSync(
|
||||
jsonEncode({
|
||||
'startedAt': legacy.toIso8601String(),
|
||||
'entries': [],
|
||||
'logs': ['synthetic legacy line'],
|
||||
}),
|
||||
);
|
||||
|
||||
final stale = now.subtract(const Duration(hours: 48));
|
||||
staleSession = File(
|
||||
'${sessions.path}/session_${stale.millisecondsSinceEpoch}.jsonl',
|
||||
)..writeAsStringSync('');
|
||||
|
||||
await DebugSessionLog.instance.init();
|
||||
});
|
||||
|
||||
test('sessions older than a day are rotated away', () {
|
||||
expect(staleSession.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('new records are appended instead of rewriting the session', () async {
|
||||
final log = DebugSessionLog.instance;
|
||||
log.recordLogLine('first synthetic line');
|
||||
log.recordRequest(1, 7, {'probe': 'first'});
|
||||
await log.flushNow();
|
||||
final before = currentSession().readAsStringSync();
|
||||
|
||||
log.recordResponse(7, 1, {'probe': 'answer'});
|
||||
log.recordLogLine('second synthetic line');
|
||||
await log.flushNow();
|
||||
final after = currentSession().readAsStringSync();
|
||||
|
||||
expect(after.startsWith(before), isTrue);
|
||||
expect(const LineSplitter().convert(after).length, 4);
|
||||
});
|
||||
|
||||
test('a payload with non-string keys still reaches the file', () async {
|
||||
final log = DebugSessionLog.instance;
|
||||
log.recordRequest(3, 8, {1: 'numeric key'});
|
||||
await log.flushNow();
|
||||
|
||||
final last = const LineSplitter()
|
||||
.convert(currentSession().readAsStringSync())
|
||||
.last;
|
||||
expect(jsonDecode(last)['p'], {'1': 'numeric key'});
|
||||
});
|
||||
|
||||
test('export reads both the appended and the legacy format', () async {
|
||||
final files = await DebugSessionLog.instance.buildExportFiles();
|
||||
final text = files!.map((file) => file.content).join('\n');
|
||||
|
||||
expect(text, contains('synthetic-request'));
|
||||
expect(text, contains('synthetic-response'));
|
||||
expect(text, contains('synthetic log line'));
|
||||
expect(text, contains('<= ошибка: synthetic failure'));
|
||||
expect(text, contains('synthetic legacy line'));
|
||||
expect(text, contains('first synthetic line'));
|
||||
expect(files.where((file) => file.name.startsWith('session_')).length, 3);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/config/qlyra_settings.dart';
|
||||
import 'package:ikar/core/media/deleted_media_keeper.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:ikar/core/utils/media_cache.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
final String directory;
|
||||
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => directory;
|
||||
}
|
||||
|
||||
const _accountId = 1;
|
||||
const _chatId = 900101;
|
||||
const _photoId = 555001;
|
||||
const _photoUrl = 'https://synthetic.invalid/photo/555001';
|
||||
final _photoBytes = List<int>.generate(64, (i) => i);
|
||||
|
||||
Map<String, dynamic> _photoMessageRow(String id) => CachedMessage(
|
||||
id: id,
|
||||
accountId: _accountId,
|
||||
chatId: _chatId,
|
||||
senderId: 20,
|
||||
time: 1000,
|
||||
payload: {
|
||||
'attaches': [
|
||||
{'_type': 'PHOTO', 'photoId': _photoId, 'baseUrl': _photoUrl},
|
||||
],
|
||||
},
|
||||
).toDbRow();
|
||||
|
||||
Future<Map<String, dynamic>?> _attachOf(String id) async {
|
||||
final row = await AppDatabase.loadMessage(_accountId, _chatId, id);
|
||||
final payload = jsonDecode(row!['payload'] as String) as Map;
|
||||
return Map<String, dynamic>.from((payload['attaches'] as List).first as Map);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_deleted_media_test',
|
||||
);
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
MediaCache.resetForTesting();
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
MediaCache.resetForTesting();
|
||||
QlyraSettings.viewDeleted.value = false;
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
await AppDatabase.init();
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: _accountId,
|
||||
firstName: 'Synthetic owner',
|
||||
phone: 100000,
|
||||
country: 'ZZ',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
);
|
||||
await AppDatabase.saveChats([
|
||||
CachedChat(
|
||||
id: _chatId,
|
||||
accountId: _accountId,
|
||||
type: 'DIALOG',
|
||||
title: 'Synthetic dialog',
|
||||
unreadCount: 0,
|
||||
lastEventTime: 0,
|
||||
cachedAt: 0,
|
||||
dontDisturbUntil: 0,
|
||||
isOnline: false,
|
||||
seenTime: 0,
|
||||
participants: const {_accountId: 0, 20: 0},
|
||||
).toDbRow()..['in_list'] = 1,
|
||||
]);
|
||||
QlyraSettings.viewDeleted.value = true;
|
||||
});
|
||||
|
||||
test('a deleted photo keeps a pinned copy the payload points at', () async {
|
||||
await AppDatabase.saveMessages([_photoMessageRow('101')]);
|
||||
final downloaded = await MediaCache.fileFor('photo_$_photoId.jpg');
|
||||
await downloaded.writeAsBytes(_photoBytes);
|
||||
|
||||
await keepDeletedMedia(_accountId, _chatId, const ['101']);
|
||||
|
||||
final attach = await _attachOf('101');
|
||||
final kept = attach!['localPath'] as String?;
|
||||
expect(kept, isNotNull);
|
||||
expect(kept, contains(MediaCache.keptPrefix));
|
||||
expect(await File(kept!).readAsBytes(), _photoBytes);
|
||||
});
|
||||
|
||||
test('without the setting nothing is copied', () async {
|
||||
QlyraSettings.viewDeleted.value = false;
|
||||
await AppDatabase.saveMessages([_photoMessageRow('102')]);
|
||||
final downloaded = await MediaCache.fileFor('photo_$_photoId.jpg');
|
||||
await downloaded.writeAsBytes(_photoBytes);
|
||||
|
||||
await keepDeletedMedia(_accountId, _chatId, const ['102']);
|
||||
|
||||
expect((await _attachOf('102'))!['localPath'], isNull);
|
||||
});
|
||||
|
||||
test('media payload update cannot overwrite a concurrent edit', () async {
|
||||
final original = _photoMessageRow('104');
|
||||
await AppDatabase.saveMessages([original]);
|
||||
await AppDatabase.saveMessages([
|
||||
Map<String, dynamic>.from(original)..['text'] = 'edited'
|
||||
..['payload'] = jsonEncode({'text': 'edited'}),
|
||||
]);
|
||||
await AppDatabase.updateMessageMediaPayload(
|
||||
_accountId, _chatId, '104', original['payload'] as String,
|
||||
jsonEncode({'attaches': [], 'localPath': 'obsolete'}),
|
||||
);
|
||||
final current = await AppDatabase.loadMessage(_accountId, _chatId, '104');
|
||||
expect(current!['text'], 'edited');
|
||||
expect(jsonDecode(current['payload'] as String), {'text': 'edited'});
|
||||
});
|
||||
|
||||
test('a dead link without a cached copy leaves the payload alone', () async {
|
||||
await AppDatabase.saveMessages([_photoMessageRow('103')]);
|
||||
|
||||
await keepDeletedMedia(_accountId, _chatId, const ['103']);
|
||||
|
||||
expect((await _attachOf('103'))!['localPath'], isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/utils/download_history.dart';
|
||||
import 'package:ikar/core/utils/media_cache.dart';
|
||||
import 'package:ikar/frontend/screens/downloads_screen.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
final String directory;
|
||||
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('download kinds are inferred from synthetic file names', () {
|
||||
expect(downloadKindForName('sample.gif'), DownloadKind.gif);
|
||||
expect(downloadKindForName('clip.mp4'), DownloadKind.video);
|
||||
expect(downloadKindForName('sound.ogg'), DownloadKind.audio);
|
||||
expect(downloadKindForName('picture.png'), DownloadKind.photo);
|
||||
expect(downloadKindForName('package.zip'), DownloadKind.file);
|
||||
});
|
||||
|
||||
testWidgets('recent downloads show persisted metadata', (tester) async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_downloads_test',
|
||||
);
|
||||
addTearDown(() {
|
||||
DownloadHistory.resetForTesting();
|
||||
MediaCache.resetForTesting();
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
MediaCache.resetForTesting();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
await tester.runAsync(() async {
|
||||
DownloadHistory.resetForTesting();
|
||||
final file = await MediaCache.fileFor('synthetic-package.zip');
|
||||
await file.writeAsBytes(List<int>.filled(2048, 1));
|
||||
await DownloadHistory.record(
|
||||
const DownloadMetadata(
|
||||
cacheName: 'synthetic-package.zip',
|
||||
name: 'sample-package.zip',
|
||||
kind: DownloadKind.file,
|
||||
sourceName: 'Synthetic channel',
|
||||
chatId: 77,
|
||||
messageId: 'synthetic-message',
|
||||
messageTime: 123456,
|
||||
),
|
||||
file,
|
||||
);
|
||||
DownloadHistory.resetForTesting();
|
||||
await DownloadHistory.load();
|
||||
});
|
||||
|
||||
final record = DownloadHistory.records.value.single;
|
||||
expect(record.chatId, 77);
|
||||
expect(record.messageId, 'synthetic-message');
|
||||
expect(record.messageTime, 123456);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
locale: Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: DownloadsScreen(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Недавние загрузки'), findsOneWidget);
|
||||
expect(find.text('sample-package.zip'), findsOneWidget);
|
||||
expect(find.text('2.0 КБ · Synthetic channel'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('downloads-settings')), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('download-more-synthetic-package.zip')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Перейти к сообщению'), findsOneWidget);
|
||||
expect(find.text('Сохранить как…'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/crypto/e2ee_service.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:komet_crypto/komet_crypto.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
const _accountId = 101;
|
||||
const _chatId = 202;
|
||||
const _peerId = 303;
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
final String directory;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
Uint8List _bytes(String text) => Uint8List.fromList(utf8.encode(text));
|
||||
|
||||
({Uint8List sender, Uint8List receiver, String offer, String answer})
|
||||
_session() {
|
||||
final senderIdentity = KometCrypto.identityCreate(
|
||||
seed: Uint8List.fromList(List.filled(32, 3)),
|
||||
);
|
||||
final receiverIdentity = KometCrypto.identityCreate(
|
||||
seed: Uint8List.fromList(List.filled(32, 7)),
|
||||
);
|
||||
final offer = KometCrypto.sessionOffer(
|
||||
identity: senderIdentity,
|
||||
chatId: _chatId,
|
||||
myId: _peerId,
|
||||
peerId: _accountId,
|
||||
);
|
||||
final answer = KometCrypto.sessionAnswer(
|
||||
identity: receiverIdentity,
|
||||
chatId: _chatId,
|
||||
myId: _accountId,
|
||||
peerId: _peerId,
|
||||
offerText: offer.text,
|
||||
);
|
||||
final senderState = KometCrypto.sessionAccept(
|
||||
identity: senderIdentity,
|
||||
pending: offer.pending,
|
||||
answerText: answer.text,
|
||||
);
|
||||
return (
|
||||
sender: senderState,
|
||||
receiver: answer.state,
|
||||
offer: offer.text,
|
||||
answer: answer.text,
|
||||
);
|
||||
}
|
||||
|
||||
CachedMessage _incoming(String id, String text, int time) => CachedMessage(
|
||||
id: id,
|
||||
accountId: _accountId,
|
||||
chatId: _chatId,
|
||||
senderId: _peerId,
|
||||
time: time,
|
||||
text: text,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final libraryPath = Platform.environment['KOMET_CRYPTO_TEST_LIB'];
|
||||
if (libraryPath != null) KometCrypto.libraryPath = libraryPath;
|
||||
final available = KometCrypto.isAvailable;
|
||||
late E2eeService service;
|
||||
late Uint8List localKey;
|
||||
|
||||
Future<void> saveSession(Uint8List state) => AppDatabase.saveE2eeSession({
|
||||
'account_id': _accountId,
|
||||
'chat_id': _chatId,
|
||||
'peer_id': _peerId,
|
||||
'phase': 'established',
|
||||
'verified': 1,
|
||||
'updated': 1,
|
||||
'state': KometCrypto.localSeal(
|
||||
key: localKey,
|
||||
plaintext: state,
|
||||
aad: _bytes('session/101/202'),
|
||||
),
|
||||
});
|
||||
|
||||
group('native history recovery', () {
|
||||
setUpAll(AppDatabase.init);
|
||||
|
||||
setUp(() async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_e2ee_history',
|
||||
);
|
||||
final previousPathProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
localKey = Uint8List.fromList(List.filled(32, 9));
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
'e2ee_local_101': base64Encode(localKey),
|
||||
});
|
||||
service = E2eeService();
|
||||
addTearDown(() async {
|
||||
service.forgetAccount(_accountId);
|
||||
await AppDatabase.close();
|
||||
PathProviderPlatform.instance = previousPathProvider;
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: _accountId,
|
||||
firstName: 'Synthetic receiver',
|
||||
phone: 100000,
|
||||
country: 'ZZ',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
);
|
||||
await AppDatabase.saveChats([
|
||||
{
|
||||
'id': _chatId,
|
||||
'account_id': _accountId,
|
||||
'type': 'DIALOG',
|
||||
'cached_at': 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'recovers cached ciphertext and keeps the recovered ratchet state',
|
||||
() async {
|
||||
final pair = _session();
|
||||
final missed = KometCrypto.sessionEncrypt(
|
||||
state: pair.sender,
|
||||
contentType: ContentType.text,
|
||||
plaintext: _bytes('Synthetic missed message'),
|
||||
);
|
||||
final next = KometCrypto.sessionEncrypt(
|
||||
state: missed.state,
|
||||
contentType: ContentType.text,
|
||||
plaintext: _bytes('Synthetic next message'),
|
||||
);
|
||||
final advanced = KometCrypto.sessionDecrypt(
|
||||
state: pair.receiver,
|
||||
text: next.text,
|
||||
);
|
||||
await saveSession(advanced.state);
|
||||
final message = _incoming('synthetic-missed', missed.text, 1);
|
||||
await AppDatabase.saveMessages([message.toDbRow()]);
|
||||
|
||||
final recovered = await service.inspectHistory(_accountId, _chatId, [
|
||||
message,
|
||||
]);
|
||||
expect(recovered.single.e2ee, CachedMessage.e2eeText);
|
||||
expect(
|
||||
await service.openText(
|
||||
_accountId,
|
||||
_chatId,
|
||||
recovered.single.sealedText!,
|
||||
),
|
||||
'Synthetic missed message',
|
||||
);
|
||||
final recoveredState = await AppDatabase.loadE2eeSession(
|
||||
_accountId,
|
||||
_chatId,
|
||||
);
|
||||
final repeated = await service.inspectHistory(_accountId, _chatId, [
|
||||
message,
|
||||
]);
|
||||
expect(repeated.single.e2ee, CachedMessage.e2eeText);
|
||||
expect(repeated.single.sealedText, recovered.single.sealedText);
|
||||
expect(
|
||||
(await AppDatabase.loadE2eeSession(_accountId, _chatId))?['state'],
|
||||
recoveredState?['state'],
|
||||
);
|
||||
final following = KometCrypto.sessionEncrypt(
|
||||
state: next.state,
|
||||
contentType: ContentType.text,
|
||||
plaintext: _bytes('Synthetic following message'),
|
||||
);
|
||||
final decrypted = await service.inspect(
|
||||
_incoming('synthetic-following', following.text, 3),
|
||||
);
|
||||
expect(decrypted.e2ee, CachedMessage.e2eeText);
|
||||
expect(
|
||||
await service.openText(_accountId, _chatId, decrypted.sealedText!),
|
||||
'Synthetic following message',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'concurrent history recovery reuses the first decrypted result',
|
||||
() async {
|
||||
final pair = _session();
|
||||
final encrypted = KometCrypto.sessionEncrypt(
|
||||
state: pair.sender,
|
||||
contentType: ContentType.text,
|
||||
plaintext: _bytes('Synthetic concurrent message'),
|
||||
);
|
||||
await saveSession(pair.receiver);
|
||||
final message = _incoming('synthetic-concurrent', encrypted.text, 1);
|
||||
await AppDatabase.saveMessages([message.toDbRow()]);
|
||||
|
||||
final results = await Future.wait([
|
||||
service.inspectHistory(_accountId, _chatId, [message]),
|
||||
service.inspectHistory(_accountId, _chatId, [message]),
|
||||
]);
|
||||
expect(results.map((batch) => batch.single.e2ee), [
|
||||
CachedMessage.e2eeText,
|
||||
CachedMessage.e2eeText,
|
||||
]);
|
||||
expect(results.first.single.sealedText, isNotNull);
|
||||
expect(results.last.single.sealedText, results.first.single.sealedText);
|
||||
final stored = await AppDatabase.loadMessagesByIds(
|
||||
_accountId,
|
||||
_chatId,
|
||||
[message.id],
|
||||
);
|
||||
expect(stored.single['e2ee'], CachedMessage.e2eeText);
|
||||
expect(stored.single['text_sealed'], results.first.single.sealedText);
|
||||
},
|
||||
);
|
||||
|
||||
for (final throughHistory in [false, true]) {
|
||||
test(
|
||||
'decrypts edited ciphertext with the same id through ${throughHistory ? 'history' : 'inspect'}',
|
||||
() async {
|
||||
final pair = _session();
|
||||
await saveSession(pair.receiver);
|
||||
final original = KometCrypto.sessionEncrypt(
|
||||
state: pair.sender,
|
||||
contentType: ContentType.text,
|
||||
plaintext: _bytes('Synthetic original message'),
|
||||
);
|
||||
final message = _incoming('synthetic-edited', original.text, 1);
|
||||
final first = await service.inspect(
|
||||
message,
|
||||
commit: (decrypted) =>
|
||||
AppDatabase.saveMessages([decrypted.toDbRow()]),
|
||||
);
|
||||
expect(first.e2ee, CachedMessage.e2eeText);
|
||||
|
||||
final edited = KometCrypto.sessionEncrypt(
|
||||
state: original.state,
|
||||
contentType: ContentType.text,
|
||||
plaintext: _bytes('Synthetic edited message'),
|
||||
);
|
||||
final editedMessage = _incoming('synthetic-edited', edited.text, 2);
|
||||
final recovered = throughHistory
|
||||
? (await service.inspectHistory(_accountId, _chatId, [
|
||||
editedMessage,
|
||||
])).single
|
||||
: await service.inspect(
|
||||
editedMessage,
|
||||
commit: (decrypted) =>
|
||||
AppDatabase.saveMessages([decrypted.toDbRow()]),
|
||||
);
|
||||
expect(recovered.e2ee, CachedMessage.e2eeText);
|
||||
expect(
|
||||
await service.openText(_accountId, _chatId, recovered.sealedText!),
|
||||
'Synthetic edited message',
|
||||
);
|
||||
final following = KometCrypto.sessionEncrypt(
|
||||
state: edited.state,
|
||||
contentType: ContentType.text,
|
||||
plaintext: _bytes('Synthetic following edit'),
|
||||
);
|
||||
final next = await service.inspect(
|
||||
_incoming('synthetic-next-edit', following.text, 3),
|
||||
);
|
||||
expect(next.e2ee, CachedMessage.e2eeText);
|
||||
expect(
|
||||
await service.openText(_accountId, _chatId, next.sealedText!),
|
||||
'Synthetic following edit',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'preserves cached legacy ciphertext that has a session-sized envelope',
|
||||
() async {
|
||||
final pair = _session();
|
||||
await saveSession(pair.receiver);
|
||||
final legacyKey = Uint8List.fromList(List.filled(32, 11));
|
||||
final plaintext = 'Synthetic legacy payload'.padRight(58, '.');
|
||||
final ciphertext = KometCrypto.encryptMessage(plaintext, legacyKey);
|
||||
expect(KometCrypto.classifyText(ciphertext), TextClass.legacy);
|
||||
final message = _incoming('synthetic-legacy', ciphertext, 1);
|
||||
await AppDatabase.saveMessages([message.toDbRow()]);
|
||||
await service.ensureLoaded(_accountId);
|
||||
final sessionBefore = await AppDatabase.loadE2eeSession(
|
||||
_accountId,
|
||||
_chatId,
|
||||
);
|
||||
|
||||
final result = await service.inspectHistory(_accountId, _chatId, [
|
||||
message,
|
||||
]);
|
||||
expect(result.single.e2ee, CachedMessage.e2eeNone);
|
||||
expect(result.single.sealedText, isNull);
|
||||
expect(
|
||||
KometCrypto.decryptMessage(result.single.text!, legacyKey),
|
||||
plaintext,
|
||||
);
|
||||
expect(
|
||||
(await AppDatabase.loadE2eeSession(_accountId, _chatId))?['state'],
|
||||
sessionBefore?['state'],
|
||||
);
|
||||
final stored = await AppDatabase.loadMessagesByIds(
|
||||
_accountId,
|
||||
_chatId,
|
||||
[message.id],
|
||||
);
|
||||
expect(stored.single['e2ee'], CachedMessage.e2eeNone);
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps cached ordinary messages and handshakes unchanged', () async {
|
||||
final pair = _session();
|
||||
await saveSession(pair.receiver);
|
||||
final messages = [
|
||||
_incoming('synthetic-ordinary', 'Synthetic ordinary history', 1),
|
||||
_incoming('synthetic-offer', pair.offer, 2),
|
||||
_incoming('synthetic-answer', pair.answer, 3),
|
||||
];
|
||||
await AppDatabase.saveMessages(messages.map((m) => m.toDbRow()).toList());
|
||||
await service.ensureLoaded(_accountId);
|
||||
final revision = service.revision.value;
|
||||
final state = await AppDatabase.loadE2eeSession(_accountId, _chatId);
|
||||
|
||||
final result = await service.inspectHistory(
|
||||
_accountId,
|
||||
_chatId,
|
||||
messages,
|
||||
);
|
||||
expect(result.map((m) => m.text), messages.map((m) => m.text));
|
||||
expect(result.every((m) => m.e2ee == CachedMessage.e2eeNone), isTrue);
|
||||
expect(service.phaseOf(_accountId, _chatId), E2eePhase.established);
|
||||
expect(service.info(_accountId, _chatId)?.verified, isTrue);
|
||||
expect(service.revision.value, revision);
|
||||
expect(
|
||||
(await AppDatabase.loadE2eeSession(_accountId, _chatId))?['state'],
|
||||
state?['state'],
|
||||
);
|
||||
});
|
||||
}, skip: !available);
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet_crypto/komet_crypto.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/backend/modules/outgoing_text.dart';
|
||||
import 'package:ikar/backend/modules/outbox_queue.dart';
|
||||
import 'package:ikar/core/crypto/e2ee_service.dart';
|
||||
import 'package:ikar/core/crypto/encrypted_photo.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:ikar/core/utils/media_cache.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
class _Paths extends PathProviderPlatform {
|
||||
_Paths(this.path);
|
||||
final String path;
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => path;
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => path;
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => path;
|
||||
}
|
||||
|
||||
class _Messages extends MessagesModule {
|
||||
_Messages() : super(Api());
|
||||
final wire = <CachedMessage>[];
|
||||
@override
|
||||
Future<String> sendMessage(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String text, {
|
||||
void Function()? beforeSend,
|
||||
int? clientId,
|
||||
bool notify = true,
|
||||
int? scheduledTime,
|
||||
int? replyToMessageId,
|
||||
int? replySourceChatId,
|
||||
List<Map<String, dynamic>> elements = const [],
|
||||
}) async {
|
||||
beforeSend?.call();
|
||||
final id = '${wire.length + 100}';
|
||||
wire.add(
|
||||
CachedMessage(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
senderId: accountId,
|
||||
text: text,
|
||||
time: wire.length + 100,
|
||||
),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> editMessage(
|
||||
int chatId,
|
||||
String messageId, {
|
||||
required String text,
|
||||
List<Map<String, dynamic>> elements = const [],
|
||||
bool sendAttachments = false,
|
||||
}) async => true;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
|
||||
final service = E2eeService.instance;
|
||||
late Directory dir;
|
||||
late _Messages transport;
|
||||
final secrets = <String, String>{};
|
||||
final previousPaths = PathProviderPlatform.instance;
|
||||
|
||||
CachedMessage incoming(CachedMessage wire, int recipient) => CachedMessage(
|
||||
id: wire.id,
|
||||
accountId: recipient,
|
||||
chatId: wire.chatId,
|
||||
senderId: wire.senderId,
|
||||
text: wire.text,
|
||||
time: wire.time,
|
||||
);
|
||||
|
||||
Future<void> handshake() async {
|
||||
expect(
|
||||
await service.startOffer(accountId: 42, chatId: 7, peerId: 84),
|
||||
isTrue,
|
||||
);
|
||||
final offer = incoming(transport.wire.last, 84);
|
||||
await service.inspectHistory(84, 7, [offer]);
|
||||
expect(service.phaseOf(84, 7), E2eePhase.pendingConsent);
|
||||
expect(await service.acceptOffer(accountId: 84, chatId: 7), isTrue);
|
||||
await service.inspect(incoming(transport.wire.last, 42));
|
||||
expect(service.phaseOf(42, 7), E2eePhase.established);
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
KometCrypto.libraryPath =
|
||||
Platform.environment['QLYRA_E2EE_LIBRARY'] ??
|
||||
'${Directory.current.path}/build/native-tests/crypto-core/Debug/komet_crypto.dll';
|
||||
expect(
|
||||
KometCrypto.isAvailable,
|
||||
isTrue,
|
||||
reason: 'Build the C crypto library first',
|
||||
);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
secrets.clear();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
final args = call.arguments as Map;
|
||||
switch (call.method) {
|
||||
case 'read':
|
||||
return secrets[args['key']];
|
||||
case 'write':
|
||||
secrets[args['key'] as String] = args['value'] as String;
|
||||
return null;
|
||||
case 'delete':
|
||||
secrets.remove(args['key']);
|
||||
return null;
|
||||
case 'readAll':
|
||||
return secrets;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
dir = await Directory.systemTemp.createTemp('qlyra-e2ee-test-');
|
||||
PathProviderPlatform.instance = _Paths(dir.path);
|
||||
MediaCache.resetForTesting();
|
||||
await AppDatabase.init();
|
||||
for (final account in [42, 84]) {
|
||||
service.forgetAccount(account);
|
||||
await AppDatabase.saveProfile(ProfileData.stub(account));
|
||||
await AppDatabase.saveChats([
|
||||
{'id': 7, 'account_id': account, 'type': 'DIALOG', 'cached_at': 1},
|
||||
]);
|
||||
}
|
||||
transport = _Messages();
|
||||
service.attach(transport);
|
||||
});
|
||||
tearDown(() async {
|
||||
service.forgetAccount(42);
|
||||
service.forgetAccount(84);
|
||||
await AppDatabase.close();
|
||||
MediaCache.resetForTesting();
|
||||
PathProviderPlatform.instance = previousPaths;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
await dir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test(
|
||||
'history establishes a session; fingerprints match; restart preserves decryption',
|
||||
() async {
|
||||
await handshake();
|
||||
expect(
|
||||
await service.fingerprint(42, 7),
|
||||
await service.fingerprint(84, 7),
|
||||
);
|
||||
final wire = await service.encryptText(42, 7, 'synthetic secret');
|
||||
final received = CachedMessage(
|
||||
id: '200',
|
||||
accountId: 84,
|
||||
chatId: 7,
|
||||
senderId: 42,
|
||||
text: wire,
|
||||
time: 200,
|
||||
);
|
||||
final decoded = await service.inspect(
|
||||
received,
|
||||
commit: (m) => AppDatabase.saveMessages([m.toDbRow()]),
|
||||
);
|
||||
expect(decoded.e2ee, CachedMessage.e2eeText);
|
||||
service.forgetAccount(84);
|
||||
await service.ensureLoaded(84);
|
||||
expect(
|
||||
await service.openText(84, 7, decoded.sealedText!),
|
||||
'synthetic secret',
|
||||
);
|
||||
final row = (await AppDatabase.loadMessage(84, 7, '200'))!;
|
||||
expect(row['text'], wire);
|
||||
expect(
|
||||
row.values.whereType<String>().join(),
|
||||
isNot(contains('synthetic secret')),
|
||||
);
|
||||
final history = await service.inspectHistory(84, 7, [received]);
|
||||
expect(history.single.sealedText, decoded.sealedText);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'outbox retry keeps ciphertext, cid and the sealed local copy',
|
||||
() async {
|
||||
await handshake();
|
||||
final wire = (await service.encryptText(42, 7, 'queued secret'))!;
|
||||
final sealed = await service.sealText(42, 7, 'queued secret');
|
||||
CachedMessage saved = OutgoingText.prepare(
|
||||
accountId: 42,
|
||||
chatId: 7,
|
||||
wireText: wire,
|
||||
encrypted: true,
|
||||
sealedText: sealed,
|
||||
e2ee: CachedMessage.e2eeText,
|
||||
);
|
||||
var fail = true;
|
||||
var clock = DateTime(2026);
|
||||
final requests = <(String, int)>[];
|
||||
OutboxQueue queue() => OutboxQueue(
|
||||
activeAccount: () async => 42,
|
||||
isOnline: () => true,
|
||||
sessionEpoch: () => 1,
|
||||
now: () => clock,
|
||||
load: (_) async => saved.status == 'pending' ? [saved] : [],
|
||||
save: (m) async {
|
||||
saved = CachedMessage.fromDbRow(m.toDbRow());
|
||||
},
|
||||
updatePending: (m) async {
|
||||
saved = CachedMessage.fromDbRow(m.toDbRow());
|
||||
return true;
|
||||
},
|
||||
replace: (_, m) async {
|
||||
saved = CachedMessage.fromDbRow(m.toDbRow());
|
||||
},
|
||||
send: (m, cid) async {
|
||||
requests.add((m.text!, cid));
|
||||
if (fail) throw TimeoutException('lost ack');
|
||||
return '250';
|
||||
},
|
||||
onChanged: (_, _) {},
|
||||
onError: (_, _) {},
|
||||
);
|
||||
final first = queue();
|
||||
await first.flush();
|
||||
first.dispose();
|
||||
service.lock();
|
||||
clock = clock.add(const Duration(minutes: 1));
|
||||
fail = false;
|
||||
final second = queue();
|
||||
await second.flush();
|
||||
second.dispose();
|
||||
expect(requests[0], requests[1]);
|
||||
expect(saved.status, 'sent');
|
||||
expect(saved.e2ee, CachedMessage.e2eeText);
|
||||
expect(await service.openText(42, 7, saved.sealedText!), 'queued secret');
|
||||
final peer = await service.inspect(incoming(saved, 84));
|
||||
expect(await service.openText(84, 7, peer.sealedText!), 'queued secret');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'own scheduled message can be recovered from history after restart',
|
||||
() async {
|
||||
await handshake();
|
||||
final wire = await service.encryptText(42, 7, 'scheduled secret');
|
||||
service.forgetAccount(42);
|
||||
final restored = (await service.inspectHistory(42, 7, [
|
||||
CachedMessage(
|
||||
id: '350',
|
||||
accountId: 42,
|
||||
chatId: 7,
|
||||
senderId: 42,
|
||||
text: wire,
|
||||
time: 350,
|
||||
),
|
||||
])).single;
|
||||
expect(
|
||||
await service.openText(42, 7, restored.sealedText!),
|
||||
'scheduled secret',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'schema 23 migration preserves existing messages and adds crypto storage',
|
||||
() async {
|
||||
await AppDatabase.saveMessages([
|
||||
CachedMessage(
|
||||
id: 'legacy',
|
||||
accountId: 42,
|
||||
chatId: 7,
|
||||
senderId: 42,
|
||||
text: 'legacy synthetic',
|
||||
time: 1,
|
||||
).toDbRow(),
|
||||
]);
|
||||
await AppDatabase.close();
|
||||
final db = await databaseFactoryFfi.openDatabase('${dir.path}/qlyra.db');
|
||||
await db.execute('DROP TABLE e2ee_outgoing');
|
||||
await db.execute('DROP TABLE e2ee_sessions');
|
||||
await db.execute('ALTER TABLE messages DROP COLUMN text_sealed');
|
||||
await db.execute('ALTER TABLE messages DROP COLUMN e2ee');
|
||||
await db.setVersion(23);
|
||||
await db.close();
|
||||
expect(
|
||||
(await AppDatabase.loadMessage(42, 7, 'legacy'))!['text'],
|
||||
'legacy synthetic',
|
||||
);
|
||||
await handshake();
|
||||
expect(await service.encryptText(42, 7, 'after migration'), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'server plaintext cannot replace an authenticated cached message',
|
||||
() async {
|
||||
await handshake();
|
||||
final wire = await service.encryptText(42, 7, 'authentic');
|
||||
final source = CachedMessage(
|
||||
id: '360',
|
||||
accountId: 84,
|
||||
chatId: 7,
|
||||
senderId: 42,
|
||||
text: wire,
|
||||
time: 360,
|
||||
);
|
||||
await service.inspect(
|
||||
source,
|
||||
commit: (m) => AppDatabase.saveMessages([m.toDbRow()]),
|
||||
);
|
||||
final result = (await service.inspectHistory(84, 7, [
|
||||
CachedMessage(
|
||||
id: source.id,
|
||||
accountId: source.accountId,
|
||||
chatId: source.chatId,
|
||||
senderId: source.senderId,
|
||||
text: 'forged',
|
||||
time: source.time,
|
||||
),
|
||||
])).single;
|
||||
expect(result.text, wire);
|
||||
expect(await service.openText(84, 7, result.sealedText!), 'authentic');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'failed export file write leaves the source session available',
|
||||
() async {
|
||||
await handshake();
|
||||
final result = await service.exportTransfer(
|
||||
42,
|
||||
'test password',
|
||||
persist: (_) async {
|
||||
throw const FileSystemException('synthetic write failure');
|
||||
},
|
||||
);
|
||||
expect(result, isNull);
|
||||
expect(service.isActive(42, 7), isTrue);
|
||||
expect(await service.encryptText(42, 7, 'still available'), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('photo uses a separate file key and an encrypted ticket', () async {
|
||||
await handshake();
|
||||
final png = Uint8List.fromList([137, 80, 78, 71]);
|
||||
final key = KometCrypto.randomBytes(KometCryptoSizes.fileKey);
|
||||
final nonce = KometCrypto.randomBytes(KometCryptoSizes.fileNonce);
|
||||
final seal = KometCrypto.fileSealer(key, nonce);
|
||||
final encrypted = File('${dir.path}/synthetic.kce');
|
||||
await encrypted.writeAsBytes(seal.chunk(png, last: true));
|
||||
seal.dispose();
|
||||
final ticket = Uint8List(key.length + nonce.length + 8)
|
||||
..setAll(0, key)
|
||||
..setAll(key.length, nonce);
|
||||
ticket[ticket.length - 1] = png.length;
|
||||
final wire = await service.encryptBytes(42, 7, ContentType.file, ticket);
|
||||
final received = await service.inspect(
|
||||
CachedMessage(
|
||||
id: '300',
|
||||
accountId: 84,
|
||||
chatId: 7,
|
||||
senderId: 42,
|
||||
text: wire,
|
||||
time: 300,
|
||||
),
|
||||
);
|
||||
expect(received.e2ee, CachedMessage.e2eeFile);
|
||||
final opened = await openE2eePhoto(
|
||||
encrypted: encrypted,
|
||||
ticket: (await service.openBytes(84, 7, received.sealedText!))!,
|
||||
);
|
||||
expect(opened.isOk, isTrue);
|
||||
expect(await opened.file!.readAsBytes(), png);
|
||||
});
|
||||
|
||||
test(
|
||||
'transfer rejects wrong password and restores a session after restart',
|
||||
() async {
|
||||
await handshake();
|
||||
final before = await service.encryptText(42, 7, 'before transfer');
|
||||
await service.inspect(
|
||||
CachedMessage(
|
||||
id: '390',
|
||||
accountId: 84,
|
||||
chatId: 7,
|
||||
senderId: 42,
|
||||
text: before,
|
||||
time: 390,
|
||||
),
|
||||
);
|
||||
final originalPublic = await service.identityPublic(42);
|
||||
final transfer = await service.exportTransfer(
|
||||
42,
|
||||
'synthetic transfer password',
|
||||
);
|
||||
expect(transfer, isNotNull);
|
||||
expect(service.isActive(42, 7), isFalse);
|
||||
expect(await service.importTransfer(42, transfer!, 'wrong'), isNull);
|
||||
final peerIdentity = await service.identityPublic(84);
|
||||
expect(
|
||||
await service.importTransfer(
|
||||
84,
|
||||
transfer,
|
||||
'synthetic transfer password',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(await service.identityPublic(84), peerIdentity);
|
||||
expect(
|
||||
await service.importTransfer(
|
||||
42,
|
||||
transfer,
|
||||
'synthetic transfer password',
|
||||
),
|
||||
1,
|
||||
);
|
||||
service.forgetAccount(42);
|
||||
await service.ensureLoaded(42);
|
||||
expect(await service.identityPublic(42), originalPublic);
|
||||
expect(service.isActive(42, 7), isTrue);
|
||||
final movedWire = await service.encryptText(
|
||||
42,
|
||||
7,
|
||||
'first after transfer',
|
||||
);
|
||||
final movedReceived = await service.inspect(
|
||||
CachedMessage(
|
||||
id: '400',
|
||||
accountId: 84,
|
||||
chatId: 7,
|
||||
senderId: 42,
|
||||
text: movedWire,
|
||||
time: 400,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await service.openText(84, 7, movedReceived.sealedText!),
|
||||
'first after transfer',
|
||||
);
|
||||
final peerWire = await service.encryptText(84, 7, 'peer after transfer');
|
||||
final received = await service.inspect(
|
||||
CachedMessage(
|
||||
id: '401',
|
||||
accountId: 42,
|
||||
chatId: 7,
|
||||
senderId: 84,
|
||||
text: peerWire,
|
||||
time: 401,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await service.openText(42, 7, received.sealedText!),
|
||||
'peer after transfer',
|
||||
);
|
||||
final reply = await service.encryptText(42, 7, 'reply after transfer');
|
||||
expect(reply, isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a transferred unused initiator session cannot emit an unreadable ratchet step',
|
||||
() async {
|
||||
await handshake();
|
||||
final transfer = (await service.exportTransfer(42, 'transfer test'))!;
|
||||
expect(await service.importTransfer(42, transfer, 'transfer test'), 1);
|
||||
await expectLater(
|
||||
service.encryptText(42, 7, 'unsafe first send'),
|
||||
throwsA(isA<E2eeAwaitingPeer>()),
|
||||
);
|
||||
await service.resetSession(accountId: 42, chatId: 7);
|
||||
expect(
|
||||
await service.startOffer(accountId: 42, chatId: 7, peerId: 84),
|
||||
isTrue,
|
||||
);
|
||||
await service.inspect(incoming(transport.wire.last, 84));
|
||||
expect(await service.acceptOffer(accountId: 84, chatId: 7), isTrue);
|
||||
await service.inspect(incoming(transport.wire.last, 42));
|
||||
expect(await service.encryptText(42, 7, 'new session'), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'photo preparation uses fresh keys and rejects corrupt content before caching',
|
||||
() async {
|
||||
final source = File('${dir.path}/photo.png');
|
||||
await source.writeAsBytes(img.encodePng(img.Image(width: 4, height: 4)));
|
||||
final a = (await prepareE2eePhoto(source: source, stamp: 'one'))!;
|
||||
final b = (await prepareE2eePhoto(source: source, stamp: 'two'))!;
|
||||
expect(a.ticket, isNot(b.ticket));
|
||||
final ciphertext = await a.file.readAsBytes();
|
||||
final corrupt = Uint8List.fromList(ciphertext)..[0] ^= 1;
|
||||
await a.file.writeAsBytes(corrupt);
|
||||
expect(
|
||||
(await openE2eePhoto(encrypted: a.file, ticket: a.ticket)).isOk,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
await (await MediaCache.fileFor(
|
||||
e2eeDecryptedCacheName(a.ticket),
|
||||
)).exists(),
|
||||
isFalse,
|
||||
);
|
||||
await a.file.writeAsBytes(ciphertext);
|
||||
final opened = await openE2eePhoto(encrypted: a.file, ticket: a.ticket);
|
||||
expect(opened.isOk, isTrue);
|
||||
expect(img.decodePng(await opened.file!.readAsBytes())!.width, 4);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'changed identity clears verification and repeated history does not reset a session',
|
||||
() async {
|
||||
await handshake();
|
||||
await service.setVerified(accountId: 42, chatId: 7, verified: true);
|
||||
final oldOffer = incoming(transport.wire.first, 84);
|
||||
await AppDatabase.saveMessages([oldOffer.toDbRow()]);
|
||||
await service.inspectHistory(84, 7, [oldOffer]);
|
||||
expect(service.phaseOf(84, 7), E2eePhase.established);
|
||||
await service.rotateIdentity(84);
|
||||
expect(
|
||||
await service.startOffer(accountId: 84, chatId: 7, peerId: 42),
|
||||
isTrue,
|
||||
);
|
||||
await service.inspect(incoming(transport.wire.last, 42));
|
||||
expect(service.phaseOf(42, 7), E2eePhase.keyChanged);
|
||||
expect(service.info(42, 7)!.verified, isFalse);
|
||||
expect(service.offerChangesPeer(42, 7), isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/crypto/e2ee_service.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
const _accountId = 101;
|
||||
const _chatId = 202;
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
final String directory;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
class _QueryGate {
|
||||
final started = Completer<void>();
|
||||
final release = Completer<void>();
|
||||
}
|
||||
|
||||
class _ControlledSessionLoader {
|
||||
_QueryGate? nextQuery;
|
||||
|
||||
_QueryGate pauseNextQuery() => nextQuery = _QueryGate();
|
||||
|
||||
Future<List<Map<String, dynamic>>> call(int accountId) async {
|
||||
final gate = nextQuery;
|
||||
nextQuery = null;
|
||||
final rows = await AppDatabase.loadE2eeSessions(accountId);
|
||||
if (gate != null) {
|
||||
gate.started.complete();
|
||||
await gate.release.future;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late E2eeService service;
|
||||
late _ControlledSessionLoader loader;
|
||||
|
||||
setUpAll(AppDatabase.init);
|
||||
|
||||
setUp(() async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_e2ee_load',
|
||||
);
|
||||
final previousPathProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
loader = _ControlledSessionLoader();
|
||||
service = E2eeService(loadSessions: loader.call);
|
||||
addTearDown(() async {
|
||||
service.forgetAccount(_accountId);
|
||||
await AppDatabase.close();
|
||||
PathProviderPlatform.instance = previousPathProvider;
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: _accountId,
|
||||
firstName: 'Synthetic account',
|
||||
phone: 100000,
|
||||
country: 'ZZ',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
);
|
||||
await AppDatabase.saveE2eeSession({
|
||||
'account_id': _accountId,
|
||||
'chat_id': _chatId,
|
||||
'peer_id': 303,
|
||||
'phase': 'established',
|
||||
'verified': 1,
|
||||
'updated': 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('concurrent consumers wait until session info is ready', () async {
|
||||
final gate = loader.pauseNextQuery();
|
||||
final first = service.ensureLoaded(_accountId);
|
||||
await gate.started.future;
|
||||
expect(service.isLoaded(_accountId), isFalse);
|
||||
var secondCompleted = false;
|
||||
final second = service.ensureLoaded(_accountId).then((_) {
|
||||
secondCompleted = true;
|
||||
});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(secondCompleted, isFalse);
|
||||
gate.release.complete();
|
||||
await Future.wait([first, second]);
|
||||
expect(service.isActive(_accountId, _chatId), isTrue);
|
||||
expect(service.isLoaded(_accountId), isTrue);
|
||||
expect(service.info(_accountId, _chatId)?.verified, isTrue);
|
||||
});
|
||||
|
||||
test('load failures reach every consumer and permit a retry', () async {
|
||||
final gate = loader.pauseNextQuery();
|
||||
final first = service.ensureLoaded(_accountId);
|
||||
await gate.started.future;
|
||||
final second = service.ensureLoaded(_accountId);
|
||||
final firstFailure = expectLater(first, throwsStateError);
|
||||
final secondFailure = expectLater(second, throwsStateError);
|
||||
gate.release.completeError(StateError('synthetic query failure'));
|
||||
await Future.wait([firstFailure, secondFailure]);
|
||||
expect(service.isLoaded(_accountId), isFalse);
|
||||
expect(service.info(_accountId, _chatId), isNull);
|
||||
await service.ensureLoaded(_accountId);
|
||||
expect(service.isActive(_accountId, _chatId), isTrue);
|
||||
});
|
||||
|
||||
test('a synchronous loader failure permits a retry', () async {
|
||||
var attempts = 0;
|
||||
service = E2eeService(
|
||||
loadSessions: (accountId) {
|
||||
if (attempts++ == 0) throw StateError('synthetic loader failure');
|
||||
return AppDatabase.loadE2eeSessions(accountId);
|
||||
},
|
||||
);
|
||||
await expectLater(service.ensureLoaded(_accountId), throwsStateError);
|
||||
expect(service.isLoaded(_accountId), isFalse);
|
||||
await service.ensureLoaded(_accountId);
|
||||
expect(service.isActive(_accountId, _chatId), isTrue);
|
||||
});
|
||||
|
||||
test('forgotten accounts discard an already running load', () async {
|
||||
final gate = loader.pauseNextQuery();
|
||||
final first = service.ensureLoaded(_accountId);
|
||||
await gate.started.future;
|
||||
service.forgetAccount(_accountId);
|
||||
expect(service.isLoaded(_accountId), isFalse);
|
||||
gate.release.complete();
|
||||
await expectLater(first, throwsStateError);
|
||||
expect(service.info(_accountId, _chatId), isNull);
|
||||
await service.ensureLoaded(_accountId);
|
||||
expect(service.isActive(_accountId, _chatId), isTrue);
|
||||
});
|
||||
|
||||
test('an obsolete load leaves a replacement load pending', () async {
|
||||
final oldGate = loader.pauseNextQuery();
|
||||
final first = service.ensureLoaded(_accountId);
|
||||
await oldGate.started.future;
|
||||
service.forgetAccount(_accountId);
|
||||
final replacementGate = loader.pauseNextQuery();
|
||||
final replacement = service.ensureLoaded(_accountId);
|
||||
await replacementGate.started.future;
|
||||
oldGate.release.complete();
|
||||
await expectLater(first, throwsStateError);
|
||||
expect(service.info(_accountId, _chatId), isNull);
|
||||
var consumerCompleted = false;
|
||||
final consumer = service.ensureLoaded(_accountId).then((_) {
|
||||
consumerCompleted = true;
|
||||
});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(consumerCompleted, isFalse);
|
||||
replacementGate.release.complete();
|
||||
await Future.wait([replacement, consumer]);
|
||||
expect(service.isActive(_accountId, _chatId), isTrue);
|
||||
});
|
||||
|
||||
test('erased accounts cannot recover info from an obsolete load', () async {
|
||||
final gate = loader.pauseNextQuery();
|
||||
final first = service.ensureLoaded(_accountId);
|
||||
await gate.started.future;
|
||||
await service.eraseAccount(_accountId);
|
||||
gate.release.complete();
|
||||
await expectLater(first, throwsStateError);
|
||||
expect(service.info(_accountId, _chatId), isNull);
|
||||
expect(await AppDatabase.loadE2eeSessions(_accountId), isEmpty);
|
||||
await service.ensureLoaded(_accountId);
|
||||
expect(service.info(_accountId, _chatId), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/admins_screen.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/channel_type_link_screen.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/chat_admin_state.dart';
|
||||
import 'package:ikar/frontend/screens/chats/chat_admin/chat_settings_screen.dart';
|
||||
import 'package:ikar/frontend/screens/profile/qlyra_settings_screen.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/main.dart' show api;
|
||||
import 'package:ikar/models/chat_info.dart';
|
||||
|
||||
final _cyrillic = RegExp('[А-Яа-яЁё]');
|
||||
|
||||
ChatAdminState _state(String type) => ChatAdminState(
|
||||
chatId: -1000,
|
||||
myId: 11,
|
||||
name: 'Synthetic chat',
|
||||
imageUrl: '',
|
||||
info: ChatInfo.fromMap({
|
||||
'id': -1000,
|
||||
'type': type,
|
||||
'owner': 11,
|
||||
'access': 'PRIVATE',
|
||||
'link': 'https://example.test/join/synthetic',
|
||||
'participantsCount': 3,
|
||||
'adminParticipants': {
|
||||
'11': {'id': 11, 'permissions': 4095},
|
||||
},
|
||||
'options': {'JOIN_REQUEST': true, 'COMMENTS': false},
|
||||
}),
|
||||
);
|
||||
|
||||
CachedMessage _message(String id, {Map<String, dynamic>? payload}) =>
|
||||
CachedMessage(
|
||||
id: id,
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 7,
|
||||
text: 'Synthetic text',
|
||||
time: DateTime(2026, 1, 1, 12, 30).millisecondsSinceEpoch,
|
||||
status: 'EDITED',
|
||||
payload: payload,
|
||||
);
|
||||
|
||||
final _screens = <String, Widget Function()>{
|
||||
'channel settings': () => ChatSettingsScreen(
|
||||
state: _state('CHANNEL'),
|
||||
onLeave: () {},
|
||||
onClearHistory: () {},
|
||||
onDelete: () {},
|
||||
),
|
||||
'group settings': () =>
|
||||
ChatSettingsScreen(state: _state('CHAT'), onLeave: () {}),
|
||||
'channel type and link': () =>
|
||||
ChannelTypeLinkScreen(state: _state('CHANNEL'), justCreated: true),
|
||||
'admins': () => AdminsScreen(state: _state('CHANNEL')),
|
||||
'komet settings': () => const QlyraSettingsScreen(),
|
||||
'message bubbles': () => Scaffold(
|
||||
body: ListView(
|
||||
children: [
|
||||
MessageBubble(
|
||||
message: _message('1'),
|
||||
isMe: true,
|
||||
myId: 1,
|
||||
chatType: 'CHAT',
|
||||
),
|
||||
MessageBubble(
|
||||
message: _message(
|
||||
'2',
|
||||
payload: {
|
||||
'link': {
|
||||
'type': 'FORWARD',
|
||||
'chatId': 5,
|
||||
'message': {
|
||||
'id': '9',
|
||||
'sender': 3,
|
||||
'text': 'Forwarded synthetic text',
|
||||
'time': 0,
|
||||
'attaches': [],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
isMe: false,
|
||||
myId: 1,
|
||||
chatType: 'CHAT',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
void main() {
|
||||
setUpAll(() => api.state);
|
||||
|
||||
for (final entry in _screens.entries) {
|
||||
testWidgets('${entry.key} has no Russian in the English locale', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2.5;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: entry.value(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final russian = [
|
||||
for (final text in tester.widgetList<Text>(find.byType(Text)))
|
||||
if ((text.data ?? text.textSpan?.toPlainText() ?? '').contains(
|
||||
_cyrillic,
|
||||
))
|
||||
text.data ?? text.textSpan!.toPlainText(),
|
||||
for (final rich in tester.widgetList<RichText>(find.byType(RichText)))
|
||||
if (rich.text.toPlainText().contains(_cyrillic))
|
||||
rich.text.toPlainText(),
|
||||
];
|
||||
expect(russian, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
test('russian plurals agree with numbers ending in one', () {
|
||||
final ru = lookupAppLocalizations(const Locale('ru'));
|
||||
|
||||
expect(ru.sharedMembersCount(1), '1 участник');
|
||||
expect(ru.sharedMembersCount(21), '21 участник');
|
||||
expect(ru.sharedMembersCount(22), '22 участника');
|
||||
expect(ru.pasteAttachTitleMany(21), 'Отправить 21 файл');
|
||||
expect(ru.pasteAttachTitleMany(25), 'Отправить 25 файлов');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/account/account_models.dart';
|
||||
import 'package:ikar/backend/modules/webapp.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_SyntheticPathProvider(this.directory);
|
||||
|
||||
final String directory;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
Map<String, Object?> _banners(List<Map<String, Object?>> items) => {
|
||||
'settings-entry-banners': [
|
||||
{'id': 1, 'items': items},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('разбор баннеров', () {
|
||||
test('ids берутся по имени иконки', () {
|
||||
final resolved = EntryBannerApps.appIdsFrom(
|
||||
_banners([
|
||||
{
|
||||
'icon': 'https://example.test/icons/digital_id_new_40_3x.png',
|
||||
'title': 'Цифровой ID',
|
||||
'appid': 111,
|
||||
},
|
||||
{
|
||||
'icon': 'https://example.test/icons/sferum_with_padding.png',
|
||||
'title': 'Войти в Cферум',
|
||||
'appid': 222,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolved, {
|
||||
EntryBannerApps.digitalIdKey: 111,
|
||||
EntryBannerApps.sferumKey: 222,
|
||||
});
|
||||
});
|
||||
|
||||
test('переименованная иконка подстрахована заголовком', () {
|
||||
final resolved = EntryBannerApps.appIdsFrom(
|
||||
_banners([
|
||||
{
|
||||
'icon': 'https://example.test/icons/epgu_white.png',
|
||||
'title': 'Цифровой ID',
|
||||
'appid': 333,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolved, {EntryBannerApps.digitalIdKey: 333});
|
||||
});
|
||||
|
||||
test('иконка важнее заголовка', () {
|
||||
final resolved = EntryBannerApps.appIdsFrom(
|
||||
_banners([
|
||||
{'title': 'Цифровой ID и документы', 'appid': 444},
|
||||
{
|
||||
'icon': 'https://example.test/icons/digital_id.png',
|
||||
'title': 'Документы',
|
||||
'appid': 555,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolved, {EntryBannerApps.digitalIdKey: 555});
|
||||
});
|
||||
|
||||
test('мусор в конфиге не ломает разбор', () {
|
||||
expect(EntryBannerApps.appIdsFrom(const {}), isEmpty);
|
||||
expect(
|
||||
EntryBannerApps.appIdsFrom(const {'settings-entry-banners': 'нет'}),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
EntryBannerApps.appIdsFrom(
|
||||
_banners([
|
||||
{'icon': 'https://example.test/digital.png', 'appid': '777'},
|
||||
{'icon': 'https://example.test/digital.png'},
|
||||
]),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('известные id есть для обеих мини-аппок', () {
|
||||
expect(
|
||||
EntryBannerApps.knownAppIds.keys.toSet(),
|
||||
EntryBannerApps.iconMatchers.keys.toSet(),
|
||||
);
|
||||
expect(EntryBannerApps.knownAppIds.values.every((id) => id > 0), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('повторный запрос конфига', () {
|
||||
const accountId = 1;
|
||||
|
||||
setUp(() async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'synthetic_entry_banner_apps',
|
||||
);
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
||||
});
|
||||
await AppDatabase.init();
|
||||
await AppDatabase.saveProfile(
|
||||
ProfileData(
|
||||
id: accountId,
|
||||
firstName: 'Synthetic owner',
|
||||
phone: 100000,
|
||||
country: 'ZZ',
|
||||
accountStatus: 0,
|
||||
updateTime: 1,
|
||||
),
|
||||
isActive: true,
|
||||
);
|
||||
await AppDatabase.setSyncValue(accountId, SyncKey.lastLogin, '1');
|
||||
await AppDatabase.setSyncValue(
|
||||
accountId,
|
||||
SyncKey.serverConfigSeen,
|
||||
'1',
|
||||
);
|
||||
await AppDatabase.setSyncValue(
|
||||
accountId,
|
||||
SyncKey.configHash,
|
||||
'synthetic-hash',
|
||||
);
|
||||
});
|
||||
|
||||
test('маркер сбрасывается, конфиг запросится заново', () async {
|
||||
var params = await LoginSyncParams.fromDatabase(accountId);
|
||||
expect(params!.serverConfigSeen, isTrue);
|
||||
|
||||
await LoginSyncParams.forgetServerConfig(accountId);
|
||||
params = await LoginSyncParams.fromDatabase(accountId);
|
||||
|
||||
expect(params!.serverConfigSeen, isFalse);
|
||||
expect(params.configHash, 'synthetic-hash');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/utils/download_progress.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/models/attachment.dart';
|
||||
|
||||
Future<void> pumpFile(
|
||||
WidgetTester tester,
|
||||
String name, {
|
||||
required VoidCallback menu,
|
||||
int? fileId,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: GestureDetector(
|
||||
onTap: menu,
|
||||
onLongPress: menu,
|
||||
child: MessageBubble(
|
||||
message: CachedMessage(
|
||||
id: '1',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 7,
|
||||
time: 0,
|
||||
attachments: [
|
||||
FileAttachment(fileId: fileId, name: name, size: 128),
|
||||
],
|
||||
),
|
||||
isMe: false,
|
||||
myId: 1,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final name in [
|
||||
'report.pdf',
|
||||
'archive.zip',
|
||||
'notes.docx',
|
||||
'unknown.bin',
|
||||
'song.mp3',
|
||||
'picture.png',
|
||||
]) {
|
||||
for (final target in ['name', 'icon']) {
|
||||
testWidgets(
|
||||
'$name $target tap starts file action instead of message menu',
|
||||
(tester) async {
|
||||
var menus = 0;
|
||||
await pumpFile(tester, name, menu: () => menus++);
|
||||
await tester.tap(
|
||||
target == 'name'
|
||||
? find.text(name)
|
||||
: find.byIcon(
|
||||
name.endsWith('.mp3')
|
||||
? Symbols.audio_file
|
||||
: Symbols.description,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(menus, 0);
|
||||
// Missing metadata proves that the file action handled the tap.
|
||||
expect(find.text('Не удалось определить файл'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('long press still reaches the message menu', (tester) async {
|
||||
var menus = 0;
|
||||
await pumpFile(tester, 'report.pdf', menu: () => menus++);
|
||||
await tester.longPress(find.text('report.pdf'));
|
||||
expect(menus, 1);
|
||||
expect(find.text('Не удалось определить файл'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tap during download neither opens menu nor starts another action',
|
||||
(tester) async {
|
||||
var menus = 0;
|
||||
MediaDownloadProgress.set('42_report.pdf', 0.5);
|
||||
addTearDown(() => MediaDownloadProgress.set('42_report.pdf', null));
|
||||
await pumpFile(tester, 'report.pdf', menu: () => menus++, fileId: 42);
|
||||
await tester.tap(find.text('report.pdf'));
|
||||
await tester.pump();
|
||||
expect(menus, 0);
|
||||
expect(find.textContaining('50%'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:ikar/core/utils/download_history.dart';
|
||||
import 'package:ikar/core/utils/file_download.dart';
|
||||
import 'package:ikar/core/utils/media_cache.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _Paths extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
_Paths(this.path);
|
||||
final String path;
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => path;
|
||||
}
|
||||
|
||||
class _RealHttpOverrides extends HttpOverrides {}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late Directory directory;
|
||||
late PathProviderPlatform paths;
|
||||
|
||||
setUp(() {
|
||||
directory = Directory.systemTemp.createTempSync('qlyra_file_download_');
|
||||
paths = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _Paths(directory.path);
|
||||
MediaCache.resetForTesting();
|
||||
DownloadHistory.resetForTesting();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
tearDown(() {
|
||||
MediaCache.resetForTesting();
|
||||
DownloadHistory.resetForTesting();
|
||||
PathProviderPlatform.instance = paths;
|
||||
directory.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
for (final name in [
|
||||
'report.pdf',
|
||||
'archive.zip',
|
||||
'document.docx',
|
||||
'data.bin',
|
||||
'song.mp3',
|
||||
'picture.png',
|
||||
]) {
|
||||
test(
|
||||
'$name downloads on first tap and opens offline on second tap',
|
||||
() async {
|
||||
await HttpOverrides.runWithHttpOverrides(() async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() => server.close(force: true));
|
||||
final bytes = List<int>.generate(2048, (i) => i % 256);
|
||||
var requests = 0;
|
||||
server.listen((request) async {
|
||||
requests++;
|
||||
request.response.contentLength = bytes.length;
|
||||
request.response.add(bytes);
|
||||
await request.response.close();
|
||||
});
|
||||
var resolves = 0;
|
||||
var opens = 0;
|
||||
var ready = 0;
|
||||
final progress = <double>[];
|
||||
Future<String?> resolve() async {
|
||||
resolves++;
|
||||
return 'http://127.0.0.1:${server.port}/file';
|
||||
}
|
||||
|
||||
Future<OpenResult> open(String path) async {
|
||||
opens++;
|
||||
expect(await File(path).readAsBytes(), bytes);
|
||||
return OpenResult(type: ResultType.done, message: 'done');
|
||||
}
|
||||
|
||||
final first = await openCachedFile(
|
||||
name,
|
||||
resolve,
|
||||
downloadOnlyIfMissing: true,
|
||||
openFile: open,
|
||||
onReady: () => ready++,
|
||||
onProgress: progress.add,
|
||||
download: DownloadMetadata(
|
||||
cacheName: name,
|
||||
name: name,
|
||||
kind: downloadKindForName(name),
|
||||
),
|
||||
);
|
||||
expect(first.ok, isTrue);
|
||||
expect(first.downloaded, isTrue);
|
||||
expect(opens, 0);
|
||||
expect(ready, 1);
|
||||
expect(progress.last, 1);
|
||||
expect(DownloadHistory.records.value.single.name, name);
|
||||
await server.close(force: true);
|
||||
|
||||
final second = await openCachedFile(
|
||||
name,
|
||||
resolve,
|
||||
downloadOnlyIfMissing: true,
|
||||
openFile: open,
|
||||
);
|
||||
expect(second.ok, isTrue);
|
||||
expect(second.downloaded, isFalse);
|
||||
expect(opens, 1);
|
||||
expect(resolves, 1);
|
||||
expect(requests, 1);
|
||||
}, _RealHttpOverrides());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('missing URL resets progress and permits retry', () async {
|
||||
var ready = 0;
|
||||
final result = await openCachedFile(
|
||||
'missing.pdf',
|
||||
() async => null,
|
||||
downloadOnlyIfMissing: true,
|
||||
onReady: () => ready++,
|
||||
openFile: (_) async => throw StateError('must not open'),
|
||||
);
|
||||
expect(result.ok, isFalse);
|
||||
expect(result.path, isNull);
|
||||
expect(ready, 1);
|
||||
expect(await MediaCache.existing('missing.pdf'), isNull);
|
||||
});
|
||||
|
||||
test('no viewer keeps the downloaded document available', () async {
|
||||
final file = await MediaCache.fileFor('report.pdf');
|
||||
await file.writeAsBytes([1, 2, 3]);
|
||||
final result = await openCachedFile(
|
||||
'report.pdf',
|
||||
() async => throw StateError('must not resolve'),
|
||||
downloadOnlyIfMissing: true,
|
||||
openFile: (_) async =>
|
||||
OpenResult(type: ResultType.noAppToOpen, message: 'No viewer'),
|
||||
);
|
||||
expect(result.noAppToOpen, isTrue);
|
||||
expect(result.path, file.path);
|
||||
expect(await file.readAsBytes(), [1, 2, 3]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/backend/modules/outbox.dart';
|
||||
import 'package:ikar/backend/modules/outgoing_text.dart';
|
||||
import 'package:ikar/core/protocol/opcode_map.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:ikar/core/storage/token_storage.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
class _Paths extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
_Paths(this.directory);
|
||||
final String directory;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => directory;
|
||||
}
|
||||
|
||||
class _Api extends Api {
|
||||
final requests = <Map<dynamic, dynamic>>[];
|
||||
|
||||
@override
|
||||
SessionState get state => SessionState.online;
|
||||
|
||||
@override
|
||||
Future<Packet> sendRequest(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
bool silent = false,
|
||||
void Function()? beforeSend,
|
||||
}) async {
|
||||
beforeSend?.call();
|
||||
expect(opcode, Opcode.msgSend);
|
||||
requests.add(payload);
|
||||
return Packet(
|
||||
cmd: CmdType.ok,
|
||||
opcode: opcode,
|
||||
payload: {
|
||||
'message': {'id': 702},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _account = 42;
|
||||
const _peer = 17;
|
||||
const _chat = _account ^ _peer;
|
||||
|
||||
CachedMessage _text({int chatId = _chat}) => OutgoingText.prepare(
|
||||
accountId: _account,
|
||||
chatId: chatId,
|
||||
wireText: 'synthetic prepared ciphertext',
|
||||
encrypted: true,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
final directory = Directory.systemTemp.createTempSync('ikar_first_dialog_');
|
||||
final paths = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _Paths(directory.path);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await AppDatabase.init();
|
||||
await AppDatabase.saveProfile(ProfileData.stub(_account));
|
||||
await TokenStorage.setActiveAccount(_account);
|
||||
addTearDown(() async {
|
||||
await OutboxService.instance.dispose();
|
||||
await AppDatabase.close();
|
||||
PathProviderPlatform.instance = paths;
|
||||
directory.deleteSync(recursive: true);
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'first dialog text survives foreign keys and is sent from the outbox',
|
||||
() async {
|
||||
final message = _text();
|
||||
// This is the old first-message path: no server chat card exists yet.
|
||||
await expectLater(
|
||||
AppDatabase.saveMessages([message.toDbRow()]),
|
||||
throwsA(isA<DatabaseException>()),
|
||||
);
|
||||
final api = _Api();
|
||||
addTearDown(api.dispose);
|
||||
var authenticated = false;
|
||||
OutboxService.instance.init(
|
||||
api,
|
||||
MessagesModule(api),
|
||||
isAuthenticated: () => authenticated,
|
||||
);
|
||||
await OutboxService.instance.enqueue(
|
||||
message,
|
||||
createDialog: true,
|
||||
dialogTitle: 'Synthetic peer',
|
||||
);
|
||||
expect(
|
||||
(await AppDatabase.loadPendingMessages(_account)).single['id'],
|
||||
message.id,
|
||||
);
|
||||
expect(
|
||||
await AppDatabase.findDialogChatByParticipant(_account, _peer),
|
||||
_chat,
|
||||
);
|
||||
authenticated = true;
|
||||
await OutboxService.instance.flush();
|
||||
expect(api.requests.single['chatId'], _chat);
|
||||
expect((api.requests.single['message'] as Map)['text'], message.text);
|
||||
expect(
|
||||
(api.requests.single['message'] as Map)['cid'],
|
||||
OutgoingText.metadata(message)!['cid'],
|
||||
);
|
||||
expect(await AppDatabase.loadPendingMessages(_account), isEmpty);
|
||||
final sent = await AppDatabase.loadMessage(_account, _chat, '702');
|
||||
expect(sent?['status'], 'sent');
|
||||
expect(sent?['text'], message.text);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
},
|
||||
);
|
||||
|
||||
test('saved messages can start with chat zero and one participant', () async {
|
||||
await AppDatabase.saveOutgoingMessage(
|
||||
_text(chatId: 0).toDbRow(),
|
||||
createDialog: true,
|
||||
);
|
||||
final row = (await AppDatabase.loadChat(_account, 0)).single;
|
||||
expect(CachedChat.fromDbRow(row).participants, {_account: 0});
|
||||
expect(
|
||||
await AppDatabase.findDialogChatByParticipant(_account, _account),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('first attachment persists after preparing its dialog', () async {
|
||||
await AppDatabase.ensureDialogCached(_account, _chat);
|
||||
final message = CachedMessage(
|
||||
id: 'synthetic_sticker',
|
||||
accountId: _account,
|
||||
chatId: _chat,
|
||||
senderId: _account,
|
||||
time: 1000,
|
||||
status: 'sent',
|
||||
payload: {
|
||||
'attaches': [
|
||||
{'_type': 'STICKER', 'stickerId': 101},
|
||||
],
|
||||
},
|
||||
);
|
||||
await AppDatabase.saveMessages([message.toDbRow()]);
|
||||
expect(
|
||||
await AppDatabase.loadMessage(_account, _chat, message.id),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'draft creation preserves an existing server chat and its messages',
|
||||
() async {
|
||||
await chats.cacheServerChat(
|
||||
{
|
||||
'id': _chat,
|
||||
'type': 'DIALOG',
|
||||
'title': 'Server title',
|
||||
'participants': {'$_account': 123, '$_peer': 456},
|
||||
},
|
||||
_account,
|
||||
inList: false,
|
||||
);
|
||||
final before = (await AppDatabase.loadChat(_account, _chat)).single;
|
||||
await AppDatabase.ensureDialogCached(
|
||||
_account,
|
||||
_chat,
|
||||
title: 'Draft title',
|
||||
);
|
||||
await AppDatabase.saveOutgoingMessage(
|
||||
_text().toDbRow(),
|
||||
createDialog: true,
|
||||
);
|
||||
expect((await AppDatabase.loadChat(_account, _chat)).single, before);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'failed first message rolls back its dialog and participant index',
|
||||
() async {
|
||||
final invalid = _text().toDbRow()..remove('sender_id');
|
||||
await expectLater(
|
||||
AppDatabase.saveOutgoingMessage(invalid, createDialog: true),
|
||||
throwsA(isA<DatabaseException>()),
|
||||
);
|
||||
expect(await AppDatabase.loadChat(_account, _chat), isEmpty);
|
||||
expect(
|
||||
await AppDatabase.findDialogChatByParticipant(_account, _peer),
|
||||
isNull,
|
||||
);
|
||||
expect(await AppDatabase.loadPendingMessages(_account), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('failed replacement retains the original pending message', () async {
|
||||
final original = _text();
|
||||
await AppDatabase.saveOutgoingMessage(
|
||||
original.toDbRow(),
|
||||
createDialog: true,
|
||||
);
|
||||
final invalid = original.toDbRow()
|
||||
..['id'] = 'synthetic_ack'
|
||||
..remove('sender_id');
|
||||
await expectLater(
|
||||
AppDatabase.saveOutgoingMessage(invalid, removeId: original.id),
|
||||
throwsA(isA<DatabaseException>()),
|
||||
);
|
||||
expect(
|
||||
await AppDatabase.loadMessage(_account, _chat, original.id),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/core/push/fkm_controller.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:ikar/frontend/screens/profile/notifications_screen.dart';
|
||||
import 'package:ikar/frontend/widgets/connection_status.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/l10n/app_localizations_ru.dart';
|
||||
import 'package:ikar/main.dart' as app;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _Paths extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
_Paths(this.path);
|
||||
final String path;
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => path;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
debugForceOffline.value = false;
|
||||
});
|
||||
tearDown(() => debugForceOffline.value = false);
|
||||
|
||||
test(
|
||||
'FKM cannot be enabled and a previous enabled state is cleared',
|
||||
() async {
|
||||
final api = Api();
|
||||
addTearDown(api.dispose);
|
||||
final fkm = FkmController.instance;
|
||||
fkm.enabled.value = true;
|
||||
await fkm.init(api);
|
||||
expect(fkm.enabled.value, isFalse);
|
||||
expect(fkm.isSupported, isFalse);
|
||||
expect(await fkm.setEnabled(true), isFalse);
|
||||
expect(fkm.enabled.value, isFalse);
|
||||
expect(await fkm.setEnabled(false), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'all disconnected states say Соединение... and online restores the title',
|
||||
() {
|
||||
final l10n = AppLocalizationsRu();
|
||||
for (final state in [
|
||||
SessionState.disconnected,
|
||||
SessionState.connecting,
|
||||
SessionState.connected,
|
||||
]) {
|
||||
expect(connectionStatusLabel(l10n, state), 'Соединение...');
|
||||
}
|
||||
expect(connectionStatusLabel(l10n, SessionState.online), isNull);
|
||||
debugForceOffline.value = true;
|
||||
expect(connectionStatusLabel(l10n, SessionState.online), 'Соединение...');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('notification menu has no FKM section or enable switch', (
|
||||
tester,
|
||||
) async {
|
||||
final directory = Directory.systemTemp.createTempSync(
|
||||
'ikar_notification_ui',
|
||||
);
|
||||
PathProviderPlatform.instance = _Paths(directory.path);
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
directory.deleteSync(recursive: true);
|
||||
});
|
||||
await AppDatabase.init();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const NotificationsScreen(),
|
||||
),
|
||||
);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 100)),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.text('FKM'), findsNothing);
|
||||
expect(find.text('Включить уведомления'), findsNothing);
|
||||
expect(find.text('Все уведомления'), findsOneWidget);
|
||||
expect(find.text('Соединение...'), findsOneWidget);
|
||||
expect(find.text('Ожидание сети...'), findsNothing);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await app.api.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/calls/active_call.dart';
|
||||
import 'package:ikar/core/calls/call_session.dart';
|
||||
import 'package:ikar/core/calls/ws2_signaling.dart';
|
||||
import 'package:ikar/frontend/widgets/floating_call_badge.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
const _name = 'Тестовый собеседник';
|
||||
|
||||
CallSession _session() => CallSession(
|
||||
ws2Config: Ws2Config(uri: Uri.parse('wss://calls.invalid/ws2'), userId: 42),
|
||||
role: CallRole.caller,
|
||||
);
|
||||
|
||||
Widget _host() => MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
builder: (context, child) => Stack(
|
||||
children: [
|
||||
child!,
|
||||
const Positioned.fill(child: FloatingCallBadgeLayer()),
|
||||
],
|
||||
),
|
||||
home: const Scaffold(body: SizedBox.expand()),
|
||||
);
|
||||
|
||||
final Finder _hangup = find.byIcon(Symbols.call_end);
|
||||
|
||||
double _controlsOpacity(WidgetTester tester) => tester
|
||||
.widget<Opacity>(
|
||||
find.ancestor(of: _hangup, matching: find.byType(Opacity)).first,
|
||||
)
|
||||
.opacity;
|
||||
|
||||
Finder get _badge => find.descendant(
|
||||
of: find.byType(FloatingCallBadgeLayer),
|
||||
matching: find.byType(ScaleTransition),
|
||||
);
|
||||
|
||||
Offset _badgeCorner(WidgetTester tester) => tester.getTopLeft(_badge);
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
ActiveCall.instance.detach();
|
||||
while (ActiveCall.instance.screenVisible.value) {
|
||||
ActiveCall.instance.leaveScreen();
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('бейджик виден только пока экран звонка закрыт', (tester) async {
|
||||
await tester.pumpWidget(_host());
|
||||
expect(find.text(_name), findsNothing);
|
||||
|
||||
ActiveCall.instance.attach(session: _session(), name: _name);
|
||||
await tester.pump();
|
||||
expect(find.text(_name), findsOneWidget);
|
||||
|
||||
ActiveCall.instance.enterScreen();
|
||||
await tester.pump();
|
||||
expect(find.text(_name), findsNothing);
|
||||
|
||||
ActiveCall.instance.leaveScreen();
|
||||
await tester.pump();
|
||||
expect(find.text(_name), findsOneWidget);
|
||||
|
||||
ActiveCall.instance.detach();
|
||||
await tester.pump();
|
||||
expect(find.text(_name), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('тап раскрывает кнопки и повторный тап их прячет', (
|
||||
tester,
|
||||
) async {
|
||||
ActiveCall.instance.attach(session: _session(), name: _name);
|
||||
await tester.pumpWidget(_host());
|
||||
|
||||
expect(
|
||||
_hangup,
|
||||
findsNothing,
|
||||
reason: 'свёрнутый бейджик не держит кнопки в дереве',
|
||||
);
|
||||
|
||||
await tester.tap(find.text(_name));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_hangup, findsOneWidget);
|
||||
expect(_controlsOpacity(tester), 1);
|
||||
|
||||
await tester.tap(find.text(_name));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_hangup, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('кнопки прячутся сами, если бейджик не трогают', (tester) async {
|
||||
ActiveCall.instance.attach(session: _session(), name: _name);
|
||||
await tester.pumpWidget(_host());
|
||||
|
||||
await tester.tap(find.text(_name));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_controlsOpacity(tester), 1);
|
||||
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_hangup, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('перетаскивание липнет к краю экрана', (tester) async {
|
||||
ActiveCall.instance.attach(session: _session(), name: _name);
|
||||
await tester.pumpWidget(_host());
|
||||
|
||||
final screen = tester.getSize(find.byType(FloatingCallBadgeLayer)).width;
|
||||
final start = _badgeCorner(tester);
|
||||
final width = tester.getSize(_badge).width;
|
||||
expect(start.dx + width, moreOrLessEquals(screen - 12));
|
||||
|
||||
await tester.timedDrag(
|
||||
find.text(_name),
|
||||
const Offset(-260, -120),
|
||||
const Duration(milliseconds: 300),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_badgeCorner(tester).dx, moreOrLessEquals(12));
|
||||
expect(_badgeCorner(tester).dy, lessThan(start.dy));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/widgets/draggable_floating_layer.dart';
|
||||
|
||||
const _screen = Size(400, 800);
|
||||
const _safe = EdgeInsets.only(top: 48, bottom: 24);
|
||||
|
||||
FloatingDockGeometry _dockOf(Size size) =>
|
||||
FloatingDockGeometry(bounds: _screen, size: size, safeArea: _safe);
|
||||
|
||||
final _badge = _dockOf(const Size(120, 118));
|
||||
|
||||
void main() {
|
||||
test('бейджик стартует у правого края над нижней панелью', () {
|
||||
final resting = _badge.resting;
|
||||
expect(resting.dx, _screen.width - 120 - 12);
|
||||
expect(resting.dy, _screen.height - 118 - 24 - 96);
|
||||
});
|
||||
|
||||
test('позиция зажата в безопасной зоне', () {
|
||||
expect(_badge.clamp(const Offset(-500, -500)), Offset(12, 48 + 12));
|
||||
expect(
|
||||
_badge.clamp(const Offset(9999, 9999)),
|
||||
Offset(_screen.width - 120 - 12, _screen.height - 118 - 24 - 12),
|
||||
);
|
||||
});
|
||||
|
||||
test('без броска липнет к ближнему краю', () {
|
||||
final left = _badge.snap(const Offset(40, 300), Offset.zero);
|
||||
expect(left.dx, 12);
|
||||
expect(
|
||||
left.dy,
|
||||
300,
|
||||
reason: 'высота не меняется без вертикальной скорости',
|
||||
);
|
||||
|
||||
final right = _badge.snap(const Offset(250, 300), Offset.zero);
|
||||
expect(right.dx, _screen.width - 120 - 12);
|
||||
});
|
||||
|
||||
test('бросок перебивает ближний край', () {
|
||||
final flungRight = _badge.snap(const Offset(40, 300), const Offset(900, 0));
|
||||
expect(flungRight.dx, _screen.width - 120 - 12);
|
||||
|
||||
final flungLeft = _badge.snap(
|
||||
const Offset(250, 300),
|
||||
const Offset(-900, 0),
|
||||
);
|
||||
expect(flungLeft.dx, 12);
|
||||
});
|
||||
|
||||
test('слабый рывок не считается броском', () {
|
||||
final weak = _badge.snap(const Offset(40, 300), const Offset(200, 0));
|
||||
expect(weak.dx, 12);
|
||||
});
|
||||
|
||||
test('вертикальный бросок продолжает движение, но не за экран', () {
|
||||
final down = _badge.snap(const Offset(250, 300), const Offset(0, 1000));
|
||||
expect(down.dy, 300 + 1000 * FloatingDockGeometry.flingSeconds);
|
||||
|
||||
final overshoot = _badge.snap(
|
||||
const Offset(250, 600),
|
||||
const Offset(0, 5000),
|
||||
);
|
||||
expect(overshoot.dy, _screen.height - 118 - 24 - 12);
|
||||
|
||||
final up = _badge.snap(const Offset(250, 100), const Offset(0, -5000));
|
||||
expect(up.dy, 48 + 12);
|
||||
});
|
||||
|
||||
test('раскрытие бейджика удерживает правый край на месте', () {
|
||||
final collapsed = _badge;
|
||||
final expanded = _dockOf(const Size(152, 166));
|
||||
|
||||
final docked = collapsed.resting;
|
||||
final grown = expanded.clamp(docked);
|
||||
|
||||
expect(
|
||||
grown.dx + 152,
|
||||
docked.dx + 120,
|
||||
reason: 'кнопки должны вырастать влево, а не выезжать за экран',
|
||||
);
|
||||
});
|
||||
|
||||
test('узкий экран не переворачивает границы', () {
|
||||
final tiny = FloatingDockGeometry(
|
||||
bounds: const Size(100, 200),
|
||||
size: const Size(120, 118),
|
||||
safeArea: EdgeInsets.zero,
|
||||
);
|
||||
expect(tiny.maxX, tiny.minX);
|
||||
expect(tiny.clamp(const Offset(50, 50)).dx, 12);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
VisibilityDetectorController.instance.updateInterval = Duration.zero;
|
||||
await testMain();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/backend/models/chat_folder.dart';
|
||||
import 'package:ikar/backend/modules/folders.dart';
|
||||
import 'package:ikar/frontend/screens/chats/folder_action_sheet.dart';
|
||||
|
||||
Future<void> _openSheet(WidgetTester tester, ChatFolder folder) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showFolderActionSheet(context, folder: folder),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a user folder offers edit, create and delete', (tester) async {
|
||||
await _openSheet(
|
||||
tester,
|
||||
const ChatFolder(id: 'synthetic-folder', title: 'Работа'),
|
||||
);
|
||||
|
||||
expect(find.text('Работа'), findsOneWidget);
|
||||
expect(find.text('Изменить'), findsOneWidget);
|
||||
expect(find.text('Новая папка'), findsOneWidget);
|
||||
expect(find.text('Удалить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the all-chats folder can only spawn a new folder', (
|
||||
tester,
|
||||
) async {
|
||||
await _openSheet(
|
||||
tester,
|
||||
const ChatFolder(id: FoldersModule.allChatsFolderId, title: 'Все чаты'),
|
||||
);
|
||||
|
||||
expect(find.text('Изменить'), findsNothing);
|
||||
expect(find.text('Удалить'), findsNothing);
|
||||
expect(find.text('Новая папка'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('server options hide the forbidden actions', (tester) async {
|
||||
await _openSheet(
|
||||
tester,
|
||||
const ChatFolder(
|
||||
id: 'synthetic-system-folder',
|
||||
title: 'Каналы',
|
||||
options: [
|
||||
FolderOption.noDelete,
|
||||
FolderOption.noTitleEdit,
|
||||
FolderOption.noFiltersEdit,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Изменить'), findsNothing);
|
||||
expect(find.text('Удалить'), findsNothing);
|
||||
expect(find.text('Новая папка'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/backend/models/chat_folder.dart';
|
||||
import 'package:ikar/frontend/screens/chats/folder_edit_sheet.dart';
|
||||
import 'package:ikar/frontend/widgets/sheet_helpers.dart';
|
||||
|
||||
Future<void> _openSheet(WidgetTester tester, {ChatFolder? folder}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showFolderEditSheet(context, folder: folder),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
}
|
||||
|
||||
Future<void> _scrollToBottom(WidgetTester tester) async {
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -600));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
SheetButton _button(WidgetTester tester, String label) =>
|
||||
tester.widget<SheetButton>(
|
||||
find.byWidgetPredicate((w) => w is SheetButton && w.label == label),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('the create layout shows every picker section', (tester) async {
|
||||
await _openSheet(tester);
|
||||
|
||||
expect(find.text('Новая папка'), findsOneWidget);
|
||||
expect(find.text('Название папки'), findsOneWidget);
|
||||
expect(find.text('0/20'), findsOneWidget);
|
||||
expect(find.text('ТИПЫ ЧАТОВ'), findsOneWidget);
|
||||
expect(find.text('Контакты'), findsOneWidget);
|
||||
expect(find.text('Не в контактах'), findsOneWidget);
|
||||
expect(find.text('Группы'), findsOneWidget);
|
||||
expect(find.text('Каналы'), findsOneWidget);
|
||||
expect(find.text('Боты'), findsOneWidget);
|
||||
|
||||
await _scrollToBottom(tester);
|
||||
expect(find.text('ПОКАЗЫВАТЬ ТОЛЬКО'), findsOneWidget);
|
||||
expect(find.text('Чаты с уведомлениями'), findsOneWidget);
|
||||
expect(find.text('Непрочитанные чаты'), findsOneWidget);
|
||||
expect(find.text('Очистить выбор'), findsOneWidget);
|
||||
expect(find.text('Создать папку'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('creating needs both a name and a selection', (tester) async {
|
||||
await _openSheet(tester);
|
||||
|
||||
expect(_button(tester, 'Создать папку').onTap, isNull);
|
||||
expect(_button(tester, 'Очистить выбор').onTap, isNull);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Работа');
|
||||
await tester.pump();
|
||||
expect(_button(tester, 'Создать папку').onTap, isNull);
|
||||
expect(find.text('6/20'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Каналы'));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(_button(tester, 'Создать папку').onTap, isNotNull);
|
||||
expect(_button(tester, 'Очистить выбор').onTap, isNotNull);
|
||||
|
||||
await tester.tap(find.text('Очистить выбор'));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(_button(tester, 'Создать папку').onTap, isNull);
|
||||
});
|
||||
|
||||
testWidgets('the edit layout preloads the folder and offers delete', (
|
||||
tester,
|
||||
) async {
|
||||
await _openSheet(
|
||||
tester,
|
||||
folder: const ChatFolder(
|
||||
id: 'synthetic-folder',
|
||||
title: 'Каналы',
|
||||
filters: [FolderFilter.channel, FolderFilter.unread],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Изменение папки'), findsOneWidget);
|
||||
expect(find.text('6/20'), findsOneWidget);
|
||||
expect(find.text('Удалить папку'), findsOneWidget);
|
||||
expect(find.text('Сохранить'), findsOneWidget);
|
||||
expect(_button(tester, 'Сохранить').onTap, isNotNull);
|
||||
expect(_button(tester, 'Удалить папку').onTap, isNotNull);
|
||||
|
||||
await _scrollToBottom(tester);
|
||||
final unreadSwitch = tester.widget<Switch>(find.byType(Switch).last);
|
||||
expect(unreadSwitch.value, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('a folder the server locks cannot be deleted', (tester) async {
|
||||
await _openSheet(
|
||||
tester,
|
||||
folder: const ChatFolder(
|
||||
id: 'synthetic-system-folder',
|
||||
title: 'Каналы',
|
||||
options: [FolderOption.noDelete],
|
||||
),
|
||||
);
|
||||
|
||||
expect(_button(tester, 'Удалить папку').onTap, isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/models/chat_folder.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
import 'package:ikar/backend/modules/folders.dart';
|
||||
|
||||
const int _me = 4242;
|
||||
const int _contactId = 777;
|
||||
|
||||
CachedChat _chat({
|
||||
required int id,
|
||||
required String type,
|
||||
int unreadCount = 0,
|
||||
int dontDisturbUntil = 0,
|
||||
Set<String> options = const {},
|
||||
int? owner,
|
||||
}) => CachedChat(
|
||||
id: id,
|
||||
accountId: _me,
|
||||
type: type,
|
||||
title: 'chat $id',
|
||||
unreadCount: unreadCount,
|
||||
lastEventTime: 1700000000000,
|
||||
cachedAt: 0,
|
||||
dontDisturbUntil: dontDisturbUntil,
|
||||
isOnline: false,
|
||||
seenTime: 0,
|
||||
participants: {_me: 1700000000000},
|
||||
options: options,
|
||||
owner: owner,
|
||||
);
|
||||
|
||||
bool _matches(CachedChat chat, ChatFolder folder) =>
|
||||
FoldersModule.chatMatchesFolder(
|
||||
chat,
|
||||
folder,
|
||||
myId: _me,
|
||||
contactIds: const {_contactId},
|
||||
);
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'archive stays out of automatic folders but explicit inclusion survives',
|
||||
() {
|
||||
final source = [
|
||||
for (final id in [101, 102, 103]) _chat(id: id, type: 'CHANNEL'),
|
||||
];
|
||||
const folder = ChatFolder(
|
||||
id: 'test',
|
||||
title: 'Test',
|
||||
filters: [FolderFilter.channel],
|
||||
include: [102],
|
||||
);
|
||||
final result = FoldersModule.chatsForFolder(
|
||||
source,
|
||||
folder,
|
||||
myId: _me,
|
||||
contactIds: {},
|
||||
archivedIds: {102, 103},
|
||||
);
|
||||
expect(result.map((chat) => chat.id), [101, 102]);
|
||||
},
|
||||
);
|
||||
group('ChatFolder.fromJson', () {
|
||||
test('reads the server payload of FOLDERS_UPDATE', () {
|
||||
final folder = ChatFolder.fromJson(const {
|
||||
'id': '6fd177c5-ba2e-4360-9593-3ae798326806',
|
||||
'title': 'Каналы',
|
||||
'include': [111, -222],
|
||||
'filters': [0, 11, 2],
|
||||
'favorites': [111],
|
||||
'options': [1, 2],
|
||||
'updateTime': 1700000000000,
|
||||
'sourceId': 1,
|
||||
});
|
||||
|
||||
expect(folder.id, '6fd177c5-ba2e-4360-9593-3ae798326806');
|
||||
expect(folder.include, [111, -222]);
|
||||
expect(folder.filters, [
|
||||
FolderFilter.unread,
|
||||
FolderFilter.notMuted,
|
||||
FolderFilter.channel,
|
||||
]);
|
||||
expect(folder.favorites, [111]);
|
||||
expect(folder.updateTime, 1700000000000);
|
||||
expect(folder.sourceId, 1);
|
||||
expect(folder.canDelete, isFalse);
|
||||
expect(folder.canEditTitle, isFalse);
|
||||
expect(folder.canEditFilters, isTrue);
|
||||
});
|
||||
|
||||
test('normalizes legacy string filters and the hideEmpty flag', () {
|
||||
final folder = ChatFolder.fromJson(const {
|
||||
'id': 'legacy',
|
||||
'title': 'legacy',
|
||||
'filters': ['CHANNEL', 'GROUP'],
|
||||
'hideEmpty': true,
|
||||
});
|
||||
|
||||
expect(folder.filters, [FolderFilter.channel, FolderFilter.chat]);
|
||||
expect(folder.options, [FolderOption.hideEmpty]);
|
||||
expect(folder.hideEmpty, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('chatMatchesFolder', () {
|
||||
const channelsFolder = ChatFolder(
|
||||
id: 'f1',
|
||||
title: 'Каналы',
|
||||
filters: [FolderFilter.channel],
|
||||
);
|
||||
|
||||
test('type filters are combined with OR', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f2',
|
||||
title: 'Каналы и боты',
|
||||
filters: [FolderFilter.channel, FolderFilter.bot],
|
||||
);
|
||||
|
||||
expect(_matches(_chat(id: 1, type: 'CHANNEL'), folder), isTrue);
|
||||
expect(
|
||||
_matches(_chat(id: 2, type: 'DIALOG', options: {'BOT'}), folder),
|
||||
isTrue,
|
||||
);
|
||||
expect(_matches(_chat(id: 3, type: 'CHAT'), folder), isFalse);
|
||||
});
|
||||
|
||||
test('a folder without filters only holds its included chats', () {
|
||||
const folder = ChatFolder(id: 'f3', title: 'Свои', include: [10]);
|
||||
|
||||
expect(_matches(_chat(id: 10, type: 'CHAT'), folder), isTrue);
|
||||
expect(_matches(_chat(id: 11, type: 'CHAT'), folder), isFalse);
|
||||
});
|
||||
|
||||
test('show-only filters narrow both types and included chats', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f4',
|
||||
title: 'Непрочитанные каналы',
|
||||
include: [10],
|
||||
filters: [FolderFilter.channel, FolderFilter.unread],
|
||||
);
|
||||
|
||||
expect(
|
||||
_matches(_chat(id: 1, type: 'CHANNEL', unreadCount: 3), folder),
|
||||
isTrue,
|
||||
);
|
||||
expect(_matches(_chat(id: 2, type: 'CHANNEL'), folder), isFalse);
|
||||
expect(
|
||||
_matches(_chat(id: 10, type: 'CHAT', unreadCount: 1), folder),
|
||||
isTrue,
|
||||
);
|
||||
expect(_matches(_chat(id: 10, type: 'CHAT'), folder), isFalse);
|
||||
});
|
||||
|
||||
test('show-only filters are combined with AND', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f5',
|
||||
title: 'Непрочитанные с уведомлениями',
|
||||
filters: [
|
||||
FolderFilter.channel,
|
||||
FolderFilter.unread,
|
||||
FolderFilter.notMuted,
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
_matches(_chat(id: 1, type: 'CHANNEL', unreadCount: 1), folder),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
_matches(
|
||||
_chat(id: 2, type: 'CHANNEL', unreadCount: 1, dontDisturbUntil: -1),
|
||||
folder,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('an expired mute counts as not muted', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f6',
|
||||
title: 'С уведомлениями',
|
||||
filters: [FolderFilter.channel, FolderFilter.notMuted],
|
||||
);
|
||||
|
||||
expect(
|
||||
_matches(
|
||||
_chat(id: 1, type: 'CHANNEL', dontDisturbUntil: 1700000000000),
|
||||
folder,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown filters do not empty a folder', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f7',
|
||||
title: 'Каналы',
|
||||
filters: [FolderFilter.channel, FolderFilter.markedUnread],
|
||||
);
|
||||
|
||||
expect(_matches(_chat(id: 1, type: 'CHANNEL'), folder), isTrue);
|
||||
});
|
||||
|
||||
test('role filters keep only chats with that role', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f8',
|
||||
title: 'Мои группы',
|
||||
filters: [FolderFilter.chat, FolderFilter.owner],
|
||||
);
|
||||
|
||||
expect(_matches(_chat(id: 1, type: 'CHAT', owner: _me), folder), isTrue);
|
||||
expect(_matches(_chat(id: 2, type: 'CHAT', owner: 5), folder), isFalse);
|
||||
});
|
||||
|
||||
test('a folder of only show-only filters narrows all chats', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f9',
|
||||
title: 'Новые',
|
||||
filters: [FolderFilter.unread],
|
||||
);
|
||||
|
||||
expect(
|
||||
_matches(_chat(id: 1, type: 'DIALOG', unreadCount: 1), folder),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
_matches(_chat(id: 2, type: 'CHAT', unreadCount: 4), folder),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
_matches(_chat(id: 3, type: 'CHANNEL', unreadCount: 2), folder),
|
||||
isTrue,
|
||||
);
|
||||
expect(_matches(_chat(id: 4, type: 'DIALOG'), folder), isFalse);
|
||||
});
|
||||
|
||||
test('a role-only folder narrows all chats', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f14',
|
||||
title: 'Мои',
|
||||
filters: [FolderFilter.owner],
|
||||
);
|
||||
|
||||
expect(_matches(_chat(id: 1, type: 'CHAT', owner: _me), folder), isTrue);
|
||||
expect(
|
||||
_matches(_chat(id: 2, type: 'CHANNEL', owner: _me), folder),
|
||||
isTrue,
|
||||
);
|
||||
expect(_matches(_chat(id: 3, type: 'CHAT', owner: 5), folder), isFalse);
|
||||
});
|
||||
|
||||
test('hand-picked chats keep a show-only folder to themselves', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f15',
|
||||
title: 'Непрочитанные свои',
|
||||
include: [10],
|
||||
filters: [FolderFilter.unread],
|
||||
);
|
||||
|
||||
expect(
|
||||
_matches(_chat(id: 10, type: 'CHAT', unreadCount: 1), folder),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
_matches(_chat(id: 11, type: 'CHAT', unreadCount: 1), folder),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown filters alone do not pull in every chat', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f16',
|
||||
title: 'Помеченные',
|
||||
filters: [FolderFilter.markedUnread],
|
||||
);
|
||||
|
||||
expect(
|
||||
_matches(_chat(id: 1, type: 'CHAT', unreadCount: 1), folder),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('channels folder ignores groups and dialogs', () {
|
||||
expect(_matches(_chat(id: 1, type: 'CHANNEL'), channelsFolder), isTrue);
|
||||
expect(_matches(_chat(id: 2, type: 'CHAT'), channelsFolder), isFalse);
|
||||
expect(_matches(_chat(id: 3, type: 'DIALOG'), channelsFolder), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('chatsForFolder', () {
|
||||
List<int> ids(
|
||||
List<CachedChat> chats,
|
||||
ChatFolder folder, {
|
||||
Set<int> archived = const {},
|
||||
}) => FoldersModule.chatsForFolder(
|
||||
chats,
|
||||
folder,
|
||||
myId: _me,
|
||||
contactIds: const {_contactId},
|
||||
archivedIds: archived,
|
||||
).map((c) => c.id).toList();
|
||||
|
||||
test('an archived chat stays in the folder it was added to by hand', () {
|
||||
const folder = ChatFolder(id: 'f10', title: 'Свои', include: [10, 11]);
|
||||
final chats = [_chat(id: 10, type: 'CHAT'), _chat(id: 11, type: 'CHAT')];
|
||||
|
||||
expect(ids(chats, folder, archived: {10, 11}), [10, 11]);
|
||||
});
|
||||
|
||||
test('archiving hides a chat that only matched a type filter', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f11',
|
||||
title: 'Каналы',
|
||||
filters: [FolderFilter.channel],
|
||||
);
|
||||
final chats = [
|
||||
_chat(id: 20, type: 'CHANNEL'),
|
||||
_chat(id: 21, type: 'CHANNEL'),
|
||||
];
|
||||
|
||||
expect(ids(chats, folder, archived: {21}), [20]);
|
||||
});
|
||||
|
||||
test('show-only filters still apply to an archived included chat', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f12',
|
||||
title: 'Непрочитанные',
|
||||
include: [30, 31],
|
||||
filters: [FolderFilter.unread],
|
||||
);
|
||||
final chats = [
|
||||
_chat(id: 30, type: 'CHAT', unreadCount: 2),
|
||||
_chat(id: 31, type: 'CHAT'),
|
||||
];
|
||||
|
||||
expect(ids(chats, folder, archived: {30, 31}), [30]);
|
||||
});
|
||||
|
||||
test('without an archive the result matches chatMatchesFolder', () {
|
||||
const folder = ChatFolder(
|
||||
id: 'f13',
|
||||
title: 'Каналы и свои',
|
||||
include: [41],
|
||||
filters: [FolderFilter.channel],
|
||||
);
|
||||
final chats = [
|
||||
_chat(id: 40, type: 'CHANNEL'),
|
||||
_chat(id: 41, type: 'CHAT'),
|
||||
_chat(id: 42, type: 'DIALOG'),
|
||||
];
|
||||
|
||||
expect(ids(chats, folder), [40, 41]);
|
||||
});
|
||||
});
|
||||
|
||||
group('reshapesChatList', () {
|
||||
test('reacts to what moves a chat between folders', () {
|
||||
final read = _chat(id: 1, type: 'CHAT');
|
||||
final unread = _chat(id: 1, type: 'CHAT', unreadCount: 1);
|
||||
final moreUnread = _chat(id: 1, type: 'CHAT', unreadCount: 5);
|
||||
final muted = _chat(id: 1, type: 'CHAT', dontDisturbUntil: -1);
|
||||
final owned = _chat(id: 1, type: 'CHAT', owner: _me);
|
||||
|
||||
expect(ChatsModule.reshapesChatList(read, unread), isTrue);
|
||||
expect(ChatsModule.reshapesChatList(unread, read), isTrue);
|
||||
expect(ChatsModule.reshapesChatList(read, muted), isTrue);
|
||||
expect(ChatsModule.reshapesChatList(read, owned), isTrue);
|
||||
expect(ChatsModule.reshapesChatList(unread, moreUnread), isFalse);
|
||||
expect(ChatsModule.reshapesChatList(read, read), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('newFolderId', () {
|
||||
test('generates distinct uuid v4 ids', () {
|
||||
final a = FoldersModule.newFolderId();
|
||||
final b = FoldersModule.newFolderId();
|
||||
|
||||
expect(a, isNot(b));
|
||||
expect(
|
||||
RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
).hasMatch(a),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/config/font_file_info.dart';
|
||||
|
||||
typedef _Name = ({int platform, int language, int nameId, List<int> bytes});
|
||||
|
||||
List<int> _utf16be(String s) => [
|
||||
for (final unit in s.codeUnits) ...[unit >> 8, unit & 0xff],
|
||||
];
|
||||
|
||||
Uint8List _nameTable(List<_Name> names) {
|
||||
final storage = <int>[];
|
||||
final records = _Bytes();
|
||||
for (final name in names) {
|
||||
records
|
||||
..u16(name.platform)
|
||||
..u16(name.platform == 3 ? 1 : 0)
|
||||
..u16(name.language)
|
||||
..u16(name.nameId)
|
||||
..u16(name.bytes.length)
|
||||
..u16(storage.length);
|
||||
storage.addAll(name.bytes);
|
||||
}
|
||||
return (_Bytes()
|
||||
..u16(0)
|
||||
..u16(names.length)
|
||||
..u16(6 + names.length * 12)
|
||||
..addAll(records.bytes)
|
||||
..addAll(storage))
|
||||
.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _font(List<_Name> names, {bool collection = false}) {
|
||||
final table = _nameTable(names);
|
||||
final headerSize = collection ? 16 : 0;
|
||||
final fontOffset = headerSize;
|
||||
final tableOffset = fontOffset + 12 + 16;
|
||||
final out = _Bytes();
|
||||
if (collection) {
|
||||
out
|
||||
..u32(0x74746366)
|
||||
..u32(0x00010000)
|
||||
..u32(1)
|
||||
..u32(fontOffset);
|
||||
}
|
||||
out
|
||||
..u32(0x00010000)
|
||||
..u16(1)
|
||||
..u16(16)
|
||||
..u16(0)
|
||||
..u16(0)
|
||||
..addAll('name'.codeUnits)
|
||||
..u32(0)
|
||||
..u32(tableOffset)
|
||||
..u32(table.length)
|
||||
..addAll(table);
|
||||
return out.toBytes();
|
||||
}
|
||||
|
||||
class _Bytes {
|
||||
final List<int> bytes = [];
|
||||
|
||||
void u16(int v) => bytes.addAll([(v >> 8) & 0xff, v & 0xff]);
|
||||
|
||||
void u32(int v) => bytes.addAll([
|
||||
(v >> 24) & 0xff,
|
||||
(v >> 16) & 0xff,
|
||||
(v >> 8) & 0xff,
|
||||
v & 0xff,
|
||||
]);
|
||||
|
||||
void addAll(Iterable<int> values) => bytes.addAll(values);
|
||||
|
||||
Uint8List toBytes() => Uint8List.fromList(bytes);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('the typographic family wins over the legacy one', () {
|
||||
final font = _font([
|
||||
(platform: 3, language: 0x0409, nameId: 1, bytes: _utf16be('Synth Bold')),
|
||||
(platform: 3, language: 0x0409, nameId: 16, bytes: _utf16be('Synth')),
|
||||
]);
|
||||
expect(FontFileInfo.familyName(font), 'Synth');
|
||||
});
|
||||
|
||||
test('the English Windows name is preferred, others are a fallback', () {
|
||||
final font = _font([
|
||||
(platform: 1, language: 0, nameId: 1, bytes: 'Mac Synth'.codeUnits),
|
||||
(platform: 3, language: 0x0419, nameId: 1, bytes: _utf16be('Синтез')),
|
||||
(platform: 3, language: 0x0409, nameId: 1, bytes: _utf16be('Synth')),
|
||||
]);
|
||||
expect(FontFileInfo.familyName(font), 'Synth');
|
||||
|
||||
final onlyRussian = _font([
|
||||
(platform: 3, language: 0x0419, nameId: 1, bytes: _utf16be('Синтез')),
|
||||
]);
|
||||
expect(FontFileInfo.familyName(onlyRussian), 'Синтез');
|
||||
});
|
||||
|
||||
test('a collection is read from its first font', () {
|
||||
final font = _font([
|
||||
(platform: 3, language: 0x0409, nameId: 1, bytes: _utf16be('Synth')),
|
||||
], collection: true);
|
||||
expect(FontFileInfo.familyName(font), 'Synth');
|
||||
});
|
||||
|
||||
test('garbage has no family name', () {
|
||||
expect(FontFileInfo.familyName(Uint8List.fromList([1, 2, 3])), isNull);
|
||||
expect(FontFileInfo.familyName(_font(const [])), isNull);
|
||||
});
|
||||
|
||||
test('the file name loses its style suffix and separators', () {
|
||||
expect(FontFileInfo.familyFromFileName('/tmp/Synth-Regular.ttf'), 'Synth');
|
||||
expect(
|
||||
FontFileInfo.familyFromFileName('Synth_Sans-VariableFont_wght.ttf'),
|
||||
'Synth Sans',
|
||||
);
|
||||
expect(FontFileInfo.familyFromFileName('Mono.otf'), 'Mono');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/forward_sender.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/crypto/e2ee_service.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:ikar/core/storage/chat_encryption_store.dart';
|
||||
import 'package:ikar/core/storage/token_storage.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_SyntheticPathProvider(this.path);
|
||||
|
||||
final String path;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => path;
|
||||
}
|
||||
|
||||
class _RecordingApi extends Api {
|
||||
int epoch = 1;
|
||||
final requests = <Map<dynamic, dynamic>>[];
|
||||
Future<void> Function()? beforeDispatch;
|
||||
Future<void> Function()? afterDispatch;
|
||||
|
||||
@override
|
||||
int get sessionEpoch => epoch;
|
||||
|
||||
@override
|
||||
Future<Packet> sendRequest(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
bool silent = false,
|
||||
void Function()? beforeSend,
|
||||
}) async {
|
||||
await beforeDispatch?.call();
|
||||
beforeSend?.call();
|
||||
requests.add(payload);
|
||||
await afterDispatch?.call();
|
||||
return Packet(
|
||||
cmd: CmdType.ok,
|
||||
payload: {
|
||||
'message': {'id': '', 'text': 'Synthetic delivery', 'time': 1000},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _source = CachedMessage(
|
||||
id: '701',
|
||||
accountId: 101,
|
||||
chatId: 303,
|
||||
senderId: 202,
|
||||
text: 'Synthetic forward',
|
||||
time: 1000,
|
||||
);
|
||||
|
||||
ForwardRequest _request({bool hideSender = false}) => ForwardRequest(
|
||||
sourceChatId: 303,
|
||||
sourceChatName: 'Synthetic source',
|
||||
sourceChatIconUrl: '',
|
||||
sourceChatType: 'CHAT',
|
||||
messages: [_source],
|
||||
hideSender: hideSender,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late _RecordingApi api;
|
||||
late MessagesModule sender;
|
||||
|
||||
setUpAll(AppDatabase.init);
|
||||
|
||||
Future<void> setPhase(String phase) async {
|
||||
E2eeService.instance.forgetAccount(101);
|
||||
await AppDatabase.saveE2eeSession({
|
||||
'account_id': 101,
|
||||
'chat_id': 404,
|
||||
'peer_id': 202,
|
||||
'phase': phase,
|
||||
'updated': 1000,
|
||||
});
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({'active_account_id': '101'});
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'synthetic_forward',
|
||||
);
|
||||
final previousProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
await databaseFactory.setDatabasesPath(directory.path);
|
||||
await AppDatabase.saveProfile(ProfileData.stub(101));
|
||||
E2eeService.instance.forgetAccount(101);
|
||||
await ChatEncryptionStore.instance.setEnabled(101, 404, false);
|
||||
api = _RecordingApi();
|
||||
sender = MessagesModule(api);
|
||||
addTearDown(() async {
|
||||
E2eeService.instance.forgetAccount(101);
|
||||
await ChatEncryptionStore.instance.setEnabled(101, 404, false);
|
||||
await api.dispose();
|
||||
await AppDatabase.close();
|
||||
PathProviderPlatform.instance = previousProvider;
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
});
|
||||
|
||||
for (final phase in [
|
||||
'established',
|
||||
'keyChanged',
|
||||
'offered',
|
||||
'pendingConsent',
|
||||
]) {
|
||||
for (final copy in [false, true]) {
|
||||
test(
|
||||
'$phase destination rejects direct ${copy ? "copy" : "forward"}',
|
||||
() async {
|
||||
await setPhase(phase);
|
||||
|
||||
await expectLater(
|
||||
copy
|
||||
? ForwardSender.sendCopy(101, 404, _source, sender: sender)
|
||||
: ForwardSender.sendForward(
|
||||
101,
|
||||
404,
|
||||
_request(),
|
||||
_source,
|
||||
sender: sender,
|
||||
),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(api.requests, isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('legacy destination rejects a caption-only resumed batch', () async {
|
||||
await ChatEncryptionStore.instance.setEnabled(101, 404, true);
|
||||
|
||||
final result = await ForwardSender.send(
|
||||
accountId: 101,
|
||||
chatIds: [404],
|
||||
request: _request(),
|
||||
caption: const ForwardCaption('Synthetic caption'),
|
||||
resumeFrom: {404: 1},
|
||||
sender: sender,
|
||||
);
|
||||
|
||||
expect(result.unfinished, {404: 1});
|
||||
expect(api.requests, isEmpty);
|
||||
});
|
||||
|
||||
test('mixed batch delivers only to the unencrypted destination', () async {
|
||||
await setPhase('established');
|
||||
|
||||
final result = await ForwardSender.send(
|
||||
accountId: 101,
|
||||
chatIds: [404, 505],
|
||||
request: _request(hideSender: true),
|
||||
caption: const ForwardCaption('Synthetic caption'),
|
||||
sender: sender,
|
||||
);
|
||||
|
||||
expect(result.delivered, {505});
|
||||
expect(result.unfinished, {404: 0});
|
||||
expect(api.requests.map((p) => p['chatId']), [505, 505]);
|
||||
});
|
||||
|
||||
test('encryption enabled between steps blocks the caption', () async {
|
||||
api.afterDispatch = () =>
|
||||
ChatEncryptionStore.instance.setEnabled(101, 404, true);
|
||||
|
||||
final result = await ForwardSender.send(
|
||||
accountId: 101,
|
||||
chatIds: [404],
|
||||
request: _request(),
|
||||
caption: const ForwardCaption('Synthetic caption'),
|
||||
sender: sender,
|
||||
);
|
||||
|
||||
expect(result.unfinished, {404: 1});
|
||||
expect(api.requests, hasLength(1));
|
||||
});
|
||||
|
||||
test(
|
||||
'encryption enabled while dispatch waits blocks the wire request',
|
||||
() async {
|
||||
api.beforeDispatch = () =>
|
||||
ChatEncryptionStore.instance.setEnabled(101, 404, true);
|
||||
|
||||
await expectLater(
|
||||
ForwardSender.sendCopy(101, 404, _source, sender: sender),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(api.requests, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'forgotten account while dispatch waits blocks the wire request',
|
||||
() async {
|
||||
api.beforeDispatch = () async => E2eeService.instance.forgetAccount(101);
|
||||
|
||||
await expectLater(
|
||||
ForwardSender.sendCopy(101, 404, _source, sender: sender),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(api.requests, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('new session while dispatch waits blocks the wire request', () async {
|
||||
api.beforeDispatch = () async => api.epoch++;
|
||||
|
||||
await expectLater(
|
||||
ForwardSender.sendCopy(101, 404, _source, sender: sender),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(api.requests, isEmpty);
|
||||
});
|
||||
|
||||
test('a batch cannot continue in the replacement session', () async {
|
||||
api.beforeDispatch = () async {
|
||||
api.beforeDispatch = null;
|
||||
api.epoch++;
|
||||
};
|
||||
|
||||
final result = await ForwardSender.send(
|
||||
accountId: 101,
|
||||
chatIds: [404, 505, 606, 707],
|
||||
request: _request(),
|
||||
sender: sender,
|
||||
);
|
||||
|
||||
expect(result.unfinished, {404: 0, 505: 0, 606: 0, 707: 0});
|
||||
expect(api.requests, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'a caption cannot follow its forward in a replacement session',
|
||||
() async {
|
||||
api.afterDispatch = () async => api.epoch++;
|
||||
|
||||
final result = await ForwardSender.send(
|
||||
accountId: 101,
|
||||
chatIds: [404],
|
||||
request: _request(),
|
||||
caption: const ForwardCaption('Synthetic caption'),
|
||||
sender: sender,
|
||||
);
|
||||
|
||||
expect(result.unfinished, {404: 1});
|
||||
expect(api.requests, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'v2 encryption enabled while dispatch waits blocks the caption',
|
||||
() async {
|
||||
api.beforeDispatch = () async {
|
||||
await setPhase('established');
|
||||
await E2eeService.instance.ensureLoaded(101);
|
||||
};
|
||||
|
||||
final result = await ForwardSender.send(
|
||||
accountId: 101,
|
||||
chatIds: [404],
|
||||
request: _request(),
|
||||
caption: const ForwardCaption('Synthetic caption'),
|
||||
resumeFrom: {404: 1},
|
||||
sender: sender,
|
||||
);
|
||||
|
||||
expect(result.unfinished, {404: 1});
|
||||
expect(api.requests, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('ordinary destination supports direct forward and copy', () async {
|
||||
await ForwardSender.sendForward(
|
||||
101,
|
||||
404,
|
||||
_request(),
|
||||
_source,
|
||||
sender: sender,
|
||||
);
|
||||
await ForwardSender.sendCopy(101, 404, _source, sender: sender);
|
||||
|
||||
expect(api.requests, hasLength(2));
|
||||
});
|
||||
|
||||
test('account switch at dispatch blocks copying', () async {
|
||||
api.beforeDispatch = () => TokenStorage.setActiveAccount(909);
|
||||
await expectLater(
|
||||
ForwardSender.sendCopy(101, 404, _source, sender: sender),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(api.requests, isEmpty);
|
||||
});
|
||||
|
||||
test('source encryption at dispatch blocks forwarding', () async {
|
||||
api.beforeDispatch = () =>
|
||||
ChatEncryptionStore.instance.setEnabled(101, 303, true);
|
||||
try {
|
||||
await expectLater(
|
||||
ForwardSender.sendForward(
|
||||
101,
|
||||
404,
|
||||
_request(),
|
||||
_source,
|
||||
sender: sender,
|
||||
),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(api.requests, isEmpty);
|
||||
} finally {
|
||||
await ChatEncryptionStore.instance.setEnabled(101, 303, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/forward_sender.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/storage/app_database.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
class _SyntheticPathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_SyntheticPathProvider(this.path);
|
||||
|
||||
final String path;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => path;
|
||||
}
|
||||
|
||||
CachedMessage _message(String id, {List<Map<String, dynamic>>? attaches}) {
|
||||
final payload = <String, dynamic>{
|
||||
'id': id,
|
||||
'text': 'Synthetic $id',
|
||||
'attaches': attaches ?? const [],
|
||||
};
|
||||
final (attachments, isControl) = CachedMessage.parseAttachments(payload);
|
||||
return CachedMessage(
|
||||
id: id,
|
||||
accountId: 1,
|
||||
chatId: 10,
|
||||
senderId: 9,
|
||||
text: 'Synthetic $id',
|
||||
time: 1000,
|
||||
payload: payload,
|
||||
attachments: attachments,
|
||||
isControl: isControl,
|
||||
);
|
||||
}
|
||||
|
||||
ForwardRequest _request({bool hideSender = false, CachedMessage? extra}) =>
|
||||
ForwardRequest(
|
||||
sourceChatId: 10,
|
||||
sourceChatName: 'Synthetic chat',
|
||||
sourceChatIconUrl: '',
|
||||
sourceChatType: 'CHAT',
|
||||
messages: [_message('701'), _message('702'), ?extra],
|
||||
hideSender: hideSender,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'synthetic_forward_resume',
|
||||
);
|
||||
final previousProvider = PathProviderPlatform.instance;
|
||||
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
||||
await AppDatabase.init();
|
||||
await databaseFactory.setDatabasesPath(directory.path);
|
||||
addTearDown(() async {
|
||||
await AppDatabase.close();
|
||||
PathProviderPlatform.instance = previousProvider;
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
});
|
||||
|
||||
test('a chat that already got everything is not sent to again', () async {
|
||||
final result = await ForwardSender.send(
|
||||
accountId: 1,
|
||||
chatIds: [20],
|
||||
request: _request(),
|
||||
resumeFrom: {20: 2},
|
||||
);
|
||||
|
||||
expect(result.delivered, {20});
|
||||
expect(result.unfinished, isEmpty);
|
||||
});
|
||||
|
||||
test('a failed chat reports the step it stopped at', () async {
|
||||
final result = await ForwardSender.send(
|
||||
accountId: 1,
|
||||
chatIds: [20, 30],
|
||||
request: _request(),
|
||||
caption: const ForwardCaption('Synthetic note'),
|
||||
resumeFrom: {20: 2},
|
||||
);
|
||||
|
||||
expect(result.delivered, isEmpty);
|
||||
expect(result.unfinished, {20: 2, 30: 0});
|
||||
});
|
||||
|
||||
test('a batch that cannot hide the sender sends nothing', () async {
|
||||
final result = await ForwardSender.send(
|
||||
accountId: 1,
|
||||
chatIds: [20],
|
||||
request: _request(
|
||||
hideSender: true,
|
||||
extra: _message(
|
||||
'703',
|
||||
attaches: [
|
||||
{'_type': 'POLL', 'title': 'Synthetic poll'},
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.delivered, isEmpty);
|
||||
expect(result.unfinished, {20: 0});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/utils/text_format.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/bubble_context.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/call_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/contact_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/file_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/forwarded_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/location_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/photo_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/share_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/sticker_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/video_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/video_note_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/voice_bubble.dart';
|
||||
import 'package:ikar/frontend/widgets/formatted_message_text.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
import 'package:ikar/models/attachment.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
Future<void> _pumpBubble(
|
||||
WidgetTester tester,
|
||||
CachedMessage message, {
|
||||
void Function(ForwardedMessageAttachment forwarded)? onSourceTap,
|
||||
bool isMe = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2.5;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MessageBubble(
|
||||
message: message,
|
||||
isMe: isMe,
|
||||
myId: 1,
|
||||
chatType: 'CHAT',
|
||||
onForwardedSourceTap: onSourceTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ForwardedMessageAttachment', () {
|
||||
test('styles a server heading', () {
|
||||
final style = applyTextFormats(const TextStyle(fontSize: 16), {
|
||||
TextFormat.heading,
|
||||
});
|
||||
|
||||
expect(style.fontWeight, FontWeight.w700);
|
||||
expect(style.fontSize, greaterThan(16));
|
||||
});
|
||||
|
||||
test('uses channel metadata as the original author', () {
|
||||
final attachment = ForwardedMessageAttachment.fromMap({
|
||||
'link': {
|
||||
'type': 'FORWARD',
|
||||
'message': {
|
||||
'id': '101',
|
||||
'time': 1000,
|
||||
'type': 'CHANNEL',
|
||||
'text': 'Synthetic channel message',
|
||||
'attaches': [
|
||||
{'_type': 'PHOTO', 'photoId': 11},
|
||||
],
|
||||
'elements': [
|
||||
{'type': 'HEADING', 'length': 9},
|
||||
],
|
||||
},
|
||||
'chatId': -10,
|
||||
'chatName': 'Example Channel',
|
||||
'chatIconUrl': 'https://example.test/channel.jpg',
|
||||
},
|
||||
});
|
||||
|
||||
expect(attachment.originalSenderId, 0);
|
||||
expect(attachment.originalSenderName, 'Example Channel');
|
||||
expect(attachment.isChannel, isTrue);
|
||||
expect(attachment.originalMessageId, '101');
|
||||
expect(attachment.originalTime, 1000);
|
||||
expect(
|
||||
attachment.originalSenderAvatar,
|
||||
'https://example.test/channel.jpg',
|
||||
);
|
||||
expect(attachment.originalChatId, -10);
|
||||
expect(attachment.originalAttachments, hasLength(1));
|
||||
expect(attachment.originalAttachments!.single, isA<PhotoAttachment>());
|
||||
expect(attachment.originalFormatRanges, hasLength(1));
|
||||
expect(attachment.originalFormatRanges.single.format, TextFormat.heading);
|
||||
});
|
||||
|
||||
test('keeps the user as the original author', () {
|
||||
final attachment = ForwardedMessageAttachment.fromMap({
|
||||
'link': {
|
||||
'type': 'FORWARD',
|
||||
'message': {
|
||||
'id': '102',
|
||||
'time': 2000,
|
||||
'type': 'USER',
|
||||
'sender': 42,
|
||||
'text': '',
|
||||
'attaches': const [],
|
||||
},
|
||||
'chatId': -20,
|
||||
'chatName': 'Example Group',
|
||||
'chatIconUrl': 'https://example.test/group.jpg',
|
||||
},
|
||||
});
|
||||
|
||||
expect(attachment.originalSenderId, 42);
|
||||
expect(attachment.isChannel, isFalse);
|
||||
expect(attachment.originalSenderName, isNull);
|
||||
expect(attachment.originalSenderAvatar, isNull);
|
||||
});
|
||||
|
||||
test('marks a forward from a private channel', () {
|
||||
final attachment = ForwardedMessageAttachment.fromMap({
|
||||
'link': {
|
||||
'type': 'FORWARD',
|
||||
'message': {
|
||||
'id': '103',
|
||||
'time': 3000,
|
||||
'type': 'CHANNEL',
|
||||
'text': 'Synthetic private post',
|
||||
'attaches': const [],
|
||||
},
|
||||
'chatId': -30,
|
||||
'chatName': 'Example Private Channel',
|
||||
'chatAccessType': 'PRIVATE',
|
||||
},
|
||||
});
|
||||
|
||||
expect(attachment.isChannel, isTrue);
|
||||
expect(attachment.originalChatAccess, 'PRIVATE');
|
||||
expect(attachment.isFromPrivateChat, isTrue);
|
||||
});
|
||||
|
||||
test('does not mark a forward without an access type as private', () {
|
||||
final attachment = ForwardedMessageAttachment.fromMap({
|
||||
'link': {
|
||||
'type': 'FORWARD',
|
||||
'message': {
|
||||
'id': '104',
|
||||
'time': 4000,
|
||||
'type': 'CHANNEL',
|
||||
'text': 'Synthetic public post',
|
||||
'attaches': const [],
|
||||
},
|
||||
'chatId': -40,
|
||||
'chatName': 'Example Public Channel',
|
||||
},
|
||||
});
|
||||
|
||||
expect(attachment.originalChatAccess, isNull);
|
||||
expect(attachment.isFromPrivateChat, isFalse);
|
||||
});
|
||||
|
||||
test('keeps channel metadata in an optimistic forward', () {
|
||||
final forwarded = MessagesModule.buildForwardMessage(
|
||||
myId: 1,
|
||||
targetChatId: 2,
|
||||
sourceChatId: -10,
|
||||
source: const CachedMessage(
|
||||
id: '101',
|
||||
accountId: 1,
|
||||
chatId: -10,
|
||||
senderId: 0,
|
||||
text: 'Synthetic channel message',
|
||||
time: 1000,
|
||||
payload: {
|
||||
'type': 'CHANNEL',
|
||||
'attaches': [],
|
||||
'elements': [
|
||||
{'type': 'STRONG', 'length': 9},
|
||||
],
|
||||
},
|
||||
),
|
||||
tempId: 'temp_1',
|
||||
time: 3000,
|
||||
status: 'sending',
|
||||
sourceChatName: 'Example Channel',
|
||||
sourceChatIconUrl: 'https://example.test/channel.jpg',
|
||||
sourceChatType: 'CHANNEL',
|
||||
);
|
||||
|
||||
final attachment =
|
||||
forwarded.attachments!.single as ForwardedMessageAttachment;
|
||||
expect(attachment.originalSenderName, 'Example Channel');
|
||||
expect(
|
||||
attachment.originalSenderAvatar,
|
||||
'https://example.test/channel.jpg',
|
||||
);
|
||||
expect(attachment.originalFormatRanges, hasLength(1));
|
||||
});
|
||||
|
||||
test('keeps the original channel when forwarding a forward', () {
|
||||
final forwarded = MessagesModule.buildForwardMessage(
|
||||
myId: 1,
|
||||
targetChatId: 2,
|
||||
sourceChatId: 3,
|
||||
source: const CachedMessage(
|
||||
id: '201',
|
||||
accountId: 1,
|
||||
chatId: 3,
|
||||
senderId: 42,
|
||||
time: 3000,
|
||||
payload: {
|
||||
'link': {
|
||||
'type': 'FORWARD',
|
||||
'message': {
|
||||
'id': '101',
|
||||
'time': 1000,
|
||||
'type': 'CHANNEL',
|
||||
'text': 'Synthetic channel message',
|
||||
'attaches': [],
|
||||
},
|
||||
'chatId': -10,
|
||||
'chatName': 'Example Channel',
|
||||
'chatIconUrl': 'https://example.test/channel.jpg',
|
||||
},
|
||||
},
|
||||
),
|
||||
tempId: 'temp_2',
|
||||
time: 4000,
|
||||
status: 'sending',
|
||||
sourceChatName: 'Current Chat',
|
||||
sourceChatIconUrl: 'https://example.test/current-chat.jpg',
|
||||
sourceChatType: 'CHAT',
|
||||
);
|
||||
|
||||
final attachment =
|
||||
forwarded.attachments!.single as ForwardedMessageAttachment;
|
||||
expect(attachment.originalSenderName, 'Example Channel');
|
||||
expect(
|
||||
attachment.originalSenderAvatar,
|
||||
'https://example.test/channel.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders a formatted caption with a forwarded photo', (
|
||||
tester,
|
||||
) async {
|
||||
const caption =
|
||||
'Bold synthetic caption that wraps within the synthetic photo width';
|
||||
final attachment = ForwardedMessageAttachment.fromMap({
|
||||
'link': {
|
||||
'type': 'FORWARD',
|
||||
'message': {
|
||||
'id': '103',
|
||||
'time': 5000,
|
||||
'type': 'CHANNEL',
|
||||
'text': caption,
|
||||
'attaches': [
|
||||
{'_type': 'PHOTO', 'photoId': 13, 'width': 200, 'height': 200},
|
||||
],
|
||||
'elements': [
|
||||
{'type': 'HEADING', 'length': 4},
|
||||
],
|
||||
},
|
||||
'chatId': -30,
|
||||
'chatName': 'Another Example Channel',
|
||||
},
|
||||
});
|
||||
final message = CachedMessage(
|
||||
id: '202',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 42,
|
||||
time: 6000,
|
||||
attachments: [attachment],
|
||||
);
|
||||
ForwardedMessageAttachment? tappedSource;
|
||||
|
||||
await _pumpBubble(
|
||||
tester,
|
||||
message,
|
||||
onSourceTap: (forwarded) => tappedSource = forwarded,
|
||||
);
|
||||
|
||||
expect(find.text('Another Example Channel'), findsOneWidget);
|
||||
expect(find.text(caption), findsOneWidget);
|
||||
final formatted = tester.widget<FormattedMessageText>(
|
||||
find.byType(FormattedMessageText),
|
||||
);
|
||||
expect(formatted.ranges.single.format, TextFormat.heading);
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byType(ForwardedHeader)).width,
|
||||
closeTo(200, 0.1),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byType(ForwardedHeader)).width,
|
||||
closeTo(tester.getSize(find.byType(PhotoBubble)).width, 0.1),
|
||||
);
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(ClipRRect).first).dy,
|
||||
lessThanOrEqualTo(tester.getTopLeft(find.text(caption)).dy),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Another Example Channel'));
|
||||
expect(tappedSource, same(attachment));
|
||||
expect(tappedSource?.isChannel, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('renders a forwarded video with its original metadata', (
|
||||
tester,
|
||||
) async {
|
||||
final attachment = ForwardedMessageAttachment.fromMap({
|
||||
'link': {
|
||||
'type': 'FORWARD',
|
||||
'message': {
|
||||
'id': 'synthetic-source-message',
|
||||
'time': 9000,
|
||||
'type': 'USER',
|
||||
'sender': 44,
|
||||
'text': 'Synthetic video caption',
|
||||
'attaches': [
|
||||
{
|
||||
'_type': 'VIDEO',
|
||||
'videoId': 7001,
|
||||
'token': 'synthetic-video-token',
|
||||
'videoType': 0,
|
||||
'duration': 9000,
|
||||
'width': 720,
|
||||
'height': 1280,
|
||||
},
|
||||
],
|
||||
},
|
||||
'chatId': -50,
|
||||
},
|
||||
});
|
||||
final video = attachment.originalAttachments!.single as VideoAttachment;
|
||||
final message = CachedMessage(
|
||||
id: 'synthetic-forward-message',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 43,
|
||||
time: 10000,
|
||||
attachments: [attachment],
|
||||
);
|
||||
|
||||
expect(video.videoId, 7001);
|
||||
expect(video.videoToken, 'synthetic-video-token');
|
||||
expect(video.videoType, 0);
|
||||
|
||||
await _pumpBubble(tester, message);
|
||||
|
||||
expect(find.byType(ForwardedHeader), findsOneWidget);
|
||||
expect(find.byType(VideoBubble), findsOneWidget);
|
||||
expect(find.text('Synthetic video caption'), findsOneWidget);
|
||||
expect(find.text('0:09'), findsOneWidget);
|
||||
final forwardedWidth = tester.getSize(find.byType(ForwardedHeader)).width;
|
||||
final videoWidth = tester.getSize(find.byType(VideoBubble)).width;
|
||||
expect(forwardedWidth, closeTo(BubbleContext.photoMaxSize, 0.1));
|
||||
expect(forwardedWidth, closeTo(videoWidth, 0.1));
|
||||
final preview = find.descendant(
|
||||
of: find.byType(VideoBubble),
|
||||
matching: find.byType(ClipRRect),
|
||||
);
|
||||
expect(
|
||||
tester.getBottomLeft(preview.first).dy,
|
||||
lessThanOrEqualTo(
|
||||
tester.getTopLeft(find.text('Synthetic video caption')).dy,
|
||||
),
|
||||
);
|
||||
final bubble = tester.widget<VideoBubble>(find.byType(VideoBubble));
|
||||
expect(bubble.ctx.sourceMessageId, 'synthetic-source-message');
|
||||
expect(bubble.ctx.sourceChatId, -50);
|
||||
final forwardedVideoSize = tester.getSize(find.byType(VideoBubble));
|
||||
final forwardedPreviewSize = tester.getSize(preview.first);
|
||||
|
||||
final regularMessage = CachedMessage(
|
||||
id: 'synthetic-regular-message',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 43,
|
||||
time: 10000,
|
||||
text: 'Synthetic video caption',
|
||||
attachments: [video],
|
||||
);
|
||||
await _pumpBubble(tester, regularMessage);
|
||||
|
||||
final regularPreview = find.descendant(
|
||||
of: find.byType(VideoBubble),
|
||||
matching: find.byType(ClipRRect),
|
||||
);
|
||||
expect(tester.getSize(find.byType(VideoBubble)), forwardedVideoSize);
|
||||
expect(tester.getSize(regularPreview.first), forwardedPreviewSize);
|
||||
expect(find.byType(ForwardedHeader), findsNothing);
|
||||
});
|
||||
|
||||
final nativeAttachmentCases =
|
||||
<({String name, MessageAttachment attachment, Type bubbleType})>[
|
||||
(
|
||||
name: 'file',
|
||||
attachment: const FileAttachment(
|
||||
fileId: 7101,
|
||||
name: 'synthetic.txt',
|
||||
size: 128,
|
||||
),
|
||||
bubbleType: FileBubble,
|
||||
),
|
||||
(
|
||||
name: 'sticker',
|
||||
attachment: const StickerAttachment(
|
||||
stickerId: 'synthetic-sticker',
|
||||
width: 128,
|
||||
height: 128,
|
||||
),
|
||||
bubbleType: StickerBubble,
|
||||
),
|
||||
(
|
||||
name: 'location',
|
||||
attachment: const LocationAttachment(
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
title: 'Synthetic location',
|
||||
),
|
||||
bubbleType: LocationBubble,
|
||||
),
|
||||
(
|
||||
name: 'call',
|
||||
attachment: const CallAttachment(isVideo: false, durationMs: 1000),
|
||||
bubbleType: CallBubble,
|
||||
),
|
||||
(
|
||||
name: 'share',
|
||||
attachment: const ShareAttachment(
|
||||
shareId: 7102,
|
||||
title: 'Synthetic preview',
|
||||
url: 'https://example.test/synthetic',
|
||||
),
|
||||
bubbleType: ShareBubble,
|
||||
),
|
||||
(
|
||||
name: 'audio',
|
||||
attachment: const AudioAttachment(
|
||||
audioId: 7103,
|
||||
duration: 1000,
|
||||
baseUrl: 'https://example.test/synthetic.ogg',
|
||||
),
|
||||
bubbleType: VoiceMessageBubble,
|
||||
),
|
||||
(
|
||||
name: 'video note',
|
||||
attachment: const VideoAttachment(
|
||||
videoId: 7104,
|
||||
videoToken: 'synthetic-note-token',
|
||||
videoType: 1,
|
||||
duration: 1000,
|
||||
width: 480,
|
||||
height: 480,
|
||||
),
|
||||
bubbleType: VideoNoteBubble,
|
||||
),
|
||||
];
|
||||
|
||||
for (final item in nativeAttachmentCases) {
|
||||
testWidgets('decorates forwarded ${item.name} native bubble', (
|
||||
tester,
|
||||
) async {
|
||||
final forwarded = ForwardedMessageAttachment(
|
||||
originalSenderId: 71,
|
||||
originalMessageId: 'synthetic-source-${item.name}',
|
||||
originalChatId: 72,
|
||||
originalText: item.name == 'share'
|
||||
? 'Synthetic forwarded caption'
|
||||
: null,
|
||||
originalAttachments: [item.attachment],
|
||||
);
|
||||
final message = CachedMessage(
|
||||
id: 'synthetic-forward-${item.name}',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 70,
|
||||
time: 11000,
|
||||
text: 'Synthetic outer text',
|
||||
attachments: [forwarded],
|
||||
);
|
||||
|
||||
await _pumpBubble(tester, message, isMe: item.name == 'audio');
|
||||
|
||||
expect(find.byType(ForwardedHeader), findsOneWidget);
|
||||
expect(find.byType(item.bubbleType), findsOneWidget);
|
||||
final usesFloatingHeader =
|
||||
item.name == 'sticker' || item.name == 'video note';
|
||||
if (usesFloatingHeader) {
|
||||
expect(find.byType(ForwardedHeaderFloating), findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(find.byType(ForwardedHeaderFloating)).bottom,
|
||||
lessThanOrEqualTo(tester.getRect(find.byType(item.bubbleType)).top),
|
||||
);
|
||||
} else {
|
||||
expect(find.byType(ForwardedHeaderFloating), findsNothing);
|
||||
}
|
||||
if (item.name == 'audio') {
|
||||
final headerRect = tester.getRect(find.byType(ForwardedHeader));
|
||||
final voiceRect = tester.getRect(find.byType(VoiceMessageBubble));
|
||||
expect(voiceRect.left - headerRect.left, closeTo(14, 0.1));
|
||||
expect(headerRect.right - voiceRect.right, closeTo(14, 0.1));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(VoiceMessageBubble),
|
||||
matching: find.byIcon(Symbols.check),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
if (item.name == 'share') {
|
||||
expect(find.text('Synthetic forwarded caption'), findsOneWidget);
|
||||
expect(find.text('Synthetic outer text'), findsNothing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('decorates the native forwarded contact bubble', (
|
||||
tester,
|
||||
) async {
|
||||
const forwarded = ForwardedMessageAttachment(
|
||||
originalSenderId: 73,
|
||||
originalMessageId: 'synthetic-contact-source',
|
||||
originalChatId: 74,
|
||||
originalContact: ContactAttachment(
|
||||
firstName: 'Synthetic',
|
||||
lastName: 'Contact',
|
||||
phoneNumber: '+10000000000',
|
||||
),
|
||||
);
|
||||
const message = CachedMessage(
|
||||
id: 'synthetic-contact-forward',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 70,
|
||||
time: 12000,
|
||||
attachments: [forwarded],
|
||||
);
|
||||
|
||||
await _pumpBubble(tester, message);
|
||||
|
||||
expect(find.byType(ForwardedHeader), findsOneWidget);
|
||||
expect(find.byType(ContactBubble), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('makes the forwarded user name clickable', (tester) async {
|
||||
const attachment = ForwardedMessageAttachment(
|
||||
originalSenderId: 42,
|
||||
originalSenderName: 'Example Person',
|
||||
originalType: 'USER',
|
||||
originalMessageId: '104',
|
||||
originalTime: 7000,
|
||||
originalText: 'Synthetic user message',
|
||||
originalChatId: -40,
|
||||
originalFormatRanges: [
|
||||
FormatRange(format: TextFormat.strong, start: 0, length: 9),
|
||||
],
|
||||
);
|
||||
const message = CachedMessage(
|
||||
id: '203',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 43,
|
||||
time: 8000,
|
||||
attachments: [attachment],
|
||||
);
|
||||
ForwardedMessageAttachment? tappedSource;
|
||||
|
||||
await _pumpBubble(
|
||||
tester,
|
||||
message,
|
||||
onSourceTap: (forwarded) => tappedSource = forwarded,
|
||||
);
|
||||
expect(find.byType(FormattedMessageText), findsOneWidget);
|
||||
await tester.tap(find.text('Example Person'));
|
||||
|
||||
expect(tappedSource, same(attachment));
|
||||
expect(tappedSource?.originalSenderId, 42);
|
||||
});
|
||||
|
||||
Finder headerAvatar() => find.descendant(
|
||||
of: find.byType(ForwardedHeader),
|
||||
matching: find.byType(CircleAvatar),
|
||||
);
|
||||
|
||||
CachedMessage channelForward({String? access}) => CachedMessage(
|
||||
id: '204',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 43,
|
||||
time: 9000,
|
||||
attachments: [
|
||||
ForwardedMessageAttachment(
|
||||
originalSenderId: 0,
|
||||
originalSenderName: 'Example Channel',
|
||||
originalType: 'CHANNEL',
|
||||
originalMessageId: '105',
|
||||
originalTime: 8500,
|
||||
originalText: 'Synthetic channel post',
|
||||
originalChatId: -50,
|
||||
originalChatAccess: access,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
testWidgets('shows the avatar of an accessible forward source', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBubble(tester, channelForward());
|
||||
|
||||
expect(find.text('Example Channel'), findsOneWidget);
|
||||
expect(headerAvatar(), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides the avatar of a private chat we are not in', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBubble(tester, channelForward(access: 'PRIVATE'));
|
||||
|
||||
expect(find.text('Example Channel'), findsOneWidget);
|
||||
expect(headerAvatar(), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:ikar/core/storage/chat_wallpaper_store.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test(
|
||||
'custom gradient survives storage reload without replacing another chat',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final store = ChatWallpaperStore.forTesting();
|
||||
await store.load();
|
||||
await store.setTheme(101, 203, 'synthetic-theme');
|
||||
const colors = [Colors.red, Colors.blue, Colors.green];
|
||||
await store.setGradient(101, 202, colors, animated: true, rotation: 0.7);
|
||||
final restored = ChatWallpaperStore.forTesting();
|
||||
await restored.load();
|
||||
final wallpaper = restored.get(101, 202)!;
|
||||
expect(wallpaper.kind, ChatWallpaperKind.gradient);
|
||||
expect(
|
||||
wallpaper.gradientColors!.map((color) => color.toARGB32()),
|
||||
colors.map((color) => color.toARGB32()),
|
||||
);
|
||||
expect(wallpaper.gradientAnimated, isTrue);
|
||||
expect(wallpaper.gradientRotation, 0.7);
|
||||
expect(restored.get(101, 203)!.themeId, 'synthetic-theme');
|
||||
expect(restored.get(102, 202), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/backend/modules/stickers.dart';
|
||||
import 'package:ikar/core/protocol/opcode_map.dart';
|
||||
|
||||
class _SyntheticCatalogApi extends Api {
|
||||
final List<Map<String, dynamic>> stickers;
|
||||
|
||||
_SyntheticCatalogApi(this.stickers);
|
||||
|
||||
@override
|
||||
Future<Map<dynamic, dynamic>?> sendRequestMap(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload,
|
||||
) async {
|
||||
if (opcode == Opcode.assetsUpdate && payload['type'] == 'STICKER') {
|
||||
return {
|
||||
'sections': [
|
||||
{
|
||||
'id': 'NEW_STICKER_SETS',
|
||||
'stickerSets': [10, 11],
|
||||
'marker': 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opcode == Opcode.assetsUpdate) return {'sections': const []};
|
||||
if (opcode == Opcode.assetsGetByIds && payload['type'] == 'STICKER_SET') {
|
||||
return {
|
||||
'stickerSets': [
|
||||
{
|
||||
'id': 10,
|
||||
'name': 'Synthetic A',
|
||||
'stickers': [1, 2],
|
||||
},
|
||||
{
|
||||
'id': 11,
|
||||
'name': 'Synthetic B',
|
||||
'stickers': [3],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opcode == Opcode.assetsGetByIds && payload['type'] == 'STICKER') {
|
||||
final ids = (payload['ids'] as List).toSet();
|
||||
return {
|
||||
'stickers': [
|
||||
for (final sticker in stickers)
|
||||
if (ids.contains(sticker['id'])) sticker,
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _WideCatalogApi extends Api {
|
||||
final int setCount;
|
||||
int stickerRequests = 0;
|
||||
|
||||
_WideCatalogApi(this.setCount);
|
||||
|
||||
@override
|
||||
Future<Map<dynamic, dynamic>?> sendRequestMap(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload,
|
||||
) async {
|
||||
if (opcode == Opcode.assetsUpdate && payload['type'] == 'STICKER') {
|
||||
return {
|
||||
'sections': [
|
||||
{
|
||||
'id': 'NEW_STICKER_SETS',
|
||||
'stickerSets': [for (var i = 0; i < setCount; i++) 100 + i],
|
||||
'marker': 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opcode == Opcode.assetsUpdate) return {'sections': const []};
|
||||
final ids = [for (final id in payload['ids'] as List) id as int];
|
||||
if (opcode == Opcode.assetsGetByIds && payload['type'] == 'STICKER_SET') {
|
||||
return {
|
||||
'stickerSets': [
|
||||
for (final id in ids)
|
||||
{
|
||||
'id': id,
|
||||
'name': 'Synthetic $id',
|
||||
'stickers': [id + 1000],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opcode == Opcode.assetsGetByIds && payload['type'] == 'STICKER') {
|
||||
stickerRequests++;
|
||||
return {
|
||||
'stickers': [
|
||||
for (final id in ids) _sticker(id, ['😀']),
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _sticker(
|
||||
int id,
|
||||
List<String> tags, {
|
||||
bool animated = true,
|
||||
}) {
|
||||
return {
|
||||
'id': id,
|
||||
'url': 'https://example.test/sticker/$id.webp',
|
||||
if (animated) 'lottieUrl': 'https://example.test/sticker/$id.json',
|
||||
'tags': tags,
|
||||
};
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('prefers an animated sticker from the server welcome list', () async {
|
||||
final api = _SyntheticCatalogApi([
|
||||
_sticker(3, ['🐣', '👋🏻']),
|
||||
_sticker(50, ['🙂']),
|
||||
_sticker(51, ['🙂'], animated: false),
|
||||
]);
|
||||
addTearDown(api.dispose);
|
||||
final module = StickersModule(api);
|
||||
|
||||
for (var attempt = 0; attempt < 5; attempt++) {
|
||||
final sticker = await module.randomGreetingSticker(const [50, 51]);
|
||||
expect(sticker?.id, 50);
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to a static welcome sticker', () async {
|
||||
final api = _SyntheticCatalogApi([
|
||||
_sticker(51, ['🙂'], animated: false),
|
||||
]);
|
||||
addTearDown(api.dispose);
|
||||
final module = StickersModule(api);
|
||||
|
||||
expect((await module.randomGreetingSticker(const [51]))?.id, 51);
|
||||
});
|
||||
|
||||
test('picks only an animated sticker tagged with a wave', () async {
|
||||
final api = _SyntheticCatalogApi([
|
||||
_sticker(1, ['😀']),
|
||||
_sticker(2, ['👋'], animated: false),
|
||||
_sticker(3, ['🐣', '👋🏻']),
|
||||
]);
|
||||
addTearDown(api.dispose);
|
||||
final module = StickersModule(api);
|
||||
|
||||
for (var attempt = 0; attempt < 5; attempt++) {
|
||||
final sticker = await module.randomGreetingSticker(const []);
|
||||
expect(sticker?.id, 3);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns nothing when no sticker greets', () async {
|
||||
final api = _SyntheticCatalogApi([
|
||||
_sticker(1, ['😀']),
|
||||
_sticker(3, ['🐣']),
|
||||
]);
|
||||
addTearDown(api.dispose);
|
||||
final module = StickersModule(api);
|
||||
|
||||
expect(await module.randomGreetingSticker(const []), isNull);
|
||||
});
|
||||
|
||||
test('gives up after a few packs without a greeting', () async {
|
||||
final api = _WideCatalogApi(20);
|
||||
addTearDown(api.dispose);
|
||||
final module = StickersModule(api);
|
||||
|
||||
expect(await module.randomGreetingSticker(const []), isNull);
|
||||
expect(api.stickerRequests, StickersModule.greetingSetScanLimit);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/widgets/custom_notification.dart';
|
||||
import 'package:ikar/frontend/widgets/hint_bubble.dart';
|
||||
|
||||
const _hint = 'Время должно быть в будущем';
|
||||
const _anchorKey = ValueKey('anchor');
|
||||
|
||||
class _Host extends StatelessWidget {
|
||||
final Alignment alignment;
|
||||
final bool anchorVisible;
|
||||
final double anchorWidth;
|
||||
|
||||
const _Host({
|
||||
this.alignment = Alignment.center,
|
||||
this.anchorVisible = true,
|
||||
this.anchorWidth = 80,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: alignment,
|
||||
child: anchorVisible
|
||||
? Builder(
|
||||
builder: (anchor) => GestureDetector(
|
||||
onTap: () => showHintBubble(anchor, _hint),
|
||||
child: SizedBox(
|
||||
key: _anchorKey,
|
||||
width: anchorWidth,
|
||||
height: 40,
|
||||
child: const ColoredBox(color: Colors.blue),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showHint(WidgetTester tester) async {
|
||||
await tester.tap(find.byKey(_anchorKey));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Rect _bubbleRect(WidgetTester tester) => tester.getRect(
|
||||
find.ancestor(of: find.text(_hint), matching: find.byType(Padding)).first,
|
||||
);
|
||||
|
||||
Finder get _bubblePaint => find
|
||||
.ancestor(of: find.text(_hint), matching: find.byType(FadeTransition))
|
||||
.first;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
trackHintBubblePresses();
|
||||
|
||||
testWidgets('подсказка встаёт над элементом по его центру', (tester) async {
|
||||
await tester.pumpWidget(const _Host());
|
||||
await _showHint(tester);
|
||||
|
||||
final anchor = tester.getRect(find.byKey(_anchorKey));
|
||||
final bubble = _bubbleRect(tester);
|
||||
expect(bubble.bottom, anchor.top - HintBubbleStyle.anchorGap);
|
||||
expect(bubble.center.dx, moreOrLessEquals(anchor.center.dx));
|
||||
expect(find.byType(CustomNotification), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('без места сверху подсказка уходит под элемент', (tester) async {
|
||||
await tester.pumpWidget(const _Host(alignment: Alignment.topCenter));
|
||||
await _showHint(tester);
|
||||
|
||||
final anchor = tester.getRect(find.byKey(_anchorKey));
|
||||
expect(_bubbleRect(tester).top, anchor.bottom + HintBubbleStyle.anchorGap);
|
||||
});
|
||||
|
||||
testWidgets('у края экрана подсказка не вылезает за поля', (tester) async {
|
||||
await tester.pumpWidget(const _Host(alignment: Alignment.bottomRight));
|
||||
await _showHint(tester);
|
||||
|
||||
final screen = tester.getRect(find.byType(Scaffold));
|
||||
expect(
|
||||
_bubbleRect(tester).right,
|
||||
screen.right - HintBubbleStyle.screenMargin,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('хвостик указывает на место нажатия, а не на центр элемента', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const _Host(anchorWidth: 800));
|
||||
final anchor = tester.getRect(find.byKey(_anchorKey));
|
||||
final press = Offset(200, anchor.center.dy);
|
||||
|
||||
await tester.tapAt(press);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
final bubble = _bubbleRect(tester);
|
||||
final insideTail = bubble.bottom + HintBubbleStyle.tail.height / 2;
|
||||
expect(bubble.center.dx, moreOrLessEquals(press.dx));
|
||||
expect(
|
||||
_bubblePaint,
|
||||
paints
|
||||
..path()
|
||||
..path(
|
||||
includes: [Offset(press.dx, insideTail)],
|
||||
excludes: [
|
||||
Offset(press.dx - HintBubbleStyle.tail.width, insideTail),
|
||||
Offset(anchor.center.dx, insideTail),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('у подсказки под элементом хвостик смотрит вверх', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const _Host(alignment: Alignment.topCenter));
|
||||
await _showHint(tester);
|
||||
|
||||
final anchor = tester.getRect(find.byKey(_anchorKey));
|
||||
final bubble = _bubbleRect(tester);
|
||||
final insideTail = bubble.top - HintBubbleStyle.tail.height / 2;
|
||||
expect(
|
||||
_bubblePaint,
|
||||
paints
|
||||
..path()
|
||||
..path(includes: [Offset(anchor.center.dx, insideTail)]),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('подсказка сама исчезает по таймеру', (tester) async {
|
||||
await tester.pumpWidget(const _Host());
|
||||
await _showHint(tester);
|
||||
expect(find.text(_hint), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_hint), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('касание мимо убирает подсказку раньше таймера', (tester) async {
|
||||
await tester.pumpWidget(const _Host());
|
||||
await _showHint(tester);
|
||||
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_hint), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('повторный показ заменяет подсказку, а не плодит вторую', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const _Host());
|
||||
await _showHint(tester);
|
||||
await _showHint(tester);
|
||||
|
||||
expect(find.text(_hint), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_hint), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('подсказка едет за элементом', (tester) async {
|
||||
await tester.pumpWidget(const _Host());
|
||||
await _showHint(tester);
|
||||
|
||||
final before = _bubbleRect(tester);
|
||||
|
||||
await tester.pumpWidget(const _Host(alignment: Alignment(0, 0.6)));
|
||||
await tester.pump();
|
||||
|
||||
final anchor = tester.getRect(find.byKey(_anchorKey));
|
||||
final bubble = _bubbleRect(tester);
|
||||
expect(bubble.top, greaterThan(before.top));
|
||||
expect(bubble.bottom, anchor.top - HintBubbleStyle.anchorGap);
|
||||
});
|
||||
|
||||
testWidgets('подсказка пропадает вместе с элементом', (tester) async {
|
||||
await tester.pumpWidget(const _Host());
|
||||
await _showHint(tester);
|
||||
|
||||
await tester.pumpWidget(const _Host(anchorVisible: false));
|
||||
await tester.pump();
|
||||
expect(find.text(_hint), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:ikar/core/utils/image_format.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
Uint8List _bytes(List<int> head, {int pad = 32}) =>
|
||||
Uint8List.fromList([...head, ...List.filled(pad, 0)]);
|
||||
|
||||
Uint8List _riff(String fourcc, {List<int> chunk = const []}) => _bytes([
|
||||
...'RIFF'.codeUnits,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...'WEBP'.codeUnits,
|
||||
...fourcc.codeUnits,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...chunk,
|
||||
]);
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
final String temporary;
|
||||
|
||||
_FakePathProvider(this.temporary);
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => temporary;
|
||||
}
|
||||
|
||||
img.Image _swatch({required bool opaque}) {
|
||||
final image = img.Image(width: 16, height: 16, numChannels: opaque ? 3 : 4);
|
||||
img.fill(
|
||||
image,
|
||||
color: opaque
|
||||
? img.ColorRgb8(12, 180, 64)
|
||||
: img.ColorRgba8(12, 180, 64, 128),
|
||||
);
|
||||
return image;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('sniffImageFormat', () {
|
||||
test('reads the signature, not the name', () {
|
||||
expect(
|
||||
sniffImageFormat(_bytes([0xFF, 0xD8, 0xFF, 0xE0])),
|
||||
ImageByteFormat.jpeg,
|
||||
);
|
||||
expect(
|
||||
sniffImageFormat(
|
||||
_bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
),
|
||||
ImageByteFormat.png,
|
||||
);
|
||||
expect(sniffImageFormat(_bytes('GIF89a'.codeUnits)), ImageByteFormat.gif);
|
||||
expect(sniffImageFormat(_riff('VP8 ')), ImageByteFormat.webp);
|
||||
expect(sniffImageFormat(_bytes('BM'.codeUnits)), ImageByteFormat.bmp);
|
||||
expect(
|
||||
sniffImageFormat(_bytes([0, 0, 0, 0x18, ...'ftypheic'.codeUnits])),
|
||||
ImageByteFormat.heic,
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown bytes stay unknown', () {
|
||||
expect(sniffImageFormat(_bytes([1, 2, 3, 4])), ImageByteFormat.unknown);
|
||||
expect(sniffImageFormat(Uint8List(0)), ImageByteFormat.unknown);
|
||||
});
|
||||
|
||||
test('RIFF without the WEBP tag is not webp', () {
|
||||
final wave = _bytes([
|
||||
...'RIFF'.codeUnits,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...'WAVE'.codeUnits,
|
||||
]);
|
||||
expect(sniffImageFormat(wave), ImageByteFormat.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
group('isAnimatedWebp', () {
|
||||
test('detects the ANIM flag in VP8X', () {
|
||||
expect(isAnimatedWebp(_riff('VP8X', chunk: [0x02])), isTrue);
|
||||
expect(isAnimatedWebp(_riff('VP8X', chunk: [0x10])), isFalse);
|
||||
expect(isAnimatedWebp(_riff('VP8 ')), isFalse);
|
||||
expect(isAnimatedWebp(_bytes([0xFF, 0xD8, 0xFF])), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('withImageExtension', () {
|
||||
test('replaces whatever extension the caller guessed', () {
|
||||
expect(withImageExtension('IMG_17.jpg', '.webp'), 'IMG_17.webp');
|
||||
expect(withImageExtension('IMG_17.jpeg', '.jpg'), 'IMG_17.jpg');
|
||||
expect(withImageExtension('IMG_17', '.png'), 'IMG_17.png');
|
||||
expect(withImageExtension('my.photo.jpg', '.png'), 'my.photo.png');
|
||||
});
|
||||
|
||||
test('keeps the name when the format is unknown', () {
|
||||
expect(withImageExtension('IMG_17.jpg', ''), 'IMG_17.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
group('extensionForImageFormat', () {
|
||||
test('jpeg keeps the short extension', () {
|
||||
expect(extensionForImageFormat(ImageByteFormat.jpeg), '.jpg');
|
||||
expect(extensionForImageFormat(ImageByteFormat.webp), '.webp');
|
||||
expect(extensionForImageFormat(ImageByteFormat.unknown), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('prepareImageForSave', () {
|
||||
late Directory workspace;
|
||||
|
||||
setUp(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
workspace = Directory.systemTemp.createTempSync('qlyra_image_format');
|
||||
PathProviderPlatform.instance = _FakePathProvider(workspace.path);
|
||||
});
|
||||
|
||||
tearDown(() => workspace.deleteSync(recursive: true));
|
||||
|
||||
File write(String name, List<int> bytes) =>
|
||||
File('${workspace.path}/$name')..writeAsBytesSync(bytes);
|
||||
|
||||
test('opaque webp is saved as a real jpeg', () async {
|
||||
final source = write('photo.webp', img.encodeWebP(_swatch(opaque: true)));
|
||||
|
||||
final ready = await prepareImageForSave(source);
|
||||
|
||||
expect(ready, isNotNull);
|
||||
expect(ready!.extension, '.jpg');
|
||||
expect(ready.temporary, isTrue);
|
||||
expect(
|
||||
sniffImageFormat(await ready.file.readAsBytes()),
|
||||
ImageByteFormat.jpeg,
|
||||
);
|
||||
await ready.discard();
|
||||
expect(ready.file.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('transparent webp keeps its pixels in png', () async {
|
||||
final source = write(
|
||||
'sticker.webp',
|
||||
img.encodeWebP(_swatch(opaque: false)),
|
||||
);
|
||||
|
||||
final ready = await prepareImageForSave(source);
|
||||
|
||||
expect(ready!.extension, '.png');
|
||||
expect(
|
||||
sniffImageFormat(await ready.file.readAsBytes()),
|
||||
ImageByteFormat.png,
|
||||
);
|
||||
await ready.discard();
|
||||
});
|
||||
|
||||
test('concurrent saves use independent temporary files', () async {
|
||||
final source = write('shared.webp', img.encodeWebP(_swatch(opaque: true)));
|
||||
final images = await Future.wait([
|
||||
prepareImageForSave(source),
|
||||
prepareImageForSave(source),
|
||||
]);
|
||||
final first = images[0]!;
|
||||
final second = images[1]!;
|
||||
expect(first.file.path, isNot(second.file.path));
|
||||
await first.discard();
|
||||
expect(second.file.existsSync(), isTrue);
|
||||
expect(source.existsSync(), isTrue);
|
||||
await second.discard();
|
||||
expect(first.file.parent.existsSync(), isFalse);
|
||||
expect(second.file.parent.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('jpeg named .webp is reported as jpeg', () async {
|
||||
final source = write('wrong.webp', img.encodeJpg(_swatch(opaque: true)));
|
||||
|
||||
final ready = await prepareImageForSave(source);
|
||||
|
||||
expect(ready!.extension, '.jpg');
|
||||
expect(ready.temporary, isFalse);
|
||||
expect(ready.file.path, source.path);
|
||||
});
|
||||
|
||||
test('png is passed through untouched', () async {
|
||||
final source = write('shot.png', img.encodePng(_swatch(opaque: true)));
|
||||
|
||||
final ready = await prepareImageForSave(source);
|
||||
|
||||
expect(ready!.extension, '.png');
|
||||
expect(ready.temporary, isFalse);
|
||||
expect(ready.file.path, source.path);
|
||||
});
|
||||
|
||||
test('missing file yields nothing to save', () async {
|
||||
expect(
|
||||
await prepareImageForSave(File('${workspace.path}/gone.webp')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/cache/info_cache.dart';
|
||||
|
||||
void main() {
|
||||
test('a missing entry is asked again instead of being cached', () async {
|
||||
var calls = 0;
|
||||
final cache = InfoCache<String>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
failureBackoff: Duration.zero,
|
||||
fetcher: (id) async {
|
||||
calls++;
|
||||
return calls == 1 ? null : 'synthetic-$id';
|
||||
},
|
||||
);
|
||||
|
||||
expect(await cache.get(7), isNull);
|
||||
expect(cache.peek(7), isNull);
|
||||
expect(await cache.get(7), 'synthetic-7');
|
||||
expect(await cache.get(7), 'synthetic-7');
|
||||
expect(calls, 2);
|
||||
});
|
||||
|
||||
test('a missing entry waits out the failure backoff', () async {
|
||||
var calls = 0;
|
||||
final cache = InfoCache<String>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
failureBackoff: const Duration(minutes: 1),
|
||||
fetcher: (id) async {
|
||||
calls++;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
expect(await cache.get(8), isNull);
|
||||
expect(await cache.get(8), isNull);
|
||||
expect(await cache.get(8, forceRefresh: true), isNull);
|
||||
expect(calls, 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/widgets/informer_banner_tile.dart';
|
||||
import 'package:ikar/models/animoji.dart';
|
||||
import 'package:ikar/models/informer_banner.dart';
|
||||
|
||||
const _banner = InformerBanner(
|
||||
id: 'synthetic-banner',
|
||||
title: 'Synthetic title',
|
||||
description: 'Synthetic description',
|
||||
settings: BannerSettings.textAnimation,
|
||||
type: BannerType.link,
|
||||
url: 'https://example.invalid/synthetic',
|
||||
);
|
||||
|
||||
Widget _app(Widget child) => MaterialApp(home: Scaffold(body: child));
|
||||
|
||||
void main() {
|
||||
testWidgets('renders the banner content and reports presentation once', (
|
||||
tester,
|
||||
) async {
|
||||
var presentations = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
InformerBannerTile(
|
||||
banner: _banner,
|
||||
onPresented: (_) => presentations++,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.text('Synthetic title'), findsOneWidget);
|
||||
expect(find.text('Synthetic description'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('informer-banner-fallback-icon')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(presentations, 1);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
InformerBannerTile(
|
||||
banner: _banner,
|
||||
onPresented: (_) => presentations++,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(presentations, 1);
|
||||
});
|
||||
|
||||
testWidgets('handles body and close actions independently', (tester) async {
|
||||
var taps = 0;
|
||||
var closes = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
InformerBannerTile(
|
||||
banner: _banner,
|
||||
onTap: () => taps++,
|
||||
onClose: () => closes++,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('informer-banner-synthetic-banner')),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(taps, 1);
|
||||
expect(closes, 0);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('informer-banner-close-synthetic-banner')),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(taps, 1);
|
||||
expect(closes, 1);
|
||||
});
|
||||
|
||||
testWidgets('honors close visibility and resolves the configured animoji', (
|
||||
tester,
|
||||
) async {
|
||||
int? requestedId;
|
||||
const banner = InformerBanner(
|
||||
id: 'synthetic-themed-banner',
|
||||
title: 'Synthetic themed title',
|
||||
settings: BannerSettings.hideCloseButton | BannerSettings.iconThemeColor,
|
||||
animojiId: 42,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
InformerBannerTile(
|
||||
banner: banner,
|
||||
animojiLoader: (id) async {
|
||||
requestedId = id;
|
||||
return const Animoji(id: 42, emoji: '🧪');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(requestedId, 42);
|
||||
expect(
|
||||
find.byKey(const ValueKey('informer-banner-animoji')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey('informer-banner-close-synthetic-themed-banner'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.byType(ColorFiltered), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
|
||||
const int _me = 1;
|
||||
|
||||
CachedMessage _withButton(Map<String, dynamic> button) =>
|
||||
CachedMessage.fromPushPayload(_me, 2, {
|
||||
'id': '7007',
|
||||
'time': DateTime(2026, 1, 1, 12, 30).millisecondsSinceEpoch,
|
||||
'type': 'USER',
|
||||
'sender': 2,
|
||||
'text': 'Ваш код',
|
||||
'attaches': [
|
||||
{
|
||||
'_type': 'INLINE_KEYBOARD',
|
||||
'keyboard': {
|
||||
'buttons': [
|
||||
[button],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Future<void> _pump(WidgetTester tester, CachedMessage message) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MessageBubble(
|
||||
message: message,
|
||||
isMe: false,
|
||||
myId: _me,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
double _horizontalDrift(WidgetTester tester, String label) {
|
||||
final button = tester.getRect(
|
||||
find.ancestor(of: find.text(label), matching: find.byType(InkWell)).first,
|
||||
);
|
||||
final text = tester.getRect(find.text(label));
|
||||
return text.center.dx - button.center.dx;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a clipboard button keeps its label centred', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
_withButton({
|
||||
'type': 'CLIPBOARD',
|
||||
'text': 'Копировать',
|
||||
'payload': '123456',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(find.text('Копировать'), findsOneWidget);
|
||||
expect(_horizontalDrift(tester, 'Копировать').abs(), lessThan(0.5));
|
||||
});
|
||||
|
||||
testWidgets('a plain callback button keeps its label centred', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
_withButton({'type': 'CALLBACK', 'text': 'Продолжить', 'payload': 'go'}),
|
||||
);
|
||||
|
||||
expect(_horizontalDrift(tester, 'Продолжить').abs(), lessThan(0.5));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/core/utils/ip_lookup_service.dart';
|
||||
import 'package:ikar/frontend/screens/profile/devices_screen.dart';
|
||||
|
||||
void main() {
|
||||
test('devices screen can be constructed with the protected lookup flow', () {
|
||||
expect(const DevicesScreen(), isA<DevicesScreen>());
|
||||
});
|
||||
|
||||
test('normalizes an ipwho.is response', () {
|
||||
final details = IpLookupDetails.fromIpWhoIs({
|
||||
'success': true,
|
||||
'city': 'Moscow',
|
||||
'country': 'Russia',
|
||||
'connection': {'asn': 64500, 'org': 'Example Org', 'isp': 'Example ISP'},
|
||||
'security': {'mobile': true, 'proxy': false},
|
||||
'timezone': {'id': 'Europe/Moscow'},
|
||||
});
|
||||
|
||||
expect(details.city, 'Moscow');
|
||||
expect(details.country, 'Russia');
|
||||
expect(details.isp, 'Example ISP');
|
||||
expect(details.network, 'AS64500 Example Org');
|
||||
expect(details.mobile, isTrue);
|
||||
expect(details.proxy, isFalse);
|
||||
expect(details.timezone, 'Europe/Moscow');
|
||||
});
|
||||
|
||||
test('rejects a failed provider response', () {
|
||||
expect(
|
||||
() => IpLookupDetails.fromIpWhoIs({
|
||||
'success': false,
|
||||
'message': 'Invalid IP',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/frontend/widgets/keyboard_dismissal.dart';
|
||||
|
||||
Widget _app(Widget body, {List<NavigatorObserver> observers = const []}) {
|
||||
return MaterialApp(
|
||||
navigatorObservers: observers,
|
||||
builder: (context, child) => KeyboardDismissal(child: child!),
|
||||
home: Scaffold(body: body),
|
||||
);
|
||||
}
|
||||
|
||||
final _ios = TargetPlatformVariant.only(TargetPlatform.iOS);
|
||||
|
||||
void main() {
|
||||
late FocusNode focus;
|
||||
|
||||
setUp(() => focus = FocusNode());
|
||||
tearDown(() => focus.dispose());
|
||||
|
||||
Future<void> focusField(WidgetTester tester) async {
|
||||
await tester.tap(find.byKey(const ValueKey('field')));
|
||||
await tester.pump();
|
||||
expect(focus.hasFocus, isTrue);
|
||||
}
|
||||
|
||||
testWidgets('iOS: a short tap outside the field hides the keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Column(
|
||||
children: [
|
||||
TextField(key: const ValueKey('field'), focusNode: focus),
|
||||
const SizedBox(key: ValueKey('outside'), height: 300),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await focusField(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('outside')));
|
||||
await tester.pump();
|
||||
expect(focus.hasFocus, isFalse);
|
||||
}, variant: _ios);
|
||||
|
||||
testWidgets('iOS: a long press or a drag keeps the keyboard', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Column(
|
||||
children: [
|
||||
TextField(key: const ValueKey('field'), focusNode: focus),
|
||||
const SizedBox(key: ValueKey('outside'), height: 300),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await focusField(tester);
|
||||
|
||||
final hold = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const ValueKey('outside'))),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await hold.up(timeStamp: const Duration(seconds: 1));
|
||||
await tester.pump();
|
||||
expect(focus.hasFocus, isTrue);
|
||||
|
||||
await tester.dragFrom(
|
||||
tester.getCenter(find.byKey(const ValueKey('outside'))),
|
||||
const Offset(120, 0),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(focus.hasFocus, isTrue);
|
||||
}, variant: _ios);
|
||||
|
||||
testWidgets('iOS: buttons inside TextFieldTapRegion keep the keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
var sent = 0;
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
TextFieldTapRegion(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const ValueKey('field'),
|
||||
focusNode: focus,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('send'),
|
||||
onPressed: () => sent++,
|
||||
icon: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await focusField(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('send')));
|
||||
await tester.pump();
|
||||
expect(sent, 1);
|
||||
expect(focus.hasFocus, isTrue);
|
||||
}, variant: _ios);
|
||||
|
||||
testWidgets(
|
||||
'iOS: vertical scrolling hides the keyboard, horizontal does not',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Column(
|
||||
children: [
|
||||
TextField(key: const ValueKey('field'), focusNode: focus),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView(
|
||||
key: const ValueKey('row'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (var i = 0; i < 20; i++)
|
||||
SizedBox(width: 80, child: Text('h$i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
key: const ValueKey('list'),
|
||||
children: [
|
||||
for (var i = 0; i < 40; i++)
|
||||
SizedBox(height: 60, child: Text('v$i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await focusField(tester);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('row')),
|
||||
const Offset(-200, 0),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(focus.hasFocus, isTrue);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('list')),
|
||||
const Offset(0, -200),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(focus.hasFocus, isFalse);
|
||||
},
|
||||
variant: _ios,
|
||||
);
|
||||
|
||||
testWidgets('iOS: scrolling inside a multiline field keeps the keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
ListView(
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('field'),
|
||||
focusNode: focus,
|
||||
maxLines: 3,
|
||||
controller: TextEditingController(
|
||||
text: List.generate(30, (i) => 'строка $i').join('\n'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await focusField(tester);
|
||||
|
||||
await tester.drag(find.byType(EditableText), const Offset(0, -60));
|
||||
await tester.pump();
|
||||
expect(focus.hasFocus, isTrue);
|
||||
}, variant: _ios);
|
||||
|
||||
testWidgets('iOS: opening a screen hides the keyboard', (tester) async {
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigator,
|
||||
navigatorObservers: [KeyboardNavigatorObserver()],
|
||||
home: Scaffold(
|
||||
body: TextField(key: const ValueKey('field'), focusNode: focus),
|
||||
),
|
||||
),
|
||||
);
|
||||
await focusField(tester);
|
||||
|
||||
navigator.currentState!.push(
|
||||
MaterialPageRoute<void>(builder: (_) => const Scaffold()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(focus.hasFocus, isFalse);
|
||||
}, variant: _ios);
|
||||
|
||||
testWidgets('iOS: starting the back swipe hides the keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(TextField(key: const ValueKey('field'), focusNode: focus)),
|
||||
);
|
||||
await focusField(tester);
|
||||
|
||||
KeyboardNavigatorObserver().didStartUserGesture(
|
||||
MaterialPageRoute<void>(builder: (_) => const SizedBox()),
|
||||
null,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(focus.hasFocus, isFalse);
|
||||
}, variant: _ios);
|
||||
|
||||
testWidgets(
|
||||
'Android keeps the default: a tap outside keeps the keyboard',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Column(
|
||||
children: [
|
||||
TextField(key: const ValueKey('field'), focusNode: focus),
|
||||
const SizedBox(key: ValueKey('outside'), height: 300),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await focusField(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('outside')));
|
||||
await tester.pump();
|
||||
expect(focus.hasFocus, isTrue);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/chats.dart';
|
||||
import 'package:ikar/core/push/launcher_badge.dart';
|
||||
|
||||
CachedChat _chat(int id, {int unread = 0, bool muted = false}) => CachedChat(
|
||||
id: id,
|
||||
accountId: 1,
|
||||
type: 'DIALOG',
|
||||
title: 'Synthetic chat $id',
|
||||
unreadCount: unread,
|
||||
lastEventTime: 1700000000000,
|
||||
cachedAt: 0,
|
||||
dontDisturbUntil: muted ? -1 : ChatsModule.muteOff,
|
||||
isOnline: false,
|
||||
seenTime: 0,
|
||||
participants: const {1: 1700000000000},
|
||||
);
|
||||
|
||||
BadgeCount _count(
|
||||
List<CachedChat> chats, {
|
||||
bool enabled = true,
|
||||
bool includeMuted = false,
|
||||
bool countMessages = true,
|
||||
Set<int> archived = const {},
|
||||
}) => BadgeCount.of(
|
||||
chats,
|
||||
enabled: enabled,
|
||||
includeMuted: includeMuted,
|
||||
countMessages: countMessages,
|
||||
isArchived: (chat) => archived.contains(chat.id),
|
||||
);
|
||||
|
||||
void main() {
|
||||
final chats = [
|
||||
_chat(1, unread: 3),
|
||||
_chat(2, unread: 5, muted: true),
|
||||
_chat(3),
|
||||
_chat(4, unread: 2),
|
||||
];
|
||||
|
||||
test('sums unread messages of chats with sound', () {
|
||||
final badge = _count(chats);
|
||||
|
||||
expect(badge.count, 5);
|
||||
expect(badge.chatIds, {1, 4});
|
||||
});
|
||||
|
||||
test('muted chats count when asked to', () {
|
||||
expect(_count(chats, includeMuted: true).count, 10);
|
||||
});
|
||||
|
||||
test('counts chats instead of messages', () {
|
||||
final badge = _count(chats, countMessages: false, includeMuted: true);
|
||||
|
||||
expect(badge.count, 3);
|
||||
expect(badge.byChats, isTrue);
|
||||
});
|
||||
|
||||
test('archived chats stay out of the badge', () {
|
||||
expect(_count(chats, archived: {1}).count, 2);
|
||||
});
|
||||
|
||||
test('a disabled badge is empty', () {
|
||||
final badge = _count(chats, enabled: false);
|
||||
|
||||
expect(badge.count, 0);
|
||||
expect(badge.chatIds, isEmpty);
|
||||
expect(badge.toArgs()['enabled'], isFalse);
|
||||
});
|
||||
|
||||
test('equal counts compare equal regardless of order', () {
|
||||
final a = _count(chats);
|
||||
final b = _count(chats.reversed.toList());
|
||||
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
expect(a == _count(chats, includeMuted: true), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/modules/messages.dart';
|
||||
import 'package:ikar/core/config/qlyra_settings.dart';
|
||||
import 'package:ikar/frontend/widgets/attachment/bubbles/meta_marks.dart';
|
||||
import 'package:ikar/frontend/widgets/message_bubble.dart';
|
||||
import 'package:ikar/l10n/app_localizations.dart';
|
||||
|
||||
const int _me = 1;
|
||||
const int _peer = 2;
|
||||
const String _hint = 'Сообщения скорее всего пересланы';
|
||||
|
||||
const int _window = MessageBubble.forwardBurstWindowMs;
|
||||
|
||||
final int _instant = DateTime(2026, 1, 1, 12, 30).millisecondsSinceEpoch;
|
||||
|
||||
CachedMessage _message(String id, {int? time, int sender = _peer}) =>
|
||||
CachedMessage.fromPushPayload(_me, _peer, {
|
||||
'id': id,
|
||||
'time': time ?? _instant,
|
||||
'type': 'USER',
|
||||
'sender': sender,
|
||||
'text': 'Сообщение $id',
|
||||
});
|
||||
|
||||
Future<void> _pump(WidgetTester tester, List<CachedMessage> messages) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < messages.length; i++)
|
||||
MessageBubble(
|
||||
message: messages[i],
|
||||
isMe: messages[i].senderId == _me,
|
||||
myId: _me,
|
||||
prevMessage: i > 0 ? messages[i - 1] : null,
|
||||
nextMessage: i < messages.length - 1 ? messages[i + 1] : null,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Finder get _marks => find.descendant(
|
||||
of: find.byType(LikelyForwardedMark),
|
||||
matching: find.byType(Icon),
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUp(() => QlyraSettings.showForward.value = true);
|
||||
tearDown(() => QlyraSettings.showForward.value = false);
|
||||
|
||||
testWidgets('метка стоит на каждом сообщении, ушедшем одной пачкой', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, [
|
||||
_message('101', time: _instant - 60000),
|
||||
_message('102'),
|
||||
_message('103', time: _instant + 144),
|
||||
_message('104', time: _instant + 144 + _window),
|
||||
_message('105', time: _instant + 144 + _window * 2 + 1),
|
||||
]);
|
||||
|
||||
expect(_marks, findsNWidgets(3));
|
||||
});
|
||||
|
||||
testWidgets('сообщение вне окна пачки остаётся без метки', (tester) async {
|
||||
await _pump(tester, [
|
||||
_message('101', time: _instant - _window - 1),
|
||||
_message('102'),
|
||||
_message('103', time: _instant + _window + 1),
|
||||
]);
|
||||
|
||||
expect(_marks, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('близкие по времени сообщения разных авторов не помечаются', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, [_message('101'), _message('102', sender: _me)]);
|
||||
|
||||
expect(_marks, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('метка слева от времени и по тапу объясняет себя', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, [_message('101'), _message('102')]);
|
||||
|
||||
final mark = tester.getRect(_marks.first);
|
||||
final clock = tester.getRect(find.text('12:30').first);
|
||||
expect(mark.right, lessThanOrEqualTo(clock.left));
|
||||
|
||||
await tester.tap(_marks.first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.text(_hint), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_hint), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('настройка включает и выключает метку на лету', (tester) async {
|
||||
QlyraSettings.showForward.value = false;
|
||||
await _pump(tester, [_message('101'), _message('102')]);
|
||||
expect(_marks, findsNothing);
|
||||
|
||||
QlyraSettings.showForward.value = true;
|
||||
await tester.pump();
|
||||
expect(_marks, findsNWidgets(2));
|
||||
|
||||
QlyraSettings.showForward.value = false;
|
||||
await tester.pump();
|
||||
expect(_marks, findsNothing);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user