Import IKAR messenger client and server

This commit is contained in:
sevenhill
2026-10-07 22:06:07 +03:00
commit 69ea5091eb
1951 changed files with 424546 additions and 0 deletions
+268
View File
@@ -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);
});
}
+165
View File
@@ -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);
},
);
}
+174
View File
@@ -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);
});
}
+116
View File
@@ -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],
);
});
}
+118
View File
@@ -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);
});
}
+74
View File
@@ -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')));
});
}
+85
View File
@@ -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));
});
}
+80
View File
@@ -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);
});
}
+50
View File
@@ -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'],
);
});
}
+55
View File
@@ -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')));
});
}
+127
View File
@@ -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, ['Пользователь не найден']);
});
}
+73
View File
@@ -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();
});
}
+96
View File
@@ -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);
});
}
+80
View File
@@ -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);
});
}
+137
View File
@@ -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);
});
}
+241
View File
@@ -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;
}
}
+128
View File
@@ -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);
});
}
+56
View File
@@ -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);
});
});
}
+76
View File
@@ -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,
);
});
}
+165
View File
@@ -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);
});
}
+233
View File
@@ -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);
});
}
+27
View File
@@ -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);
});
}
+56
View File
@@ -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);
});
}
+179
View File
@@ -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);
});
});
}
+129
View File
@@ -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);
});
}
+106
View File
@@ -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);
});
}
+27
View File
@@ -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'},
],
});
});
}
+20
View File
@@ -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);
});
});
}
+566
View File
@@ -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();
});
}
+74
View File
@@ -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);
},
);
}
+621
View File
@@ -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);
});
});
}
+205
View File
@@ -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);
});
}
+19
View File
@@ -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'),
);
});
}
+32
View File
@@ -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);
});
}
}
+148
View File
@@ -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);
});
});
}
+141
View File
@@ -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]);
},
);
}
+369
View File
@@ -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'))),
);
});
}
+105
View File
@@ -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);
},
);
}
}
}
}
+130
View File
@@ -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]);
});
});
}
+11
View File
@@ -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);
});
}
+132
View File
@@ -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);
});
});
}
+57
View File
@@ -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);
});
}
+136
View File
@@ -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');
},
);
}
+89
View File
@@ -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);
});
});
}
+196
View File
@@ -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);
});
}
+186
View File
@@ -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);
});
});
}
+78
View File
@@ -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);
});
}
+132
View File
@@ -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);
});
});
}
+366
View File
@@ -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);
});
});
}
+38
View File
@@ -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);
});
}
+92
View File
@@ -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));
},
);
}
+49
View File
@@ -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'));
});
}
+123
View File
@@ -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();
}
});
}
+45
View File
@@ -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...>');
});
}
+179
View File
@@ -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();
},
);
}
+191
View File
@@ -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);
});
});
}
+149
View File
@@ -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,
);
});
}
+357
View File
@@ -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);
});
}
+60
View File
@@ -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);
});
}
+43
View File
@@ -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,
);
});
}
+69
View File
@@ -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);
}
});
}
+200
View File
@@ -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);
});
}
+228
View File
@@ -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});
});
}
+40
View File
@@ -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;
});
}
+119
View File
@@ -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'}),
),
'Чат создан',
);
});
});
}
+114
View File
@@ -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));
});
}
+128
View File
@@ -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);
});
}
+144
View File
@@ -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);
});
}
+96
View File
@@ -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);
});
}
+355
View File
@@ -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);
}
+543
View File
@@ -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);
},
);
}
+182
View File
@@ -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);
});
}
+138
View File
@@ -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 файлов');
});
}
+155
View File
@@ -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');
});
});
}
+106
View File
@@ -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);
},
);
}
+144
View File
@@ -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]);
});
}
+225
View File
@@ -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,
);
});
}
+97
View File
@@ -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();
});
}
+128
View File
@@ -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));
});
}
+94
View File
@@ -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);
});
}
+8
View File
@@ -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();
}
+72
View File
@@ -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);
});
}
+118
View File
@@ -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);
});
}
+387
View File
@@ -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,
);
});
});
}
+121
View File
@@ -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');
});
}
+328
View File
@@ -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);
}
});
}
+114
View File
@@ -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});
});
}
+634
View File
@@ -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);
});
});
}
+32
View File
@@ -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);
},
);
}
+178
View File
@@ -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);
});
}
+191
View File
@@ -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);
});
}
+209
View File
@@ -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,
);
});
});
}
+39
View File
@@ -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);
});
}
+122
View File
@@ -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);
});
}
+83
View File
@@ -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));
});
}
+38
View File
@@ -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,
);
});
}
+243
View File
@@ -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),
);
}
+79
View File
@@ -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);
});
}
+126
View File
@@ -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