From 21f4b4bfa0f3af57c358f2c65a348fcc07f5135a Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 15 May 2025 14:56:04 +0700 Subject: [PATCH] TF-3719 Write unit test for `_updateEmailCache` in ThreadRepositoryImplement and `getChanges` in ThreadAPI Signed-off-by: dab246 --- .../thread/data/network/thread_api_test.dart | 352 +++++++++++++ .../thread_repository_impl_test.dart | 477 ++++++++++++++++++ 2 files changed, 829 insertions(+) diff --git a/test/features/thread/data/network/thread_api_test.dart b/test/features/thread/data/network/thread_api_test.dart index 8bf3a05f4..8532f89f5 100644 --- a/test/features/thread/data/network/thread_api_test.dart +++ b/test/features/thread/data/network/thread_api_test.dart @@ -6,14 +6,19 @@ import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/error/method/error_method_response.dart'; import 'package:jmap_dart_client/jmap/core/filter/filter.dart'; import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/state.dart'; import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_filter_condition.dart'; import 'package:jmap_dart_client/jmap/mail/email/get/get_email_response.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:jmap_dart_client/jmap/mail/email/query/query_email_response.dart'; import 'package:jmap_dart_client/jmap/mail/email/search_snippet/search_snippet.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:model/extensions/account_id_extensions.dart'; import 'package:model/extensions/email_id_extensions.dart'; +import 'package:tmail_ui_user/features/thread/data/model/email_change_response.dart'; import 'package:tmail_ui_user/features/thread/data/network/thread_api.dart'; import 'package:tmail_ui_user/features/thread/domain/model/search_email.dart'; import 'package:tmail_ui_user/features/thread/domain/model/search_emails_response.dart'; @@ -49,12 +54,17 @@ class MockQueryEmailResponse extends QueryEmailResponse { void main() { final baseOption = BaseOptions(method: 'POST'); final dio = Dio(baseOption)..options.baseUrl = 'http://domain.com/jmap'; + final dioAdapterHeaders = { + 'accept': 'application/json;jmapVersion=rfc-8621', + }; final dioAdapter = DioAdapter(dio: dio); final httpClient = HttpClient(dio); final threadApi = ThreadAPI(httpClient); final sessionState = State('some-session-state'); final state = State('some-state'); + final sinceState = State('since-state'); + final newState = State('new-state'); final filter = EmailFilterCondition(text: 'some-text'); final queryState = State('some-query-state'); @@ -423,5 +433,347 @@ void main() { ); }); }); + + group('getChanges:', () { + int emailSize = 10; + + final createdEmailIds = List.generate( + emailSize, + (index) => EmailId(Id('created_$index')), + ); + + final updatedEmailIds = List.generate( + emailSize, + (index) => EmailId(Id('updated_$index')), + ); + + final destroyedEmailIds = List.generate( + emailSize, + (index) => EmailId(Id('destroyed_$index')), + ); + + final createdEmails = List.generate( + emailSize, + (index) => Email( + id: EmailId(Id('created_$index')), + subject: 'Subject $index', + preview: 'Preview $index', + ), + ); + + final updatedEmails = List.generate( + emailSize, + (index) => Email( + id: EmailId(Id('updated_$index')), + keywords: { + KeyWordIdentifier.emailSeen: true, + }, + mailboxIds: { + MailboxId(Id('inbox-id')): true, + }, + ), + ); + + final defaultCreatedProperties = Properties({ + 'subject', + 'preview', + }); + + final defaultUpdatedProperties = Properties({ + 'mailboxIds', + 'keywords', + }); + + Map generateRequest({ + Properties? propertiesCreated, + Properties? propertiesUpdated, + }) => + { + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:apache:james:params:jmap:mail:shares" + ], + "methodCalls": [ + [ + "Email/changes", + { + "accountId": AccountFixtures.aliceAccountId.asString, + "sinceState": sinceState.value, + }, + "c0" + ], + if (propertiesUpdated != null) + [ + "Email/get", + { + "accountId": AccountFixtures.aliceAccountId.asString, + "#ids": { + "resultOf": "c0", + "name": "Email/changes", + "path": "/updated/*" + }, + "properties": propertiesUpdated.value.toList() + }, + "c1" + ], + if (propertiesCreated != null) + [ + "Email/get", + { + "accountId": AccountFixtures.aliceAccountId.asString, + "#ids": { + "resultOf": "c0", + "name": "Email/changes", + "path": "/created/*" + }, + "properties": propertiesCreated.value.toList() + }, + if (propertiesUpdated != null) "c2" else "c1" + ] + ], + }; + + Map generateResponse({ + Properties? propertiesCreated, + Properties? propertiesUpdated, + bool existNotFoundCreatedEmails = false, + bool existNotFoundUpdatedEmails = false, + }) => + { + "sessionState": sessionState.value, + "methodResponses": [ + [ + "Email/changes", + { + "accountId": AccountFixtures.aliceAccountId.asString, + "oldState": sinceState.value, + "newState": newState.value, + "hasMoreChanges": false, + "created": createdEmailIds + .map((emailId) => emailId.id.value) + .toList(), + "updated": updatedEmailIds + .map((emailId) => emailId.id.value) + .toList(), + "destroyed": destroyedEmailIds + .map((emailId) => emailId.id.value) + .toList(), + }, + "c0" + ], + if (propertiesUpdated != null) + [ + "Email/get", + { + "accountId": AccountFixtures.aliceAccountId.asString, + "state": newState.value, + "list": existNotFoundUpdatedEmails + ? updatedEmails.map((email) => email.toJson()).toList().sublist(0, 5) + : updatedEmails.map((email) => email.toJson()).toList(), + "notFound": existNotFoundUpdatedEmails + ? updatedEmailIds.map((emailId) => emailId.id.value).toList().sublist(5, 10) + : [], + }, + "c1" + ], + if (propertiesCreated != null) + [ + "Email/get", + { + "accountId": AccountFixtures.aliceAccountId.asString, + "state": newState.value, + "list": existNotFoundUpdatedEmails + ? createdEmails.map((email) => email.toJson()).toList().sublist(0, 5) + : createdEmails.map((email) => email.toJson()).toList(), + "notFound": existNotFoundUpdatedEmails + ? createdEmailIds.map((emailId) => emailId.id.value).toList().sublist(5, 10) + : [], + }, + if (propertiesUpdated != null) "c2" else "c1" + ] + ] + }; + + test( + 'should return created, updated, destroyed emails ' + 'when has both created and updated properties ' + 'and response Email/get has list', () async { + // arrange + dioAdapter.onPost( + '', + (server) => server.reply( + 200, + generateResponse( + propertiesCreated: defaultCreatedProperties, + propertiesUpdated: defaultUpdatedProperties, + ), + ), + data: generateRequest( + propertiesCreated: defaultCreatedProperties, + propertiesUpdated: defaultUpdatedProperties, + ), + headers: dioAdapterHeaders..addAll({'content-length': 1245}), + ); + + // act + final result = await threadApi.getChanges( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + sinceState, + propertiesCreated: defaultCreatedProperties, + propertiesUpdated: defaultUpdatedProperties, + ); + + // assert + expect( + result, + equals( + EmailChangeResponse( + updated: updatedEmails, + created: createdEmails, + destroyed: destroyedEmailIds, + newStateChanges: newState, + newStateEmail: newState, + hasMoreChanges: false, + updatedProperties: defaultUpdatedProperties, + ), + ), + ); + }); + + test( + 'should return created, updated, destroyed emails ' + 'when has both created and updated properties ' + 'and response Email/get has list and not found emails', () async { + // arrange + dioAdapter.onPost( + '', + (server) => server.reply( + 200, + generateResponse( + propertiesCreated: defaultCreatedProperties, + propertiesUpdated: defaultUpdatedProperties, + existNotFoundCreatedEmails: true, + existNotFoundUpdatedEmails: true, + ), + ), + data: generateRequest( + propertiesCreated: defaultCreatedProperties, + propertiesUpdated: defaultUpdatedProperties, + ), + headers: dioAdapterHeaders..addAll({'content-length': 1245}), + ); + + // act + final result = await threadApi.getChanges( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + sinceState, + propertiesCreated: defaultCreatedProperties, + propertiesUpdated: defaultUpdatedProperties, + ); + + // assert + final newDestroyedEmailIds = destroyedEmailIds + + updatedEmailIds.sublist(5, 10) + + createdEmailIds.sublist(5, 10); + + final newUpdatedEmails = updatedEmails.sublist(0, 5); + final newCreatedEmails = createdEmails.sublist(0, 5); + + expect( + result, + equals( + EmailChangeResponse( + updated: newUpdatedEmails, + created: newCreatedEmails, + destroyed: newDestroyedEmailIds, + newStateChanges: newState, + newStateEmail: newState, + hasMoreChanges: false, + updatedProperties: defaultUpdatedProperties, + ), + ), + ); + }); + + test( + 'should return created, destroyed emails ' + 'when only has created properties ' + 'and response Email/get has list', () async { + // arrange + dioAdapter.onPost( + '', + (server) => server.reply( + 200, + generateResponse( + propertiesCreated: defaultCreatedProperties, + ), + ), + data: generateRequest( + propertiesCreated: defaultCreatedProperties, + ), + headers: dioAdapterHeaders..addAll({'content-length': 798}), + ); + + // act + final result = await threadApi.getChanges( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + sinceState, + propertiesCreated: defaultCreatedProperties, + ); + + // assert + expect( + result, + equals( + EmailChangeResponse( + created: createdEmails, + destroyed: destroyedEmailIds, + newStateChanges: newState, + newStateEmail: newState, + hasMoreChanges: false, + ), + ), + ); + }); + + test( + 'should only return destroyed emails ' + 'when both created, updated properties are absent ' + 'and response Email/changes has destroyed list', () async { + // arrange + dioAdapter.onPost( + '', + (server) => server.reply( + 200, + generateResponse(), + ), + data: generateRequest(), + headers: dioAdapterHeaders..addAll({'content-length': 355}), + ); + + // act + final result = await threadApi.getChanges( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + sinceState, + ); + + // assert + expect( + result, + equals( + EmailChangeResponse( + destroyed: destroyedEmailIds, + newStateChanges: newState, + hasMoreChanges: false, + ), + ), + ); + }); + }); }); } \ No newline at end of file diff --git a/test/features/thread/data/repository/thread_repository_impl_test.dart b/test/features/thread/data/repository/thread_repository_impl_test.dart index 10e0435f2..8d2329e8a 100644 --- a/test/features/thread/data/repository/thread_repository_impl_test.dart +++ b/test/features/thread/data/repository/thread_repository_impl_test.dart @@ -14,6 +14,7 @@ import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants. import 'package:tmail_ui_user/features/thread/domain/model/email_filter.dart'; import 'package:tmail_ui_user/features/thread/domain/model/email_response.dart'; import 'package:tmail_ui_user/features/thread/domain/model/filter_message_option.dart'; +import 'package:tmail_ui_user/features/thread/domain/model/get_email_request.dart'; import '../../../../fixtures/account_fixtures.dart'; import '../../../../fixtures/session_fixtures.dart'; @@ -531,4 +532,480 @@ void main() { )); }); }); + + group('The functions regard to updateEmailCache:', () { + test( + 'when local cache is empty should fetch from network ' + 'and network has mail not found ' + 'should delete mail not found in cache', () async { + // Arrange + when(threadDataSource.getAllEmailCache( + any, + any, + filterOption: anyNamed('filterOption'), + inMailboxId: anyNamed('inMailboxId'), + limit: anyNamed('limit'), + sort: anyNamed('sort'), + )).thenAnswer((_) => Future.value([])); + + when(stateDataSource.getState(any, any, any)) + .thenAnswer((_) => Future.value(null)); + + final networkEmails = List.generate( + ThreadConstants.defaultLimit.value.toInt(), + (index) => Email(id: EmailId(Id('network_$index'))), + ); + + final networkNotFoundEmailIds = List.generate( + 5, + (index) => EmailId(Id('network_not_found_$index')), + ); + + when(threadDataSource.getAllEmail( + any, + any, + position: anyNamed('position'), + filter: anyNamed('filter'), + limit: anyNamed('limit'), + sort: anyNamed('sort'), + properties: anyNamed('properties'), + )).thenAnswer((_) => Future.value(EmailsResponse( + emailList: networkEmails, + notFoundEmailIds: networkNotFoundEmailIds, + state: State('network_state'), + ))); + + // Act + final responses = await threadRepository + .getAllEmail( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + ) + .toList(); + + // Assert + expect(responses.length, 2); + expect(responses[0].emailList, networkEmails); + expect(responses[0].notFoundEmailIds, networkNotFoundEmailIds); + verify(threadDataSource.update( + any, + any, + created: networkEmails, + destroyed: networkNotFoundEmailIds, + )); + verify(stateDataSource.saveState( + any, + any, + any, + )); + }); + + test( + 'when filter option is not "all" ' + 'and no need getLatestChanges ' + 'then fetch first page of all messages for this folder ' + 'and network has mail not found ' + 'should delete mail not found in cache', () async { + // Arrange + final mailboxId = MailboxId(Id('mailbox_id')); + final localEmails = List.generate( + 10, + (index) => Email(id: EmailId(Id('local_$index'))), + ); + when(threadDataSource.getAllEmailCache( + any, + any, + filterOption: anyNamed('filterOption'), + inMailboxId: anyNamed('inMailboxId'), + limit: anyNamed('limit'), + sort: anyNamed('sort'), + )).thenAnswer((_) => Future.value(localEmails)); + + when(stateDataSource.getState(any, any, any)) + .thenAnswer((_) => Future.value(State('local_state'))); + + final networkEmails = List.generate( + ThreadConstants.defaultLimit.value.toInt(), + (index) => Email(id: EmailId(Id('network_$index'))), + ); + + final networkNotFoundEmailIds = List.generate( + 5, + (index) => EmailId(Id('network_not_found_$index')), + ); + + when(threadDataSource.getAllEmail( + any, + any, + limit: anyNamed('limit'), + position: anyNamed('position'), + sort: anyNamed('sort'), + filter: anyNamed('filter'), + properties: anyNamed('properties'), + )).thenAnswer((_) => Future.value(EmailsResponse( + emailList: networkEmails, + notFoundEmailIds: networkNotFoundEmailIds, + state: State('network_state'), + ))); + + // Act + final responses = await threadRepository + .getAllEmail( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + emailFilter: EmailFilter( + filterOption: FilterMessageOption.unread, + mailboxId: mailboxId, + ), + getLatestChanges: false, + ) + .toList(); + + // Assert + expect(responses.length, 2); + verify(threadDataSource.getAllEmail( + any, + any, + limit: anyNamed('limit'), + filter: anyNamed('filter'), + )).called(2); // Once for initial fetch, once for first page + verify(threadDataSource.update( + any, + any, + created: networkEmails, + destroyed: networkNotFoundEmailIds, + )).called(2); + verifyNever(stateDataSource.saveState( + any, + any, + any, + )); + }); + + test( + 'when local has mail in cache and getLatestChanges is true ' + 'and email change has destroyed mail ' + 'should delete destroyed mail in cache', () async { + // Arrange + final localEmails = List.generate( + ThreadConstants.defaultLimit.value.toInt(), + (index) => Email(id: EmailId(Id('local_$index'))), + ); + when(threadDataSource.getAllEmailCache( + any, + any, + filterOption: anyNamed('filterOption'), + inMailboxId: anyNamed('inMailboxId'), + limit: anyNamed('limit'), + sort: anyNamed('sort'), + )).thenAnswer((_) => Future.value(localEmails)); + + when(stateDataSource.getState(any, any, any)) + .thenAnswer((_) => Future.value(State('local_state'))); + + final changedEmails = List.generate( + 5, + (index) => Email(id: EmailId(Id('changed_$index'))), + ); + + final destroyedEmailIds = List.generate( + 5, + (index) => EmailId(Id('destroyed_mail_$index')), + ); + when(threadDataSource.getChanges( + any, + any, + any, + propertiesCreated: anyNamed('propertiesCreated'), + propertiesUpdated: anyNamed('propertiesUpdated'), + )).thenAnswer((_) => Future.value(EmailChangeResponse( + hasMoreChanges: false, + created: changedEmails, + destroyed: destroyedEmailIds, + newStateEmail: State('new_state')))); + + // Act + final responses = await threadRepository + .getAllEmail( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + getLatestChanges: true, + ) + .toList(); + + // Assert + expect(responses.length, 2); + verifyNever(threadDataSource.getAllEmail(any, any)); + verify(threadDataSource.getChanges( + any, + any, + any, + propertiesCreated: anyNamed('propertiesCreated'), + propertiesUpdated: anyNamed('propertiesUpdated'), + )); + verify(threadDataSource.update( + any, + any, + created: changedEmails, + destroyed: destroyedEmailIds, + )); + verify(stateDataSource.saveState(any, any, any)); + }); + + test( + 'when getChanges has more changes should fetch all changes ' + 'and first email change has destroyed mail ' + 'should delete destroyed mail in cache', () async { + // Arrange + final localEmails = List.generate( + ThreadConstants.defaultLimit.value.toInt(), + (index) => Email(id: EmailId(Id('local_$index'))), + ); + when(threadDataSource.getAllEmailCache( + any, + any, + filterOption: anyNamed('filterOption'), + inMailboxId: anyNamed('inMailboxId'), + limit: anyNamed('limit'), + sort: anyNamed('sort'), + )).thenAnswer((_) => Future.value(localEmails)); + + when(stateDataSource.getState(any, any, any)) + .thenAnswer((_) => Future.value(State('local_state'))); + + final firstChanges = List.generate( + 5, + (index) => Email(id: EmailId(Id('change1_$index'))), + ); + final secondChanges = List.generate( + 5, + (index) => Email(id: EmailId(Id('change2_$index'))), + ); + + var callCount = 0; + + final firstDestroyedEmailIds = List.generate( + 5, + (index) => EmailId(Id('destroyed_mail_$index')), + ); + + when(threadDataSource.getChanges( + any, + any, + any, + propertiesCreated: anyNamed('propertiesCreated'), + propertiesUpdated: anyNamed('propertiesUpdated'), + )).thenAnswer((_) { + callCount++; + if (callCount == 1) { + return Future.value(EmailChangeResponse( + hasMoreChanges: true, + created: firstChanges, + destroyed: firstDestroyedEmailIds, + newStateChanges: State('intermediate_state'), + newStateEmail: State('intermediate_state_email'), + )); + } else { + return Future.value(EmailChangeResponse( + hasMoreChanges: false, + created: secondChanges, + newStateChanges: State('final_state'), + newStateEmail: State('final_state_email'), + )); + } + }); + + // Act + final responses = await threadRepository + .getAllEmail( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + getLatestChanges: true, + ) + .toList(); + + // Assert + expect(responses.length, 2); + verify(threadDataSource.getChanges( + any, + any, + any, + propertiesCreated: anyNamed('propertiesCreated'), + propertiesUpdated: anyNamed('propertiesUpdated'), + )).called(2); + verify(threadDataSource.update( + any, + any, + created: anyNamed('created'), + destroyed: anyNamed('destroyed'), + )); + verify(stateDataSource.saveState(any, any, any)); + }); + + test( + 'when local has mail in cache but network errors ' + 'and return email from local ' + 'should not call update email cache', () async { + // Arrange + final localEmails = List.generate( + ThreadConstants.defaultLimit.value.toInt(), + (index) => Email(id: EmailId(Id('local_$index'))), + ); + when(threadDataSource.getAllEmailCache( + any, + any, + filterOption: anyNamed('filterOption'), + inMailboxId: anyNamed('inMailboxId'), + limit: anyNamed('limit'), + sort: anyNamed('sort'), + )).thenAnswer((_) => Future.value(localEmails)); + + when(stateDataSource.getState(any, any, any)) + .thenAnswer((_) => Future.value(State('local_state'))); + + when(threadDataSource.getAllEmail( + any, + any, + limit: anyNamed('limit'), + position: anyNamed('position'), + sort: anyNamed('sort'), + filter: anyNamed('filter'), + properties: anyNamed('properties'), + )).thenThrow(Exception('Network error')); + + // Act + final responses = await threadRepository + .getAllEmail( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + getLatestChanges: false, + ) + .toList(); + + // Assert + expect(responses.length, 2); + expect(responses[0].emailList, localEmails); + verifyNever(threadDataSource.getAllEmail( + any, + any, + limit: anyNamed('limit'), + position: anyNamed('position'), + sort: anyNamed('sort'), + filter: anyNamed('filter'), + properties: anyNamed('properties'), + )); + verifyNever(threadDataSource.update( + any, + any, + created: anyNamed('created'), + updated: anyNamed('updated'), + destroyed: anyNamed('created'), + )); + verifyNever(stateDataSource.saveState( + any, + any, + any, + )); + }); + + test( + 'when fetching more emails ' + 'and network has mails and not found mails ' + 'should delete mail not found in cache', () async { + // Arrange + final networkEmails = List.generate( + ThreadConstants.defaultLimit.value.toInt(), + (index) => Email(id: EmailId(Id('network_$index'))), + ); + + final networkNotFoundEmailIds = List.generate( + 5, + (index) => EmailId(Id('network_not_found_$index')), + ); + + when(threadDataSource.getAllEmail( + any, + any, + position: anyNamed('position'), + filter: anyNamed('filter'), + limit: anyNamed('limit'), + sort: anyNamed('sort'), + properties: anyNamed('properties'), + )).thenAnswer((_) => Future.value(EmailsResponse( + emailList: networkEmails, + notFoundEmailIds: networkNotFoundEmailIds, + state: State('network_state'), + ))); + + // Act + final responses = await threadRepository + .loadMoreEmails(GetEmailRequest( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + )) + .toList(); + + // Assert + expect(responses.length, 1); + expect(responses[0].emailList, networkEmails); + expect(responses[0].notFoundEmailIds, networkNotFoundEmailIds); + verify(threadDataSource.getAllEmail( + any, + any, + limit: anyNamed('limit'), + position: anyNamed('position'), + sort: anyNamed('sort'), + filter: anyNamed('filter'), + properties: anyNamed('properties'), + )); + verify(threadDataSource.update( + any, + any, + created: networkEmails, + destroyed: networkNotFoundEmailIds, + )); + }); + + test( + 'when fetching more emails and network errors ' + 'should not call update mail cache', () async { + // Arrange + when(threadDataSource.getAllEmail( + any, + any, + position: anyNamed('position'), + filter: anyNamed('filter'), + limit: anyNamed('limit'), + sort: anyNamed('sort'), + properties: anyNamed('properties'), + )).thenThrow(Exception('Network error')); + + // Assert + expectLater( + () => threadRepository + .loadMoreEmails(GetEmailRequest( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + )) + .toList(), + throwsA(isA().having((e) => e.toString(), 'description', contains('Network error'))), + ); + verifyNever(threadDataSource.getAllEmail( + any, + any, + limit: anyNamed('limit'), + position: anyNamed('position'), + sort: anyNamed('sort'), + filter: anyNamed('filter'), + properties: anyNamed('properties'), + )); + verifyNever(threadDataSource.update( + any, + any, + created: anyNamed('created'), + updated: anyNamed('updated'), + destroyed: anyNamed('created'), + )); + }); + }); }