From a7c8e6cab5107c70f56ca5c8d8c0f7286f8b7150 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 4 May 2022 18:54:29 -0700 Subject: [PATCH] Fix #4249, part of #4064: Domain components for learner analytics (#4267) ## Explanation Fix #4249 Fix part of #4064 Introduces the domain utilities necessary for logging learner analytics, but doesn't make them available for actual usage yet (that is being done in #4269 to keep this PR smaller & more focused). Some notes on the history of this PR: - This PR is a rebase of #4253 to remove dependencies on #2173 (which has since been merged into developer) for a much cleaner history. - This PR is pulling out elements from #4118, #4247, and #4248 which contained completed work by @Sarthak2601. - This PR extracts just the 'domain' pieces from the above, and changes a bunch about its architecture & adds tests. This PR should have little to no impact on the behavior of the app since the new logging functionality isn't being used yet, or is even accessible to broad components in the app. - This is starting as 'pt4' since it's continuing the work introduced in: #4114, #4115, and #4116. This PR makes a number of changes over the original design document and implementation, the most noteworthy being: - This PR organizes learner analytics logging into its own logger (and makes changes to event bundle creation & the generic ``OppiaLogger``). I think that we should move toward this pattern generally in the future rather than continuing with a generic ``OppiaLogger`` as it seems to help keep things much more focused. Existing logging should not be affected. - The notion of a device ID has been dropped as there's no reliable way retrieve such an ID (see https://developer.android.com/training/articles/user-data-ids). Instead, we're using a per-device ID (by leveraging ``PersistentCacheStore``), and have confirmed with study partners that this is workable. - The logging logic for the new logs was rearranged such that all new analytics logs will be logged for everyone, but the user and installation-tied IDs won't be logged in such cases (since they are more sensitive data). These events are generally useful for the platform, so we shouldn't restrict them as such. - Learner ID generation for profiles only occurs if the experiment is enabled, and otherwise stays empty. We may add future cleanup code to ensure it's erased across studies, but this at least lays the initial groundwork to keep such IDs separate when they aren't needed. For a high-level on the design, please refer to [this design document](https://docs.google.com/document/d/1c8lpH-IUvoU1t4LUoYNqNilP2e9yCnzGnSSG0yBxBrY/edit). Other noteworthy design choices: - ``DebugEventLogger`` was updated to call through to the real logger (as it makes event verification simpler in developer builds; normally analytics is off so this won't have any effects for the broader team) - Both ``DebugEventLogger`` and ``FakeEventLogger`` were updated to be thread-safe - Some extended functionality was added to ``FakeEventLogger`` - ``LearnerAnalyticsLogger`` is designed a bit differently compared to other domain classes in that it actually provides session-specific objects to the application-wide singleton graph (which is needed for logging certain situations, such as the user playing/stopping audio during a play session) - ``LoggingIdentifierController`` makes use of a lazy retrieval for session ID now (which is fine because it's guaranteed to compute exactly one initial ID) - ``StateFlow`` is used for easier cross-thread communication, including to expose internal asynchronous state across domain components (the only way we had to do this before was ``Deferred``, and that can be clunky; the new approach is much cleaner) - An ``EventLogSubject`` was introduced to make testing event logs easier. It's used extensively in tests for this feature, but most existing use cases weren't migrated. #4272 is tracking adding tests for this subject (hence the test file exemption). - There were TODOs introduced on #4064 to provide explicit clarity to reviewers on what needs to be changed in later PRs (as there's some things being introduced before the final PR that aren't actually used yet to help break up the project) - Multiple test suites verify behaviors with and without the feature enabled to be very explicit about what behavior occurs when - ``EventBundleCreatorTest`` in particular has very strict tests to ensure that sensitive IDs are logged exactly when expected (initially, never since they aren't turned on in this PR; this is fixed in a later PR) - ``ExplorationDataController`` was updated to introduce new play entrypoints, but these aren't "interesting" yet as the underlying ``ExplorationProgressController`` changes are coming in a later PR. Further, testing coverage technically removes checking ``playExploration``, but it'll be removed (and it's technically tested through the other functions since they call through). - A new ``ClipboardManager`` was introduced with the specific design of not allowing the broad app access to clipboard information from other apps. Instead, it provides an interface to confirm whether the app's known clipboard has been kept. A regex content check was added to ensure developers never use the clipboard service directly and instead use this manager. - ``PersistentCacheStore`` was updated to include a new ``primeInMemoryAndDiskCacheAsync`` function which works more predictably for initialization than ``primeInMemoryCacheAsync`` (formerly ``primeCacheAsync``). In particular, ``primeInMemoryCacheAsync`` is better for ensuring that the cache will quickly be read once it needs to be (and, if it isn't, will default in the same way the cache store normally defaults). However, there are cases when the app wants to change the default values such that: (1) the normal default is never used, (2) the default has to be computed and isn't cheap, and (3) it should never compute that default again once saved on disk. ``primeInMemoryAndDiskCacheAsync`` makes these assurances which, in turn, makes the installation ID cache store even possible without potential race conditions or breaking Dagger's cheap-initialization best practice. Test exemptions: all exemptions are annotations or interfaces except ``EventBundleCreatorTest`` (which is explained above). ## Essential Checklist - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)). ## For UI-specific PRs only N/A -- This PR doesn't make UI changes, and existing flows shouldn't be affected. Commits: * strings for learner analytics * platform parameter impl for learner analytics * nit * nit * event action enum update * addition of contexts * nit * controller level logging and contexts * nit * nit fixes. * nit fixes. * event bundle modifications * sync status, logging identifiers, profile update, lifecycle owner * ui impl: part 1 -- basic * admin control strings * strings correction * strings correction * device id correction * exhaustive when fix. * exhaustive when fix. * todo formatting * nits. * nits. * collapsed contexts, added spacing, added comments * event action removal + nits * tests + dev options event logs fixes post event action removal * nits * removal of method for event action formatted string * nits, null context changes. * nits * reserved fixes and help index fix * bazel imports * bazel build fixes * test fixes * nit * logging identifier controller, module + uuid wrapper, real impl * logging identifier controller tests, fake uuid, tests * sync status manager + fake * logging methods, test setup * profile management, tests * sync status update. * lifecycle observer * Post-merge fixes + Bazel support. * Lots of reorganizing & changes. New tests and documentation have also been added. More broadly, this changes the device ID computation, but actually breaks it so more work will be needed in subsequent commits. * Lint fixes. * Post-merge fix (proper merge of maven_install). * Lint fixes (includes post-merge cleanups). * Lots of stuff. Restructured a lot of the UI, addressed most failing static checks (except KDocs and lint which will be in the follow-up commit), added tests, fixed copying, and generally finished the UI. Sync status seems broken, and it's not yet clear whether events are actually being logged (I need to investigate this). Analytics are disabled in local testing, so that might also be the reason for logs being stuck in an uploading state. * Documentation + lint fixes. This also changes the contract of ClipboardController. * Finish remaining planned tests. * Move over changes from learner-analytics-proto. * Manually pull in changes from 3d6c716efda5b8504a354563d525f33e734eae10. Note that this is operating on a different base). * Post-merge fixes. These at least ensure that the app can build, but many tests will still fail (which is fine seeing as much of this code is going to be split up soon, anyway). Rebase version: app build is no longer guaranteed. * Lint fixes. * Undo all learner analytics changes. I'll be pulling in specific components in specific PRs to organize the changes across 4 PRs. Note that I took this approach to preserve the history from the earlier commits. Those changes will still be included in this PR chain, just a bit awkwardly (i.e. it'll look like I introduced them originally, but that distinction is lost during the squash-and-merge, anyway). * Manually pull in non-app module changes. A bunch of work is still needed to finish these, and I'm still trying to figure out whether I can de-couple the module changes to make reviewing a bit nicer. * Post-merge fixes. All tests verified as building & passing. * Add sync status for no connectivity case. * Remove unnecessary sync manager. * Copy over changes from #4263. These are the domain changes needed for finishing learner analytics support. Cleanup, documentation, and testing all still need to be completed. * Add domain changes for AudioPlayerController. These originate from #4263. * Add missing Javadoc from #4263. * Finish tests & documentation. This also renames 'device ID' to be 'installation ID' for more correctness. * Lint fixes. * Fix OS-specific issue in ClipboardController. Co-authored-by: Sarthak Agarwal --- .../data/persistence/PersistentCacheStore.kt | 76 +- .../persistence/PersistentCacheStoreTest.kt | 220 ++- domain/BUILD.bazel | 5 + domain/build.gradle | 1 + domain/src/main/assets/13.json | 6 +- domain/src/main/assets/13.textproto | 2 + domain/src/main/assets/test_exp_id_2.json | 33 +- .../src/main/assets/test_exp_id_2.textproto | 11 + domain/src/main/assets/test_exp_id_5.json | 32 +- .../src/main/assets/test_exp_id_5.textproto | 11 + .../domain/audio/AudioPlayerController.kt | 14 +- .../android/domain/clipboard/BUILD.bazel | 22 + .../domain/clipboard/ClipboardController.kt | 156 ++ .../exploration/ExplorationDataController.kt | 153 +- .../domain/exploration/ExplorationProgress.kt | 2 +- .../ExplorationProgressController.kt | 207 ++- .../ExplorationCheckpointController.kt | 2 +- .../onboarding/AppStartupStateController.kt | 2 +- .../domain/oppialogger/ApplicationIdSeed.kt | 13 + .../android/domain/oppialogger/BUILD.bazel | 36 +- .../LoggingIdentifierController.kt | 130 ++ .../oppialogger/LoggingIdentifierModule.kt | 13 + .../android/domain/oppialogger/OppiaLogger.kt | 20 +- .../analytics/AnalyticsController.kt | 110 +- .../analytics/ApplicationLifecycleModule.kt | 21 + .../analytics/ApplicationLifecycleObserver.kt | 71 + .../domain/oppialogger/analytics/BUILD.bazel | 62 +- .../LearnerAnalyticsInactivityLimitMillis.kt | 10 + .../analytics/LearnerAnalyticsLogger.kt | 477 ++++++ .../domain/oppialogger/exceptions/BUILD.bazel | 2 +- .../loguploader/LogUploadWorker.kt | 10 +- .../profile/ProfileManagementController.kt | 118 +- .../oppia/android/domain/state/StateDeck.kt | 64 +- .../domain/topic/StoryProgressController.kt | 2 +- .../android/domain/util/StateRetriever.kt | 1 + .../android/domain/clipboard/BUILD.bazel | 36 + .../clipboard/ClipboardControllerTest.kt | 298 ++++ .../android/domain/exploration/BUILD.bazel | 6 +- .../ExplorationDataControllerTest.kt | 193 ++- .../ExplorationProgressControllerTest.kt | 1417 +++------------- .../lightweightcheckpointing/BUILD.bazel | 4 +- .../LoggingIdentifierControllerTest.kt | 423 +++++ .../LoggingIdentifierModuleTest.kt | 82 + .../domain/oppialogger/OppiaLoggerTest.kt | 144 +- .../analytics/AnalyticsControllerTest.kt | 419 ++--- .../ApplicationLifecycleModuleTest.kt | 171 ++ .../ApplicationLifecycleObserverTest.kt | 211 +++ .../domain/oppialogger/analytics/BUILD.bazel | 132 ++ .../analytics/LearnerAnalyticsLoggerTest.kt | 1460 +++++++++++++++++ .../LogUploadWorkManagerInitializerTest.kt | 8 +- .../loguploader/LogUploadWorkerTest.kt | 32 +- .../ProfileManagementControllerTest.kt | 287 +++- .../oppia/android/domain/question/BUILD.bazel | 4 +- .../android/domain/util/StateRetrieverTest.kt | 51 +- model/src/main/proto/exploration.proto | 6 + model/src/main/proto/oppia_logger.proto | 55 +- model/src/main/proto/profile.proto | 5 + .../file_content_validation_checks.textproto | 10 + .../assets/kdoc_validity_exemptions.textproto | 1 - scripts/assets/test_file_exemptions.textproto | 5 + .../regex/RegexPatternValidationCheckTest.kt | 23 + .../oppia/android/testing/FakeEventLogger.kt | 2 +- .../oppia/android/testing/logging/BUILD.bazel | 50 + .../testing/logging/EventLogSubject.kt | 1271 ++++++++++++++ .../testing/logging/FakeSyncStatusManager.kt | 37 + .../testing/logging/SyncStatusTestModule.kt | 12 + .../android/testing/FakeEventLoggerTest.kt | 21 +- .../oppia/android/testing/logging/BUILD.bazel | 60 + .../logging/FakeSyncStatusManagerTest.kt | 210 +++ .../logging/SyncStatusTestModuleTest.kt | 85 + utility/BUILD.bazel | 2 + .../oppia/android/util/logging/BUILD.bazel | 29 + .../util/logging/EventBundleCreator.kt | 450 +++-- .../oppia/android/util/logging/EventLogger.kt | 10 +- .../android/util/logging/SyncStatusManager.kt | 51 + .../util/logging/SyncStatusManagerImpl.kt | 26 + .../android/util/logging/SyncStatusModule.kt | 11 + .../android/util/logging/firebase/BUILD.bazel | 2 + .../util/logging/firebase/DebugEventLogger.kt | 18 +- .../firebase/DebugLogReportingModule.kt | 9 +- .../logging/firebase/FirebaseEventLogger.kt | 42 +- .../logging/firebase/LogReportingModule.kt | 21 +- .../util/logging/EventBundleCreatorTest.kt | 1243 ++++++++++++-- .../util/logging/SyncStatusManagerImplTest.kt | 228 +++ 84 files changed, 9491 insertions(+), 1997 deletions(-) create mode 100644 domain/src/main/java/org/oppia/android/domain/clipboard/BUILD.bazel create mode 100644 domain/src/main/java/org/oppia/android/domain/clipboard/ClipboardController.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/ApplicationIdSeed.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierController.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierModule.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleModule.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserver.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsInactivityLimitMillis.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLogger.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/clipboard/BUILD.bazel create mode 100644 domain/src/test/java/org/oppia/android/domain/clipboard/ClipboardControllerTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierControllerTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierModuleTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleModuleTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserverTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel create mode 100644 domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt create mode 100644 testing/src/main/java/org/oppia/android/testing/logging/BUILD.bazel create mode 100644 testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt create mode 100644 testing/src/main/java/org/oppia/android/testing/logging/FakeSyncStatusManager.kt create mode 100644 testing/src/main/java/org/oppia/android/testing/logging/SyncStatusTestModule.kt create mode 100644 testing/src/test/java/org/oppia/android/testing/logging/BUILD.bazel create mode 100644 testing/src/test/java/org/oppia/android/testing/logging/FakeSyncStatusManagerTest.kt create mode 100644 testing/src/test/java/org/oppia/android/testing/logging/SyncStatusTestModuleTest.kt create mode 100644 utility/src/main/java/org/oppia/android/util/logging/SyncStatusManager.kt create mode 100644 utility/src/main/java/org/oppia/android/util/logging/SyncStatusManagerImpl.kt create mode 100644 utility/src/main/java/org/oppia/android/util/logging/SyncStatusModule.kt create mode 100644 utility/src/test/java/org/oppia/android/util/logging/SyncStatusManagerImplTest.kt diff --git a/data/src/main/java/org/oppia/android/data/persistence/PersistentCacheStore.kt b/data/src/main/java/org/oppia/android/data/persistence/PersistentCacheStore.kt index c0305216ab8..b1c131bc498 100644 --- a/data/src/main/java/org/oppia/android/data/persistence/PersistentCacheStore.kt +++ b/data/src/main/java/org/oppia/android/data/persistence/PersistentCacheStore.kt @@ -23,7 +23,7 @@ import kotlin.concurrent.withLock * An on-disk persistent cache for proto messages that ensures reads and writes happen in a * well-defined order. Note that if this cache is used like a [DataProvider], there is a race * condition between the initial store's data being retrieved and any early writes to the store - * (writes generally win). If this is not ideal, callers should use [primeCacheAsync] to + * (writes generally win). If this is not ideal, callers should use [primeInMemoryCacheAsync] to * synchronously kick-off a read update to the store that is guaranteed to complete before any * writes. This will be reflected in the first time the store's state is delivered to a subscriber * to a LiveData version of this data provider. @@ -97,7 +97,7 @@ class PersistentCacheStore private constructor( * LiveData-converted version of this data provider, so it must be handled at the callsite for * this method. */ - fun primeCacheAsync(forceUpdate: Boolean = false): Deferred { + fun primeInMemoryCacheAsync(forceUpdate: Boolean = false): Deferred { return cache.updateIfPresentAsync { cachePayload -> if (forceUpdate || cachePayload.state == CacheState.UNLOADED) { // Store the retrieved on-disk cache, if it's present (otherwise set up state such that @@ -111,6 +111,47 @@ class PersistentCacheStore private constructor( } } + /** + * Primes the current cache such that both the in-memory and on-disk versions of this cache are + * guaranteed to be in sync, returning a [Deferred] that completes only after the operation is + * finished. + * + * The provided [initialize] initializer will only ever be called if the on-disk cache is not yet + * initialized, and it will be passed the initial value used to create this cache store. The value + * it returns will be used to initialize both the in-memory and on-disk copies of the cache. + * + * The value of the returned [Deferred] is not useful. The state of the cache should monitored by + * treating this provider as a [DataProvider]. This method may result in multiple update + * notifications to observers of this [DataProvider], but the latest value will be the source of + * truth. + * + * Where [primeInMemoryCacheAsync] is useful to ensure any on-disk cache is properly loaded into + * memory prior to using a cache store, this method is useful when a disk cache has a + * contextually-sensitive initialization routine (such as an ID that cannot change after + * initialization) as it ensures a reliable, initial clean state for the cache store that will be + * consistent with future runs of the app. + */ + fun primeInMemoryAndDiskCacheAsync(initialize: (T) -> T): Deferred { + return cache.updateIfPresentAsync { cachePayload -> + when (cachePayload.state) { + CacheState.UNLOADED -> { + val loadedPayload = loadFileCache(cachePayload) + when (loadedPayload.state) { + // The state should never stay as UNLOADED. + CacheState.UNLOADED -> + error("Something went wrong loading the cache during priming: $cacheFile") + CacheState.IN_MEMORY_ONLY -> storeFileCache(loadedPayload, initialize) // Needs saving. + CacheState.IN_MEMORY_AND_ON_DISK -> loadedPayload // Loaded from disk successfully. + } + } + // This generally indicates that something went wrong reading the on-disk cache, so make + // sure it's properly initialized. + CacheState.IN_MEMORY_ONLY -> storeFileCache(cachePayload, initialize) + CacheState.IN_MEMORY_AND_ON_DISK -> cachePayload + } + } + } + /** * Callers should use this read function if they they don't care or specifically do not want to * observe changes to the underlying store. If the file is not in memory, it will loaded from disk @@ -157,7 +198,7 @@ class PersistentCacheStore private constructor( /** See [storeDataAsync]. Stores data and allows for a custom deferred result. */ fun storeDataWithCustomChannelAsync( updateInMemoryCache: Boolean = true, - update: (T) -> Pair + update: suspend (T) -> Pair ): Deferred { return cache.updateWithCustomChannelIfPresentAsync { cachedPayload -> val (updatedPayload, customResult) = storeFileCacheWithCustomChannel(cachedPayload, update) @@ -173,7 +214,7 @@ class PersistentCacheStore private constructor( * does notify subscribers. */ fun clearCacheAsync(): Deferred { - return cache.updateIfPresentAsync { + return cache.updateIfPresentAsync { currentPayload -> if (cacheFile.exists()) { cacheFile.delete() } @@ -183,7 +224,7 @@ class PersistentCacheStore private constructor( // Always clear the in-memory cache and reset it to the initial value (the cache itself should // never be fully deleted since the rest of the store assumes a value is always present in // it). - CachePayload(state = CacheState.UNLOADED, value = initialValue) + currentPayload.copy(state = CacheState.UNLOADED, value = initialValue) } } @@ -206,12 +247,12 @@ class PersistentCacheStore private constructor( private fun loadFileCache(currentPayload: CachePayload): CachePayload { if (!cacheFile.exists()) { // The store is not yet persisted on disk. - return currentPayload.moveToState(CacheState.IN_MEMORY_ONLY) + return currentPayload.copy(state = CacheState.IN_MEMORY_ONLY) } val cacheBuilder = currentPayload.value.toBuilder() return try { - CachePayload( + currentPayload.copy( state = CacheState.IN_MEMORY_AND_ON_DISK, value = FileInputStream(cacheFile).use { cacheBuilder.mergeFrom(it) }.build() as T ) @@ -221,10 +262,7 @@ class PersistentCacheStore private constructor( } // Update the cache to have an in-memory copy of the current payload since on-disk retrieval // failed. - CachePayload( - state = CacheState.IN_MEMORY_ONLY, - value = currentPayload.value - ) + currentPayload.copy(state = CacheState.IN_MEMORY_ONLY, value = currentPayload.value) } } @@ -235,18 +273,19 @@ class PersistentCacheStore private constructor( private fun storeFileCache(currentPayload: CachePayload, update: (T) -> T): CachePayload { val updatedCacheValue = update(currentPayload.value) FileOutputStream(cacheFile).use { updatedCacheValue.writeTo(it) } - return CachePayload(state = CacheState.IN_MEMORY_AND_ON_DISK, value = updatedCacheValue) + return currentPayload.copy(state = CacheState.IN_MEMORY_AND_ON_DISK, value = updatedCacheValue) } /** See [storeFileCache]. Returns payload and custom result. */ - private fun storeFileCacheWithCustomChannel( + private suspend fun storeFileCacheWithCustomChannel( currentPayload: CachePayload, - update: (T) -> Pair + update: suspend (T) -> Pair ): Pair, V> { val (updatedCacheValue, customResult) = update(currentPayload.value) + // TODO(#4264): Move this over to using an I/O-specific dispatcher. FileOutputStream(cacheFile).use { updatedCacheValue.writeTo(it) } return Pair( - CachePayload(state = CacheState.IN_MEMORY_AND_ON_DISK, value = updatedCacheValue), + currentPayload.copy(state = CacheState.IN_MEMORY_AND_ON_DISK, value = updatedCacheValue), customResult ) } @@ -265,12 +304,7 @@ class PersistentCacheStore private constructor( IN_MEMORY_AND_ON_DISK } - private data class CachePayload(val state: CacheState, val value: T) { - /** Returns a copy of this payload with the new, specified [CacheState]. */ - fun moveToState(newState: CacheState): CachePayload { - return CachePayload(state = newState, value = value) - } - } + private data class CachePayload(val state: CacheState, val value: T) // TODO(#59): Use @ApplicationContext instead of Context once package dependencies allow for // cross-module circular ependencies. Currently, the data module cannot depend on the app module. diff --git a/data/src/test/java/org/oppia/android/data/persistence/PersistentCacheStoreTest.kt b/data/src/test/java/org/oppia/android/data/persistence/PersistentCacheStoreTest.kt index 11d58728a53..c14643c3baa 100644 --- a/data/src/test/java/org/oppia/android/data/persistence/PersistentCacheStoreTest.kt +++ b/data/src/test/java/org/oppia/android/data/persistence/PersistentCacheStoreTest.kt @@ -6,14 +6,18 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import com.google.protobuf.MessageLite import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -24,6 +28,7 @@ import org.oppia.android.testing.data.DataProviderTestMonitor import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider @@ -31,8 +36,10 @@ import org.oppia.android.util.threading.BackgroundDispatcher import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import java.io.File +import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException +import java.lang.IllegalStateException import javax.inject.Inject import javax.inject.Singleton @@ -52,6 +59,7 @@ class PersistentCacheStoreTest { private val TEST_MESSAGE_V2 = TestMessage.newBuilder().setIntValue(2).build() } + @Inject lateinit var context: Context @Inject lateinit var cacheFactory: PersistentCacheStore.Factory @Inject lateinit var dataProviders: DataProviders @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers @@ -229,7 +237,7 @@ class PersistentCacheStoreTest { // Create a new cache with the same name and update it, then observe it. However, first prime // it. val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance()) - val primeOp = cacheStore2.primeCacheAsync() + val primeOp = cacheStore2.primeInMemoryCacheAsync() testCoroutineDispatchers.advanceUntilIdle() val storeOp2 = cacheStore2.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_V2 } testCoroutineDispatchers.advanceUntilIdle() @@ -252,7 +260,7 @@ class PersistentCacheStoreTest { // Create a new cache with the same name and update it, then observe it. However, first prime // it. val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance()) - val primeOp = cacheStore2.primeCacheAsync() + val primeOp = cacheStore2.primeInMemoryCacheAsync() testCoroutineDispatchers.advanceUntilIdle() val storeOp2 = cacheStore2.storeDataAsync { TEST_MESSAGE_V2 } testCoroutineDispatchers.advanceUntilIdle() @@ -273,7 +281,7 @@ class PersistentCacheStoreTest { val storeOp = cacheStore.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_V1 } testCoroutineDispatchers.advanceUntilIdle() - val primeOp = cacheStore.primeCacheAsync(forceUpdate = false) + val primeOp = cacheStore.primeInMemoryCacheAsync(forceUpdate = false) testCoroutineDispatchers.advanceUntilIdle() // Both ops will succeed, and the observer will receive the old value due to the update not @@ -291,7 +299,7 @@ class PersistentCacheStoreTest { val storeOp = cacheStore.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_V1 } testCoroutineDispatchers.advanceUntilIdle() - val primeOp = cacheStore.primeCacheAsync(forceUpdate = true) + val primeOp = cacheStore.primeInMemoryCacheAsync(forceUpdate = true) testCoroutineDispatchers.advanceUntilIdle() // The observer will receive the new value because the prime was forced. This ensures the @@ -430,21 +438,213 @@ class PersistentCacheStoreTest { assertThat(error).isInstanceOf(IOException::class.java) } + @Test + fun testNewCache_notYetRead_noCacheFileOnDisk() { + cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance()) + + val cacheFile = getCacheFile(CACHE_NAME_1) + assertThat(cacheFile.exists()).isFalse() + } + + @Test + fun testNewCache_readIntoMemory_noCacheFileOnDisk() { + val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance()) + + monitorFactory.ensureDataProviderExecutes(cacheStore) + + // Even though the cache was 'read', it doesn't yet exist on disk. This test helps provide the + // initial state for primeInMemoryAndDiskCacheAsync (to ensure it does what the caller expects). + val cacheFile = getCacheFile(CACHE_NAME_1) + assertThat(cacheFile.exists()).isFalse() + } + + @Test + fun testNewCache_fileAlreadyOnDisk_readIntoMemory_returnsOnDiskValue() { + writeFileCache(CACHE_NAME_1, TestMessage.newBuilder().apply { strValue = "initial" }.build()) + val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance()) + + monitorFactory.ensureDataProviderExecutes(cacheStore) + + val cacheFile = getCacheFile(CACHE_NAME_1) + assertThat(cacheFile.exists()).isTrue() + assertThat(monitorFactory.waitForNextSuccessfulResult(cacheStore).strValue).isEqualTo("initial") + } + + @Test + fun testPrimeInMemoryAndOnDisk_newCache_notOnDisk_notInMem_writesFileAndRetsNewVal() { + val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance()) + + val deferred = cacheStore.primeInMemoryAndDiskCacheAsync { + it.toBuilder().apply { strValue += " first transform" }.build() + } + + // The on-disk and in-memory values should change. + deferred.waitForSuccessfulResult() + val onDiskValue = readFileCache(CACHE_NAME_1, TestMessage.getDefaultInstance()) + val cacheValue = monitorFactory.waitForNextSuccessfulResult(cacheStore) + assertThat(cacheValue).isEqualTo(onDiskValue) + assertThat(cacheValue.strValue.trim()).isEqualTo("first transform") + } + + @Test + fun testPrimeInMemoryAndOnDisk_newCache_onDisk_notInMem_writesFileAndRetsOldVal() { + writeFileCache(CACHE_NAME_1, TestMessage.newBuilder().apply { strValue = "initial" }.build()) + val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance()) + + val deferred = cacheStore.primeInMemoryAndDiskCacheAsync { + it.toBuilder().apply { strValue += " first transform" }.build() + } + + // The on-disk value should be the same, and the in-memory value should become the on-disk + // value. The initializer shouldn't be used since the value is already on disk. + deferred.waitForSuccessfulResult() + val onDiskValue = readFileCache(CACHE_NAME_1, TestMessage.getDefaultInstance()) + val cacheValue = monitorFactory.waitForNextSuccessfulResult(cacheStore) + assertThat(cacheValue).isEqualTo(onDiskValue) + assertThat(cacheValue.strValue).isEqualTo("initial") + } + + @Test + fun testPrimeInMemoryAndOnDisk_existingCache_unchanged_onDisk_inMem_onlyReadsFileAndRetsOldVal() { + writeFileCache(CACHE_NAME_1, TestMessage.newBuilder().apply { strValue = "initial" }.build()) + val cacheStore = cacheFactory.create( + CACHE_NAME_1, TestMessage.newBuilder().apply { strValue = "different initial" }.build() + ) + + val deferred = cacheStore.primeInMemoryAndDiskCacheAsync { + it.toBuilder().apply { strValue += " first transform" }.build() + } + + // Priming should ignore both the on-disk and in-memory values of the cache store since only the + // initial value matters. + deferred.waitForSuccessfulResult() + val onDiskValue = readFileCache(CACHE_NAME_1, TestMessage.getDefaultInstance()) + val cacheValue = monitorFactory.waitForNextSuccessfulResult(cacheStore) + assertThat(cacheValue).isEqualTo(onDiskValue) + assertThat(cacheValue.strValue).isEqualTo("initial") + } + + @Test + fun testPrimeInMemoryAndOnDisk_existingCache_changed_notOnDisk_inMem_writesFileAndRetsOldVal() { + val cacheStore = cacheFactory.create( + CACHE_NAME_1, TestMessage.newBuilder().apply { strValue = "different initial" }.build() + ) + cacheStore.storeDataAsync { + it.toBuilder().apply { strValue = "different update" }.build() + }.waitForResult() + + val deferred = cacheStore.primeInMemoryAndDiskCacheAsync { + it.toBuilder().apply { strValue += " first transform" }.build() + } + + // Priming shouldn't really change much since the recent change to the cache store takes + // precedence. + deferred.waitForSuccessfulResult() + val onDiskValue = readFileCache(CACHE_NAME_1, TestMessage.getDefaultInstance()) + val cacheValue = monitorFactory.waitForNextSuccessfulResult(cacheStore) + assertThat(cacheValue).isEqualTo(onDiskValue) + assertThat(cacheValue.strValue).isEqualTo("different update") + } + + @Test + fun testPrimeInMemoryAndOnDisk_existingCache_changed_onDisk_inMem_onlyReadsFileAndRetsOldVal() { + writeFileCache(CACHE_NAME_1, TestMessage.newBuilder().apply { strValue = "initial" }.build()) + val cacheStore = cacheFactory.create( + CACHE_NAME_1, TestMessage.newBuilder().apply { strValue = "different initial" }.build() + ) + cacheStore.storeDataAsync { + it.toBuilder().apply { strValue = "different update" }.build() + }.waitForResult() + + val deferred = cacheStore.primeInMemoryAndDiskCacheAsync { + it.toBuilder().apply { strValue += " first transform" }.build() + } + + // Priming shouldn't really change much since the recent change to the cache store takes + // precedence. + deferred.waitForSuccessfulResult() + val onDiskValue = readFileCache(CACHE_NAME_1, TestMessage.getDefaultInstance()) + val cacheValue = monitorFactory.waitForNextSuccessfulResult(cacheStore) + assertThat(cacheValue).isEqualTo(onDiskValue) + assertThat(cacheValue.strValue).isEqualTo("different update") + } + + @Test + fun testPrimeInMemoryAndOnDisk_existingCache_corruptedOnDisk_updatesFileAndRetsNewVal() { + corruptFileCache(CACHE_NAME_1) + val cacheStore1 = cacheFactory.create( + CACHE_NAME_1, TestMessage.newBuilder().apply { strValue = "different initial" }.build() + ) + val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance()) + monitorFactory.ensureDataProviderExecutes(cacheStore1) + + val deferred = cacheStore1.primeInMemoryAndDiskCacheAsync { + it.toBuilder().apply { strValue += " first transform" }.build() + } + + // The corrupted cache will trigger an in-memory only state that will lead to the cache being + // overwritten (since it can't be determined whether the on-disk cache matches the expected + // value). Note that the cache will be in a bad state since it failed to read its original + // state, but the on-disk copy will still be updated. Note also that the second instance of the + // cache wasn't yet primed until this step, so it's being used to validate that the in-memory + // copy is also correct after the on-disk value has been updated. + deferred.waitForSuccessfulResult() + monitorFactory.waitForNextFailureResult(cacheStore1) + val onDiskValue = readFileCache(CACHE_NAME_1, TestMessage.getDefaultInstance()) + val cacheValue = monitorFactory.waitForNextSuccessfulResult(cacheStore2) + assertThat(cacheValue).isEqualTo(onDiskValue) + assertThat(onDiskValue.strValue).isEqualTo("different initial first transform") + } + + private fun getCacheFile(cacheName: String) = File(context.filesDir, "$cacheName.cache") + private fun corruptFileCache(cacheName: String) { // NB: This is unfortunately tied to the implementation details of PersistentCacheStore. If this // ends up being an issue, the store should be updated to call into a file path provider that // can also be used in this test to retrieve the file cache. This may also be needed for // downstream profile work if per-profile data stores are done via subdirectories or altered // filenames. - val cacheFileName = "$cacheName.cache" - val cacheFile = File( - ApplicationProvider.getApplicationContext().filesDir, cacheFileName - ) - FileOutputStream(cacheFile).use { - it.write(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)) + getCacheFile(cacheName).writeBytes(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)) + } + + private fun writeFileCache(cacheName: String, value: T) { + getCacheFile(cacheName).writeBytes(value.toByteArray()) + } + + private fun File.writeBytes(data: ByteArray) { + FileOutputStream(this).use { it.write(data) } + } + + private inline fun readFileCache(cacheName: String, baseMessage: T): T { + return FileInputStream(getCacheFile(cacheName)).use { + baseMessage.newBuilderForType().mergeFrom(it).build() + } as T + } + + private fun Deferred.waitForSuccessfulResult() { + return when (val result = waitForResult()) { + is AsyncResult.Pending -> error("Deferred never finished.") + is AsyncResult.Success -> {} // Nothing to do; the result succeeded. + is AsyncResult.Failure -> throw IllegalStateException("Deferred failed", result.error) } } + private fun Deferred.waitForResult() = toStateFlow().waitForLatestValue() + + private fun Deferred.toStateFlow(): StateFlow> { + val deferred = this + return MutableStateFlow>(value = AsyncResult.Pending()).also { flow -> + backgroundDispatcherScope.async { + flow.emit(AsyncResult.Success(deferred.await())) + }.invokeOnCompletion { + it?.let { flow.tryEmit(AsyncResult.Failure(it)) } + } + } + } + + private fun StateFlow.waitForLatestValue(): T = + also { testCoroutineDispatchers.runCurrent() }.value + private fun setUpTestApplicationComponent() { ApplicationProvider.getApplicationContext().inject(this) } diff --git a/domain/BUILD.bazel b/domain/BUILD.bazel index dfe70e6d2aa..1f351a56907 100755 --- a/domain/BUILD.bazel +++ b/domain/BUILD.bazel @@ -108,6 +108,7 @@ kt_android_library( "//domain/src/main/java/org/oppia/android/domain/oppialogger:startup_listener", "//domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions:controller", "//domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader:worker_factory", + "//domain/src/main/java/org/oppia/android/domain/profile:profile_management_controller", "//domain/src/main/java/org/oppia/android/domain/state:state_deck", "//domain/src/main/java/org/oppia/android/domain/state:state_graph", "//domain/src/main/java/org/oppia/android/domain/state:state_list", @@ -191,6 +192,7 @@ TEST_DEPS = [ "//domain/src/main/java/org/oppia/android/domain/feedbackreporting:report_schema_version", "//domain/src/main/java/org/oppia/android/domain/onboarding:retriever_prod_module", "//domain/src/main/java/org/oppia/android/domain/onboarding:state_controller", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", "//domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions:logger_module", "//domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions:startup_listener", "//domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader:worker_factory", @@ -199,6 +201,9 @@ TEST_DEPS = [ "//testing", "//testing/src/main/java/org/oppia/android/testing/data:async_result_subject", "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject", + "//testing/src/main/java/org/oppia/android/testing/logging:fake_sync_status_manager", + "//testing/src/main/java/org/oppia/android/testing/logging:sync_status_test_module", "//testing/src/main/java/org/oppia/android/testing/network:network", "//testing/src/main/java/org/oppia/android/testing/network:test_module", "//testing/src/main/java/org/oppia/android/testing/platformparameter:test_module", diff --git a/domain/build.gradle b/domain/build.gradle index 894b47c3dc8..ed6c93adc63 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -88,6 +88,7 @@ dependencies { 'androidx.appcompat:appcompat:1.0.2', 'androidx.exifinterface:exifinterface:1.0.0-rc01', 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha03', + 'androidx.lifecycle:lifecycle-extensions:2.0.0', 'androidx.work:work-runtime-ktx:2.4.0', 'com.google.dagger:dagger:2.24', 'com.google.firebase:firebase-analytics-ktx:17.5.0', diff --git a/domain/src/main/assets/13.json b/domain/src/main/assets/13.json index 0ff6b0de55c..d6daf4d73af 100644 --- a/domain/src/main/assets/13.json +++ b/domain/src/main/assets/13.json @@ -50,7 +50,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_1" }, "ImageClickInput": { "content": { @@ -464,7 +465,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_1" } }, "objective": "learn how to arrange the sentence", diff --git a/domain/src/main/assets/13.textproto b/domain/src/main/assets/13.textproto index f71a38838bd..59c04319ecf 100644 --- a/domain/src/main/assets/13.textproto +++ b/domain/src/main/assets/13.textproto @@ -39,6 +39,7 @@ states { } } } + linked_skill_id: "test_skill_id_1" } } states { @@ -623,6 +624,7 @@ states { } } } + linked_skill_id: "test_skill_id_1" } } init_state_name: "ImageClickInput" diff --git a/domain/src/main/assets/test_exp_id_2.json b/domain/src/main/assets/test_exp_id_2.json index 15c1519483e..49e1dfc7495 100644 --- a/domain/src/main/assets/test_exp_id_2.json +++ b/domain/src/main/assets/test_exp_id_2.json @@ -233,7 +233,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "MultipleChoice": { "content": { @@ -376,7 +377,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "Fractions": { "content": { @@ -546,7 +548,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "Continue": { "content": { @@ -612,7 +615,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "NumberInput": { "content": { @@ -808,7 +812,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "RatioInput": { "content": { @@ -988,7 +993,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "Text": { "content": { @@ -1119,7 +1125,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "ItemSelectionMinOne": { "content": { @@ -1268,7 +1275,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "DragDropNoGroup": { "content": { @@ -1495,7 +1503,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "DragDropGroup": { "content": { @@ -1691,7 +1700,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" }, "End": { "content": { @@ -1734,7 +1744,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_0" } }, "objective": "Demonstrate core interactions used in the Oppia prototype.", diff --git a/domain/src/main/assets/test_exp_id_2.textproto b/domain/src/main/assets/test_exp_id_2.textproto index afb0c32de8e..d7882d51023 100644 --- a/domain/src/main/assets/test_exp_id_2.textproto +++ b/domain/src/main/assets/test_exp_id_2.textproto @@ -325,6 +325,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -512,6 +513,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -727,6 +729,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -798,6 +801,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -1040,6 +1044,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -1266,6 +1271,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -1429,6 +1435,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -1632,6 +1639,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -1969,6 +1977,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -2264,6 +2273,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } states { @@ -2306,6 +2316,7 @@ states { } } } + linked_skill_id: "test_skill_id_0" } } init_state_name: "Continue" diff --git a/domain/src/main/assets/test_exp_id_5.json b/domain/src/main/assets/test_exp_id_5.json index 16d831f25b1..93f94c1d3e4 100644 --- a/domain/src/main/assets/test_exp_id_5.json +++ b/domain/src/main/assets/test_exp_id_5.json @@ -2,7 +2,7 @@ "exploration_id": "test_exp_id_5", "preferred_audio_language_code": "", "correctness_feedback_enabled": false, - "version": 0, + "version": 5, "record_playthrough_probability": 0.0, "exploration": { "init_state_name": "NumericExpressionInput.MatchesExactlyWith", @@ -123,7 +123,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" }, "NumericExpressionInput.MatchesUpToTrivialManipulations": { "content": { @@ -217,7 +218,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" }, "NumericExpressionInput.IsEquivalentTo": { "content": { @@ -311,7 +313,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" }, "AlgebraicExpressionInput.MatchesExactlyWith": { "content": { @@ -412,7 +415,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" }, "AlgebraicExpressionInput.MatchesUpToTrivialManipulations": { "content": { @@ -510,7 +514,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" }, "AlgebraicExpressionInput.IsEquivalentTo": { "content": { @@ -608,7 +613,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" }, "MathEquationInput.MatchesExactlyWith": { "content": { @@ -709,7 +715,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" }, "MathEquationInput.MatchesUpToTrivialManipulations": { "content": { @@ -807,7 +814,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" }, "MathEquationInput.IsEquivalentTo": { "content": { @@ -905,7 +913,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" }, "End": { "content": { @@ -948,7 +957,8 @@ } }, "solicit_answer_details": false, - "next_content_id_index": -1 + "next_content_id_index": -1, + "linked_skill_id": "test_skill_id_2" } }, "objective": "Demonstrate math interactions.", diff --git a/domain/src/main/assets/test_exp_id_5.textproto b/domain/src/main/assets/test_exp_id_5.textproto index 8a72003adc9..c958337ff60 100644 --- a/domain/src/main/assets/test_exp_id_5.textproto +++ b/domain/src/main/assets/test_exp_id_5.textproto @@ -136,6 +136,7 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } states { @@ -241,6 +242,7 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } states { @@ -346,6 +348,7 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } states { @@ -467,6 +470,7 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } states { @@ -582,6 +586,7 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } states { @@ -697,6 +702,7 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } states { @@ -821,6 +827,7 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } states { @@ -939,6 +946,7 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } states { @@ -1057,6 +1065,7 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } states { @@ -1099,9 +1108,11 @@ states { } } } + linked_skill_id: "test_skill_id_2" } } init_state_name: "NumericExpressionInput.MatchesExactlyWith" objective: "Demonstrate math interactions." title: "Math Expressions" language_code: "en" +version: 5 diff --git a/domain/src/main/java/org/oppia/android/domain/audio/AudioPlayerController.kt b/domain/src/main/java/org/oppia/android/domain/audio/AudioPlayerController.kt index 756177c31ef..f115924d0d3 100644 --- a/domain/src/main/java/org/oppia/android/domain/audio/AudioPlayerController.kt +++ b/domain/src/main/java/org/oppia/android/domain/audio/AudioPlayerController.kt @@ -88,6 +88,7 @@ class AudioPlayerController @Inject constructor( private var isReleased = false private var duration = 0 private var completed = false + private var currentContentId: String? = null private val SEEKBAR_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1) @@ -110,13 +111,15 @@ class AudioPlayerController @Inject constructor( return progressLiveData } + // TODO(#4064): Pass in a content ID here. /** * Changes audio source to specified. * Stops sending seek bar updates and put MediaPlayer in preparing state. */ - fun changeDataSource(url: String) { + fun changeDataSource(url: String, contentId: String? = null) { audioLock.withLock { prepared = false + currentContentId = contentId stopUpdatingSeekBar() mediaPlayer.reset() prepareDataSource(url) @@ -193,12 +196,19 @@ class AudioPlayerController @Inject constructor( * Puts MediaPlayer in started state and begins sending seek bar updates. * Controller must already have audio prepared. */ - fun play() { + fun play(isPlayingFromAutoPlay: Boolean = false, reloadingMainContent: Boolean = false) { audioLock.withLock { check(prepared) { "Media Player not in a prepared state" } if (!mediaPlayer.isPlaying) { mediaPlayer.start() scheduleNextSeekBarUpdate() + + // Log an auto play only if it's the one that initiates playing audio (since it more or less + // corresponds to manually clicking the 'play' button). Note this will not log any play + // events after the state completes (since there'll no longer be a state logger). + if (!isPlayingFromAutoPlay || !reloadingMainContent) { + // TODO(#4064): Remove the defaults above, and log the 'play voice over' event here. + } } } } diff --git a/domain/src/main/java/org/oppia/android/domain/clipboard/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/clipboard/BUILD.bazel new file mode 100644 index 00000000000..9b485b17b05 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/clipboard/BUILD.bazel @@ -0,0 +1,22 @@ +""" +Domain services & definitions corresponding to managing the system clipboard when copying & pasting +text. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +kt_android_library( + name = "clipboard_controller", + srcs = [ + "ClipboardController.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":dagger", + "//utility/src/main/java/org/oppia/android/util/data:data_provider", + "//utility/src/main/java/org/oppia/android/util/data:data_providers", + ], +) + +dagger_rules() diff --git a/domain/src/main/java/org/oppia/android/domain/clipboard/ClipboardController.kt b/domain/src/main/java/org/oppia/android/domain/clipboard/ClipboardController.kt new file mode 100644 index 00000000000..74e389cf9b9 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/clipboard/ClipboardController.kt @@ -0,0 +1,156 @@ +package org.oppia.android.domain.clipboard + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +private const val CURRENT_CLIP_PROVIDER_ID = "ClipboardController.current_clip" +private const val SET_CLIP_PROVIDER_ID = "ClipboardController.set_clip" + +/** + * Controller for checking the state of, and copying text to, the user's system clipboard so that + * they can easily copy text into other apps. + * + * Note that this controller is designed specifically with privacy in mind: it does not allow + * exposing the actual contents of the clipboard unless they originated from Oppia (even + * accidentally). See [getCurrentClip] and [setCurrentClip] for specifics. + */ +@Singleton +class ClipboardController @Inject constructor( + private val dataProviders: DataProviders, + context: Context +) { + // Note that this has to be initialized upon construction to ensure that it occurs on the main + // thread (since older versions of Android assume that ClipboardManager is only ever created on + // the main thread). + private val clipboardManager = + (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).also { + it.addPrimaryClipChangedListener(this::maybeRecomputeCurrentClip) + } + private val state = MutableStateFlow(CurrentClip.Unknown) + + /** + * Returns a [DataProvider] that represents the [CurrentClip] copied to the user's clipboard. + * + * The returned [DataProvider] will generally automatically update if the clipboard is modified + * inside or outside the app, however there are a few caveats: + * - It will not restore its state after a process death (instead defaulting per [CurrentClip]). + * - It may not receive notice of clipboard changes from outside Oppia if Android decides that + * Oppia doesn't have focus (though Android seems to eventually notify the app of the change + * when it's foregrounded, so no additional effort is taken by the controller in these cases). + * + * The current clip can be changed via [setCurrentClip]. + * + * Note that observing the clipboard state is inherently prone to data races since both the app + * and system can change the clipboard simultaneously, so some effort is taken by the controller + * to reduce data races. However, it cannot guarantee notification order, or eventual consistency + * in cases where the clipboard is changed in quick succession by the app and then the system. + * However, since it's expected for the clipboard to only ever be changed manually by the user, + * this case ought to be rare and possibly due to another app misbehaving. + */ + fun getCurrentClip(): DataProvider = dataProviders.run { + state.convertToAutomaticDataProvider(CURRENT_CLIP_PROVIDER_ID) + } + + /** + * Copies the specified [text] with a specified human-readable [label] to the user's clipboard, + * updating the [DataProvider] returned by [getCurrentClip] in the process. + * + * Note that this method returns a [DataProvider] that **must** be observed via the UI in order + * for the text to actually copy. This is a mechanism to ensure that text can only ever be copied + * from the app layer with the app in the foreground. + */ + fun setCurrentClip(label: String, text: String): DataProvider { + val operationCompleted = AtomicBoolean() + return dataProviders.createInMemoryDataProviderAsync(SET_CLIP_PROVIDER_ID) { + // Only copy the text if it hasn't been copied yet. + if (!operationCompleted.get()) { + while (!operationCompleted.compareAndSet(/* expect= */ false, /* update= */ true)) { + // Spin until the operation is reported as completed to avoid copying content multiple + // times. + } + + // This must use the setter since property syntax seems to break on SDK 30. + @Suppress("UsePropertyAccessSyntax") + clipboardManager.setPrimaryClip(ClipData.newPlainText(label, text)) + state.emit(CurrentClip.SetWithAppText(label, text)) + } + + return@createInMemoryDataProviderAsync AsyncResult.Success(null) + } + } + + private fun maybeRecomputeCurrentClip() { + // This is a loop to ensure that the atomic value is updated in cases when the state actually + // changes, and tries to account for data races (such as when the clipboard changes mid-loop). + do { + val currentPrimaryClipText = clipboardManager.primaryClip?.extractText() + val oldState = state.value + val maybeNewState = when (oldState) { + is CurrentClip.SetWithAppText -> { + // Check if the current clip is actually what's been set from the Oppia app, or if it's + // different (including null which might indicate a non-text clip, or a cleared + // clipboard). + if (currentPrimaryClipText == oldState.text) oldState else CurrentClip.SetWithOtherContent + } + // The clipboard state has potentially changed, but the app isn't interested in the change. + // Note that this keeps 'Unknown' as 'Unknown' until Oppia sets a clipboard value. + CurrentClip.SetWithOtherContent, CurrentClip.Unknown -> oldState + } + + // If nothing has changed, don't bother updating the atomic. + if (oldState == maybeNewState) return + } while (!state.compareAndSet(oldState, maybeNewState)) + + // No explicit notification is needed since changes to the value will automatically notify + // observers. + } + + /** + * Represents all possible values which may be reported by [ClipboardController] as the current + * state of the user's system clipboard. + * + * See the subclasses for specific states. + */ + sealed class CurrentClip { + /** + * Indicates that the current clipboard contents aren't known. + * + * Note that this always indicates that the app has yet to copy anything to the clipboard, and + * nothing can be assumed about the current contents of the user's clipboard (e.g. it might + * contain actual content from the Oppia app from a previous instance). + */ + object Unknown : CurrentClip() + + /** + * Indicates that the current clipboard contents originated from the app. + * + * Note that per the caveats mentioned in [getCurrentClip] this may not represent the exact + * current clipboard state, only what the app thinks is the current clipboard state. + * + * @property label the human-readable label of the clipboard contents + * @property text the plaintext contents that is currently on the user's clipboard + */ + data class SetWithAppText(val label: String, val text: String) : CurrentClip() + + /** + * Indicates that the clipboard's contents are currently content defined by another app. + * + * This case implies that the user has copied text from the Oppia app at least once during the + * lifetime of the current app instance since otherwise the reported clip state would be + * [Unknown]. + */ + object SetWithOtherContent : CurrentClip() + } + + private companion object { + private fun ClipData.extractText() = if (itemCount > 0) getItemAt(0).text else null + } +} diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationDataController.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationDataController.kt index 2873d326892..37510f84214 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationDataController.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationDataController.kt @@ -37,6 +37,138 @@ class ExplorationDataController @Inject constructor( } } + // TODO(#4064): Add usage for this & the following new methods, and remove the old one. + /** + * Begins playing an exploration of the specified ID. + * + * [ExplorationProgressController] should be used to manage the play state, and monitor the load + * success/failure of the exploration. + * + * This can be called even if a session is currently active as it will force initiate a new play + * session, resetting any data from the previous session (though any pending unsaved checkpoint + * progress is guaranteed to be saved from the previous session, first). + * + * [stopPlayingExploration] may be optionally called to clean up the session--see the + * documentation for that method for details. + * + * Note that this method is specifically meant to only be called for explorations which have never + * been played by the current user before, as it will not resume any saved progress checkpoints, + * and it will save the user's progress. See [resumeExploration], [restartExploration], and + * [replayExploration] for other situations. + * + * @param internalProfileId the ID corresponding to the profile for which exploration is to be + * played + * @param topicId the ID corresponding to the topic for which exploration has to be played + * @param storyId the ID corresponding to the story for which exploration has to be played + * @param explorationId the ID of the exploration which has to be played + * @return a [DataProvider] to observe whether initiating the play request succeeded + */ + fun startPlayingNewExploration( + internalProfileId: Int, + topicId: String, + storyId: String, + explorationId: String + ): DataProvider { + return startPlayingExploration( + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress = true, + explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance(), + isRestart = false + ) + } + + /** + * Resumes the specified exploration indicated by [topicId], [storyId], and [explorationId] for + * the user corresponding to [internalProfileId] by restoring the provided + * [explorationCheckpoint], and returns a [DataProvider] tracking whether the start succeeded. + * + * This method behaves the same as [startPlayingNewExploration] except it resumes a previous + * session. All progress that the user makes during this session will be recorded. + * + * This method should generally be called when a user wants to play a lesson for which they have + * saved progress (unless they want to start over in which case [restartExploration] should be + * used). + */ + fun resumeExploration( + internalProfileId: Int, + topicId: String, + storyId: String, + explorationId: String, + explorationCheckpoint: ExplorationCheckpoint + ): DataProvider { + return startPlayingExploration( + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress = true, + explorationCheckpoint, + isRestart = false + ) + } + + /** + * Restarts the specified exploration indicated by [topicId], [storyId], and [explorationId] for + * the user corresponding to [internalProfileId], and returns a [DataProvider] tracking whether + * the start succeeded. + * + * This method behaves the same as [resumeExploration] except any prior progress the user might + * had for the lesson is dropped and overwritten by any new progress that they achieve. + * + * This method should only be used when a user has saved lesson progress and wishes to restart the + * lesson (otherwise [resumeExploration] should be used to resume the lesson). + */ + fun restartExploration( + internalProfileId: Int, + topicId: String, + storyId: String, + explorationId: String + ): DataProvider { + return startPlayingExploration( + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress = true, // Implied since only checkpoints can be restarted. + explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance(), + isRestart = true + ) + } + + /** + * Replays the specified exploration indicated by [topicId], [storyId], and [explorationId] for + * the user corresponding to [internalProfileId], and returns a [DataProvider] tracking whether + * the start succeeded. + * + * This method behaves the same as [startPlayingNewExploration] except no progress is tracked + * during the lesson. This method is only meant to be used in cases when a user wants to play a + * lesson but has already completed that lesson (and, since partial progress can't be conveyed in + * the UI for completed lessons, such lessons do not have their progress retained). + * + * This method should only be called when starting a lesson that's been completed, otherwise one + * of [startPlayingNewExploration], [resumeExploration], or [restartExploration] should be used,\ + * instead, depending on the specific situation. + */ + fun replayExploration( + internalProfileId: Int, + topicId: String, + storyId: String, + explorationId: String + ): DataProvider { + return startPlayingExploration( + internalProfileId, + topicId, + storyId, + explorationId, + shouldSavePartialProgress = false, // Finished lessons can't be partially saved. + explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance(), + isRestart = false + ) + } + /** * Begins playing an exploration of the specified ID. * @@ -67,7 +199,8 @@ class ExplorationDataController @Inject constructor( storyId: String, explorationId: String, shouldSavePartialProgress: Boolean, - explorationCheckpoint: ExplorationCheckpoint + explorationCheckpoint: ExplorationCheckpoint, + isRestart: Boolean = false ): DataProvider { return explorationProgressController.beginExplorationAsync( ProfileId.newBuilder().apply { internalId = internalProfileId }.build(), @@ -75,25 +208,27 @@ class ExplorationDataController @Inject constructor( storyId, explorationId, shouldSavePartialProgress, - explorationCheckpoint + explorationCheckpoint, + isRestart ) } + // TODO(#4064): Remove the default value for 'isCompletion' below. /** - * Finishes the most recent exploration started by [startPlayingExploration], and returns a - * [DataProvider] indicating whether the operation succeeded. + * Finishes the most recent exploration started by [startPlayingNewExploration], + * [resumeExploration], [restartExploration], or [replayExploration], and returns a [DataProvider] + * indicating whether the operation succeeded. * * This method should only be called if an active exploration is being played, otherwise the * resulting provider will fail. Note that this doesn't actually need to be called between * sessions unless the caller wants to ensure other providers monitored from * [ExplorationProgressController] are reset to a proper out-of-session state. * - * Note that the returned provider monitors the long-term stopping state of exploration sessions - * and will be reset to 'pending' when a session is currently active, or before any session has - * started. + * @param isCompletion indicates whether this stop action is fully ending the exploration (i.e. no + * checkpoint will be saved since this indicates the exploration is completed) */ - fun stopPlayingExploration(): DataProvider = - explorationProgressController.finishExplorationAsync() + fun stopPlayingExploration(isCompletion: Boolean = false): DataProvider = + explorationProgressController.finishExplorationAsync(isCompletion) /** * Fetches the details of the oldest saved exploration for a specified profileId. diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgress.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgress.kt index 17e96ffa6a0..c897ebd7174 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgress.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgress.kt @@ -119,7 +119,7 @@ internal class ExplorationProgress { * instance, the variables of [StateDeck] are reset. Otherwise, the variables of [StateDeck] are * re-initialized with the values created from the saved [ExplorationCheckpoint]. */ - fun resumeStateDeckForSavedState(exploration: Exploration) { + fun resumeStateDeckForSavedState() { stateDeck.resumeDeck( stateGraph.getState(explorationCheckpoint.pendingStateName), getPreviousStatesFromCheckpoint(), diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt index 2b7d5a58bba..98018536d87 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt @@ -5,16 +5,23 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import org.oppia.android.app.model.AnswerOutcome import org.oppia.android.app.model.CheckpointState import org.oppia.android.app.model.EphemeralState import org.oppia.android.app.model.Exploration import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.model.HelpIndex +import org.oppia.android.app.model.HelpIndex.IndexTypeCase.EVERYTHING_REVEALED +import org.oppia.android.app.model.HelpIndex.IndexTypeCase.INDEXTYPE_NOT_SET +import org.oppia.android.app.model.HelpIndex.IndexTypeCase.LATEST_REVEALED_HINT_INDEX +import org.oppia.android.app.model.HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX +import org.oppia.android.app.model.HelpIndex.IndexTypeCase.SHOW_SOLUTION import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.UserAnswer import org.oppia.android.domain.classify.AnswerClassificationController @@ -26,6 +33,7 @@ import org.oppia.android.domain.exploration.lightweightcheckpointing.Exploration import org.oppia.android.domain.hintsandsolution.HintHandler import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController +import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.domain.topic.StoryProgressController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.data.AsyncResult @@ -93,6 +101,7 @@ class ExplorationProgressController @Inject constructor( private val hintHandlerFactory: HintHandler.Factory, private val translationController: TranslationController, private val dataProviders: DataProviders, + private val profileManagementController: ProfileManagementController, @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher ) { // TODO(#179): Add support for parameters. @@ -105,9 +114,9 @@ class ExplorationProgressController @Inject constructor( // for getCurrentState). private lateinit var profileId: ProfileId - private var mostRecentSessionId: String? = null + private var mostRecentSessionId = MutableStateFlow(null) private val activeSessionId: String - get() = mostRecentSessionId ?: DEFAULT_SESSION_ID + get() = mostRecentSessionId.value ?: DEFAULT_SESSION_ID private var mostRecentEphemeralStateFlow = createAsyncResultStateFlow( @@ -129,11 +138,12 @@ class ExplorationProgressController @Inject constructor( storyId: String, explorationId: String, shouldSavePartialProgress: Boolean, - explorationCheckpoint: ExplorationCheckpoint + explorationCheckpoint: ExplorationCheckpoint, + isRestart: Boolean ): DataProvider { val ephemeralStateFlow = createAsyncResultStateFlow() val sessionId = UUID.randomUUID().toString().also { - mostRecentSessionId = it + mostRecentSessionId.value = it mostRecentEphemeralStateFlow = ephemeralStateFlow mostRecentCommandQueue = createControllerCommandActor() } @@ -146,6 +156,7 @@ class ExplorationProgressController @Inject constructor( explorationId, shouldSavePartialProgress, explorationCheckpoint, + isRestart, ephemeralStateFlow, sessionId, beginExplorationResultFlow @@ -165,16 +176,27 @@ class ExplorationProgressController @Inject constructor( * [submitAnswer] with one additional caveat: this method does not actually need to be called when * a session is over. Calling it ensures all other [DataProvider]s reset to a correct * out-of-session state, but subsequent calls to [beginExplorationAsync] will reset the session. + * + * @param isCompletion whether this finish action indicates that the exploration was finished by + * the user */ - internal fun finishExplorationAsync(): DataProvider { + internal fun finishExplorationAsync(isCompletion: Boolean): DataProvider { val finishExplorationResultFlow = createAsyncResultStateFlow() - val message = ControllerMessage.FinishExploration(activeSessionId, finishExplorationResultFlow) + val message = + ControllerMessage.FinishExploration( + isCompletion, activeSessionId, finishExplorationResultFlow + ) sendCommandForOperation(message) { "Failed to schedule command for cleaning up after finishing the exploration." } return finishExplorationResultFlow.convertToSessionProvider( FINISH_EXPLORATION_RESULT_PROVIDER_ID - ) + ).also { + // Reset state to ensure post-session events don't expect any particular state from the + // previous command queue. + mostRecentSessionId.value = null + mostRecentCommandQueue = null + } } /** @@ -354,6 +376,7 @@ class ExplorationProgressController @Inject constructor( private fun createControllerCommandActor(): SendChannel> { lateinit var controllerState: ControllerState + // Use an unlimited capacity buffer so that commands can be sent asynchronously without blocking // the main thread or scheduling an extra coroutine. @Suppress("JoinDeclarationAndAssignment") // Warning is incorrect in this case. @@ -366,11 +389,18 @@ class ExplorationProgressController @Inject constructor( @Suppress("UNUSED_VARIABLE") // A variable is used to create an exhaustive when statement. val unused = when (message) { is ControllerMessage.InitializeController -> { + // Synchronously fetch the learner & installation IDs (these may result in file I/O). + profileManagementController.fetchLearnerId(message.profileId) + // Ensure the state is completely recreated for each session to avoid leaking state // across sessions. controllerState = ControllerState( - ExplorationProgress(), message.sessionId, message.ephemeralStateFlow, commandQueue + ExplorationProgress(), + message.isRestart, + message.sessionId, + message.ephemeralStateFlow, + commandQueue ).also { it.beginExplorationImpl( message.callbackFlow, @@ -387,7 +417,7 @@ class ExplorationProgressController @Inject constructor( try { // Ensure finish is always executed even if the controller state isn't yet // initialized. - controllerState.finishExplorationImpl(message.callbackFlow) + controllerState.finishExplorationImpl(message.callbackFlow, message.isCompletion) } finally { // Ensure the actor ends since the session requires no further message processing. break @@ -404,6 +434,8 @@ class ExplorationProgressController @Inject constructor( controllerState.moveToPreviousStateImpl(message.callbackFlow) is ControllerMessage.MoveToNextState -> controllerState.moveToNextStateImpl(message.callbackFlow) + is ControllerMessage.LogUpdatedHelpIndex -> + controllerState.maybeLogUpdatedHelpIndex(message.helpIndex, activeSessionId) is ControllerMessage.ProcessSavedCheckpointResult -> { controllerState.processSaveCheckpointResult( message.profileId, @@ -479,7 +511,9 @@ class ExplorationProgressController @Inject constructor( this.explorationCheckpoint = explorationCheckpoint } hintHandler = hintHandlerFactory.create() - hintHandler.getCurrentHelpIndex().onEach { + hintHandler.getCurrentHelpIndex().onFirstAndEach { + commandQueue.send(ControllerMessage.LogUpdatedHelpIndex(it, sessionId)) + // Fire an event to save the latest progress state in a checkpoint to avoid cross-thread // synchronization being required (since the state of hints/solutions has changed). commandQueue.send(ControllerMessage.SaveCheckpoint(sessionId)) @@ -490,12 +524,19 @@ class ExplorationProgressController @Inject constructor( } private suspend fun ControllerState?.finishExplorationImpl( - finishExplorationResultFlow: MutableStateFlow> + finishExplorationResultFlow: MutableStateFlow>, + isCompletion: Boolean ) { checkNotNull(this) { "Cannot finish playing an exploration that hasn't yet been started" } tryOperation(finishExplorationResultFlow, recomputeState = false) { explorationProgress.advancePlayStageTo(NOT_PLAYING) } + + // The only way to be sure of an exploration completion is if the user clicks the 'Return to + // Topic' button. All other cases (even if they reached the terminal state) will result in an + // exit action. This also matches the progress tracking for the lesson: it's only considered + // completed when 'Return to Topic' is clicked. + finishExplorationAndLog(isCompletion) } private suspend fun ControllerState.submitAnswerImpl( @@ -531,11 +572,13 @@ class ExplorationProgressController @Inject constructor( explorationProgress.stateDeck.submitAnswer( userAnswer, answerOutcome.feedback, answerOutcome.labelledAsCorrectAnswer ) + // TODO(#4064): Log the 'submit answer' event. // Follow the answer's outcome to another part of the graph if it's different. val ephemeralState = computeBaseCurrentEphemeralState() when { answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME -> { + endState() val newState = explorationProgress.stateGraph.getState(answerOutcome.stateName) explorationProgress.stateDeck.pushState(newState, prohibitSameStateName = true) hintHandler.finishState(newState) @@ -654,10 +697,23 @@ class ExplorationProgressController @Inject constructor( // Only mark checkpoint if current state is pending state. This ensures that checkpoints // will not be marked on any of the completed states. saveExplorationCheckpoint() + + // Ensure the state has been started the first time it's reached. + maybeStartState(explorationProgress.stateDeck.getViewedStateCount()) } } } + private fun ControllerState.maybeLogUpdatedHelpIndex( + helpIndex: HelpIndex, + activeSessionId: String + ) { + // Only log if the current session is active. + if (sessionId == activeSessionId) { + checkForChangedHintState(helpIndex) + } + } + private suspend fun ControllerState.tryOperation( resultFlow: MutableStateFlow>, recomputeState: Boolean = true, @@ -735,19 +791,29 @@ class ExplorationProgressController @Inject constructor( // The exploration must be initialized first since other lazy fields depend on it being inited. progress.currentExploration = exploration progress.stateGraph.reset(exploration.statesMap) + initializeEventLogger(exploration) if (progress.explorationCheckpoint != ExplorationCheckpoint.getDefaultInstance()) { // Restore the StateDeck and the HintHandler if the exploration is being resumed. - progress.resumeStateDeckForSavedState(exploration) + progress.resumeStateDeckForSavedState() hintHandler.resumeHintsForSavedState( progress.explorationCheckpoint.pendingUserAnswersCount, progress.explorationCheckpoint.helpIndex, progress.stateDeck.getCurrentState() ) + // TODO(#4064): Log the 'resume exploration' event. + startState(logStartCard = false) } else { // If the exploration is not being resumed, reset the StateDeck and the HintHandler. progress.stateDeck.resetDeck(progress.stateGraph.getState(exploration.initStateName)) - hintHandler.startWatchingForHintsInNewState(progress.stateDeck.getCurrentState()) + + if (isRestart) { + // TODO(#4064): Log the 'start exploration over' event. + } + + val state = progress.stateDeck.getCurrentState() + hintHandler.startWatchingForHintsInNewState(state) + startState(logStartCard = true) } // Advance the stage, but do not notify observers since the current state can be reported @@ -953,6 +1019,7 @@ class ExplorationProgressController @Inject constructor( */ private class ControllerState( val explorationProgress: ExplorationProgress, + val isRestart: Boolean, val sessionId: String, val ephemeralStateFlow: MutableStateFlow>, val commandQueue: SendChannel> @@ -962,6 +1029,93 @@ class ExplorationProgressController @Inject constructor( * controller state. */ lateinit var hintHandler: HintHandler + + private var helpIndex = HelpIndex.getDefaultInstance() + private var availableCardCount: Int = -1 + + /** + * Initializes this state for event logging for the given [Exploration]. + * + * This allows [startState] to be used. + */ + fun initializeEventLogger(exploration: Exploration) { + // TODO(#4064): Log the 'begin exploration' event. + availableCardCount = explorationProgress.stateDeck.getViewedStateCount() + } + + /** + * Indicates that a new state has started and to prepare for state-based logging, and logs the + * new card change. + */ + fun startState(logStartCard: Boolean = true) { + if (logStartCard) { + // TODO(#4064): Log the 'start card' event. + } + + // Force the card count to update. + availableCardCount = explorationProgress.stateDeck.getViewedStateCount() + } + + /** + * Indicates that a new state has started only if forward progress in the exploration has been + * made (i.e. that [availableCardCount] is larger than what was previously known). + */ + fun maybeStartState(availableCardCount: Int) { + // Only start the state if it hasn't already been started. + if (this.availableCardCount < availableCardCount) { + startState() + this.availableCardCount = availableCardCount + } + } + + /** Ends state-based logging for the current state and logs that the card has ended. */ + fun endState() { + // TODO(#4064): Log the 'end card' event. + } + + /** Checks and logs for hint-based changes based on the provided [HelpIndex]. */ + fun checkForChangedHintState(newHelpIndex: HelpIndex) { + if (helpIndex != newHelpIndex) { + // If the index changed to the new HelpIndex, that implies that whatever is observed in the + // new HelpIndex indicates its previous state and therefore what changed. + when (newHelpIndex.indexTypeCase) { + NEXT_AVAILABLE_HINT_INDEX -> { + // TODO(#4064): Log the 'hint offered' event. + } + LATEST_REVEALED_HINT_INDEX -> { + // TODO(#4064): Log the 'view hint' event. + } + SHOW_SOLUTION -> { + // TODO(#4064): Log the 'solution offered' event. + } + EVERYTHING_REVEALED -> when (helpIndex.indexTypeCase) { + SHOW_SOLUTION -> { + // TODO(#4064): Log the 'view solution' event. + } + NEXT_AVAILABLE_HINT_INDEX -> { + // No solution, so revealing the hint ends available help. + // TODO(#4064): Log the 'view hint' event. + } + // Nothing to do in these cases. + LATEST_REVEALED_HINT_INDEX, EVERYTHING_REVEALED, INDEXTYPE_NOT_SET, null -> {} + } + INDEXTYPE_NOT_SET, null -> {} // Nothing to do here. + } + helpIndex = newHelpIndex + } + } + + /** + * Finishes the current exploration and logs its ending, also disabling any exploration-based + * logging capabilities. + */ + fun finishExplorationAndLog(isCompletion: Boolean) { + if (isCompletion) { + // TODO(#4064): Log the 'finish exploration' event. + } else { + // TODO(#4064): Log the 'exit exploration' event. + } + } } /** @@ -993,6 +1147,7 @@ class ExplorationProgressController @Inject constructor( val explorationId: String, val shouldSavePartialProgress: Boolean, val explorationCheckpoint: ExplorationCheckpoint, + val isRestart: Boolean, val ephemeralStateFlow: MutableStateFlow>, override val sessionId: String, override val callbackFlow: MutableStateFlow> @@ -1000,6 +1155,7 @@ class ExplorationProgressController @Inject constructor( /** [ControllerMessage] for ending the current play session. */ data class FinishExploration( + val isCompletion: Boolean, override val sessionId: String, override val callbackFlow: MutableStateFlow> ) : ControllerMessage() @@ -1053,6 +1209,19 @@ class ExplorationProgressController @Inject constructor( override val callbackFlow: MutableStateFlow>? = null ) : ControllerMessage() + /** + * [ControllerMessage] to log cases when [HelpIndex] has updated for the current session. + * + * Specific measures are taken to ensure that the handler for this message does not log the + * change if the current active session has changed (since that's generally indicative of an + * error--hints can't continue to change after the session has ended). + */ + data class LogUpdatedHelpIndex( + val helpIndex: HelpIndex, + override val sessionId: String, + override val callbackFlow: MutableStateFlow>? = null + ) : ControllerMessage() + /** * [ControllerMessage] to ensure a successfully saved checkpoint is reflected in other parts of * the app (e.g. that an exploration is considered 'in-progress' in such circumstances). @@ -1080,4 +1249,16 @@ class ExplorationProgressController @Inject constructor( override val callbackFlow: MutableStateFlow>? = null ) : ControllerMessage() } + + private companion object { + /** + * Returns a collectable [Flow] that notifies [collector] for this [StateFlow]s initial state, + * and every change after. + * + * It should guarantee that [collector] receives all values ever present in this flow. + */ + private fun StateFlow.onFirstAndEach( + collector: suspend (T) -> Unit + ): Flow = onStart { collector(value) }.onEach(collector) + } } diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointController.kt b/domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointController.kt index 21bf8e7767b..b69e949a859 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointController.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointController.kt @@ -282,7 +282,7 @@ class ExplorationCheckpointController @Inject constructor( cacheStore } - cacheStore.primeCacheAsync().invokeOnCompletion { throwable -> + cacheStore.primeInMemoryCacheAsync().invokeOnCompletion { throwable -> throwable?.let { oppiaLogger.e( "ExplorationCheckpointController", diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt b/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt index 7d8c9db9f06..e6196c6174e 100644 --- a/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt +++ b/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt @@ -34,7 +34,7 @@ class AppStartupStateController @Inject constructor( init { // Prime the cache ahead of time so that any existing history is read prior to any calls to // markOnboardingFlowCompleted(). - onboardingFlowStore.primeCacheAsync().invokeOnCompletion { + onboardingFlowStore.primeInMemoryCacheAsync().invokeOnCompletion { it?.let { oppiaLogger.e( "DOMAIN", "Failed to prime cache ahead of data retrieval for user onboarding data.", it diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/ApplicationIdSeed.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/ApplicationIdSeed.kt new file mode 100644 index 00000000000..fe3011f0cf5 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/ApplicationIdSeed.kt @@ -0,0 +1,13 @@ +package org.oppia.android.domain.oppialogger + +import javax.inject.Qualifier + +/** + * Corresponds to an injectable application-level [Long] that provides a static seed that may be + * used for generating seeds that must be consistent for the lifetime of the application (but not + * necessarily across application instances). + * + * Tests may override the value corresponding to this qualifier in the Dagger graph to ensure + * deterministic behavior for corresponding random functions that depend on it. + */ +@Qualifier annotation class ApplicationIdSeed diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/oppialogger/BUILD.bazel index 0faf401f0fa..b59a8992ace 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/BUILD.bazel @@ -5,6 +5,17 @@ Package for providing logging support. load("@dagger//:workspace_defs.bzl", "dagger_rules") load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") +kt_android_library( + name = "application_id_seed", + srcs = [ + "ApplicationIdSeed.kt", + ], + visibility = ["//domain:__subpackages__"], + deps = [ + "//third_party:javax_inject_javax_inject", + ], +) + kt_android_library( name = "oppia_logger", srcs = [ @@ -27,16 +38,37 @@ kt_android_library( ) kt_android_library( - name = "storage_module", + name = "logging_identifier_controller", + srcs = [ + "LoggingIdentifierController.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":application_id_seed", + "//data/src/main/java/org/oppia/android/data/persistence:cache_store", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger", + "//domain/src/main/java/org/oppia/android/domain/util:extensions", + "//model/src/main/proto:event_logger_java_proto_lite", + "//utility/src/main/java/org/oppia/android/util/data:async_data_subscription_manager", + "//utility/src/main/java/org/oppia/android/util/data:data_providers", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", + ], +) + +kt_android_library( + name = "prod_module", srcs = [ "LogStorageModule.kt", + "LoggingIdentifierModule.kt", ], visibility = [ - "//:oppia_testing_visibility", + "//:oppia_prod_module_visibility", "//domain/src/main/java/org/oppia/android/domain/oppialogger:__subpackages__", ], deps = [ + ":application_id_seed", ":dagger", + "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", ], ) diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierController.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierController.kt new file mode 100644 index 00000000000..3440f62fe0f --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierController.kt @@ -0,0 +1,130 @@ +package org.oppia.android.domain.oppialogger + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.oppia.android.app.model.DeviceContextDatabase +import org.oppia.android.data.persistence.PersistentCacheStore +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders +import org.oppia.android.util.data.DataProviders.Companion.transform +import org.oppia.android.util.locale.OppiaLocale +import java.security.MessageDigest +import java.util.Random +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +private const val SESSION_ID_DATA_PROVIDER_ID = "LoggingIdentifierController.session_id" +private const val INSTALLATION_ID_DATA_PROVIDER_ID = "LoggingIdentifierController.installation_id" + +/** Controller that handles logging identifiers related operations. */ +@Singleton +class LoggingIdentifierController @Inject constructor( + private val dataProviders: DataProviders, + @ApplicationIdSeed private val applicationIdSeed: Long, + private val machineLocale: OppiaLocale.MachineLocale, + private val persistentCacheStoreFactory: PersistentCacheStore.Factory, + private val oppiaLogger: OppiaLogger +) { + private val learnerIdRandom by lazy { Random(applicationIdSeed) } + + private val sessionId by lazy { MutableStateFlow(computeSessionId()) } + private val sessionIdDataProvider by lazy { + dataProviders.run { sessionId.convertToAutomaticDataProvider(SESSION_ID_DATA_PROVIDER_ID) } + } + private val installationIdStore by lazy { + persistentCacheStoreFactory.create( + cacheName = "device_context_database", DeviceContextDatabase.getDefaultInstance() + ).also { + it.primeInMemoryAndDiskCacheAsync { database -> + database.toBuilder().apply { + installationId = computeInstallationId() + }.build() + }.invokeOnCompletion { failure -> + if (failure != null) { + oppiaLogger.e( + "LoggingIdentifierController", "Failed to initialize the installation ID", failure + ) + } + } + } + } + + /** + * Creates and returns a unique identifier which will be used to identify the current learner. + * + * Each call to this function will return a unique learner ID, so it's up to the caller to ensure + * long-lived IDs are properly persisted. + */ + fun createLearnerId(): String = machineLocale.run { + "%08x".formatForMachines(learnerIdRandom.nextInt()) + } + + /** + * Returns a data provider that provides a one-time string which acts as a unique installation ID + * for the current app installation context. + * + * Note that the returned ID is *not* guaranteed to be unique across devices, or even across app + * installations. It's an approximation for a hardware-based unique ID which is expected to be + * suitable for short-term user studies. + */ + fun getInstallationId(): DataProvider { + return installationIdStore.transform( + INSTALLATION_ID_DATA_PROVIDER_ID, DeviceContextDatabase::getInstallationId + ) + } + + /** + * Returns the most recently known installation ID per [getInstallationId]. + * + * Since checking for the latest installation ID is inherently asynchronous, this operation should + * only be called from a background coroutine. + */ + suspend fun fetchInstallationId(): String? = + installationIdStore.readDataAsync().await().installationId.takeUnless(String::isEmpty) + + /** + * Returns an in-memory data provider pointing to a class variable of [sessionId]. + * + * This ID is unique to each session. A session starts when an exploration begins. + */ + fun getSessionId(): DataProvider = sessionIdDataProvider + + /** + * Returns the [StateFlow] backing the current session ID indicated by [getSessionId]. + * + * Where the [DataProvider] returned by [getSessionId] can be composed by domain controllers or + * observed by the UI layer, the [StateFlow] returned by this method can be observed in background + * contexts. + */ + fun getSessionIdFlow(): StateFlow = sessionId + + /** + * Regenerates [sessionId] and notifies the data provider. + * + * The [sessionId] is generally updated when: + * 1. An exploration is started/resumed/started-over. + * 2. Inactivity duration exceeds the maximum time limit for an active session. + */ + fun updateSessionId() { + sessionId.value = computeSessionId() + } + + private fun computeSessionId(): String = learnerIdRandom.randomUuid().toString() + + private fun computeInstallationId(): String { + return machineLocale.run { + MessageDigest.getInstance("SHA-1") + .digest(learnerIdRandom.randomUuid().toString().toByteArray()) + .joinToString("") { "%02x".formatForMachines(it) } + .substring(startIndex = 0, endIndex = 12) + } + } + + /** + * Returns a new [UUID] using random values sourced from this [Random] that's very similar to the + * one created by default in [UUID.randomUUID]. + */ + private fun Random.randomUuid(): UUID = + UUID.nameUUIDFromBytes(ByteArray(16).also { this@randomUuid.nextBytes(it) }) +} diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierModule.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierModule.kt new file mode 100644 index 00000000000..cf0f05f82ff --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierModule.kt @@ -0,0 +1,13 @@ +package org.oppia.android.domain.oppialogger + +import dagger.Module +import dagger.Provides +import org.oppia.android.util.system.OppiaClock + +/** Provider to return any constants required during operations on logging identifiers. */ +@Module +class LoggingIdentifierModule { + @Provides + @ApplicationIdSeed + fun provideApplicationIdSeed(oppiaClock: OppiaClock): Long = oppiaClock.getCurrentTimeMs() +} diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt index ec97842cdbe..521dee49c8c 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt @@ -3,13 +3,21 @@ package org.oppia.android.domain.oppialogger import org.oppia.android.app.model.EventLog import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.util.logging.ConsoleLogger +import org.oppia.android.util.system.OppiaClock import javax.inject.Inject -/** Logger that handles event logging. */ +/** Logger that handles general-purpose logging throughout the domain & UI layers. */ class OppiaLogger @Inject constructor( private val analyticsController: AnalyticsController, - private val consoleLogger: ConsoleLogger + private val consoleLogger: ConsoleLogger, + private val oppiaClock: OppiaClock ) { + /** Logs high-priority events. See [AnalyticsController.logImportantEvent] for more context. */ + fun logImportantEvent(eventContext: EventLog.Context) { + analyticsController.logImportantEvent(oppiaClock.getCurrentTimeMs(), eventContext) + } + + // TODO(#4064): Remove this method & migrate callsites to use logImportantEvent. /** Logs transition events. See [AnalyticsController.logTransitionEvent] for more context. */ fun logTransitionEvent( timestamp: Long, @@ -18,14 +26,6 @@ class OppiaLogger @Inject constructor( analyticsController.logTransitionEvent(timestamp, eventContext) } - /** Logs click events. See [AnalyticsController.logClickEvent] for more context. */ - fun logClickEvent( - timestamp: Long, - eventContext: EventLog.Context - ) { - analyticsController.logClickEvent(timestamp, eventContext) - } - /** Logs a verbose message with the specified tag. See [ConsoleLogger.v] for more context */ fun v(tag: String, msg: String) { consoleLogger.v(tag, msg) diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/AnalyticsController.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/AnalyticsController.kt index 95bed588f8d..370cbf6f7a6 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/AnalyticsController.kt +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/AnalyticsController.kt @@ -11,11 +11,14 @@ import org.oppia.android.util.logging.EventLogger import org.oppia.android.util.logging.ExceptionLogger import org.oppia.android.util.networking.NetworkConnectionUtil import org.oppia.android.util.networking.NetworkConnectionUtil.ProdConnectionStatus.NONE +import java.lang.IllegalStateException import javax.inject.Inject -/** Controller for handling analytics event logging. - * [OppiaLogger] should be the only caller of this class. Any other classes that want to log - * events should call either [OppiaLogger.logTransitionEvent] or [OppiaLogger.logClickEvent]. +/** + * Controller for handling analytics event logging. + * + * Callers should not use this class directly; instead, they should use ``OppiaLogger`` which + * provides convenience log methods. */ class AnalyticsController @Inject constructor( private val eventLogger: EventLogger, @@ -28,6 +31,20 @@ class AnalyticsController @Inject constructor( private val eventLogStore = cacheStoreFactory.create("event_logs", OppiaEventLogs.getDefaultInstance()) + /** + * Logs a high priority event defined by [eventContext] corresponding to time [timestamp]. + * + * This will schedule a background upload of the event if there's internet connectivity, otherwise + * it will cache the event for a later upload. + * + * This method should only be used for events which are important to log and should be prioritized + * over events logged via [logLowPriorityEvent]. + */ + fun logImportantEvent(timestamp: Long, eventContext: EventLog.Context) { + uploadOrCacheEventLog(createEventLog(timestamp, eventContext, Priority.ESSENTIAL)) + } + + // TODO(#4064): Remove the below method and migrate it to the above. /** * Logs transition events. * These events are given HIGH priority. @@ -46,41 +63,36 @@ class AnalyticsController @Inject constructor( } /** - * Logs click events. - * These events are given LOW priority. + * Logs a low priority event defined by [eventContext] corresponding to time [timestamp]. + * + * This will schedule a background upload of the event if there's internet connectivity, otherwise + * it will cache the event for a later upload. + * + * Low priority events may be removed from the event cache if device space is limited, and there's + * no connectivity for immediately sending events. + * + * Callers should use this for events that are nice to have, but okay to miss occasionally (as + * it's unexpected for events to actually be dropped since the app is configured to support a + * large number of cached events at one time). */ - fun logClickEvent( - timestamp: Long, - eventContext: EventLog.Context - ) { - uploadOrCacheEventLog( - createEventLog( - timestamp, - eventContext, - Priority.OPTIONAL - ) - ) + fun logLowPriorityEvent(timestamp: Long, eventContext: EventLog.Context) { + uploadOrCacheEventLog(createEventLog(timestamp, eventContext, Priority.OPTIONAL)) } /** Returns an event log containing relevant data for event reporting. */ private fun createEventLog( timestamp: Long, - eventContext: EventLog.Context, + context: EventLog.Context, priority: Priority ): EventLog { - val event: EventLog.Builder = EventLog.newBuilder() - event.timestamp = timestamp - event.priority = priority - event.context = eventContext - return event.build() + return EventLog.newBuilder().apply { + this.timestamp = timestamp + this.priority = priority + this.context = context + }.build() } - /** - * Checks network connectivity of the device. - * - * Saves the [eventLog] to the [eventLogStore] in the absence of it. - * Uploads to remote service in the presence of it. - */ + /** Either uploads or caches [eventLog] depending on current internet connectivity. */ private fun uploadOrCacheEventLog(eventLog: EventLog) { when (networkConnectionUtil.getCurrentConnectionStatus()) { NONE -> cacheEventLog(eventLog) @@ -91,10 +103,10 @@ class AnalyticsController @Inject constructor( /** * Adds an event to the storage. * - * At first, it checks if the size of the store isn't exceeding [eventLogStorageCacheSize] - * If the limit is exceeded then the least recent event is removed from the [eventLogStore] - * After this, the [eventLog] is added to the store. - * */ + * At first, it checks if the size of the store isn't exceeding [eventLogStorageCacheSize]. If the + * limit is exceeded then the least recent event is removed from the [eventLogStore]. After this, + * the [eventLog] is added to the store. + */ private fun cacheEventLog(eventLog: EventLog) { eventLogStore.storeDataAsync(updateInMemoryCache = true) { oppiaEventLogs -> val storeSize = oppiaEventLogs.eventLogList.size @@ -108,38 +120,33 @@ class AnalyticsController @Inject constructor( } else { // TODO(#1433): Refactoring for logging exceptions to both console and exception loggers. val exception = - NullPointerException("Least Recent Event index absent -- EventLogCacheStoreSize is 0") - consoleLogger.e("Analytics Controller", exception.toString()) + IllegalStateException("Least Recent Event index absent -- EventLogCacheStoreSize is 0") + consoleLogger.e("AnalyticsController", "Failure while caching event.", exception) exceptionLogger.logException(exception) } } return@storeDataAsync oppiaEventLogs.toBuilder().addEventLog(eventLog).build() }.invokeOnCompletion { - it?.let { - consoleLogger.e( - "Analytics Controller", - "Failed to store event log", - it - ) - } + it?.let { consoleLogger.e("AnalyticsController", "Failed to store event log.", it) } } } /** - * Returns the index of the least recent event from the existing store on the basis of recency and priority. + * Returns the index of the least recent event from the existing store on the basis of recency and + * priority. * - * At first, it checks the index of the least recent event which has OPTIONAL priority. - * If that returns null, then the index of the least recent event regardless of the priority is returned. + * At first, it checks the index of the least recent event which has OPTIONAL priority. If that + * returns null, then the index of the least recent event regardless of the priority is returned. */ private fun getLeastRecentEventIndex(oppiaEventLogs: OppiaEventLogs): Int? = oppiaEventLogs.eventLogList.withIndex() .filter { it.value.priority == Priority.OPTIONAL } - .minBy { it.value.timestamp }?.index ?: getLeastRecentGeneralEventIndex(oppiaEventLogs) + .minByOrNull { it.value.timestamp }?.index ?: getLeastRecentGeneralEventIndex(oppiaEventLogs) /** Returns the index of the least recent event regardless of their priority. */ private fun getLeastRecentGeneralEventIndex(oppiaEventLogs: OppiaEventLogs): Int? = oppiaEventLogs.eventLogList.withIndex() - .minBy { it.value.timestamp }?.index + .minByOrNull { it.value.timestamp }?.index /** Returns a data provider for log reports that have been recorded for upload. */ fun getEventLogStore(): DataProvider { @@ -149,7 +156,8 @@ class AnalyticsController @Inject constructor( /** * Returns a list of event log reports that have been recorded for upload. * - * As we are using the await call on the deferred output of readDataAsync, the failure case would be caught and it'll throw an error. + * As we are using the await call on the deferred output of readDataAsync, the failure case would + * be caught and it'll throw an error. */ suspend fun getEventLogStoreList(): MutableList { return eventLogStore.readDataAsync().await().eventLogList @@ -160,13 +168,7 @@ class AnalyticsController @Inject constructor( eventLogStore.storeDataAsync(updateInMemoryCache = true) { oppiaEventLogs -> return@storeDataAsync oppiaEventLogs.toBuilder().removeEventLog(0).build() }.invokeOnCompletion { - it?.let { - consoleLogger.e( - "Analytics Controller", - "Failed to remove event log", - it - ) - } + it?.let { consoleLogger.e("AnalyticsController", "Failed to remove event log.", it) } } } } diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleModule.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleModule.kt new file mode 100644 index 00000000000..afb9c33a7cb --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleModule.kt @@ -0,0 +1,21 @@ +package org.oppia.android.domain.oppialogger.analytics + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import org.oppia.android.domain.oppialogger.ApplicationStartupListener +import java.util.concurrent.TimeUnit + +/** Application-level module that provides application-bound domain utilities. */ +@Module +class ApplicationLifecycleModule { + @Provides + @IntoSet + fun bindLifecycleObserver( + applicationLifecycleObserver: ApplicationLifecycleObserver + ): ApplicationStartupListener = applicationLifecycleObserver + + @Provides + @LearnerAnalyticsInactivityLimitMillis + fun provideLearnerAnalyticsInactivityLimitMillis(): Long = TimeUnit.MINUTES.toMillis(30) +} diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserver.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserver.kt new file mode 100644 index 00000000000..6f0edf62648 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserver.kt @@ -0,0 +1,71 @@ +package org.oppia.android.domain.oppialogger.analytics + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.oppia.android.domain.oppialogger.ApplicationStartupListener +import org.oppia.android.domain.oppialogger.LoggingIdentifierController +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.profile.ProfileManagementController +import org.oppia.android.util.system.OppiaClock +import org.oppia.android.util.threading.BackgroundDispatcher +import javax.inject.Inject +import javax.inject.Singleton + +/** Observer that observes application lifecycle. */ +@Singleton +class ApplicationLifecycleObserver @Inject constructor( + private val oppiaClock: OppiaClock, + private val loggingIdentifierController: LoggingIdentifierController, + private val profileManagementController: ProfileManagementController, + private val oppiaLogger: OppiaLogger, + @LearnerAnalyticsInactivityLimitMillis private val inactivityLimitMillis: Long, + @BackgroundDispatcher private val backgroundDispatcher: CoroutineDispatcher +) : ApplicationStartupListener, LifecycleObserver { + + override fun onCreate() { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + // Use a large Long value such that the time difference based on any timestamp will be negative + // and thus ignored until the app goes into the background at least once. + private var firstTimestamp: Long = Long.MAX_VALUE + + /** Occurs when application comes to foreground. */ + @OnLifecycleEvent(Lifecycle.Event.ON_START) + fun onAppInForeground() { + val timeDifferenceMs = oppiaClock.getCurrentTimeMs() - firstTimestamp + if (timeDifferenceMs > inactivityLimitMillis) { + loggingIdentifierController.updateSessionId() + } + // TODO(#4064): Log the 'app in foreground' event here. + } + + /** Occurs when application goes to background. */ + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + fun onAppInBackground() { + firstTimestamp = oppiaClock.getCurrentTimeMs() + // TODO(#4064): Log the 'app in background' event here. + } + + @Suppress("unused") // TODO(#4064): Add usage for this method. + private fun logAppLifecycleEventInBackground(logMethod: (String?, String?) -> Unit) { + CoroutineScope(backgroundDispatcher).launch { + val installationId = loggingIdentifierController.fetchInstallationId() + val learnerId = profileManagementController.fetchCurrentLearnerId() + logMethod(installationId, learnerId) + }.invokeOnCompletion { failure -> + if (failure != null) { + oppiaLogger.e( + "ApplicationLifecycleObserver", + "Encountered error while trying to log app lifecycle event.", + failure + ) + } + } + } +} diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel index 0166ef20c36..5616ff68418 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel @@ -2,6 +2,7 @@ Library for providing logging analytics to the Oppia android app. """ +load("@dagger//:workspace_defs.bzl", "dagger_rules") load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") kt_android_library( @@ -11,8 +12,9 @@ kt_android_library( ], visibility = ["//domain/src/main/java/org/oppia/android/domain/oppialogger:__subpackages__"], deps = [ + ":dagger", "//data/src/main/java/org/oppia/android/data/persistence:cache_store", - "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", "//model/src/main/proto:event_logger_java_proto_lite", "//utility/src/main/java/org/oppia/android/util/data:data_provider", "//utility/src/main/java/org/oppia/android/util/logging:console_logger", @@ -21,3 +23,61 @@ kt_android_library( "//utility/src/main/java/org/oppia/android/util/networking:network_connection_util", ], ) + +kt_android_library( + name = "learner_analytics_logger", + srcs = [ + "LearnerAnalyticsLogger.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:logging_identifier_controller", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger", + "//model/src/main/proto:event_logger_java_proto_lite", + "//model/src/main/proto:exploration_checkpoint_java_proto_lite", + ], +) + +kt_android_library( + name = "learner_analytics_inactivity_limit_millis", + srcs = [ + "LearnerAnalyticsInactivityLimitMillis.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + "//third_party:javax_inject_javax_inject", + ], +) + +kt_android_library( + name = "application_lifecycle_observer", + srcs = [ + "ApplicationLifecycleObserver.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":dagger", + ":learner_analytics_inactivity_limit_millis", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:logging_identifier_controller", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:startup_listener", + "//domain/src/main/java/org/oppia/android/domain/profile:profile_management_controller", + "//third_party:androidx_lifecycle_lifecycle-extensions", + "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", + ], +) + +kt_android_library( + name = "prod_module", + srcs = [ + "ApplicationLifecycleModule.kt", + ], + visibility = ["//:oppia_prod_module_visibility"], + deps = [ + ":application_lifecycle_observer", + ":dagger", + ":learner_analytics_inactivity_limit_millis", + ], +) + +dagger_rules() diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsInactivityLimitMillis.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsInactivityLimitMillis.kt new file mode 100644 index 00000000000..77202ef1ae6 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsInactivityLimitMillis.kt @@ -0,0 +1,10 @@ +package org.oppia.android.domain.oppialogger.analytics + +import javax.inject.Qualifier + +/** + * Corresponds to an injectable application-level [Long] that corresponds to the number of + * milliseconds in which the app needs to be in the background before the user is considered + * 'inactive' from an analytics perspective. + */ +@Qualifier annotation class LearnerAnalyticsInactivityLimitMillis diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLogger.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLogger.kt new file mode 100644 index 00000000000..7616fe66fec --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLogger.kt @@ -0,0 +1,477 @@ +package org.oppia.android.domain.oppialogger.analytics + +import com.google.protobuf.MessageLite +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.oppia.android.app.model.EventLog.CardContext +import org.oppia.android.app.model.EventLog.ExplorationContext +import org.oppia.android.app.model.EventLog.HintContext +import org.oppia.android.app.model.EventLog.LearnerDetailsContext +import org.oppia.android.app.model.EventLog.PlayVoiceOverContext +import org.oppia.android.app.model.EventLog.SubmitAnswerContext +import org.oppia.android.app.model.Exploration +import org.oppia.android.app.model.State +import org.oppia.android.domain.oppialogger.LoggingIdentifierController +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.LearnerAnalyticsLogger.BaseLogger.Companion.maybeLogEvent +import javax.inject.Inject +import javax.inject.Singleton +import org.oppia.android.app.model.EventLog.Context as EventContext +import org.oppia.android.app.model.EventLog.Context.Builder as EventBuilder + +/** + * Convenience logger for learner-related analytics events. + * + * This logger is meant primarily to be used directly in the controller responsible for exploration + * play session management, but it may be accessed outside that controller but within the same play + * session scope (e.g. for events that occur outside the core progress state). + * + * [beginExploration] can be used to initiate logging for a particular exploration, and + * [explorationAnalyticsLogger] can be used to access the corresponding [ExplorationAnalyticsLogger] + * for the current session, if any. + */ +@Singleton +class LearnerAnalyticsLogger @Inject constructor( + private val oppiaLogger: OppiaLogger, + private val loggingIdentifierController: LoggingIdentifierController +) { + /** + * The [ExplorationAnalyticsLogger] corresponding to the current play session, or ``null`` if + * there is no ongoing session. + */ + val explorationAnalyticsLogger by lazy { mutableExpAnalyticsLogger.asStateFlow() } + + private val mutableExpAnalyticsLogger = MutableStateFlow(null) + + /** + * Starts logging support for a new exploration play session and returns the + * [ExplorationAnalyticsLogger] corresponding to that session. + * + * Calling this function will override the current session logger indicated by + * [explorationAnalyticsLogger] (and notify any observers of the flow). + * + * When the session is over, [endExploration] should be called to make ensure that + * [explorationAnalyticsLogger] is properly reset. + * + * @param installationId the device installation ID corresponding to the new play session, or null + * if not known (which may impact whether events are logged) + * @param learnerId the personal profile/learner ID corresponding to the new session learner, or + * null if not known (which may impact whether events are logged) + * @param exploration the [Exploration] for which a play session is starting + * @param topicId the ID of the topic to which the story indicated by [storyId] belongs + * @param storyId the ID of the story to which [exploration] belongs + */ + fun beginExploration( + installationId: String?, + learnerId: String?, + exploration: Exploration, + topicId: String, + storyId: String + ): ExplorationAnalyticsLogger { + return ExplorationAnalyticsLogger( + installationId, + learnerId, + topicId, + storyId, + exploration.id, + exploration.version, + oppiaLogger, + loggingIdentifierController + ).also { + if (!mutableExpAnalyticsLogger.compareAndSet(expect = null, update = it)) { + oppiaLogger.w( + "LearnerAnalyticsLogger", + "Attempting to start an exploration without ending the previous." + ) + + // Force the logger to a new state. + mutableExpAnalyticsLogger.value = it + } + } + } + + /** + * Ends the most recent session started by [beginExploration] and resets + * [explorationAnalyticsLogger]. + */ + fun endExploration() { + if (mutableExpAnalyticsLogger.value == null) { + oppiaLogger.w( + "LearnerAnalyticsLogger", "Attempting to end an exploration that hasn't been started." + ) + } + mutableExpAnalyticsLogger.value = null + } + + /** + * Logs that the app has entered the background. + * + * @param installationId the device installation ID corresponding to the new play session, or null + * if not known (which may impact whether the event is logged) + * @param learnerId the personal profile/learner ID corresponding to the new session learner, or + * null if not known (which may impact whether the event is logged) + */ + fun logAppInBackground(installationId: String?, learnerId: String?) { + val learnerDetailsContext = createLearnerDetailsContextWithIdsPresent(installationId, learnerId) + oppiaLogger.maybeLogEvent( + installationId, + createAnalyticsEvent(learnerDetailsContext, EventBuilder::setAppInBackgroundContext) + ) + } + + /** + * Logs that the app has entered the foreground. + * + * @param installationId the device installation ID corresponding to the new play session, or null + * if not known (which may impact whether the event is logged) + * @param learnerId the personal profile/learner ID corresponding to the new session learner, or + * null if not known (which may impact whether the event is logged) + */ + fun logAppInForeground(installationId: String?, learnerId: String?) { + val learnerDetailsContext = createLearnerDetailsContextWithIdsPresent(installationId, learnerId) + oppiaLogger.maybeLogEvent( + installationId, + createAnalyticsEvent(learnerDetailsContext, EventBuilder::setAppInForegroundContext) + ) + } + + /** + * Logs that the profile corresponding to [learnerId] was deleted from the device and can no + * longer be used (i.e. no further events will be logged for this profile). + * + * @param installationId the device installation ID corresponding to the new play session, or null + * if not known (which may impact whether the event is logged) + * @param learnerId the personal profile/learner ID corresponding to the new session learner, or + * null if not known (which may impact whether the event is logged) + */ + fun logDeleteProfile(installationId: String?, learnerId: String?) { + val learnerDetailsContext = createLearnerDetailsContextWithIdsPresent(installationId, learnerId) + oppiaLogger.maybeLogEvent( + installationId, + createAnalyticsEvent(learnerDetailsContext, EventBuilder::setDeleteProfileContext) + ) + } + + /** + * Analytics logger for a specific exploration play session. + * + * Similar to how this logger has its own lifecycle defined within [LearnerAnalyticsLogger], + * [stateAnalyticsLogger] provides access to logging state-specific events corresponding to the + * current active (pending) state, if any. + */ + class ExplorationAnalyticsLogger internal constructor( + installationId: String?, + learnerId: String?, + topicId: String, + storyId: String, + explorationId: String, + explorationVersion: Int, + private val oppiaLogger: OppiaLogger, + private val loggingIdentifierController: LoggingIdentifierController + ) { + /** + * The [StateAnalyticsLogger] corresponding to the current, pending state, or null if there is + * none (i.e. the most recent state is completed, or the terminal state has been reached). + */ + val stateAnalyticsLogger by lazy { mutableStateAnalyticsLogger.asStateFlow() } + + private val mutableStateAnalyticsLogger = MutableStateFlow(null) + + private val baseLogger by lazy { BaseLogger(oppiaLogger, installationId) } + private val learnerDetailsContext by lazy { + createLearnerDetailsContext(installationId, learnerId) + } + private val explorationLogContext by lazy { + learnerDetailsContext?.let { learnerDetails -> + val learnerSessionId = loggingIdentifierController.getSessionIdFlow().value + ExplorationContext.newBuilder().apply { + sessionId = learnerSessionId + this.explorationId = explorationId + this.topicId = topicId + this.storyId = storyId + this.explorationVersion = explorationVersion + this.learnerDetails = learnerDetails + }.build() + }?.ensureNonEmpty() + } + + /** Logs that the current exploration was started by resuming from previous progress. */ + fun logResumeExploration() { + baseLogger.maybeLogLearnerEvent(learnerDetailsContext) { + createAnalyticsEvent(it, EventBuilder::setResumeExplorationContext) + } + } + + /** Logs that the current exploration was restarted, ignoring previously available progress. */ + fun logStartExplorationOver() { + baseLogger.maybeLogLearnerEvent(learnerDetailsContext) { + createAnalyticsEvent(it, EventBuilder::setStartOverExplorationContext) + } + } + + /** Logs that the current exploration has been exited (i.e. not finished). */ + fun logExitExploration() { + getExpectedStateLogger()?.logExitExploration() + } + + /** Logs that the current exploration has been fully completed by the learner. */ + fun logFinishExploration() { + getExpectedStateLogger()?.logFinishExploration() + } + + /** + * Begins analytics logging for the specified [newState], returning the [StateAnalyticsLogger] + * that can be used to log events for the [State]. + * + * This overrides the current [stateAnalyticsLogger]. + * + * [endCard] should be called when the current [State] is completed. + */ + fun startCard(newState: State): StateAnalyticsLogger { + return StateAnalyticsLogger( + loggingIdentifierController, baseLogger, newState, explorationLogContext + ).also { + if (!mutableStateAnalyticsLogger.compareAndSet(expect = null, update = it)) { + oppiaLogger.w( + "LearnerAnalyticsLogger", "Attempting to start a card without ending the previous." + ) + + // Force the logger to a new state. + mutableStateAnalyticsLogger.value = it + } + } + } + + /** + * Resets the current [stateAnalyticsLogger], indicating that the most recent [State] has been + * completed. + */ + fun endCard() { + if (mutableStateAnalyticsLogger.value == null) { + oppiaLogger.w("LearnerAnalyticsLogger", "Attempting to end a card not yet started.") + } else mutableStateAnalyticsLogger.value = null + } + + private fun getExpectedStateLogger(): StateAnalyticsLogger? { + return mutableStateAnalyticsLogger.value.also { + if (it == null) { + oppiaLogger.w("LearnerAnalyticsLogger", "Attempting to log a state event outside state.") + } + } + } + } + + /** Analytics logger for [State]-specific events. */ + class StateAnalyticsLogger internal constructor( + private val loggingIdentifierController: LoggingIdentifierController, + private val baseLogger: BaseLogger, + private val currentState: State, + private val baseExplorationLogContext: ExplorationContext? + ) { + private val linkedSkillId by lazy { currentState.linkedSkillId } + + /** Logs that the current exploration has been exited (at this state). */ + internal fun logExitExploration() { + logStateEvent(EventBuilder::setExitExplorationContext) + } + + /** Logs that the current exploration has been finished (at this state). */ + internal fun logFinishExploration() { + logStateEvent(EventBuilder::setFinishExplorationContext) + } + + /** Logs that this card has been started. */ + fun logStartCard() { + logStateEvent(linkedSkillId, ::createCardContext, EventBuilder::setStartCardContext) + } + + /** Logs that this card has been completed. */ + fun logEndCard() { + logStateEvent(linkedSkillId, ::createCardContext, EventBuilder::setEndCardContext) + } + + /** Logs that the hint corresponding to [hintIndex] has been offered to the learner. */ + fun logHintOffered(hintIndex: Int) { + logStateEvent(hintIndex, ::createHintContext, EventBuilder::setHintOfferedContext) + } + + /** Logs that the hint corresponding to [hintIndex] has been viewed by the learner. */ + fun logViewHint(hintIndex: Int) { + logStateEvent(hintIndex, ::createHintContext, EventBuilder::setAccessHintContext) + } + + /** Logs that the solution to the current card has been offered to the learner. */ + fun logSolutionOffered() { + logStateEvent(EventBuilder::setSolutionOfferedContext) + } + + /** Logs that the solution to the current card has been viewed by the learner. */ + fun logViewSolution() { + logStateEvent(EventBuilder::setAccessSolutionContext) + } + + /** + * Logs that the learner submitted an answer, where [isCorrect] indicates whether the answer was + * labelled as correct. + */ + fun logSubmitAnswer(isCorrect: Boolean) { + logStateEvent(isCorrect, ::createSubmitAnswerContext, EventBuilder::setSubmitAnswerContext) + } + + /** + * Logs that the learner started playing a voice over audio track corresponding to [contentId] + * (or null if something failed when retrieving the content ID--note that this may affect + * whether the event is logged). + */ + fun logPlayVoiceOver(contentId: String?) { + logStateEvent(contentId, ::createPlayVoiceOverContext, EventBuilder::setPlayVoiceOverContext) + } + + private fun logStateEvent(setter: EventBuilder.(ExplorationContext) -> EventBuilder) = + logStateEvent(Unit, { _, context -> context }, setter) + + private fun logStateEvent( + detail: D, + baseContextFactory: (D, ExplorationContext) -> T, + setter: EventBuilder.(T) -> EventBuilder + ) { + // The nullness checks here prevent an empty log from getting sent (since that'd be mostly + // useless), but it does allow for the log-specific data to be absent so long as the context + // is present. + baseLogger.maybeLogEvent( + computeLogContext()?.let { + createAnalyticsEvent(baseContextFactory(detail, it), setter) + }?.ensureNonEmpty() + ) + } + + private fun computeLogContext(): ExplorationContext? { + return baseExplorationLogContext?.toBuilder()?.apply { + stateName = currentState.name + learnerDetails = learnerDetails.toBuilder().apply { + // Ensure the session ID is the latest for this event (in case it's changed). + sessionId = loggingIdentifierController.getSessionIdFlow().value + }.build() + }?.build() + } + } + + /** + * The common base logger used by [ExplorationAnalyticsLogger] and [StateAnalyticsLogger], and + * should never be interacted with outside this class. + */ + internal class BaseLogger internal constructor( + private val oppiaLogger: OppiaLogger, + private val installationId: String? + ) { + /** + * Logs a learner-specific event defined by [learnerDetailsContext] and converted to a full + * [EventContext] by the provided [creationDelegate]. + * + * See [maybeLogEvent] for specifics on when the event is logged. + * + * Also, this method is only meant to be used as a convenience function for nicer syntax when + * logging certain learner events. + */ + fun maybeLogLearnerEvent( + learnerDetailsContext: LearnerDetailsContext?, + creationDelegate: (LearnerDetailsContext) -> EventContext + ) { + // Note that this tries to ensure that the event is always logged even if details are missing. + maybeLogEvent( + creationDelegate(learnerDetailsContext ?: LearnerDetailsContext.getDefaultInstance()) + ) + } + + /** See [OppiaLogger.maybeLogEvent]. */ + fun maybeLogEvent(context: EventContext?) = oppiaLogger.maybeLogEvent(installationId, context) + + internal companion object { + /** + * Conditionally logs the event specified by [context] for the provided [installationId] in + * this logger if [context] is not null, otherwise logs an error analytics event for error + * tracking in the analytics pipeline. + */ + internal fun OppiaLogger.maybeLogEvent(installationId: String?, context: EventContext?) { + if (context != null) { + logImportantEvent(context) + } else { + this.e( + "LearnerAnalyticsLogger", + "Event is being dropped due to incomplete event (or missing learner ID for profile)." + ) + logImportantEvent(createFailedToLogLearnerAnalyticsEvent(installationId)) + } + } + } + } + + private companion object { + private const val DEFAULT_INSTALLATION_ID = "unknown_installation_id" + + private fun T.ensureNonEmpty(): T? = + takeIf { it != it.defaultInstanceForType } + + private fun createLearnerDetailsContext( + installationId: String?, + learnerId: String? + ): LearnerDetailsContext? { + return createLearnerDetailsContextWithIdsPresent( + installationId?.takeUnless(String::isEmpty), learnerId?.takeUnless(String::isEmpty) + ).ensureNonEmpty() + } + + private fun createLearnerDetailsContextWithIdsPresent( + installationId: String?, + learnerId: String? + ): LearnerDetailsContext { + return LearnerDetailsContext.newBuilder().apply { + installationId?.let { installId = it } + learnerId?.let { this.learnerId = it } + }.build() + } + + private fun createCardContext( + skillId: String, + explorationDetails: ExplorationContext + ) = CardContext.newBuilder().apply { + this.skillId = skillId + this.explorationDetails = explorationDetails + }.build() + + private fun createHintContext( + hintIndex: Int, + explorationDetails: ExplorationContext + ) = HintContext.newBuilder().apply { + this.hintIndex = hintIndex + this.explorationDetails = explorationDetails + }.build() + + private fun createSubmitAnswerContext( + isAnswerCorrect: Boolean, + explorationDetails: ExplorationContext + ) = SubmitAnswerContext.newBuilder().apply { + this.isAnswerCorrect = isAnswerCorrect + this.explorationDetails = explorationDetails + }.build() + + private fun createPlayVoiceOverContext( + contentId: String?, + explorationDetails: ExplorationContext + ) = PlayVoiceOverContext.newBuilder().apply { + contentId?.let { this.contentId = it } + this.explorationDetails = explorationDetails + }.build() + + private fun createAnalyticsEvent( + baseContext: T, + setter: EventBuilder.(T) -> EventContext.Builder + ) = EventContext.newBuilder().setter(baseContext).build() + + private fun createFailedToLogLearnerAnalyticsEvent(installId: String?): EventContext { + return EventContext.newBuilder().apply { + installIdForFailedAnalyticsLog = installId ?: DEFAULT_INSTALLATION_ID + }.build() + } + } +} diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions/BUILD.bazel index af13183d30c..3d479108604 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions/BUILD.bazel @@ -13,7 +13,7 @@ kt_android_library( visibility = ["//domain:__subpackages__"], deps = [ "//data/src/main/java/org/oppia/android/data/persistence:cache_store", - "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", "//model/src/main/proto:event_logger_java_proto_lite", "//utility/src/main/java/org/oppia/android/util/data:data_provider", "//utility/src/main/java/org/oppia/android/util/logging:console_logger", diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorker.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorker.kt index 68fbf8f2997..35569dd8c81 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorker.kt +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorker.kt @@ -80,13 +80,11 @@ class LogUploadWorker private constructor( /** Extracts event logs from the cache store and logs them to the remote service. */ private suspend fun uploadEvents(): Result { + // TODO(#4064): Ensure sync status is set correctly when events are being uploaded here. return try { - val eventLogs = analyticsController.getEventLogStoreList() - eventLogs.let { - for (eventLog in it) { - eventLogger.logEvent(eventLog) - analyticsController.removeFirstEventLogFromStore() - } + analyticsController.getEventLogStoreList().forEach { eventLog -> + eventLogger.logEvent(eventLog) + analyticsController.removeFirstEventLogFromStore() } Result.success() } catch (e: Exception) { diff --git a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt index 700c456168e..71cc12a4e2b 100644 --- a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt +++ b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt @@ -57,6 +57,7 @@ private const val UPDATE_READING_TEXT_SIZE_PROVIDER_ID = private const val UPDATE_APP_LANGUAGE_PROVIDER_ID = "update_app_language_provider_id" private const val UPDATE_AUDIO_LANGUAGE_PROVIDER_ID = "update_audio_language_provider_id" +private const val UPDATE_LEARNER_ID_PROVIDER_ID = "update_learner_id_provider_id" /** Controller for retrieving, adding, updating, and deleting profiles. */ @Singleton @@ -121,7 +122,7 @@ class ProfileManagementController @Inject constructor( // TODO(#272): Remove init block when storeDataAsync is fixed init { - profileDataStore.primeCacheAsync().invokeOnCompletion { + profileDataStore.primeInMemoryCacheAsync().invokeOnCompletion { it?.let { oppiaLogger.e( "DOMAIN", @@ -213,34 +214,37 @@ class ProfileManagementController @Inject constructor( val nextProfileId = it.nextProfileId val profileDir = directoryManagementUtil.getOrCreateDir(nextProfileId.toString()) - val newProfileBuilder = Profile.newBuilder() - .setName(name) - .setPin(pin) - .setAllowDownloadAccess(allowDownloadAccess) - .setId(ProfileId.newBuilder().setInternalId(nextProfileId)) - .setDateCreatedTimestampMs(oppiaClock.getCurrentTimeMs()) - .setIsAdmin(isAdmin) - .setReadingTextSize(ReadingTextSize.MEDIUM_TEXT_SIZE) - .setAppLanguage(AppLanguage.ENGLISH_APP_LANGUAGE) - .setAudioLanguage(AudioLanguage.ENGLISH_AUDIO_LANGUAGE) - - if (avatarImagePath != null) { - val imageUri = - saveImageToInternalStorage(avatarImagePath, profileDir) - ?: return@storeDataWithCustomChannelAsync Pair( - it, - ProfileActionStatus.FAILED_TO_STORE_IMAGE - ) - newProfileBuilder.avatar = ProfileAvatar.newBuilder().setAvatarImageUri(imageUri).build() - } else { - newProfileBuilder.avatar = ProfileAvatar.newBuilder().setAvatarColorRgb(colorRgb).build() - } + val newProfile = Profile.newBuilder().apply { + this.name = name + this.pin = pin + this.allowDownloadAccess = allowDownloadAccess + this.id = ProfileId.newBuilder().setInternalId(nextProfileId).build() + dateCreatedTimestampMs = oppiaClock.getCurrentTimeMs() + this.isAdmin = isAdmin + readingTextSize = ReadingTextSize.MEDIUM_TEXT_SIZE + appLanguage = AppLanguage.ENGLISH_APP_LANGUAGE + audioLanguage = AudioLanguage.ENGLISH_AUDIO_LANGUAGE + + // TODO(#4064): Initialize the learner ID here (only if the study parameter is enabled). + + avatar = ProfileAvatar.newBuilder().apply { + if (avatarImagePath != null) { + val imageUri = + saveImageToInternalStorage(avatarImagePath, profileDir) + ?: return@storeDataWithCustomChannelAsync Pair( + it, + ProfileActionStatus.FAILED_TO_STORE_IMAGE + ) + avatarImageUri = imageUri + } else avatarColorRgb = colorRgb + }.build() + }.build() val wasProfileEverAdded = it.profilesCount > 0 val profileDatabaseBuilder = it.toBuilder() - .putProfiles(nextProfileId, newProfileBuilder.build()) + .putProfiles(nextProfileId, newProfile) .setWasProfileEverAdded(wasProfileEverAdded) .setNextProfileId(nextProfileId + 1) Pair(profileDatabaseBuilder.build(), ProfileActionStatus.SUCCESS) @@ -527,6 +531,40 @@ class ProfileManagementController @Inject constructor( } } + /** + * Initializes the learner ID of the specified profile (if not set), otherwise clears it if there + * is no ongoing study. + * + * @param profileId the ID corresponding to the profile being updated + */ + fun initializeLearnerId(profileId: ProfileId): DataProvider { + val deferred = profileDataStore.storeDataWithCustomChannelAsync( + updateInMemoryCache = true + ) { + val profile = + it.profilesMap[profileId.internalId] ?: return@storeDataWithCustomChannelAsync Pair( + it, + ProfileActionStatus.PROFILE_NOT_FOUND + ) + val updatedProfile = profile.toBuilder().apply { + learnerId = when { + // TODO(#4064): Update the learner ID here (only if the study parameter is enabled). + else -> learnerId // Keep it unchanged. + } + }.build() + val profileDatabaseBuilder = it.toBuilder().putProfiles( + profileId.internalId, + updatedProfile + ) + Pair(profileDatabaseBuilder.build(), ProfileActionStatus.SUCCESS) + } + return dataProviders.createInMemoryDataProviderAsync( + UPDATE_LEARNER_ID_PROVIDER_ID + ) { + return@createInMemoryDataProviderAsync getDeferredResult(profileId, null, deferred) + } + } + /** * Updates the audio language of the profile. * @@ -619,10 +657,8 @@ class ProfileManagementController @Inject constructor( if (!directoryManagementUtil.deleteDir(profileId.internalId.toString())) { return@storeDataWithCustomChannelAsync Pair(it, ProfileActionStatus.FAILED_TO_DELETE_DIR) } - val profileDatabaseBuilder = it.toBuilder().removeProfiles( - profileId.internalId - ) - Pair(profileDatabaseBuilder.build(), ProfileActionStatus.SUCCESS) + // TODO(#4064): Log the 'delete profile' event here. + Pair(it.toBuilder().removeProfiles(profileId.internalId).build(), ProfileActionStatus.SUCCESS) } return dataProviders.createInMemoryDataProviderAsync(DELETE_PROFILE_PROVIDER_ID) { return@createInMemoryDataProviderAsync getDeferredResult(profileId, null, deferred) @@ -637,6 +673,32 @@ class ProfileManagementController @Inject constructor( return ProfileId.newBuilder().setInternalId(currentProfileId).build() } + /** + * Returns the learner ID corresponding to the current logged-in profile (as given by + * [getCurrentProfileId]), or null if there's no currently logged-in user. + * + * See [fetchLearnerId] for specifics. + */ + suspend fun fetchCurrentLearnerId(): String? = fetchLearnerId(getCurrentProfileId()) + + /** + * Returns the learner ID corresponding to the specified [profileId], or null if the specified + * profile doesn't exist. + * + * There are three important considerations when using this method: + * 1. The returned ID may be empty or undefined if analytics IDs are not currently enabled for + * logging. + * 2. The learner ID can change for a profile, so this method only guarantees returning the + * *current* learner ID corresponding to the profile. A [DataProvider] on the profile itself + * should be used if the caller requires the learner ID be kept up-to-date. + * 3. This method is meant to only be called by background coroutines and should never be used + * from UI code. + */ + suspend fun fetchLearnerId(profileId: ProfileId): String? { + val profileDatabase = profileDataStore.readDataAsync().await() + return profileDatabase.profilesMap[profileId.internalId]?.learnerId + } + private suspend fun getDeferredResult( profileId: ProfileId?, name: String?, diff --git a/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt b/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt index 83d984f1872..21df74c2f30 100644 --- a/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt +++ b/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt @@ -14,8 +14,8 @@ import org.oppia.android.app.model.UserAnswer // TODO(#59): Hide the visibility of this class to domain implementations. /** - * Tracks the progress of a dynamic playing session through a graph of State cards. This class treats the learner's - * progress like a deck of cards to simplify forward/backward navigation. + * Tracks the progress of a dynamic playing session through a graph of state cards. This class + * treats the learner's progress like a deck of cards to simplify forward/backward navigation. */ class StateDeck constructor( initialState: State, @@ -49,33 +49,39 @@ class StateDeck constructor( this.stateIndex = stateIndex } - /** Navigates to the previous State in the deck, or fails if this isn't possible. */ + /** Navigates to the previous state in the deck, or fails if this isn't possible. */ fun navigateToPreviousState() { check(!isCurrentStateInitial()) { "Cannot navigate to previous state; at initial state." } stateIndex-- } - /** Navigates to the next State in the deck, or fails if this isn't possible. */ + /** Navigates to the next state in the deck, or fails if this isn't possible. */ fun navigateToNextState() { check(!isCurrentStateTopOfDeck()) { "Cannot navigate to next state; at most recent state." } val previousState = previousStates[stateIndex] stateIndex++ if (!previousState.hasNextState) { - // Update the previous state to indicate that it has a next state now that its next state has actually been - // 'created' by navigating to it. + // Update the previous state to indicate that it has a next state now that its next state has + // actually been reated' by navigating to it. previousStates[stateIndex - 1] = previousState.toBuilder().setHasNextState(true).build() } } /** - * Returns the [State] corresponding to the latest card in the deck, regardless of whichever State the learner is - * currently viewing. + * Returns the [State] corresponding to the latest card in the deck, regardless of whichever state + * the learner is currently viewing. */ fun getPendingTopState(): State = pendingTopState /** Returns the index of the current selected card of the deck. */ fun getTopStateIndex(): Int = stateIndex + /** + * Returns the number of unique states that have been viewed so far in the deck (i.e. the size of + * the deck). + */ + fun getViewedStateCount(): Int = previousStates.size + /** Returns the current [State] being viewed by the learner. */ fun getCurrentState(): State { return when { @@ -86,9 +92,10 @@ class StateDeck constructor( /** Returns the current [EphemeralState] the learner is viewing. */ fun getCurrentEphemeralState(helpIndex: HelpIndex): EphemeralState { - // Note that the terminal state is evaluated first since it can only return true if the current state is the top - // of the deck, and that state is the terminal one. Otherwise the terminal check would never be triggered since - // the second case assumes the top of the deck must be pending. + // Note that the terminal state is evaluated first since it can only return true if the current + // state is the top of the deck, and that state is the terminal one. Otherwise the terminal + // check would never be triggered since the second case assumes the top of the deck must be + // pending. return when { isCurrentStateTerminal() -> getCurrentTerminalState() isCurrentStateTopOfDeck() -> getCurrentPendingState(helpIndex) @@ -97,12 +104,16 @@ class StateDeck constructor( } /** - * Pushes a new State onto the deck. This cannot happen if the learner isn't at the most recent State, if the - * current State is not terminal, or if the learner hasn't submitted an answer to the most recent State. This - * operation implies that the most recently submitted answer was the correct answer to the previously current State. - * This does NOT change the user's position in the deck, it just marks the current state as completed. + * Pushes a new [State] onto the deck. * - * @param prohibitSameStateName whether to enable a sanity check to ensure the same state isn't routed to twice + * This operation cannot happen if the learner isn't at the most recent state, if the current + * state is not terminal, or if the learner hasn't submitted an answer to the most recent state. + * This operation implies that the most recently submitted answer was the correct answer to the + * previously current state. This does NOT change the user's position in the deck, it just marks + * the current state as completed. + * + * @param prohibitSameStateName whether to enable a sanity check to ensure the same state isn't + * routed to twice */ fun pushState(state: State, prohibitSameStateName: Boolean) { check(isCurrentStateTopOfDeck()) { @@ -119,8 +130,8 @@ class StateDeck constructor( "Cannot route from the same state to itself as a new card." } } - // NB: This technically has a 'next' state, but it's not marked until it's first navigated away since the new state - // doesn't become fully realized until navigated to. + // NB: This technically has a 'next' state, but it's not marked until it's first navigated away + // since the new state doesn't become fully realized until navigated to. previousStates += EphemeralState.newBuilder() .setState(pendingTopState) .setHasPreviousState(!isCurrentStateInitial()) @@ -131,9 +142,10 @@ class StateDeck constructor( } /** - * Submits an answer & feedback dialog the learner experience in the current State. This fails if the user is not at - * the most recent State in the deck, or if the most recent State is terminal (since no answer can be submitted to a - * terminal interaction). + * Submits an answer & feedback dialog the learner experience in the current state. + * + * This operation fails if the user is not at the most recent state in the deck, or if the most + * recent state is terminal (since no answer can be submitted to a terminal interaction). */ fun submitAnswer(userAnswer: UserAnswer, feedback: SubtitledHtml, isCorrectAnswer: Boolean) { check(isCurrentStateTopOfDeck()) { "Cannot submit an answer except to the most recent state." } @@ -198,20 +210,20 @@ class StateDeck constructor( return previousStates[stateIndex] } - /** Returns whether the current scrolled State is the first State of the exploration. */ + /** Returns whether the current scrolled state is the first state of the exploration. */ private fun isCurrentStateInitial(): Boolean { return stateIndex == 0 } - /** Returns whether the current scrolled State is the most recent State played by the learner. */ + /** Returns whether the current scrolled state is the most recent state played by the learner. */ fun isCurrentStateTopOfDeck(): Boolean { return stateIndex == previousStates.size } - /** Returns whether the current State is terminal. */ + /** Returns whether the current state is terminal. */ private fun isCurrentStateTerminal(): Boolean { - // Cards not on top of the deck cannot be terminal/the terminal card must be the last card in the deck, if it's - // present. + // Cards not on top of the deck cannot be terminal/the terminal card must be the last card in + // the deck, if it's present. return isCurrentStateTopOfDeck() && isTopOfDeckTerminal() } diff --git a/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt b/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt index e6778a31774..bba4660a16f 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt @@ -371,7 +371,7 @@ class StoryProgressController @Inject constructor( cacheStore } - cacheStore.primeCacheAsync().invokeOnCompletion { + cacheStore.primeInMemoryCacheAsync().invokeOnCompletion { it?.let { it -> oppiaLogger.e( "StoryProgressController", diff --git a/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt b/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt index 04539bc6b92..71ac96f542c 100644 --- a/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt +++ b/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt @@ -56,6 +56,7 @@ class StateRetriever @Inject constructor() { createWrittenTranslationMappingsFromJson(stateJson.getJSONObject("written_translations")) ) } + stateJson.optString("linked_skill_id")?.let { linkedSkillId = it } }.build() // Creates an interaction from JSON diff --git a/domain/src/test/java/org/oppia/android/domain/clipboard/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/clipboard/BUILD.bazel new file mode 100644 index 00000000000..29840326197 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/clipboard/BUILD.bazel @@ -0,0 +1,36 @@ +""" +Tests for clipboard management domain services & definitions. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "ClipboardControllerTest", + srcs = ["ClipboardControllerTest.kt"], + custom_package = "org.oppia.android.domain.clipboard", + test_class = "org.oppia.android.domain.clipboard.ClipboardControllerTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/clipboard:clipboard_controller", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/logging:fake_sync_status_manager", + "//testing/src/main/java/org/oppia/android/testing/logging:sync_status_test_module", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_mockito_mockito-core", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +dagger_rules() diff --git a/domain/src/test/java/org/oppia/android/domain/clipboard/ClipboardControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/clipboard/ClipboardControllerTest.kt new file mode 100644 index 00000000000..f508418b833 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/clipboard/ClipboardControllerTest.kt @@ -0,0 +1,298 @@ +package org.oppia.android.domain.clipboard + +import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.domain.clipboard.ClipboardController.CurrentClip +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.data.DataProviderTestMonitor +import org.oppia.android.testing.logging.SyncStatusTestModule +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [ClipboardController]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = ClipboardControllerTest.TestApplication::class) +class ClipboardControllerTest { + private companion object { + private const val TEST_LABEL_FROM_OPPIA_1 = "test label from Oppia one" + private const val TEST_LABEL_FROM_OPPIA_2 = "test label from Oppia two" + private const val TEST_TEXT_FROM_OPPIA_1 = "test text to copy from Oppia one" + private const val TEST_TEXT_FROM_OPPIA_2 = "test text to copy from Oppia two" + private const val TEST_TEXT_FROM_OTHER_APP_1 = "test text to copy from another app one" + private const val TEST_TEXT_FROM_OTHER_APP_2 = "test text to copy from another app two" + } + + @Inject lateinit var context: Context + @Inject lateinit var clipboardController: ClipboardController + @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + + private val clipboardManager by lazy { + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + } + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testGetCurrentClip_initialState_returnsUnknown() { + val currentClipProvider = clipboardController.getCurrentClip() + + val currentClip = monitorFactory.waitForNextSuccessfulResult(currentClipProvider) + assertThat(currentClip).isEqualTo(CurrentClip.Unknown) + } + + @Test + fun testGetCurrentClip_afterAnotherAppChangesClipboard_returnsUnknown() { + val currentClipProvider = clipboardController.getCurrentClip() + + updateClipboard(TEST_TEXT_FROM_OTHER_APP_1) + + val currentClip = monitorFactory.waitForNextSuccessfulResult(currentClipProvider) + assertThat(currentClip).isEqualTo(CurrentClip.Unknown) + } + + @Test + fun testGetCurrentClip_afterSettingClip_withoutObserving_returnsUnknown() { + val currentClipProvider = clipboardController.getCurrentClip() + + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ) + + // Not observing the result of setCurrentClip will result in its effects not executing. + val currentClip = monitorFactory.waitForNextSuccessfulResult(currentClipProvider) + assertThat(currentClip).isEqualTo(CurrentClip.Unknown) + } + + @Test + fun testGetCurrentClip_afterSettingClip_returnsSetWithAppText() { + val currentClipProvider = clipboardController.getCurrentClip() + + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ).waitForCompletion() + + val currentClip = monitorFactory.waitForNextSuccessfulResult(currentClipProvider) + assertThat(currentClip).isEqualTo( + CurrentClip.SetWithAppText(label = TEST_LABEL_FROM_OPPIA_1, text = TEST_TEXT_FROM_OPPIA_1) + ) + } + + @Test + fun testGetCurrentClip_afterSettingClip_again_notifiesNewSetWithAppText() { + val currentClipProvider = clipboardController.getCurrentClip() + val monitor = monitorFactory.createMonitor(currentClipProvider) + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ).waitForCompletion() + + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_2, TEST_TEXT_FROM_OPPIA_2 + ).waitForCompletion() + + val currentClip = monitor.ensureNextResultIsSuccess() + assertThat(currentClip).isEqualTo( + CurrentClip.SetWithAppText(label = TEST_LABEL_FROM_OPPIA_2, text = TEST_TEXT_FROM_OPPIA_2) + ) + } + + @Test + fun testGetCurrentClip_afterSettingClip_again_newSub_returnsNewSetWithAppText() { + val currentClipProvider = clipboardController.getCurrentClip() + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ).waitForCompletion() + + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_2, TEST_TEXT_FROM_OPPIA_2 + ).waitForCompletion() + + val currentClip = monitorFactory.waitForNextSuccessfulResult(currentClipProvider) + assertThat(currentClip).isEqualTo( + CurrentClip.SetWithAppText(label = TEST_LABEL_FROM_OPPIA_2, text = TEST_TEXT_FROM_OPPIA_2) + ) + } + + @Test + fun testGetCurrentClip_setClip_otherAppChangesClipboard_notifiesSetWithOtherContent() { + val currentClipProvider = clipboardController.getCurrentClip() + val monitor = monitorFactory.createMonitor(currentClipProvider) + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ).waitForCompletion() + + updateClipboard(TEST_TEXT_FROM_OTHER_APP_1) + + // The other app changing the clipboard results in Oppia's content being removed. + val currentClip = monitor.waitForNextSuccessResult() + assertThat(currentClip).isEqualTo(CurrentClip.SetWithOtherContent) + } + + @Test + fun testGetCurrentClip_setClip_otherAppChangesClipboard_newSub_returnsSetWithOtherContent() { + val currentClipProvider = clipboardController.getCurrentClip() + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ).waitForCompletion() + + updateClipboard(TEST_TEXT_FROM_OTHER_APP_1) + + // The other app changing the clipboard results in Oppia's content being removed. + val currentClip = monitorFactory.waitForNextSuccessfulResult(currentClipProvider) + assertThat(currentClip).isEqualTo(CurrentClip.SetWithOtherContent) + } + + @Test + fun testGetCurrentClip_setClip_otherAppChangesClipboard_again_doesNotRenotify() { + val currentClipProvider = clipboardController.getCurrentClip() + val monitor = monitorFactory.createMonitor(currentClipProvider) + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ).waitForCompletion() + updateClipboard(TEST_TEXT_FROM_OTHER_APP_1) + monitor.waitForNextResult() + + // Copy different text from another app. + updateClipboard(TEST_TEXT_FROM_OTHER_APP_2) + + // There shouldn't actually be a notification in this case since the state didn't technically + // change (since the controller never exposes clipboard content from other apps). + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testGetCurrentClip_setClip_otherAppChanges_setClipAgain_notifiesNewSetWithAppText() { + val currentClipProvider = clipboardController.getCurrentClip() + val monitor = monitorFactory.createMonitor(currentClipProvider) + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ).waitForCompletion() + updateClipboard(TEST_TEXT_FROM_OTHER_APP_1) + monitor.waitForNextResult() + + // Copy the same Oppia text again. + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ).waitForCompletion() + + // The clip should be updated with the new Oppia-sourced text. + val currentClip = monitor.ensureNextResultIsSuccess() + assertThat(currentClip).isEqualTo( + CurrentClip.SetWithAppText(label = TEST_LABEL_FROM_OPPIA_1, text = TEST_TEXT_FROM_OPPIA_1) + ) + } + + @Test + fun testGetCurrentClip_setClip_otherAppChanges_twice_notifiesSetWithOtherContent() { + val currentClipProvider = clipboardController.getCurrentClip() + val monitor = monitorFactory.createMonitor(currentClipProvider) + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_1, TEST_TEXT_FROM_OPPIA_1 + ).waitForCompletion() + updateClipboard(TEST_TEXT_FROM_OTHER_APP_1) + clipboardController.setCurrentClip( + TEST_LABEL_FROM_OPPIA_2, TEST_TEXT_FROM_OPPIA_2 + ).waitForCompletion() + + updateClipboard(TEST_TEXT_FROM_OTHER_APP_2) + + // Ending with copying text from another app should result in the clipboard containing 'other + // content'. + val currentClip = monitor.waitForNextSuccessResult() + assertThat(currentClip).isEqualTo(CurrentClip.SetWithOtherContent) + } + + private fun updateClipboard(text: String) { + // Simulate copying text from another app by directly modifying the clipboard outside + // ClipboardController. + + // This must use the setter since property syntax seems to break on SDK 30. + @Suppress("UsePropertyAccessSyntax") + clipboardManager.setPrimaryClip( + ClipData.newPlainText(/* label= */ "label of text from other app", text) + ) + } + + private fun DataProvider.waitForCompletion() { + monitorFactory.createMonitor(this).waitForNextResult() + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, TestLogReportingModule::class, RobolectricModule::class, + TestDispatcherModule::class, NetworkConnectionUtilDebugModule::class, + FakeOppiaClockModule::class, PlatformParameterModule::class, + PlatformParameterSingletonModule::class, LoggingIdentifierModule::class, + SyncStatusTestModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(test: ClipboardControllerTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerClipboardControllerTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(test: ClipboardControllerTest) { + component.inject(test) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel index eee95811580..7beb84c3ac1 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel +++ b/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel @@ -28,7 +28,8 @@ oppia_android_test( "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericinput:numeric_input_rule_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/ratioinput:ratio_input_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module", - "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", "//testing", "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", @@ -68,7 +69,8 @@ oppia_android_test( "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericinput:numeric_input_rule_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/ratioinput:ratio_input_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module", - "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", "//testing", "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt index 8e483a33554..39dd275e583 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt @@ -13,6 +13,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.oppia.android.app.model.ExplorationCheckpoint +import org.oppia.android.app.model.ProfileId import org.oppia.android.domain.classify.InteractionsModule import org.oppia.android.domain.classify.rules.algebraicexpressioninput.AlgebraicExpressionInputModule import org.oppia.android.domain.classify.rules.continueinteraction.ContinueModule @@ -27,10 +28,15 @@ import org.oppia.android.domain.classify.rules.numericexpressioninput.NumericExp import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationStorageDatabaseSize import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule +import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.topic.FRACTIONS_EXPLORATION_ID_0 import org.oppia.android.domain.topic.FRACTIONS_EXPLORATION_ID_1 import org.oppia.android.domain.topic.RATIOS_EXPLORATION_ID_0 @@ -63,6 +69,7 @@ import org.oppia.android.util.logging.EnableConsoleLog import org.oppia.android.util.logging.EnableFileLog import org.oppia.android.util.logging.GlobalLogLevel import org.oppia.android.util.logging.LogLevel +import org.oppia.android.util.logging.SyncStatusModule import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode @@ -70,8 +77,9 @@ import javax.inject.Inject import javax.inject.Singleton /** Tests for [ExplorationDataController]. */ -// Function name: test names are conventionally named with underscores. -@Suppress("FunctionName") +// FunctionName: test names are conventionally named with underscores. +// SameParameterValue: tests should have specific context included/excluded for readability. +@Suppress("FunctionName", "SameParameterValue") @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) @Config(application = ExplorationDataControllerTest.TestApplication::class) @@ -80,18 +88,15 @@ class ExplorationDataControllerTest { @Inject lateinit var fakeExceptionLogger: FakeExceptionLogger @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + @Inject lateinit var explorationCheckpointController: ExplorationCheckpointController - private val internalProfileId: Int = -1 + private val profileId = ProfileId.newBuilder().setInternalId(0).build() @Before fun setUp() { setUpTestApplicationComponent() } - private fun setUpTestApplicationComponent() { - ApplicationProvider.getApplicationContext().inject(this) - } - @Test fun testController_providesInitialStateForFractions0Exploration() { val explorationResult = explorationDataController.getExplorationById(FRACTIONS_EXPLORATION_ID_0) @@ -172,9 +177,105 @@ class ExplorationDataControllerTest { assertThat(exception).hasMessageThat().contains("Asset doesn't exist: NON_EXISTENT_TEST") } + @Test + fun testStartPlayingNewExploration_returnsSuccess() { + val startProvider = + explorationDataController.startPlayingNewExploration( + profileId.internalId, TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2 + ) + + monitorFactory.waitForNextSuccessfulResult(startProvider) + } + + @Test + fun testStartPlayingNewExploration_afterCompletingIt_returnsSuccess() { + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) + stopExploration() + + val secondStartProvider = + explorationDataController.startPlayingNewExploration( + profileId.internalId, TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2 + ) + + monitorFactory.waitForNextSuccessfulResult(secondStartProvider) + } + + @Test + fun testResumeExploration_afterStartingIt_returnsSuccess() { + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) + stopExploration(isCompletion = false) + + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + val secondStartProvider = + explorationDataController.resumeExploration( + profileId.internalId, TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint + ) + + monitorFactory.waitForNextSuccessfulResult(secondStartProvider) + } + + @Test + fun testRestartExploration_returnsSuccess() { + val startProvider = + explorationDataController.restartExploration( + profileId.internalId, TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2 + ) + + monitorFactory.waitForNextSuccessfulResult(startProvider) + } + + @Test + fun testRestartExploration_afterStartingIt_returnsSuccess() { + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) + stopExploration(isCompletion = false) + + val secondStartProvider = + explorationDataController.restartExploration( + profileId.internalId, TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2 + ) + + monitorFactory.waitForNextSuccessfulResult(secondStartProvider) + } + + @Test + fun testReplayExploration_returnsSuccess() { + val startProvider = + explorationDataController.replayExploration( + profileId.internalId, TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2 + ) + + monitorFactory.waitForNextSuccessfulResult(startProvider) + } + + @Test + fun testReplayExploration_afterCompletingIt_returnsSuccess() { + replayExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) + stopExploration() + + val secondStartProvider = + explorationDataController.replayExploration( + profileId.internalId, TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2 + ) + + monitorFactory.waitForNextSuccessfulResult(secondStartProvider) + } + + @Test + fun testReplayExploration_withoutStoppingPreviousSession_returnsSuccess() { + replayExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) + + val dataProvider = + explorationDataController.replayExploration( + profileId.internalId, TEST_TOPIC_ID_1, TEST_STORY_ID_2, TEST_EXPLORATION_ID_4 + ) + + // The new session overwrites the previous. + monitorFactory.waitForNextSuccessfulResult(dataProvider) + } + @Test fun testStopPlayingExploration_withoutStartingSession_returnsFailure() { - val resultProvider = explorationDataController.stopPlayingExploration() + val resultProvider = explorationDataController.stopPlayingExploration(isCompletion = false) val result = monitorFactory.waitForNextFailureResult(resultProvider) assertThat(result).isInstanceOf(java.lang.IllegalStateException::class.java) @@ -182,27 +283,62 @@ class ExplorationDataControllerTest { } @Test - fun testStartPlayingExploration_withoutStoppingSession_succeeds() { - explorationDataController.startPlayingExploration( - internalProfileId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + fun testStopPlayingExploration_afterStarting_notCompletion_returnsSuccess() { + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) + + val resultProvider = explorationDataController.stopPlayingExploration(isCompletion = false) + + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + @Test + fun testStopPlayingExploration_afterStarting_completion_returnsSuccess() { + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) - val dataProvider = explorationDataController.startPlayingExploration( - internalProfileId, - TEST_TOPIC_ID_1, - TEST_STORY_ID_2, - TEST_EXPLORATION_ID_4, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() + val resultProvider = explorationDataController.stopPlayingExploration(isCompletion = true) + + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + private fun startPlayingNewExploration(topicId: String, storyId: String, explorationId: String) { + val startPlayingProvider = + explorationDataController.startPlayingNewExploration( + profileId.internalId, topicId, storyId, explorationId + ) + monitorFactory.waitForNextSuccessfulResult(startPlayingProvider) + } + + private fun restartExploration(topicId: String, storyId: String, explorationId: String) { + val startPlayingProvider = + explorationDataController.restartExploration( + profileId.internalId, topicId, storyId, explorationId + ) + monitorFactory.waitForNextSuccessfulResult(startPlayingProvider) + } + + private fun replayExploration(topicId: String, storyId: String, explorationId: String) { + val startPlayingProvider = + explorationDataController.replayExploration( + profileId.internalId, topicId, storyId, explorationId + ) + monitorFactory.waitForNextSuccessfulResult(startPlayingProvider) + } + + private fun retrieveExplorationCheckpoint( + explorationId: String + ): ExplorationCheckpoint { + return monitorFactory.waitForNextSuccessfulResult( + explorationCheckpointController.retrieveExplorationCheckpoint(profileId, explorationId) ) + } - // The new session overwrites the previous. - monitorFactory.waitForNextSuccessfulResult(dataProvider) + private fun stopExploration(isCompletion: Boolean = true) { + val stopProvider = explorationDataController.stopPlayingExploration(isCompletion) + monitorFactory.waitForNextSuccessfulResult(stopProvider) } // TODO(#89): Move this to a common test application component. @@ -272,7 +408,10 @@ class ExplorationDataControllerTest { TestExplorationStorageModule::class, HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, AssetModule::class, LocaleProdModule::class, NumericExpressionInputModule::class, - AlgebraicExpressionInputModule::class, MathEquationInputModule::class + AlgebraicExpressionInputModule::class, MathEquationInputModule::class, + LoggingIdentifierModule::class, ApplicationLifecycleModule::class, + SyncStatusModule::class, PlatformParameterModule::class, + PlatformParameterSingletonModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt index 3ce354153c3..3f95eb5df82 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt @@ -24,6 +24,10 @@ import org.oppia.android.app.model.EphemeralState.StateTypeCase.TERMINAL_STATE import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.model.Fraction import org.oppia.android.app.model.HelpIndex +import org.oppia.android.app.model.HelpIndex.IndexTypeCase.EVERYTHING_REVEALED +import org.oppia.android.app.model.HelpIndex.IndexTypeCase.LATEST_REVEALED_HINT_INDEX +import org.oppia.android.app.model.HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX +import org.oppia.android.app.model.HelpIndex.IndexTypeCase.SHOW_SOLUTION import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.ListOfSetsOfTranslatableHtmlContentIds import org.oppia.android.app.model.OppiaLanguage @@ -56,6 +60,10 @@ import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule import org.oppia.android.domain.hintsandsolution.isHintRevealed import org.oppia.android.domain.hintsandsolution.isSolutionRevealed import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule +import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_13 import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2 import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_4 @@ -71,7 +79,6 @@ import org.oppia.android.testing.OppiaTestRule import org.oppia.android.testing.RunOn import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.assertThrows -import org.oppia.android.testing.data.AsyncResultSubject.Companion.assertThat import org.oppia.android.testing.data.DataProviderTestMonitor import org.oppia.android.testing.environment.TestEnvironmentConfig import org.oppia.android.testing.robolectric.RobolectricModule @@ -90,6 +97,7 @@ import org.oppia.android.util.logging.EnableConsoleLog import org.oppia.android.util.logging.EnableFileLog import org.oppia.android.util.logging.GlobalLogLevel import org.oppia.android.util.logging.LogLevel +import org.oppia.android.util.logging.SyncStatusModule import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode @@ -123,7 +131,6 @@ class ExplorationProgressControllerTest { // - testMoveToPrevious_whileSubmittingAnswer_failsWithError @get:Rule val oppiaTestRule = OppiaTestRule() - @Inject lateinit var context: Context @Inject lateinit var explorationDataController: ExplorationDataController @Inject lateinit var explorationProgressController: ExplorationProgressController @@ -152,13 +159,8 @@ class ExplorationProgressControllerTest { @Test fun testPlayExploration_invalid_returnsSuccess() { val resultDataProvider = - explorationDataController.startPlayingExploration( - profileId.internalId, - INVALID_TOPIC_ID, - INVALID_STORY_ID, - INVALID_EXPLORATION_ID, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() + explorationDataController.replayExploration( + profileId.internalId, INVALID_TOPIC_ID, INVALID_STORY_ID, INVALID_EXPLORATION_ID ) // An invalid exploration is not known until it's fully loaded, and that's observed via @@ -168,14 +170,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_playInvalidExploration_returnsFailure() { - playExploration( - profileId.internalId, - INVALID_TOPIC_ID, - INVALID_STORY_ID, - INVALID_EXPLORATION_ID, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(INVALID_TOPIC_ID, INVALID_STORY_ID, INVALID_EXPLORATION_ID) val error = waitForGetCurrentStateFailureLoad() @@ -185,13 +180,8 @@ class ExplorationProgressControllerTest { @Test fun testPlayExploration_valid_returnsSuccess() { val resultDataProvider = - explorationDataController.startPlayingExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() + explorationDataController.replayExploration( + profileId.internalId, TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2 ) monitorFactory.waitForNextSuccessfulResult(resultDataProvider) @@ -199,14 +189,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_playExploration_loaded_returnsInitialStatePending() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() @@ -218,25 +201,11 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_playInvalidExploration_thenPlayValidExp_returnsInitialPendingState() { // Start with playing an invalid exploration. - playExploration( - profileId.internalId, - INVALID_TOPIC_ID, - INVALID_STORY_ID, - INVALID_EXPLORATION_ID, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(INVALID_TOPIC_ID, INVALID_STORY_ID, INVALID_EXPLORATION_ID) endExploration() // Then a valid one. - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) // The latest result should correspond to the valid ID, and the progress controller should // gracefully recover. @@ -248,7 +217,7 @@ class ExplorationProgressControllerTest { @Test fun testFinishExploration_beforePlaying_isFailure() { - val resultDataProvider = explorationDataController.stopPlayingExploration() + val resultDataProvider = explorationDataController.stopPlayingExploration(isCompletion = false) // The operation should be failing since the session hasn't started. val result = monitorFactory.waitForNextFailureResult(resultDataProvider) @@ -258,25 +227,13 @@ class ExplorationProgressControllerTest { @Test fun testPlayExploration_withoutFinishingPrevious_succeeds() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() // Try playing another exploration without finishing the previous one. val resultDataProvider = - explorationDataController.startPlayingExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() + explorationDataController.replayExploration( + profileId.internalId, TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2 ) // The new session will overwrite the previous. @@ -286,26 +243,12 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_playSecondExploration_afterFinishingPrev_loaded_returnsInitialState() { // Start with playing a valid exploration, then stop. - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() endExploration() // Then another valid one. - playExploration( - profileId.internalId, - TEST_TOPIC_ID_1, - TEST_STORY_ID_2, - TEST_EXPLORATION_ID_4, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_1, TEST_STORY_ID_2, TEST_EXPLORATION_ID_4) // The latest result should correspond to the valid ID, and the progress controller should // gracefully recover. @@ -327,14 +270,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forMultipleChoice_correctAnswer_succeeds() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeMultipleChoiceState() @@ -346,14 +282,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forMultipleChoice_correctAnswer_returnsOutcomeWithTransition() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeMultipleChoiceState() @@ -367,14 +296,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forMultipleChoice_wrongAnswer_succeeds() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeMultipleChoiceState() @@ -386,14 +308,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forMultipleChoice_wrongAnswer_providesDefFeedbackAndSameStateTransition() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeMultipleChoiceState() @@ -407,14 +322,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterSubmittingCorrectMultiChoiceAnswer_becomesCompletedState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeMultipleChoiceState() @@ -430,14 +338,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterSubmittingWrongMultiChoiceAnswer_updatesPendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeMultipleChoiceState() @@ -454,14 +355,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterSubmittingWrongThenRightAnswer_updatesToStateWithBothAnswers() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeMultipleChoiceState() submitMultipleChoiceAnswer(0) @@ -483,7 +377,6 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_beforePlaying_isFailure() { val moveToStateResult = explorationProgressController.moveToNextState() - val monitor = monitorFactory.createMonitor(moveToStateResult) // The operation should be failing since the session hasn't started. val result = monitorFactory.waitForNextFailureResult(moveToStateResult) @@ -493,14 +386,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_forPendingInitialState_failsWithError() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() val moveToStateResult = explorationProgressController.moveToNextState() @@ -514,14 +400,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_forCompletedState_succeeds() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() submitPrototypeState1Answer() @@ -532,14 +411,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_forCompletedState_movesToNextState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() submitPrototypeState1Answer() @@ -551,14 +423,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_afterMovingFromCompletedState_failsWithError() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() submitPrototypeState1Answer() moveToNextState() @@ -585,14 +450,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_onPendingInitialState_failsWithError() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() val moveToStateResult = explorationProgressController.moveToPreviousState() @@ -606,14 +464,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_onCompletedInitialState_failsWithError() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() submitPrototypeState1Answer() @@ -628,14 +479,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_forStateWithCompletedPreviousState_succeeds() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -648,14 +492,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_forCompletedState_movesToPreviousState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -670,14 +507,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_navigatedForwardThenBackToInitial_failsWithError() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() moveToPreviousState() @@ -694,14 +524,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forTextInput_correctAnswer_returnsOutcomeWithTransition() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeTextInputState() @@ -715,14 +538,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forTextInput_wrongAnswer_returnsDefaultOutcome() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeTextInputState() @@ -737,14 +553,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forFractionInput_wrongAnswer_returnsDefaultOutcome_hasHint() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeFractionInputState() @@ -764,14 +573,7 @@ class ExplorationProgressControllerTest { @Test fun testRevealHint_forWrongAnswers_showHint_returnHintIsRevealed() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeFractionInputState() // Submit 2 wrong answers to trigger a hint becoming available. @@ -791,14 +593,7 @@ class ExplorationProgressControllerTest { @Test fun testRevealSolution_triggeredSolution_showSolution_returnSolutionIsRevealed() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeFractionInputState() // Submit 2 wrong answers to trigger the hint. @@ -825,14 +620,7 @@ class ExplorationProgressControllerTest { @Test fun testHintsAndSolution_noHintVisible_checkHelpIndexIsCorrect() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() val ephemeralState = playThroughPrototypeState1AndMoveToNextState() @@ -844,14 +632,7 @@ class ExplorationProgressControllerTest { @Test fun testHintsAndSolution_wait60Seconds_unrevealedHintIsVisible_checkHelpIndexIsCorrect() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() // Make the first hint visible by submitting two wrong answers. @@ -863,20 +644,13 @@ class ExplorationProgressControllerTest { val ephemeralState = waitForGetCurrentStateSuccessfulLoad() assertThat(ephemeralState.isHintRevealed(0)).isFalse() assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX) + .isEqualTo(NEXT_AVAILABLE_HINT_INDEX) assertThat(ephemeralState.pendingState.helpIndex.nextAvailableHintIndex).isEqualTo(0) } @Test fun testHintsAndSolution_submitTwoWrongAnswers_unrevealedHintIsVisible_checkHelpIndexIsCorrect() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() // Make the first hint visible by submitting two wrong answers. @@ -887,20 +661,13 @@ class ExplorationProgressControllerTest { // unrevealed hint is visible. assertThat(ephemeralState.isHintRevealed(0)).isFalse() assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX) + .isEqualTo(NEXT_AVAILABLE_HINT_INDEX) assertThat(ephemeralState.pendingState.helpIndex.nextAvailableHintIndex).isEqualTo(0) } @Test fun testHintsAndSolution_revealedHintIsVisible_checkHelpIndexIsCorrect() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() submitWrongAnswerForPrototypeState2() @@ -915,20 +682,13 @@ class ExplorationProgressControllerTest { assertThat(ephemeralState.isHintRevealed(0)).isTrue() assertThat(ephemeralState.isSolutionRevealed()).isFalse() assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.LATEST_REVEALED_HINT_INDEX) + .isEqualTo(LATEST_REVEALED_HINT_INDEX) assertThat(ephemeralState.pendingState.helpIndex.latestRevealedHintIndex).isEqualTo(0) } @Test fun testHintsAndSolution_allHintsVisible_wait30Seconds_solutionVisible_checkHelpIndexIsCorrect() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() submitWrongAnswerForPrototypeState2() @@ -947,19 +707,12 @@ class ExplorationProgressControllerTest { assertThat(ephemeralState.isHintRevealed(0)).isTrue() assertThat(ephemeralState.isSolutionRevealed()).isFalse() assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.SHOW_SOLUTION) + .isEqualTo(SHOW_SOLUTION) } @Test fun testHintAndSol_hintsVisible_submitWrongAns_wait10Second_solVisible_checkHelpIndexIsCorrect() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() submitWrongAnswerForPrototypeState2() @@ -969,7 +722,7 @@ class ExplorationProgressControllerTest { testCoroutineDispatchers.runCurrent() submitWrongAnswerForPrototypeState2() - // The solution should be visible after 10 seconds becuase one wrong answer was submitted. + // The solution should be visible after 10 seconds because one wrong answer was submitted. testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) testCoroutineDispatchers.runCurrent() @@ -979,19 +732,12 @@ class ExplorationProgressControllerTest { assertThat(ephemeralState.isHintRevealed(0)).isTrue() assertThat(ephemeralState.isSolutionRevealed()).isFalse() assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.SHOW_SOLUTION) + .isEqualTo(SHOW_SOLUTION) } @Test fun testHintsAndSolution_revealedSolutionIsVisible_checkHelpIndexIsCorrect() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() submitWrongAnswerForPrototypeState2() @@ -1013,19 +759,12 @@ class ExplorationProgressControllerTest { assertThat(ephemeralState.isHintRevealed(0)).isTrue() assertThat(ephemeralState.isSolutionRevealed()).isTrue() assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.EVERYTHING_REVEALED) + .isEqualTo(EVERYTHING_REVEALED) } @Test fun testSubmitAnswer_forTextInput_wrongAnswer_afterAllHintsAreExhausted_showSolution() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeFractionInputState() @@ -1046,14 +785,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_secondState_submitRightAnswer_pendingStateBecomesCompleted() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeTextInputState() @@ -1071,14 +803,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forTextInput_withSpaces_updatesStateWithVerbatimAnswer() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeTextInputState() @@ -1098,14 +823,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_eighthState_submitWrongAnswer_updatePendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeTextInputState() @@ -1124,14 +842,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterMovePreviousAndNext_returnsCurrentState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -1145,14 +856,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterMoveNextAndPrevious_returnsCurrentState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() submitPrototypeState2Answer() // Submit the answer but do not proceed to the next state. @@ -1167,14 +871,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterMoveToPrev_onThirdState_newObserver_receivesCompletedSecondState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() playThroughPrototypeState2AndMoveToNextState() @@ -1190,14 +887,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_forFirstState_doesNotHaveNextState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // The initial state should not have a next state. @@ -1206,14 +896,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_forFirstState_afterAnswerSubmission_doesNotHaveNextState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() val ephemeralState = submitPrototypeState1Answer() @@ -1225,14 +908,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_forSecondState_doesNotHaveNextState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() val ephemeralState = playThroughPrototypeState1AndMoveToNextState() @@ -1243,14 +919,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_forSecondState_navigateBackward_hasNextState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -1262,14 +931,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_forSecondState_navigateBackwardThenForward_doesNotHaveNextState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -1282,14 +944,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forNumericInput_correctAnswer_returnsOutcomeWithTransition() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeNumericInputState() @@ -1303,14 +958,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forNumericInput_wrongAnswer_returnsOutcomeWithTransition() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeNumericInputState() @@ -1324,14 +972,7 @@ class ExplorationProgressControllerTest { @Test fun testSubmitAnswer_forContinue_returnsOutcomeWithTransition() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() // The first state of the exploration is the Continue interaction. @@ -1345,14 +986,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_eleventhState_isTerminalState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() val ephemeralState = playThroughPrototypeExploration() @@ -1363,14 +997,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterMoveToPrevious_onThirdState_updatesToCompletedSecondState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() playThroughPrototypeState2AndMoveToNextState() @@ -1392,14 +1019,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToNext_onFinalState_failsWithError() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeExploration() @@ -1414,14 +1034,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterPlayingFullSecondExploration_returnsTerminalState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_13, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_13) waitForGetCurrentStateSuccessfulLoad() submitImageRegionAnswer(clickX = 0.5f, clickY = 0.5f, clickedRegion = "Saturn") @@ -1435,14 +1048,7 @@ class ExplorationProgressControllerTest { fun testGetCurrentState_afterPlayingFullSecondExploration_diffPath_returnsTerminalState() { // Click on Jupiter before Saturn to take a slightly different (valid) path through the // exploration. (Note that this does not include actual branching). - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_13, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_13) waitForGetCurrentStateSuccessfulLoad() submitImageRegionAnswer(clickX = 0.2f, clickY = 0.5f, clickedRegion = "Jupiter") @@ -1455,26 +1061,12 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_afterPlayingThroughPreviousExplorations_returnsStateFromSecondExp() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeExploration() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_13, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_13) waitForGetCurrentStateSuccessfulLoad() val ephemeralState = submitImageRegionAnswer(clickX = 0.2f, clickY = 0.5f, clickedRegion = "Jupiter") @@ -1487,14 +1079,7 @@ class ExplorationProgressControllerTest { @Test fun testMoveToPrevious_navigatedForwardThenBackToInitial_failsWithError_logsException() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() moveToPreviousState() @@ -1511,14 +1096,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_playInvalidExploration_returnsFailure_logsException() { - playExploration( - profileId.internalId, - INVALID_TOPIC_ID, - INVALID_STORY_ID, - INVALID_EXPLORATION_ID, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + restartExploration(INVALID_TOPIC_ID, INVALID_STORY_ID, INVALID_EXPLORATION_ID) waitForGetCurrentStateFailureLoad() @@ -1529,14 +1107,7 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_loadExploration_checkCheckpointIsSaved() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() val result = @@ -1549,83 +1120,38 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_playThroughMultipleStates_verifyCheckpointHasCorrectPendingStateName() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() - verifyCheckpointHasCorrectPendingStateName( - profileId, - TEST_EXPLORATION_ID_2, - pendingStateName = "Continue" - ) + assertThat(retrieveCheckpointStateName(TEST_EXPLORATION_ID_2)).isEqualTo("Continue") playThroughPrototypeState1AndMoveToNextState() - verifyCheckpointHasCorrectPendingStateName( - profileId, - TEST_EXPLORATION_ID_2, - pendingStateName = "Fractions", - ) + assertThat(retrieveCheckpointStateName(TEST_EXPLORATION_ID_2)).isEqualTo("Fractions") playThroughPrototypeState2AndMoveToNextState() - verifyCheckpointHasCorrectPendingStateName( - profileId, - TEST_EXPLORATION_ID_2, - pendingStateName = "MultipleChoice", - ) + assertThat(retrieveCheckpointStateName(TEST_EXPLORATION_ID_2)).isEqualTo("MultipleChoice") playThroughPrototypeState3AndMoveToNextState() - verifyCheckpointHasCorrectPendingStateName( - profileId, - TEST_EXPLORATION_ID_2, - pendingStateName = "ItemSelectionMinOne", - ) + assertThat(retrieveCheckpointStateName(TEST_EXPLORATION_ID_2)).isEqualTo("ItemSelectionMinOne") } @Test fun testCheckpointing_advToFourthState_backToPrevState_verifyCheckpointHasCorrectPendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() playThroughPrototypeState2AndMoveToNextState() playThroughPrototypeState3AndMoveToNextState() - verifyCheckpointHasCorrectPendingStateName( - profileId, - TEST_EXPLORATION_ID_2, - pendingStateName = "ItemSelectionMinOne", - ) + assertThat(retrieveCheckpointStateName(TEST_EXPLORATION_ID_2)).isEqualTo("ItemSelectionMinOne") moveToPreviousState() - verifyCheckpointHasCorrectPendingStateName( - profileId, - TEST_EXPLORATION_ID_2, - pendingStateName = "ItemSelectionMinOne", - ) + assertThat(retrieveCheckpointStateName(TEST_EXPLORATION_ID_2)).isEqualTo("ItemSelectionMinOne") } @Test fun testCheckpointing_backTwoStates_nextState_verifyCheckpointHasCorrectPendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -1635,23 +1161,12 @@ class ExplorationProgressControllerTest { moveToPreviousState() moveToNextState() - verifyCheckpointHasCorrectPendingStateName( - profileId, - TEST_EXPLORATION_ID_2, - pendingStateName = "ItemSelectionMinOne", - ) + assertThat(retrieveCheckpointStateName(TEST_EXPLORATION_ID_2)).isEqualTo("ItemSelectionMinOne") } @Test fun testCheckpointing_advanceToThirdState_submitMultipleAns_checkCheckpointIsSavedAfterEachAns() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -1660,21 +1175,13 @@ class ExplorationProgressControllerTest { submitMultipleChoiceAnswer(choiceIndex = 0) testCoroutineDispatchers.runCurrent() - verifyCheckpointHasCorrectCountOfAnswers( - profileId, - TEST_EXPLORATION_ID_2, - countOfAnswers = 1 - ) + assertThat(retrieveCheckpointPendingAnswerCount(TEST_EXPLORATION_ID_2)).isEqualTo(1) // option 2 is the correct answer to the third state. submitMultipleChoiceAnswer(choiceIndex = 1) testCoroutineDispatchers.runCurrent() - verifyCheckpointHasCorrectCountOfAnswers( - profileId, - TEST_EXPLORATION_ID_2, - countOfAnswers = 2 - ) + assertThat(retrieveCheckpointPendingAnswerCount(TEST_EXPLORATION_ID_2)).isEqualTo(2) // option 2 is the correct answer to the third state. submitMultipleChoiceAnswer(choiceIndex = 2) @@ -1682,23 +1189,12 @@ class ExplorationProgressControllerTest { // count should be equal to zero because on submission of the correct answer, the // pendingTopState changes. - verifyCheckpointHasCorrectCountOfAnswers( - profileId, - TEST_EXPLORATION_ID_2, - countOfAnswers = 0 - ) + assertThat(retrieveCheckpointPendingAnswerCount(TEST_EXPLORATION_ID_2)).isEqualTo(0) } @Test fun testCheckpointing_advToThirdState_submitAns_prevState_checkCheckpointIsSavedAfterEachAns() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -1707,64 +1203,34 @@ class ExplorationProgressControllerTest { submitMultipleChoiceAnswer(choiceIndex = 1) testCoroutineDispatchers.runCurrent() - verifyCheckpointHasCorrectCountOfAnswers( - profileId, - TEST_EXPLORATION_ID_2, - countOfAnswers = 1 - ) + assertThat(retrieveCheckpointPendingAnswerCount(TEST_EXPLORATION_ID_2)).isEqualTo(1) // option 2 is the correct answer to the third state. submitMultipleChoiceAnswer(choiceIndex = 1) testCoroutineDispatchers.runCurrent() - verifyCheckpointHasCorrectCountOfAnswers( - profileId, - TEST_EXPLORATION_ID_2, - countOfAnswers = 2 - ) + assertThat(retrieveCheckpointPendingAnswerCount(TEST_EXPLORATION_ID_2)).isEqualTo(2) moveToPreviousState() - verifyCheckpointHasCorrectCountOfAnswers( - profileId, - TEST_EXPLORATION_ID_2, - countOfAnswers = 2 - ) + assertThat(retrieveCheckpointPendingAnswerCount(TEST_EXPLORATION_ID_2)).isEqualTo(2) } @Test fun testCheckpointing_advToThirdState_moveToPrevState_checkCheckpointHasStateIndexOfThirdState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() playThroughPrototypeState2AndMoveToNextState() moveToPreviousState() - verifyCheckpointHasCorrectStateIndex( - profileId, - TEST_EXPLORATION_ID_2, - stateIndex = 2 - ) + assertThat(retrieveCheckpointStateIndex(TEST_EXPLORATION_ID_2)).isEqualTo(2) } @Test fun testCheckpointing_advToThirdState_prevStates_nextState_checkCheckpointHasCorrectStateIndex() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -1773,23 +1239,12 @@ class ExplorationProgressControllerTest { moveToPreviousState() moveToNextState() - verifyCheckpointHasCorrectStateIndex( - profileId, - TEST_EXPLORATION_ID_2, - stateIndex = 2 - ) + assertThat(retrieveCheckpointStateIndex(TEST_EXPLORATION_ID_2)).isEqualTo(2) } @Test fun testCheckpointing_hintIsVisible_checkHintIsSavedInCheckpoint() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeFractionInputState() @@ -1797,25 +1252,14 @@ class ExplorationProgressControllerTest { submitWrongAnswerForPrototypeState2() submitWrongAnswerForPrototypeState2() - verifyCheckpointHasCorrectHelpIndex( - profileId, - TEST_EXPLORATION_ID_2, - helpIndex = HelpIndex.newBuilder().apply { - nextAvailableHintIndex = 0 - }.build() - ) + val helpIndex = retrieveCheckpointHelpIndex(TEST_EXPLORATION_ID_2) + assertThat(helpIndex.indexTypeCase).isEqualTo(NEXT_AVAILABLE_HINT_INDEX) + assertThat(helpIndex.nextAvailableHintIndex).isEqualTo(0) } @Test fun testCheckpointing_revealHint_checkHintIsSavedInCheckpoint() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeFractionInputState() @@ -1826,25 +1270,14 @@ class ExplorationProgressControllerTest { monitorFactory.waitForNextSuccessfulResult( explorationProgressController.submitHintIsRevealed(hintIndex = 0) ) - verifyCheckpointHasCorrectHelpIndex( - profileId, - TEST_EXPLORATION_ID_2, - helpIndex = HelpIndex.newBuilder().apply { - latestRevealedHintIndex = 0 - }.build() - ) + val helpIndex = retrieveCheckpointHelpIndex(TEST_EXPLORATION_ID_2) + assertThat(helpIndex.indexTypeCase).isEqualTo(LATEST_REVEALED_HINT_INDEX) + assertThat(helpIndex.latestRevealedHintIndex).isEqualTo(0) } @Test fun testCheckpointing_solutionIsVisible_checkCheckpointIsSaved() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeFractionInputState() @@ -1858,25 +1291,14 @@ class ExplorationProgressControllerTest { submitWrongAnswerForPrototypeState2() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) - verifyCheckpointHasCorrectHelpIndex( - profileId, - TEST_EXPLORATION_ID_2, - helpIndex = HelpIndex.newBuilder().apply { - showSolution = true - }.build() - ) + val helpIndex = retrieveCheckpointHelpIndex(TEST_EXPLORATION_ID_2) + assertThat(helpIndex.indexTypeCase).isEqualTo(SHOW_SOLUTION) + assertThat(helpIndex.showSolution).isTrue() } @Test fun testCheckpointing_revealSolution_checkCheckpointIsSaved() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() navigateToPrototypeFractionInputState() @@ -1893,47 +1315,27 @@ class ExplorationProgressControllerTest { monitorFactory.waitForNextSuccessfulResult( explorationProgressController.submitSolutionIsRevealed() ) - verifyCheckpointHasCorrectHelpIndex( - profileId, - TEST_EXPLORATION_ID_2, - helpIndex = HelpIndex.newBuilder().apply { - everythingRevealed = true - }.build() - ) + val helpIndex = retrieveCheckpointHelpIndex(TEST_EXPLORATION_ID_2) + assertThat(helpIndex.indexTypeCase).isEqualTo(EVERYTHING_REVEALED) + assertThat(helpIndex.everythingRevealed).isTrue() } @Test fun testCheckpointing_onStateWithContinueInteraction_pressContinue_correctCheckpointIsSaved() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() // Verify that checkpoint is saved when the exploration moves to the new pendingTopState. - verifyCheckpointHasCorrectPendingStateName( - profileId, - TEST_EXPLORATION_ID_2, - pendingStateName = "Fractions" - ) + assertThat(retrieveCheckpointStateName(TEST_EXPLORATION_ID_2)).isEqualTo("Fractions") } @Test fun testCheckpointing_noCheckpointSaved_checkCheckpointStateIsUnsaved() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = false, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + // 'Replay' the exploration (since the only way to not have saved progress is for the lesson to + // already be completed). + replayExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() assertThat(ephemeralState.checkpointState).isEqualTo(CheckpointState.CHECKPOINT_UNSAVED) @@ -1941,14 +1343,7 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_saveCheckpoint_checkCheckpointStateIsSavedDatabaseNotExceededLimit() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() assertThat(ephemeralState.checkpointState) @@ -1957,14 +1352,7 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_saveCheckpoint_databaseFull_checkpointStateIsSavedDatabaseExceededLimit() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() // For testing, size limit of checkpoint database is set to 150 Bytes, this makes the database // exceed the allocated limit when checkpoint is saved on completing prototypeState 2. @@ -1977,27 +1365,14 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_onSecondState_resumeExploration_expResumedFromCorrectPendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that we're on the second state of the second exploration. @@ -2007,28 +1382,15 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_onSecondState_navigateBack_resumeExploration_checkResumedFromSecondState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() moveToPreviousState() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that we're on the second state of the second exploration. @@ -2038,14 +1400,7 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_onSecondState_submitWrongAns_resumeExploration_checkWrongAnswersVisible() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -2054,14 +1409,8 @@ class ExplorationProgressControllerTest { submitWrongAnswerForPrototypeState2() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that three wrong answers are visible to the user. @@ -2071,14 +1420,7 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_onSecondState_submitRightAns_resumeExploration_expResumedFromCompState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -2087,14 +1429,8 @@ class ExplorationProgressControllerTest { submitPrototypeState2Answer() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that we're on the second state of the second exploration because the continue button @@ -2105,14 +1441,7 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_submitAns_moveToNextState_resumeExploration_answersVisibleOnPrevState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -2121,14 +1450,8 @@ class ExplorationProgressControllerTest { playThroughPrototypeState2AndMoveToNextState() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) waitForGetCurrentStateSuccessfulLoad() val ephemeralState = moveToPreviousState() @@ -2141,27 +1464,14 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_onSecondState_resumeExploration_checkPendingStateDoesNotHaveANextState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that we're on the second state of the second exploration because the continue button @@ -2173,25 +1483,12 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_onFirstState_resumeExploration_checkStateDoesNotHaveAPrevState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that we're on the second state of the second exploration because the continue button @@ -2203,27 +1500,14 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_onSecondState_resumeExploration_checkFirstStateHasANextState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) waitForGetCurrentStateSuccessfulLoad() val ephemeralState = moveToPreviousState() @@ -2236,27 +1520,14 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_onSecondState_resumeExploration_checkFirstStateDoesNotHaveAPrevState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) waitForGetCurrentStateSuccessfulLoad() val ephemeralState = moveToPreviousState() @@ -2269,27 +1540,14 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_onSecondState_resumeExploration_checkSecondStateHasAPrevState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that we're on the second state of the second exploration because the continue button @@ -2301,28 +1559,15 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_submitAns_doNotPressContinueBtn_resumeExp_pendingStateHasNoNextState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() submitPrototypeState2Answer() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that the current state is a completed state but has no next state because we have @@ -2334,27 +1579,14 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_noHintVisible_resumeExp_notHintVisibleOnPendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that the helpIndex.IndexTypeCase is equal to INDEX_TYPE_NOT_SET because no hint @@ -2365,14 +1597,7 @@ class ExplorationProgressControllerTest { @Test fun testCheckpointing_unrevealedHintIsVisible_resumeExp_unrevealedHintIsVisibleOnPendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -2381,36 +1606,23 @@ class ExplorationProgressControllerTest { submitWrongAnswerForPrototypeState2() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that the helpIndex.IndexTypeCase is equal AVAILABLE_NEXT_HINT_HINT_INDEX because a new // unrevealed hint is visible assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX) + .isEqualTo(NEXT_AVAILABLE_HINT_INDEX) assertThat(ephemeralState.isHintRevealed(0)).isFalse() assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX) + .isEqualTo(NEXT_AVAILABLE_HINT_INDEX) assertThat(ephemeralState.pendingState.helpIndex.nextAvailableHintIndex).isEqualTo(0) } @Test fun testCheckpointing_revealedHintIsVisible_resumeExp_revealedHintIsVisibleOnPendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -2421,33 +1633,20 @@ class ExplorationProgressControllerTest { ) endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that the helpIndex.IndexTypeCase is equal LATEST_REVEALED_HINT_INDEX because a new // revealed hint is visible assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.LATEST_REVEALED_HINT_INDEX) + .isEqualTo(LATEST_REVEALED_HINT_INDEX) assertThat(ephemeralState.isHintRevealed(0)).isTrue() } @Test fun testCheckpointing_revealedHintIsVisible_resumeExp_wait10Seconds_solutionIsNotVisible() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -2458,14 +1657,8 @@ class ExplorationProgressControllerTest { ) endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) waitForGetCurrentStateSuccessfulLoad() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(10)) @@ -2473,21 +1666,14 @@ class ExplorationProgressControllerTest { // revealed hint is visible val ephemeralState = waitForGetCurrentStateSuccessfulLoad() assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.LATEST_REVEALED_HINT_INDEX) + .isEqualTo(LATEST_REVEALED_HINT_INDEX) assertThat(ephemeralState.isHintRevealed(0)).isTrue() assertThat(ephemeralState.isSolutionRevealed()).isFalse() } @Test fun testCheckpointing_revealedHintIsVisible_resumeExp_wait30Seconds_solutionIsNotVisible() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -2498,14 +1684,8 @@ class ExplorationProgressControllerTest { ) endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) waitForGetCurrentStateSuccessfulLoad() testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) @@ -2513,20 +1693,13 @@ class ExplorationProgressControllerTest { // revealed hint is visible val ephemeralState = waitForGetCurrentStateSuccessfulLoad() assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.SHOW_SOLUTION) + .isEqualTo(SHOW_SOLUTION) assertThat(ephemeralState.isSolutionRevealed()).isFalse() } @Test fun testCheckpointing_solutionIsVisible_resumeExp_unrevealedSolutionIsVisibleOnPendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -2541,34 +1714,21 @@ class ExplorationProgressControllerTest { testCoroutineDispatchers.advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - explorationCheckpoint = retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that the helpIndex.IndexTypeCase is equal EVERYTHING_IS_REVEALED because all available // help has been revealed. assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.SHOW_SOLUTION) + .isEqualTo(SHOW_SOLUTION) assertThat(ephemeralState.isHintRevealed(0)).isTrue() assertThat(ephemeralState.isSolutionRevealed()).isFalse() } @Test fun testCheckpointing_revealedSolution_resumeExp_revealedSolIsVisibleOnPendingState() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() playThroughPrototypeState1AndMoveToNextState() @@ -2586,34 +1746,21 @@ class ExplorationProgressControllerTest { testCoroutineDispatchers.runCurrent() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() // Verify that the helpIndex.IndexTypeCase is equal EVERYTHING_IS_REVEALED because all available // help has been revealed. assertThat(ephemeralState.pendingState.helpIndex.indexTypeCase) - .isEqualTo(HelpIndex.IndexTypeCase.EVERYTHING_REVEALED) + .isEqualTo(EVERYTHING_REVEALED) assertThat(ephemeralState.isHintRevealed(0)).isTrue() assertThat(ephemeralState.isSolutionRevealed()).isTrue() } @Test fun testCheckpointing_playSomeStates_resumeExp_playRemainingState_verifyTerminalStateReached() { - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) waitForGetCurrentStateSuccessfulLoad() // Play through some states in the exploration. @@ -2624,14 +1771,8 @@ class ExplorationProgressControllerTest { playThroughPrototypeState5AndMoveToNextState() endExploration() - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - retrieveExplorationCheckpoint(profileId, TEST_EXPLORATION_ID_2) - ) + val checkpoint = retrieveExplorationCheckpoint(TEST_EXPLORATION_ID_2) + resumeExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, checkpoint) waitForGetCurrentStateSuccessfulLoad() // Resume exploration and play through the remaining states in the exploration. @@ -2651,14 +1792,7 @@ class ExplorationProgressControllerTest { @RunOn(buildEnvironments = [BuildEnvironment.BAZEL]) // Languages unsupported in Gradle builds. fun testGetCurrentState_englishLocale_defaultContentLang_includesTranslationContextForEnglish() { forceDefaultLocale(Locale.US) - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() @@ -2674,14 +1808,7 @@ class ExplorationProgressControllerTest { @RunOn(buildEnvironments = [BuildEnvironment.BAZEL]) // Languages unsupported in Gradle builds. fun testGetCurrentState_arabicLocale_defaultContentLang_includesTranslationContextForArabic() { forceDefaultLocale(EGYPT_ARABIC_LOCALE) - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() @@ -2693,14 +1820,7 @@ class ExplorationProgressControllerTest { @Test fun testGetCurrentState_turkishLocale_defaultContentLang_includesDefaultTranslationContext() { forceDefaultLocale(TURKEY_TURKISH_LOCALE) - playExploration( - profileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() - ) + startPlayingNewExploration(TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() @@ -2713,13 +1833,8 @@ class ExplorationProgressControllerTest { fun testGetCurrentState_englishLangProfile_includesTranslationContextForEnglish() { val englishProfileId = ProfileId.newBuilder().apply { internalId = 1 }.build() updateContentLanguage(englishProfileId, OppiaLanguage.ENGLISH) - playExploration( - englishProfileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() + startPlayingNewExploration( + TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, englishProfileId ) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() @@ -2736,13 +1851,8 @@ class ExplorationProgressControllerTest { fun testGetCurrentState_englishLangProfile_switchToArabic_includesTranslationContextForArabic() { val englishProfileId = ProfileId.newBuilder().apply { internalId = 1 }.build() updateContentLanguage(englishProfileId, OppiaLanguage.ENGLISH) - playExploration( - englishProfileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() + startPlayingNewExploration( + TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, englishProfileId ) val monitor = monitorFactory.createMonitor(explorationProgressController.getCurrentState()) monitor.waitForNextSuccessResult() @@ -2763,13 +1873,8 @@ class ExplorationProgressControllerTest { val arabicProfileId = ProfileId.newBuilder().apply { internalId = 2 }.build() updateContentLanguage(englishProfileId, OppiaLanguage.ENGLISH) updateContentLanguage(arabicProfileId, OppiaLanguage.ARABIC) - playExploration( - arabicProfileId.internalId, - TEST_TOPIC_ID_0, - TEST_STORY_ID_0, - TEST_EXPLORATION_ID_2, - shouldSavePartialProgress = true, - ExplorationCheckpoint.getDefaultInstance() + startPlayingNewExploration( + TEST_TOPIC_ID_0, TEST_STORY_ID_0, TEST_EXPLORATION_ID_2, arabicProfileId ) val ephemeralState = waitForGetCurrentStateSuccessfulLoad() @@ -2783,32 +1888,65 @@ class ExplorationProgressControllerTest { ApplicationProvider.getApplicationContext().inject(this) } - private fun retrieveExplorationCheckpoint( - profileId: ProfileId, - explorationId: String - ): ExplorationCheckpoint { - val explorationCheckpointDataProvider = - explorationCheckpointController.retrieveExplorationCheckpoint(profileId, explorationId) - return monitorFactory.waitForNextSuccessfulResult(explorationCheckpointDataProvider) + private fun startPlayingNewExploration( + topicId: String, + storyId: String, + explorationId: String, + profileId: ProfileId = this.profileId + ) { + val startPlayingProvider = + explorationDataController.startPlayingNewExploration( + profileId.internalId, topicId, storyId, explorationId + ) + monitorFactory.waitForNextSuccessfulResult(startPlayingProvider) } - private fun playExploration( - internalProfileId: Int, + private fun resumeExploration( topicId: String, storyId: String, explorationId: String, - shouldSavePartialProgress: Boolean, - explorationCheckpoint: ExplorationCheckpoint + explorationCheckpoint: ExplorationCheckpoint, + profileId: ProfileId = this.profileId ) { - monitorFactory.waitForNextSuccessfulResult( - explorationDataController.startPlayingExploration( - internalProfileId, - topicId, - storyId, - explorationId, - shouldSavePartialProgress, - explorationCheckpoint + val startPlayingProvider = + explorationDataController.resumeExploration( + profileId.internalId, topicId, storyId, explorationId, explorationCheckpoint ) + monitorFactory.waitForNextSuccessfulResult(startPlayingProvider) + } + + private fun restartExploration( + topicId: String, + storyId: String, + explorationId: String, + profileId: ProfileId = this.profileId + ) { + val startPlayingProvider = + explorationDataController.restartExploration( + profileId.internalId, topicId, storyId, explorationId + ) + monitorFactory.waitForNextSuccessfulResult(startPlayingProvider) + } + + private fun replayExploration( + topicId: String, + storyId: String, + explorationId: String, + profileId: ProfileId = this.profileId + ) { + val startPlayingProvider = + explorationDataController.replayExploration( + profileId.internalId, topicId, storyId, explorationId + ) + monitorFactory.waitForNextSuccessfulResult(startPlayingProvider) + } + + private fun retrieveExplorationCheckpoint( + explorationId: String, + profileId: ProfileId = this.profileId + ): ExplorationCheckpoint { + return monitorFactory.waitForNextSuccessfulResult( + explorationCheckpointController.retrieveExplorationCheckpoint(profileId, explorationId) ) } @@ -3056,7 +2194,9 @@ class ExplorationProgressControllerTest { } private fun endExploration() { - monitorFactory.waitForNextSuccessfulResult(explorationDataController.stopPlayingExploration()) + monitorFactory.waitForNextSuccessfulResult( + explorationDataController.stopPlayingExploration(isCompletion = false) + ) } private fun createContinueButtonAnswer() = @@ -3181,41 +2321,17 @@ class ExplorationProgressControllerTest { private fun EphemeralState.isSolutionRevealed(): Boolean = pendingState.helpIndex.isSolutionRevealed() - private fun verifyCheckpointHasCorrectPendingStateName( - profileId: ProfileId, - explorationId: String, - pendingStateName: String - ) { - val checkpoint = retrieveExplorationCheckpoint(profileId, explorationId) - assertThat(checkpoint.pendingStateName).isEqualTo(pendingStateName) - } + private fun retrieveCheckpointPendingAnswerCount(explorationId: String) = + retrieveExplorationCheckpoint(explorationId).pendingUserAnswersCount - private fun verifyCheckpointHasCorrectCountOfAnswers( - profileId: ProfileId, - explorationId: String, - countOfAnswers: Int - ) { - val checkpoint = retrieveExplorationCheckpoint(profileId, explorationId) - assertThat(checkpoint.pendingUserAnswersCount).isEqualTo(countOfAnswers) - } + private fun retrieveCheckpointStateName(explorationId: String) = + retrieveExplorationCheckpoint(explorationId).pendingStateName - private fun verifyCheckpointHasCorrectStateIndex( - profileId: ProfileId, - explorationId: String, - stateIndex: Int - ) { - val checkpoint = retrieveExplorationCheckpoint(profileId, explorationId) - assertThat(checkpoint.stateIndex).isEqualTo(stateIndex) - } + private fun retrieveCheckpointStateIndex(explorationId: String) = + retrieveExplorationCheckpoint(explorationId).stateIndex - private fun verifyCheckpointHasCorrectHelpIndex( - profileId: ProfileId, - explorationId: String, - helpIndex: HelpIndex - ) { - val checkpoint = retrieveExplorationCheckpoint(profileId, explorationId) - assertThat(checkpoint.helpIndex).isEqualTo(helpIndex) - } + private fun retrieveCheckpointHelpIndex(explorationId: String) = + retrieveExplorationCheckpoint(explorationId).helpIndex // TODO(#89): Move this to a common test application component. @Module @@ -3282,7 +2398,10 @@ class ExplorationProgressControllerTest { TestExplorationStorageModule::class, HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, AssetModule::class, LocaleProdModule::class, NumericExpressionInputModule::class, - AlgebraicExpressionInputModule::class, MathEquationInputModule::class + AlgebraicExpressionInputModule::class, MathEquationInputModule::class, + LoggingIdentifierModule::class, ApplicationLifecycleModule::class, + SyncStatusModule::class, PlatformParameterModule::class, + PlatformParameterSingletonModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/BUILD.bazel index 5b8b31df22c..3f0e7adec63 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/BUILD.bazel +++ b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/BUILD.bazel @@ -13,7 +13,7 @@ oppia_android_test( test_manifest = "//domain:test_manifest", deps = [ ":dagger", - "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", "//testing", "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", @@ -38,7 +38,7 @@ oppia_android_test( test_manifest = "//domain:test_manifest", deps = [ ":dagger", - "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", "//testing", "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", "//testing/src/main/java/org/oppia/android/testing/threading:test_module", diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierControllerTest.kt new file mode 100644 index 00000000000..917b6847310 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierControllerTest.kt @@ -0,0 +1,423 @@ +package org.oppia.android.domain.oppialogger + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.protobuf.MessageLite +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.DeviceContextDatabase +import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.data.DataProviderTestMonitor +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestCoroutineDispatchers +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.data.AsyncDataSubscriptionManager +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.logging.EnableConsoleLog +import org.oppia.android.util.logging.EnableFileLog +import org.oppia.android.util.logging.GlobalLogLevel +import org.oppia.android.util.logging.LogLevel +import org.oppia.android.util.logging.SyncStatusModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi +import org.oppia.android.util.platformparameter.LearnerStudyAnalytics +import org.oppia.android.util.platformparameter.PlatformParameterValue +import org.oppia.android.util.platformparameter.SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SplashScreenWelcomeMsg +import org.oppia.android.util.platformparameter.SyncUpWorkerTimePeriodHours +import org.oppia.android.util.threading.BackgroundDispatcher +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.io.File +import java.io.FileOutputStream +import java.lang.IllegalStateException +import java.util.Random +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [LoggingIdentifierController]. */ +// Same parameter value: helpers reduce test context, even if they are used by 1 test. +// Function name: test names are conventionally named with underscores. +@Suppress("SameParameterValue", "FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = LoggingIdentifierControllerTest.TestApplication::class) +class LoggingIdentifierControllerTest { + @Inject lateinit var loggingIdentifierController: LoggingIdentifierController + @Inject lateinit var asyncDataSubscriptionManager: AsyncDataSubscriptionManager + @Inject lateinit var context: Context + @Inject lateinit var machineLocale: OppiaLocale.MachineLocale + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + @field:[BackgroundDispatcher Inject] lateinit var backgroundDispatcher: CoroutineDispatcher + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testCreateLearnerId_verifyCreatesCorrectRandomValue() { + val randomLearnerId = loggingIdentifierController.createLearnerId() + + val testLearnerId = machineLocale.run { + "%08x".formatForMachines(Random(TestLoggingIdentifierModule.applicationIdSeed).nextInt()) + } + assertThat(randomLearnerId).isEqualTo(testLearnerId) + assertThat(randomLearnerId.length).isEqualTo(8) + } + + @Test + fun testCreateLearnerId_twice_bothAreDifferent() { + val learnerId1 = loggingIdentifierController.createLearnerId() + val learnerId2 = loggingIdentifierController.createLearnerId() + + // There's no actual good way to test the generator beyond just verifying that two subsequent + // IDs are different. Note that this technically has an extremely slim chance to flake, but it + // realistically should never happen. + assertThat(learnerId1).isNotEqualTo(learnerId2) + } + + @Test + fun testGetInstallationId_initialAppState_providerReturnsNewInstallationIdValue() { + val installationId = + monitorFactory.waitForNextSuccessfulResult(loggingIdentifierController.getInstallationId()) + + assertThat(installationId).isEqualTo("bc1f80ab5d8c") + assertThat(installationId.length).isEqualTo(12) + } + + @Test + fun testGetInstallationId_secondAppOpen_providerReturnsSameInstallationIdValue() { + monitorFactory.ensureDataProviderExecutes(loggingIdentifierController.getInstallationId()) + setUpTestApplicationComponent() // Simulate an app re-open. + + val installationId = + monitorFactory.waitForNextSuccessfulResult(loggingIdentifierController.getInstallationId()) + + // The same value should return for the second instance of the controller. + assertThat(installationId).isEqualTo("bc1f80ab5d8c") + } + + @Test + fun testGetInstallationId_secondAppOpen_emptiedDatabase_providerReturnsEmptyString() { + writeFileCache("device_context_database", DeviceContextDatabase.getDefaultInstance()) + + val installationId = + monitorFactory.waitForNextSuccessfulResult(loggingIdentifierController.getInstallationId()) + + // The installation ID is empty since the database was overwritten. + assertThat(installationId).isEmpty() + } + + @Test + fun testFetchInstallationId_initialAppState_returnsNewInstallationIdValue() { + val installationId = fetchSuccessfulAsyncValue(loggingIdentifierController::fetchInstallationId) + + assertThat(installationId).isEqualTo("bc1f80ab5d8c") + assertThat(installationId?.length).isEqualTo(12) + } + + @Test + fun testFetchInstallationId_secondAppOpen_returnsSameInstallationIdValue() { + monitorFactory.ensureDataProviderExecutes(loggingIdentifierController.getInstallationId()) + setUpTestApplicationComponent() // Simulate an app re-open. + + val installationId = fetchSuccessfulAsyncValue(loggingIdentifierController::fetchInstallationId) + + // The same value should return for the second instance of the controller. + assertThat(installationId).isEqualTo("bc1f80ab5d8c") + } + + @Test + fun testFetchInstallationId_secondAppOpen_emptiedDatabase_returnsNull() { + writeFileCache("device_context_database", DeviceContextDatabase.getDefaultInstance()) + + val installationId = fetchSuccessfulAsyncValue(loggingIdentifierController::fetchInstallationId) + + // The installation ID is null since the database was overwritten. + assertThat(installationId).isNull() + } + + @Test + fun testGetSessionId_initialState_returnsRandomId() { + val sessionIdProvider = loggingIdentifierController.getSessionId() + + val sessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + assertThat(sessionId).isEqualTo("1c46e9d5-5902-311a-bbba-a75973c3ccd2") + } + + @Test + fun testGetSessionId_secondCall_returnsSameRandomId() { + monitorFactory.ensureDataProviderExecutes(loggingIdentifierController.getSessionId()) + + val sessionIdProvider = loggingIdentifierController.getSessionId() + + // The second call should return the same ID (since the ID doesn't automatically change). + val sessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + assertThat(sessionId).isEqualTo("1c46e9d5-5902-311a-bbba-a75973c3ccd2") + } + + @Test + fun testGetSessionIdFlow_initialState_returnsFlowWithRandomId() { + val sessionIdFlow = loggingIdentifierController.getSessionIdFlow() + + val sessionId = sessionIdFlow.waitForLatestValue() + assertThat(sessionId).isEqualTo("1c46e9d5-5902-311a-bbba-a75973c3ccd2") + } + + @Test + fun testGetSessionIdFlow_secondCall_returnsFlowWithSameRandomId() { + loggingIdentifierController.getSessionIdFlow().waitForLatestValue() + + val sessionIdFlow = loggingIdentifierController.getSessionIdFlow() + + // The second call should return the same ID (since the ID doesn't automatically change). + val sessionId = sessionIdFlow.waitForLatestValue() + assertThat(sessionId).isEqualTo("1c46e9d5-5902-311a-bbba-a75973c3ccd2") + } + + @Test + fun testUpdateSessionId_changesRandomIdReturnedByGetSessionId() { + loggingIdentifierController.updateSessionId() + testCoroutineDispatchers.runCurrent() + + val sessionIdProvider = loggingIdentifierController.getSessionId() + + // The session ID should be changed since updateSessionId() was called. + val sessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + assertThat(sessionId).isEqualTo("8808493e-6576-3e26-9cbf-d1008051b253") + } + + @Test + fun testUpdateSessionId_notifiesExistingProviderOfTheChange() { + val sessionIdProvider = loggingIdentifierController.getSessionId() + val monitor = monitorFactory.createMonitor(sessionIdProvider) + monitor.waitForNextResult() // Fetch the initial state. + + loggingIdentifierController.updateSessionId() + testCoroutineDispatchers.runCurrent() + + // The existing provider should've been notified of the changed session ID. + val sessionId = monitor.ensureNextResultIsSuccess() + assertThat(sessionId).isEqualTo("8808493e-6576-3e26-9cbf-d1008051b253") + } + + @Test + fun testUpdateSessionId_twice_changesRandomIdReturnedByGetSessionIdAgain() { + // Update the session ID twice. + loggingIdentifierController.updateSessionId() + testCoroutineDispatchers.runCurrent() + loggingIdentifierController.updateSessionId() + testCoroutineDispatchers.runCurrent() + + val sessionIdProvider = loggingIdentifierController.getSessionId() + + // The session ID should be changed yet again due to updateSessionId() being called twice. + val sessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + assertThat(sessionId).isEqualTo("8aeabb00-af70-39e4-89b3-c47c9900ec4f") + } + + @Test + fun testUpdateSessionId_changesRandomIdReturnedByGetSessionIdFlow() { + loggingIdentifierController.updateSessionId() + testCoroutineDispatchers.runCurrent() + + val sessionIdFlow = loggingIdentifierController.getSessionIdFlow() + + // The session ID should be changed since updateSessionId() was called. + val sessionId = sessionIdFlow.waitForLatestValue() + assertThat(sessionId).isEqualTo("8808493e-6576-3e26-9cbf-d1008051b253") + } + + @Test + fun testUpdateSessionId_updatesExistingSessionIdFlowValue() { + val sessionIdFlow = loggingIdentifierController.getSessionIdFlow() + sessionIdFlow.waitForLatestValue() // Fetch the initial value. + + loggingIdentifierController.updateSessionId() + testCoroutineDispatchers.runCurrent() + + // The current value of the exist flow should be changed now since the session ID was updated. + assertThat(sessionIdFlow.value).isEqualTo("8808493e-6576-3e26-9cbf-d1008051b253") + } + + private fun writeFileCache(cacheName: String, value: T) { + getCacheFile(cacheName).writeBytes(value.toByteArray()) + } + + private fun getCacheFile(cacheName: String) = File(context.filesDir, "$cacheName.cache") + + private fun File.writeBytes(data: ByteArray) { + FileOutputStream(this).use { it.write(data) } + } + + private fun fetchSuccessfulAsyncValue(block: suspend () -> T) = + CoroutineScope(backgroundDispatcher).async { block() }.waitForSuccessfulResult() + + private fun Deferred.waitForSuccessfulResult(): T { + return when (val result = waitForResult()) { + is AsyncResult.Pending -> error("Deferred never finished.") + is AsyncResult.Success -> result.value + is AsyncResult.Failure -> throw IllegalStateException("Deferred failed", result.error) + } + } + + private fun Deferred.waitForResult() = toStateFlow().waitForLatestValue() + + private fun Deferred.toStateFlow(): StateFlow> { + val deferred = this + return MutableStateFlow>(value = AsyncResult.Pending()).also { flow -> + CoroutineScope(backgroundDispatcher).async { + flow.emit(AsyncResult.Success(deferred.await())) + }.invokeOnCompletion { + it?.let { flow.tryEmit(AsyncResult.Failure(it)) } + } + } + } + + private fun StateFlow.waitForLatestValue(): T = + also { testCoroutineDispatchers.runCurrent() }.value + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + // TODO(#59): Either isolate these to their own shared test module, or use the real logging + // module in tests to avoid needing to specify these settings for tests. + @EnableConsoleLog + @Provides + fun provideEnableConsoleLog(): Boolean = true + + @EnableFileLog + @Provides + fun provideEnableFileLog(): Boolean = false + + @GlobalLogLevel + @Provides + fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + } + + @Module + class TestLogStorageModule { + + @Provides + @EventLogStorageCacheSize + fun provideEventLogStorageCacheSize(): Int = 2 + } + + @Module + class TestLoggingIdentifierModule { + companion object { + internal const val applicationIdSeed = 1L + } + + @Provides + @ApplicationIdSeed + fun provideApplicationIdSeed(): Long = applicationIdSeed + } + + @Module + class TestPlatformParameterModule { + + companion object { + var forceLearnerAnalyticsStudy: Boolean = false + } + + @Provides + @SplashScreenWelcomeMsg + fun provideSplashScreenWelcomeMsgParam(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE) + } + + @Provides + @SyncUpWorkerTimePeriodHours + fun provideSyncUpWorkerTimePeriod(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE + ) + } + + @Provides + @EnableLanguageSelectionUi + fun provideEnableLanguageSelectionUi(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE + ) + } + + @Provides + @LearnerStudyAnalytics + fun provideLearnerStudyAnalytics(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(forceLearnerAnalyticsStudy) + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, TestLogReportingModule::class, TestLogStorageModule::class, + TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, + NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, + TestPlatformParameterModule::class, PlatformParameterSingletonModule::class, + TestLoggingIdentifierModule::class, ApplicationLifecycleModule::class, SyncStatusModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(loggingIdentifierControllerTest: LoggingIdentifierControllerTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerLoggingIdentifierControllerTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(loggingIdentifierControllerTest: LoggingIdentifierControllerTest) { + component.inject(loggingIdentifierControllerTest) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierModuleTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierModuleTest.kt new file mode 100644 index 00000000000..627db547a66 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierModuleTest.kt @@ -0,0 +1,82 @@ +package org.oppia.android.domain.oppialogger + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.util.system.OppiaClock +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [LoggingIdentifierModule]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class LoggingIdentifierModuleTest { + private companion object { + private const val FIXED_CURRENT_TIME_MS = 12345L + } + + @field:[JvmField Inject ApplicationIdSeed] var applicationIdSeed: Long = Long.MIN_VALUE + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testInjectApplicationIdSeed_isTheCurrentTimeInMillis() { + assertThat(applicationIdSeed).isEqualTo(FIXED_CURRENT_TIME_MS) + } + + private fun setUpTestApplicationComponent() { + DaggerLoggingIdentifierModuleTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + // FakeOppiaClock can't be used since this test suite needs to verify an injection-time clock + // call, and the fake defaults to wall-clock time and can't be configured until after injection + // time. + @Provides + fun provideOppiaClock(): OppiaClock = object : OppiaClock { + override fun getCurrentTimeMs(): Long = FIXED_CURRENT_TIME_MS + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component(modules = [TestModule::class, LoggingIdentifierModule::class]) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(test: LoggingIdentifierModuleTest) + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt index 87e9108580f..33973b482c6 100644 --- a/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt @@ -24,68 +24,102 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_QUE import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_CARD import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_TAB import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_STORY_ACTIVITY +import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.testing.FakeEventLogger import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.EnableConsoleLog import org.oppia.android.util.logging.EnableFileLog import org.oppia.android.util.logging.GlobalLogLevel import org.oppia.android.util.logging.LogLevel +import org.oppia.android.util.logging.SyncStatusModule import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi +import org.oppia.android.util.platformparameter.LearnerStudyAnalytics +import org.oppia.android.util.platformparameter.PlatformParameterValue +import org.oppia.android.util.platformparameter.SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SplashScreenWelcomeMsg +import org.oppia.android.util.platformparameter.SyncUpWorkerTimePeriodHours import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import org.robolectric.shadows.ShadowLog import javax.inject.Inject import javax.inject.Singleton -private const val TEST_TOPIC_ID = "test_topicId" -private const val TEST_STORY_ID = "test_storyId" -private const val TEST_EXPLORATION_ID = "test_explorationId" -private const val TEST_QUESTION_ID = "test_questionId" -private const val TEST_SKILL_ID = "test_skillId" -private const val TEST_SKILL_LIST_ID = "test_skillListId" -private const val TEST_SUB_TOPIC_ID = 1 - -private const val TEST_VERBOSE_LOG_TAG = "test_verbose_log_tag" -private const val TEST_VERBOSE_LOG_MSG = "test_verbose_log_msg" -private const val TEST_VERBOSE_LOG_EXCEPTION = "test_verbose_log_exception" - -private const val TEST_DEBUG_LOG_TAG = "test_debug_log_tag" -private const val TEST_DEBUG_LOG_MSG = "test_debug_log_msg" -private const val TEST_DEBUG_LOG_EXCEPTION = "test_debug_log_exception" - -private const val TEST_INFO_LOG_TAG = "test_info_log_tag" -private const val TEST_INFO_LOG_MSG = "test_info_log_msg" -private const val TEST_INFO_LOG_EXCEPTION = "test_info_log_exception" - -private const val TEST_WARN_LOG_TAG = "test_warn_log_tag" -private const val TEST_WARN_LOG_MSG = "test_warn_log_msg" -private const val TEST_WARN_LOG_EXCEPTION = "test_warn_log_exception" - -private const val TEST_ERROR_LOG_TAG = "test_error_log_tag" -private const val TEST_ERROR_LOG_MSG = "test_error_log_msg" -private const val TEST_ERROR_LOG_EXCEPTION = "test_error_log_exception" - +/** Tests for [OppiaLogger]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) @Config(manifest = Config.NONE) class OppiaLoggerTest { + private companion object { + private const val TEST_TIMESTAMP = 1234567898765 + private const val TEST_TOPIC_ID = "test_topicId" + private const val TEST_STORY_ID = "test_storyId" + private const val TEST_EXPLORATION_ID = "test_explorationId" + private const val TEST_QUESTION_ID = "test_questionId" + private const val TEST_SKILL_ID = "test_skillId" + private const val TEST_SKILL_LIST_ID = "test_skillListId" + private const val TEST_SUB_TOPIC_ID = 1 + + private const val TEST_VERBOSE_LOG_TAG = "test_verbose_log_tag" + private const val TEST_VERBOSE_LOG_MSG = "test_verbose_log_msg" + private const val TEST_VERBOSE_LOG_EXCEPTION = "test_verbose_log_exception" + + private const val TEST_DEBUG_LOG_TAG = "test_debug_log_tag" + private const val TEST_DEBUG_LOG_MSG = "test_debug_log_msg" + private const val TEST_DEBUG_LOG_EXCEPTION = "test_debug_log_exception" + + private const val TEST_INFO_LOG_TAG = "test_info_log_tag" + private const val TEST_INFO_LOG_MSG = "test_info_log_msg" + private const val TEST_INFO_LOG_EXCEPTION = "test_info_log_exception" + + private const val TEST_WARN_LOG_TAG = "test_warn_log_tag" + private const val TEST_WARN_LOG_MSG = "test_warn_log_msg" + private const val TEST_WARN_LOG_EXCEPTION = "test_warn_log_exception" + + private const val TEST_ERROR_LOG_TAG = "test_error_log_tag" + private const val TEST_ERROR_LOG_MSG = "test_error_log_msg" + private const val TEST_ERROR_LOG_EXCEPTION = "test_error_log_exception" + + private val TEST_VERBOSE_EXCEPTION = Throwable(TEST_VERBOSE_LOG_EXCEPTION) + private val TEST_DEBUG_EXCEPTION = Throwable(TEST_DEBUG_LOG_EXCEPTION) + private val TEST_INFO_EXCEPTION = Throwable(TEST_INFO_LOG_EXCEPTION) + private val TEST_WARN_EXCEPTION = Throwable(TEST_WARN_LOG_EXCEPTION) + private val TEST_ERROR_EXCEPTION = Throwable(TEST_ERROR_LOG_EXCEPTION) + } + + @Inject lateinit var oppiaLogger: OppiaLogger + @Inject lateinit var fakeEventLogger: FakeEventLogger + @Inject lateinit var fakeOppiaClock: FakeOppiaClock + @Before fun setUp() { setUpTestApplicationComponent() ShadowLog.reset() } - @Inject - lateinit var oppiaLogger: OppiaLogger + @Test + fun testLogImportantEvent_forOpenHomeEvent_logsEssentialEventWithCurrentTime() { + fakeOppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + fakeOppiaClock.setCurrentTimeMs(TEST_TIMESTAMP) + val openHomeEventContext = oppiaLogger.createOpenHomeContext() + + oppiaLogger.logImportantEvent(openHomeEventContext) - private val TEST_VERBOSE_EXCEPTION = Throwable(TEST_VERBOSE_LOG_EXCEPTION) - private val TEST_DEBUG_EXCEPTION = Throwable(TEST_DEBUG_LOG_EXCEPTION) - private val TEST_INFO_EXCEPTION = Throwable(TEST_INFO_LOG_EXCEPTION) - private val TEST_WARN_EXCEPTION = Throwable(TEST_WARN_LOG_EXCEPTION) - private val TEST_ERROR_EXCEPTION = Throwable(TEST_ERROR_LOG_EXCEPTION) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + } @Test fun testConsoleLogger_logVerboseMessage_checkLoggedMessageIsCorrect() { @@ -316,6 +350,42 @@ class OppiaLoggerTest { fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE } + @Module + class TestPlatformParameterModule { + + companion object { + var forceLearnerAnalyticsStudy: Boolean = false + } + + @Provides + @SplashScreenWelcomeMsg + fun provideSplashScreenWelcomeMsgParam(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE) + } + + @Provides + @SyncUpWorkerTimePeriodHours + fun provideSyncUpWorkerTimePeriod(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE + ) + } + + @Provides + @EnableLanguageSelectionUi + fun provideEnableLanguageSelectionUi(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE + ) + } + + @Provides + @LearnerStudyAnalytics + fun provideLearnerStudyAnalytics(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(forceLearnerAnalyticsStudy) + } + } + @Module class TestLogStorageModule { @@ -330,7 +400,9 @@ class OppiaLoggerTest { modules = [ TestModule::class, TestLogReportingModule::class, TestLogStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, - NetworkConnectionUtilDebugModule::class, LocaleProdModule::class + NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, + TestPlatformParameterModule::class, PlatformParameterSingletonModule::class, + LoggingIdentifierModule::class, SyncStatusModule::class, ApplicationLifecycleModule::class ] ) interface TestApplicationComponent { diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/AnalyticsControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/AnalyticsControllerTest.kt index 6c221881999..f5e0c26ac20 100644 --- a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/AnalyticsControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/AnalyticsControllerTest.kt @@ -12,21 +12,17 @@ import dagger.Provides import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_CONCEPT_CARD -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_EXPLORATION_ACTIVITY -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_INFO_TAB -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_LESSONS_TAB -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_PRACTICE_TAB -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_QUESTION_PLAYER -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_CARD -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_TAB -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_STORY_ACTIVITY -import org.oppia.android.app.model.EventLog.Priority import org.oppia.android.domain.oppialogger.EventLogStorageCacheSize +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.testing.FakeEventLogger import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.data.DataProviderTestMonitor +import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat +import org.oppia.android.testing.logging.FakeSyncStatusManager +import org.oppia.android.testing.logging.SyncStatusTestModule import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClockModule @@ -55,6 +51,7 @@ private const val TEST_SKILL_ID = "test_skillId" private const val TEST_SKILL_LIST_ID = "test_skillListId" private const val TEST_SUB_TOPIC_ID = 1 +/** Tests for [AnalyticsController]. */ // FunctionName: test names are conventionally named with underscores. @Suppress("FunctionName") @RunWith(AndroidJUnit4::class) @@ -67,6 +64,7 @@ class AnalyticsControllerTest { @Inject lateinit var fakeEventLogger: FakeEventLogger @Inject lateinit var dataProviders: DataProviders @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + @Inject lateinit var fakeSyncStatusManager: FakeSyncStatusManager @Before fun setUp() { @@ -74,8 +72,8 @@ class AnalyticsControllerTest { } @Test - fun testController_logTransitionEvent_withQuestionContext_checkLogsEvent() { - analyticsController.logTransitionEvent( + fun testController_logImportantEvent_withQuestionContext_checkLogsEvent() { + analyticsController.logImportantEvent( TEST_TIMESTAMP, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -85,16 +83,15 @@ class AnalyticsControllerTest { ) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // ESSENTIAL priority confirms that the event logged is a transition event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.ESSENTIAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_QUESTION_PLAYER) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenQuestionPlayerContext() } @Test - fun testController_logTransitionEvent_withExplorationContext_checkLogsEvent() { - analyticsController.logTransitionEvent( + fun testController_logImportantEvent_withExplorationContext_checkLogsEvent() { + analyticsController.logImportantEvent( TEST_TIMESTAMP, oppiaLogger.createOpenExplorationActivityContext( TEST_TOPIC_ID, @@ -103,114 +100,99 @@ class AnalyticsControllerTest { ) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // ESSENTIAL priority confirms that the event logged is a transition event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.ESSENTIAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_EXPLORATION_ACTIVITY) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenExplorationActivityContext() } @Test - fun testController_logTransitionEvent_withOpenInfoTabContext_checkLogsEvent() { - analyticsController.logTransitionEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenInfoTabContext(TEST_TOPIC_ID) + fun testController_logImportantEvent_withOpenInfoTabContext_checkLogsEvent() { + analyticsController.logImportantEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenInfoTabContext(TEST_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.ESSENTIAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_INFO_TAB) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenInfoTabContext() } @Test - fun testController_logTransitionEvent_withOpenPracticeTabContext_checkLogsEvent() { - analyticsController.logTransitionEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenPracticeTabContext(TEST_TOPIC_ID) + fun testController_logImportantEvent_withOpenPracticeTabContext_checkLogsEvent() { + analyticsController.logImportantEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenPracticeTabContext(TEST_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.ESSENTIAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_PRACTICE_TAB) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenPracticeTabContext() } @Test - fun testController_logTransitionEvent_withOpenLessonsTabContext_checkLogsEvent() { - analyticsController.logTransitionEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenLessonsTabContext(TEST_TOPIC_ID) + fun testController_logImportantEvent_withOpenLessonsTabContext_checkLogsEvent() { + analyticsController.logImportantEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenLessonsTabContext(TEST_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.ESSENTIAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_LESSONS_TAB) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenLessonsTabContext() } @Test - fun testController_logTransitionEvent_withOpenRevisionTabContext_checkLogsEvent() { - analyticsController.logTransitionEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenRevisionTabContext(TEST_TOPIC_ID) + fun testController_logImportantEvent_withOpenRevisionTabContext_checkLogsEvent() { + analyticsController.logImportantEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenRevisionTabContext(TEST_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.ESSENTIAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_REVISION_TAB) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenRevisionTabContext() } @Test - fun testController_logTransitionEvent_withStoryContext_checkLogsEvent() { - analyticsController.logTransitionEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenStoryActivityContext(TEST_TOPIC_ID, TEST_STORY_ID) + fun testController_logImportantEvent_withStoryContext_checkLogsEvent() { + analyticsController.logImportantEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenStoryActivityContext(TEST_TOPIC_ID, TEST_STORY_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // ESSENTIAL priority confirms that the event logged is a transition event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.ESSENTIAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_STORY_ACTIVITY) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenStoryActivityContext() } @Test - fun testController_logTransitionEvent_withRevisionContext_checkLogsEvent() { - analyticsController.logTransitionEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenRevisionCardContext(TEST_TOPIC_ID, TEST_SUB_TOPIC_ID) + fun testController_logImportantEvent_withRevisionContext_checkLogsEvent() { + analyticsController.logImportantEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenRevisionCardContext(TEST_TOPIC_ID, TEST_SUB_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // ESSENTIAL priority confirms that the event logged is a transition event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.ESSENTIAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_REVISION_CARD) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenRevisionCardContext() } @Test - fun testController_logTransitionEvent_withConceptCardContext_checkLogsEvent() { - analyticsController.logTransitionEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenConceptCardContext(TEST_SKILL_ID) + fun testController_logImportantEvent_withConceptCardContext_checkLogsEvent() { + analyticsController.logImportantEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenConceptCardContext(TEST_SKILL_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // ESSENTIAL priority confirms that the event logged is a transition event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.ESSENTIAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_CONCEPT_CARD) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenConceptCardContext() } @Test - fun testController_logClickEvent_withQuestionContext_checkLogsEvent() { - analyticsController.logClickEvent( + fun testController_logLowPriorityEvent_withQuestionContext_checkLogsEvent() { + analyticsController.logLowPriorityEvent( TEST_TIMESTAMP, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -220,16 +202,15 @@ class AnalyticsControllerTest { ) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.OPTIONAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_QUESTION_PLAYER) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenQuestionPlayerContext() } @Test - fun testController_logClickEvent_withExplorationContext_checkLogsEvent() { - analyticsController.logClickEvent( + fun testController_logLowPriorityEvent_withExplorationContext_checkLogsEvent() { + analyticsController.logLowPriorityEvent( TEST_TIMESTAMP, oppiaLogger.createOpenExplorationActivityContext( TEST_TOPIC_ID, @@ -238,118 +219,103 @@ class AnalyticsControllerTest { ) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.OPTIONAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_EXPLORATION_ACTIVITY) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenExplorationActivityContext() } @Test - fun testController_logClickEvent_withOpenInfoTabContext_checkLogsEvent() { - analyticsController.logClickEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenInfoTabContext(TEST_TOPIC_ID) + fun testController_logLowPriorityEvent_withOpenInfoTabContext_checkLogsEvent() { + analyticsController.logLowPriorityEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenInfoTabContext(TEST_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.OPTIONAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_INFO_TAB) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenInfoTabContext() } @Test - fun testController_logClickEvent_withOpenPracticeTabContext_checkLogsEvent() { - analyticsController.logClickEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenPracticeTabContext(TEST_TOPIC_ID) + fun testController_logLowPriorityEvent_withOpenPracticeTabContext_checkLogsEvent() { + analyticsController.logLowPriorityEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenPracticeTabContext(TEST_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.OPTIONAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_PRACTICE_TAB) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenPracticeTabContext() } @Test - fun testController_logClickEvent_withOpenLessonsTabContext_checkLogsEvent() { - analyticsController.logClickEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenLessonsTabContext(TEST_TOPIC_ID) + fun testController_logLowPriorityEvent_withOpenLessonsTabContext_checkLogsEvent() { + analyticsController.logLowPriorityEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenLessonsTabContext(TEST_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.OPTIONAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_LESSONS_TAB) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenLessonsTabContext() } @Test - fun testController_logClickEvent_withOpenRevisionTabContext_checkLogsEvent() { - analyticsController.logClickEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenRevisionTabContext(TEST_TOPIC_ID) + fun testController_logLowPriorityEvent_withOpenRevisionTabContext_checkLogsEvent() { + analyticsController.logLowPriorityEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenRevisionTabContext(TEST_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.OPTIONAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_REVISION_TAB) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenRevisionTabContext() } @Test - fun testController_logClickEvent_withStoryContext_checkLogsEvent() { - analyticsController.logClickEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenStoryActivityContext(TEST_TOPIC_ID, TEST_STORY_ID) + fun testController_logLowPriorityEvent_withStoryContext_checkLogsEvent() { + analyticsController.logLowPriorityEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenStoryActivityContext(TEST_TOPIC_ID, TEST_STORY_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.OPTIONAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_STORY_ACTIVITY) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenStoryActivityContext() } @Test - fun testController_logClickEvent_withRevisionContext_checkLogsEvent() { - analyticsController.logClickEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenRevisionCardContext(TEST_TOPIC_ID, TEST_SUB_TOPIC_ID) + fun testController_logLowPriorityEvent_withRevisionContext_checkLogsEvent() { + analyticsController.logLowPriorityEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenRevisionCardContext(TEST_TOPIC_ID, TEST_SUB_TOPIC_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.OPTIONAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_REVISION_CARD) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenRevisionCardContext() } @Test - fun testController_logClickEvent_withConceptCardContext_checkLogsEvent() { - analyticsController.logClickEvent( - TEST_TIMESTAMP, - oppiaLogger.createOpenConceptCardContext(TEST_SKILL_ID) + fun testController_logLowPriorityEvent_withConceptCardContext_checkLogsEvent() { + analyticsController.logLowPriorityEvent( + TEST_TIMESTAMP, oppiaLogger.createOpenConceptCardContext(TEST_SKILL_ID) ) - assertThat(fakeEventLogger.getMostRecentEvent().timestamp).isEqualTo(TEST_TIMESTAMP) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(fakeEventLogger.getMostRecentEvent().priority).isEqualTo(Priority.OPTIONAL) - assertThat(fakeEventLogger.getMostRecentEvent().context.activityContextCase) - .isEqualTo(OPEN_CONCEPT_CARD) + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenConceptCardContext() } // TODO(#3621): Addition of tests tracking behaviour of the controller after uploading of logs to // the remote service. @Test - fun testController_logTransitionEvent_withNoNetwork_checkLogsEventToStore() { + fun testController_logImportantEvent_withNoNetwork_checkLogsEventToStore() { networkConnectionUtil.setCurrentConnectionStatus(NONE) - analyticsController.logTransitionEvent( + analyticsController.logImportantEvent( TEST_TIMESTAMP, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -362,16 +328,15 @@ class AnalyticsControllerTest { val eventLogsProvider = analyticsController.getEventLogStore() val eventLog = monitorFactory.waitForNextSuccessfulResult(eventLogsProvider).getEventLog(0) - // ESSENTIAL priority confirms that the event logged is a transition event. - assertThat(eventLog.priority).isEqualTo(Priority.ESSENTIAL) - assertThat(eventLog.context.activityContextCase).isEqualTo(OPEN_QUESTION_PLAYER) - assertThat(eventLog.timestamp).isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasOpenQuestionPlayerContext() } @Test - fun testController_logClickEvent_withNoNetwork_checkLogsEventToStore() { + fun testController_logLowPriorityEvent_withNoNetwork_checkLogsEventToStore() { networkConnectionUtil.setCurrentConnectionStatus(NONE) - analyticsController.logClickEvent( + analyticsController.logLowPriorityEvent( TEST_TIMESTAMP, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -384,14 +349,13 @@ class AnalyticsControllerTest { val eventLogsProvider = analyticsController.getEventLogStore() val eventLog = monitorFactory.waitForNextSuccessfulResult(eventLogsProvider).getEventLog(0) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(eventLog.priority).isEqualTo(Priority.OPTIONAL) - assertThat(eventLog.context.activityContextCase).isEqualTo(OPEN_QUESTION_PLAYER) - assertThat(eventLog.timestamp).isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(eventLog).isOptionalPriority() + assertThat(eventLog).hasOpenQuestionPlayerContext() } @Test - fun testController_logTransitionEvent_withNoNetwork_exceedLimit_checkEventLogStoreSize() { + fun testController_logImportantEvent_withNoNetwork_exceedLimit_checkEventLogStoreSize() { networkConnectionUtil.setCurrentConnectionStatus(NONE) logMultipleEvents() @@ -402,9 +366,9 @@ class AnalyticsControllerTest { } @Test - fun testController_logTransitionEvent_logClickEvent_withNoNetwork_checkOrderinCache() { + fun testController_logImportantEvent_logLowPriorityEvent_withNoNetwork_checkOrderinCache() { networkConnectionUtil.setCurrentConnectionStatus(NONE) - analyticsController.logClickEvent( + analyticsController.logLowPriorityEvent( TEST_TIMESTAMP, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -413,7 +377,7 @@ class AnalyticsControllerTest { ) ) ) - analyticsController.logTransitionEvent( + analyticsController.logImportantEvent( TEST_TIMESTAMP, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -429,15 +393,13 @@ class AnalyticsControllerTest { val firstEventLog = eventLogs.getEventLog(0) val secondEventLog = eventLogs.getEventLog(1) - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(firstEventLog.priority).isEqualTo(Priority.OPTIONAL) - // ESSENTIAL priority confirms that the event logged is a transition event. - assertThat(secondEventLog.priority).isEqualTo(Priority.ESSENTIAL) + assertThat(firstEventLog).isOptionalPriority() + assertThat(secondEventLog).isEssentialPriority() } @Test - fun testController_logTransitionEvent_switchToNoNetwork_logClickEvent_checkManagement() { - analyticsController.logTransitionEvent( + fun testController_logImportantEvent_switchToNoNetwork_logLowPriorityEvent_checkManagement() { + analyticsController.logImportantEvent( TEST_TIMESTAMP, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -447,7 +409,7 @@ class AnalyticsControllerTest { ) ) networkConnectionUtil.setCurrentConnectionStatus(NONE) - analyticsController.logClickEvent( + analyticsController.logLowPriorityEvent( TEST_TIMESTAMP, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -462,15 +424,13 @@ class AnalyticsControllerTest { val uploadedEventLog = fakeEventLogger.getMostRecentEvent() val cachedEventLog = monitorFactory.waitForNextSuccessfulResult(logsProvider).getEventLog(0) - // ESSENTIAL priority confirms that the event logged is a transition event. - assertThat(uploadedEventLog.priority).isEqualTo(Priority.ESSENTIAL) - assertThat(uploadedEventLog.context.activityContextCase).isEqualTo(OPEN_QUESTION_PLAYER) - assertThat(uploadedEventLog.timestamp).isEqualTo(TEST_TIMESTAMP) + assertThat(uploadedEventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(uploadedEventLog).isEssentialPriority() + assertThat(uploadedEventLog).hasOpenQuestionPlayerContext() - // OPTIONAL priority confirms that the event logged is a click event. - assertThat(cachedEventLog.priority).isEqualTo(Priority.OPTIONAL) - assertThat(cachedEventLog.context.activityContextCase).isEqualTo(OPEN_QUESTION_PLAYER) - assertThat(cachedEventLog.timestamp).isEqualTo(TEST_TIMESTAMP) + assertThat(cachedEventLog).hasTimestampThat().isEqualTo(TEST_TIMESTAMP) + assertThat(cachedEventLog).isOptionalPriority() + assertThat(cachedEventLog).hasOpenQuestionPlayerContext() } @Test @@ -486,8 +446,8 @@ class AnalyticsControllerTest { assertThat(eventLogs.eventLogList).hasSize(2) // In this case, 3 ESSENTIAL and 1 OPTIONAL event was logged. So while pruning, none of the // retained logs should have OPTIONAL priority. - assertThat(firstEventLog.priority).isNotEqualTo(Priority.OPTIONAL) - assertThat(secondEventLog.priority).isNotEqualTo(Priority.OPTIONAL) + assertThat(firstEventLog).isEssentialPriority() + assertThat(secondEventLog).isEssentialPriority() // If we analyse the implementation of logMultipleEvents(), we can see that record pruning will // begin from the logging of the third record. At first, the second event log will be removed as // it has OPTIONAL priority and the event logged at the third place will become the event record @@ -495,8 +455,57 @@ class AnalyticsControllerTest { // purely based on timestamp of the event as both event logs have ESSENTIAL priority. As the // third event's timestamp was lesser than that of the first event, it will be pruned from the // store and the forth event will become the second event in the store. - assertThat(firstEventLog.timestamp).isEqualTo(1556094120000) - assertThat(secondEventLog.timestamp).isEqualTo(1556094100000) + assertThat(firstEventLog).hasTimestampThat().isEqualTo(1556094120000) + assertThat(secondEventLog).hasTimestampThat().isEqualTo(1556094100000) + } + + @Test + fun testController_logEvent_withoutNetwork_verifySyncStatusIsUnchanged() { + networkConnectionUtil.setCurrentConnectionStatus(NONE) + analyticsController.logImportantEvent( + 1556094120000, + oppiaLogger.createOpenQuestionPlayerContext( + TEST_QUESTION_ID, + listOf( + TEST_SKILL_LIST_ID, TEST_SKILL_LIST_ID + ) + ) + ) + + // TODO(#4064): Ensure that sync status changes here. + assertThat(fakeSyncStatusManager.getSyncStatuses()).isEmpty() + } + + @Test + fun testController_logEvent_afterCompletion_verifySyncStatusIsUnchanged() { + analyticsController.logImportantEvent( + 1556094120000, + oppiaLogger.createOpenQuestionPlayerContext( + TEST_QUESTION_ID, + listOf( + TEST_SKILL_LIST_ID, TEST_SKILL_LIST_ID + ) + ) + ) + + // TODO(#4064): Ensure that sync status changes here. + assertThat(fakeSyncStatusManager.getSyncStatuses()).isEmpty() + } + + @Test + fun testController_logEvent_beforeCompletion_verifySyncStatusIsUnchanged() { + analyticsController.logImportantEvent( + 1556094120000, + oppiaLogger.createOpenQuestionPlayerContext( + TEST_QUESTION_ID, + listOf( + TEST_SKILL_LIST_ID, TEST_SKILL_LIST_ID + ) + ) + ) + + // TODO(#4064): Ensure that sync status changes here. + assertThat(fakeSyncStatusManager.getSyncStatuses()).isEmpty() } private fun setUpTestApplicationComponent() { @@ -504,7 +513,7 @@ class AnalyticsControllerTest { } private fun logMultipleEvents() { - analyticsController.logTransitionEvent( + analyticsController.logImportantEvent( 1556094120000, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -514,7 +523,7 @@ class AnalyticsControllerTest { ) ) - analyticsController.logClickEvent( + analyticsController.logLowPriorityEvent( 1556094110000, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -524,7 +533,7 @@ class AnalyticsControllerTest { ) ) - analyticsController.logTransitionEvent( + analyticsController.logImportantEvent( 1556093100000, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -534,7 +543,7 @@ class AnalyticsControllerTest { ) ) - analyticsController.logTransitionEvent( + analyticsController.logImportantEvent( 1556094100000, oppiaLogger.createOpenQuestionPlayerContext( TEST_QUESTION_ID, @@ -583,7 +592,9 @@ class AnalyticsControllerTest { modules = [ TestModule::class, TestLogReportingModule::class, RobolectricModule::class, TestDispatcherModule::class, TestLogStorageModule::class, - NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, FakeOppiaClockModule::class + NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, FakeOppiaClockModule::class, + PlatformParameterModule::class, PlatformParameterSingletonModule::class, + LoggingIdentifierModule::class, SyncStatusTestModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleModuleTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleModuleTest.kt new file mode 100644 index 00000000000..d4964d1df9c --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleModuleTest.kt @@ -0,0 +1,171 @@ +package org.oppia.android.domain.oppialogger.analytics + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.Binds +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import dagger.multibindings.Multibinds +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.domain.oppialogger.ApplicationIdSeed +import org.oppia.android.domain.oppialogger.ApplicationStartupListener +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi +import org.oppia.android.util.platformparameter.LearnerStudyAnalytics +import org.oppia.android.util.platformparameter.PlatformParameterValue +import org.oppia.android.util.platformparameter.SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SplashScreenWelcomeMsg +import org.oppia.android.util.platformparameter.SyncUpWorkerTimePeriodHours +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [ApplicationLifecycleModule]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = ApplicationLifecycleModuleTest.TestApplication::class) +class ApplicationLifecycleModuleTest { + @Inject lateinit var startupListeners: Set<@JvmSuppressWildcards ApplicationStartupListener> + + @field:[JvmField Inject LearnerAnalyticsInactivityLimitMillis] + var inactivityLimitMillis: Long = Long.MIN_VALUE + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testInjectApplicationStartupListenerSet_includesApplicationLifecycleObserver() { + assertThat(startupListeners.any { it is ApplicationLifecycleObserver }).isTrue() + } + + @Test + fun testLearnerAnalyticsInactivityLimit_isDefaultValue() { + // This is a change detector test to ensure that changes to the inactivity limit are explicitly + // considered to help avoid potential unintended changes to this analytics behavioral + // configuration property. + assertThat(inactivityLimitMillis).isEqualTo(TimeUnit.MINUTES.toMillis(30)) + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + interface TestModule { + @Binds + fun provideContext(application: Application): Context + + @Multibinds + fun bindStartupListenerSet(): Set + } + + @Module + class TestLoggingIdentifierModule { + + companion object { + const val applicationIdSeed = 1L + } + + @Provides + @ApplicationIdSeed + fun provideApplicationIdSeed(): Long = applicationIdSeed + } + + @Module + class TestPlatformParameterModule { + + companion object { + var forceLearnerAnalyticsStudy: Boolean = false + } + + @Provides + @SplashScreenWelcomeMsg + fun provideSplashScreenWelcomeMsgParam(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE) + } + + @Provides + @SyncUpWorkerTimePeriodHours + fun provideSyncUpWorkerTimePeriod(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE + ) + } + + @Provides + @EnableLanguageSelectionUi + fun provideEnableLanguageSelectionUi(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE + ) + } + + @Provides + @LearnerStudyAnalytics + fun provideLearnerStudyAnalytics(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(forceLearnerAnalyticsStudy) + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, TestLogReportingModule::class, LogStorageModule::class, + TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, + NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, + TestPlatformParameterModule::class, PlatformParameterSingletonModule::class, + TestLoggingIdentifierModule::class, ApplicationLifecycleModule::class, LoggerModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(applicationLifecycleObserverImplTest: ApplicationLifecycleModuleTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerApplicationLifecycleModuleTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(test: ApplicationLifecycleModuleTest) { + component.inject(test) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserverTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserverTest.kt new file mode 100644 index 00000000000..c53823d4baa --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserverTest.kt @@ -0,0 +1,211 @@ +package org.oppia.android.domain.oppialogger.analytics + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.domain.oppialogger.ApplicationIdSeed +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.oppialogger.LoggingIdentifierController +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.data.DataProviderTestMonitor +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestCoroutineDispatchers +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClock +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.EnableConsoleLog +import org.oppia.android.util.logging.EnableFileLog +import org.oppia.android.util.logging.GlobalLogLevel +import org.oppia.android.util.logging.LogLevel +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi +import org.oppia.android.util.platformparameter.LearnerStudyAnalytics +import org.oppia.android.util.platformparameter.PlatformParameterValue +import org.oppia.android.util.platformparameter.SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SplashScreenWelcomeMsg +import org.oppia.android.util.platformparameter.SyncUpWorkerTimePeriodHours +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [ApplicationLifecycleObserver]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = ApplicationLifecycleObserverTest.TestApplication::class) +class ApplicationLifecycleObserverTest { + @Inject lateinit var loggingIdentifierController: LoggingIdentifierController + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject lateinit var applicationLifecycleObserver: ApplicationLifecycleObserver + @Inject lateinit var fakeOppiaClock: FakeOppiaClock + @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testObserver_getSessionId_backgroundApp_thenForeground_limitExceeded_sessionIdUpdated() { + fakeOppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + val sessionIdProvider = loggingIdentifierController.getSessionId() + val firstSessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + + waitInBackgroundFor(TimeUnit.MINUTES.toMillis(45)) + + val latestSessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + assertThat(firstSessionId).isNotEqualTo(latestSessionId) + } + + @Test + fun testObserver_getSessionId_backgroundApp_thenForeground_limitNotExceeded_sessionIdUnchanged() { + fakeOppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + val sessionIdProvider = loggingIdentifierController.getSessionId() + val firstSessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + + waitInBackgroundFor(TimeUnit.MINUTES.toMillis(15)) + + val latestSessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + assertThat(firstSessionId).isEqualTo(latestSessionId) + } + + private fun waitInBackgroundFor(millis: Long) { + applicationLifecycleObserver.onAppInBackground() + testCoroutineDispatchers.runCurrent() + fakeOppiaClock.setCurrentTimeMs(fakeOppiaClock.getCurrentTimeMs() + millis) + testCoroutineDispatchers.advanceTimeBy(millis) + + applicationLifecycleObserver.onAppInForeground() + testCoroutineDispatchers.runCurrent() + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + // TODO(#59): Either isolate these to their own shared test module, or use the real logging + // module in tests to avoid needing to specify these settings for tests. + @EnableConsoleLog + @Provides + fun provideEnableConsoleLog(): Boolean = true + + @EnableFileLog + @Provides + fun provideEnableFileLog(): Boolean = false + + @GlobalLogLevel + @Provides + fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + } + + @Module + class TestLoggingIdentifierModule { + + companion object { + const val applicationIdSeed = 1L + } + + @Provides + @ApplicationIdSeed + fun provideApplicationIdSeed(): Long = applicationIdSeed + } + + @Module + class TestPlatformParameterModule { + + companion object { + var forceLearnerAnalyticsStudy: Boolean = false + } + + @Provides + @SplashScreenWelcomeMsg + fun provideSplashScreenWelcomeMsgParam(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE) + } + + @Provides + @SyncUpWorkerTimePeriodHours + fun provideSyncUpWorkerTimePeriod(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE + ) + } + + @Provides + @EnableLanguageSelectionUi + fun provideEnableLanguageSelectionUi(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE + ) + } + + @Provides + @LearnerStudyAnalytics + fun provideLearnerStudyAnalytics(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(forceLearnerAnalyticsStudy) + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, TestLogReportingModule::class, LogStorageModule::class, + TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, + NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, + TestPlatformParameterModule::class, PlatformParameterSingletonModule::class, + TestLoggingIdentifierModule::class, ApplicationLifecycleModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(applicationLifecycleObserverImplTest: ApplicationLifecycleObserverTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerApplicationLifecycleObserverTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(applicationLifecycleObserverImplTest: ApplicationLifecycleObserverTest) { + component.inject(applicationLifecycleObserverImplTest) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel new file mode 100644 index 00000000000..9607cf63a70 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel @@ -0,0 +1,132 @@ +""" +Tests for app analytics logging support. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "AnalyticsControllerTest", + srcs = ["AnalyticsControllerTest.kt"], + custom_package = "org.oppia.android.domain.oppialogger.analytics", + test_class = "org.oppia.android.domain.oppialogger.analytics.AnalyticsControllerTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject", + "//testing/src/main/java/org/oppia/android/testing/logging:fake_sync_status_manager", + "//testing/src/main/java/org/oppia/android/testing/logging:sync_status_test_module", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_mockito_mockito-core", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +oppia_android_test( + name = "ApplicationLifecycleModuleTest", + srcs = ["ApplicationLifecycleModuleTest.kt"], + custom_package = "org.oppia.android.domain.oppialogger.analytics", + test_class = "org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModuleTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_mockito_mockito-core", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +oppia_android_test( + name = "ApplicationLifecycleObserverTest", + srcs = ["ApplicationLifecycleObserverTest.kt"], + custom_package = "org.oppia.android.domain.oppialogger.analytics", + test_class = "org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleObserverTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +oppia_android_test( + name = "LearnerAnalyticsLoggerTest", + srcs = ["LearnerAnalyticsLoggerTest.kt"], + custom_package = "org.oppia.android.domain.oppialogger.analytics", + test_class = "org.oppia.android.domain.oppialogger.analytics.LearnerAnalyticsLoggerTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/classify:interactions_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput:algebraic_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/continueinteraction:continue_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/dragAndDropSortInput:drag_and_drop_sort_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/fractioninput:fraction_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/imageClickInput:image_click_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/itemselectioninput:item_selection_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/mathequationinput:math_equation_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/multiplechoiceinput:multiple_choice_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numberwithunits:number_with_units_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericexpressioninput:numeric_expression_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericinput:numeric_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/ratioinput:ratio_input_module", + "//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:learner_analytics_logger", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/junit:oppia_parameterized_test_runner", + "//testing/src/main/java/org/oppia/android/testing/junit:parameterized_robolectric_test_runner", + "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject", + "//testing/src/main/java/org/oppia/android/testing/logging:sync_status_test_module", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +dagger_rules() diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt new file mode 100644 index 00000000000..a1914c0eadc --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt @@ -0,0 +1,1460 @@ +package org.oppia.android.domain.oppialogger.analytics + +import android.app.Application +import android.content.Context +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.Exploration +import org.oppia.android.domain.classify.InteractionsModule +import org.oppia.android.domain.classify.rules.algebraicexpressioninput.AlgebraicExpressionInputModule +import org.oppia.android.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.android.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule +import org.oppia.android.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.android.domain.classify.rules.imageClickInput.ImageClickInputModule +import org.oppia.android.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.android.domain.classify.rules.mathequationinput.MathEquationInputModule +import org.oppia.android.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.android.domain.classify.rules.numericexpressioninput.NumericExpressionInputModule +import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule +import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.android.domain.exploration.ExplorationDataController +import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationStorageModule +import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule +import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule +import org.oppia.android.domain.oppialogger.ApplicationIdSeed +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2 +import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_5 +import org.oppia.android.testing.FakeEventLogger +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.data.DataProviderTestMonitor +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.Iteration +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.Parameter +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.RunParameterized +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.SelectRunnerPlatform +import org.oppia.android.testing.junit.ParameterizedRobolectricTestRunner +import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat +import org.oppia.android.testing.logging.SyncStatusTestModule +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetModule +import org.oppia.android.util.caching.testing.CachingTestModule +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.shadows.ShadowLog +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [LearnerAnalyticsLogger]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(OppiaParameterizedTestRunner::class) +@SelectRunnerPlatform(ParameterizedRobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = LearnerAnalyticsLoggerTest.TestApplication::class) +class LearnerAnalyticsLoggerTest { + private companion object { + private const val TEST_INSTALL_ID = "test_installation_id" + private const val TEST_LEARNER_ID = "test_learner_id" + private const val UNKNOWN_INSTALL_ID = "unknown_installation_id" + private const val TEST_TOPIC_ID = "test_topic_id" + private const val TEST_STORY_ID = "test_story_id" + private const val TEST_EXP_5_STATE_THREE_NAME = "NumericExpressionInput.IsEquivalentTo" + private const val TEST_EXP_5_STATE_FOUR_NAME = "AlgebraicExpressionInput.MatchesExactlyWith" + private const val DEFAULT_INITIAL_SESSION_ID = "e6eacc69-e636-3c90-ba29-32bf3dd17161" + } + + @Inject lateinit var learnerAnalyticsLogger: LearnerAnalyticsLogger + @Inject lateinit var explorationDataController: ExplorationDataController + @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + @Inject lateinit var fakeEventLogger: FakeEventLogger + + @Parameter lateinit var iid: String + @Parameter lateinit var lid: String + @Parameter lateinit var eid: String + @Parameter lateinit var elid: String + + private val learnerIdParameter: String? get() = lid.takeIf { it != "null" } + private val installIdParameter: String? get() = iid.takeIf { it != "null" } + private val expectedLearnerIdParameter: String get() = elid + private val expectedInstallIdParameter: String get() = eid + + @Before + fun setUp() { + setUpTestApplicationComponent() + ShadowLog.reset() + } + + @Test + fun testExplorationAnalyticsLogger_preSession_isNull() { + val expLogger = learnerAnalyticsLogger.explorationAnalyticsLogger.value + + assertThat(expLogger).isNull() + } + + @Test + fun testBeginExploration_noOngoingSession_returnsNewLogger() { + val exploration = loadExploration(TEST_EXPLORATION_ID_5) + + val logger = learnerAnalyticsLogger.beginExploration(exploration) + + assertThat(logger).isNotNull() + } + + @Test + fun testBeginExploration_noOngoingSession_doesNotLogEvent() { + val exploration = loadExploration(TEST_EXPLORATION_ID_5) + + learnerAnalyticsLogger.beginExploration(exploration) + + assertThat(fakeEventLogger.noEventsPresent()).isTrue() + } + + @Test + fun testBeginExploration_noOngoingSession_setsGlobalAnalyticsLogger() { + val exploration = loadExploration(TEST_EXPLORATION_ID_5) + + val logger = learnerAnalyticsLogger.beginExploration(exploration) + + assertThat(learnerAnalyticsLogger.explorationAnalyticsLogger.value).isEqualTo(logger) + } + + @Test + fun testBeginExploration_withOngoingSession_returnsNewDifferentLogger() { + val exploration2 = loadExploration(TEST_EXPLORATION_ID_2) + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val firstLogger = learnerAnalyticsLogger.beginExploration(exploration2) + + val secondLogger = learnerAnalyticsLogger.beginExploration(exploration5) + + assertThat(firstLogger).isNotEqualTo(secondLogger) + } + + @Test + fun testBeginExploration_withOngoingSession_updatesGlobalAnalyticsLogger() { + learnerAnalyticsLogger.beginExploration(loadExploration(TEST_EXPLORATION_ID_2)) + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + + val logger = learnerAnalyticsLogger.beginExploration(exploration5) + + assertThat(learnerAnalyticsLogger.explorationAnalyticsLogger.value).isEqualTo(logger) + } + + @Test + fun testBeginExploration_withOngoingSession_logsConsoleWarning() { + learnerAnalyticsLogger.beginExploration(loadExploration(TEST_EXPLORATION_ID_2)) + + learnerAnalyticsLogger.beginExploration(loadExploration(TEST_EXPLORATION_ID_5)) + + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(log.msg).contains("Attempting to start an exploration without ending the previous") + assertThat(log.type).isEqualTo(Log.WARN) + } + + @Test + fun testEndExploration_noOngoingSession_logsConsoleWarning() { + learnerAnalyticsLogger.endExploration() + + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(log.msg).contains("Attempting to end an exploration that hasn't been started") + assertThat(log.type).isEqualTo(Log.WARN) + } + + @Test + fun testEndExploration_ongoingSession_setsGlobalAnalyticsLoggerToNull() { + learnerAnalyticsLogger.beginExploration(loadExploration(TEST_EXPLORATION_ID_5)) + + learnerAnalyticsLogger.endExploration() + + assertThat(learnerAnalyticsLogger.explorationAnalyticsLogger.value).isNull() + } + + @Test + fun testEndExploration_ongoingSession_doesNotLogEvent() { + learnerAnalyticsLogger.beginExploration(loadExploration(TEST_EXPLORATION_ID_5)) + + learnerAnalyticsLogger.endExploration() + + assertThat(fakeEventLogger.noEventsPresent()).isTrue() + } + + @Test + fun testLogAppInBackground_noOngoingSession_logsEventWithIds() { + learnerAnalyticsLogger.logAppInBackground(TEST_INSTALL_ID, TEST_LEARNER_ID) + + val event = fakeEventLogger.getMostRecentEvent() + assertThat(event).isEssentialPriority() + assertThat(event).hasAppInBackgroundContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testLogAppInBackground_ongoingSession_logsEventWithIds() { + learnerAnalyticsLogger.beginExploration(loadExploration(TEST_EXPLORATION_ID_5)) + + learnerAnalyticsLogger.logAppInBackground(TEST_INSTALL_ID, TEST_LEARNER_ID) + + val event = fakeEventLogger.getMostRecentEvent() + assertThat(event).isEssentialPriority() + assertThat(event).hasAppInBackgroundContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testLogAppInBackground_withoutInstallationId_logsEventWithoutInstallationId() { + learnerAnalyticsLogger.logAppInBackground(installationId = null, TEST_LEARNER_ID) + + assertThat(fakeEventLogger.getMostRecentEvent()).hasAppInBackgroundContextThat { + hasLearnerIdThat().isNotEmpty() + hasInstallationIdThat().isEmpty() + } + } + + @Test + fun testLogAppInBackground_withoutLearnerId_logsEventWithoutLearnerId() { + learnerAnalyticsLogger.logAppInBackground(TEST_INSTALL_ID, learnerId = null) + + assertThat(fakeEventLogger.getMostRecentEvent()).hasAppInBackgroundContextThat { + hasLearnerIdThat().isEmpty() + hasInstallationIdThat().isNotEmpty() + } + } + + @Test + fun testLogAppInBackground_withoutIds_logsEventWithoutIds() { + learnerAnalyticsLogger.logAppInBackground(installationId = null, learnerId = null) + + val event = fakeEventLogger.getMostRecentEvent() + assertThat(event).hasAppInBackgroundContextThat().isEqualToDefaultInstance() + } + + @Test + fun testLogAppInForeground_noOngoingSession_logsEventWithIds() { + learnerAnalyticsLogger.logAppInForeground(TEST_INSTALL_ID, TEST_LEARNER_ID) + + val event = fakeEventLogger.getMostRecentEvent() + assertThat(event).isEssentialPriority() + assertThat(event).hasAppInForegroundContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testLogAppInForeground_ongoingSession_logsEventWithIds() { + learnerAnalyticsLogger.beginExploration(loadExploration(TEST_EXPLORATION_ID_5)) + + learnerAnalyticsLogger.logAppInForeground(TEST_INSTALL_ID, TEST_LEARNER_ID) + + val event = fakeEventLogger.getMostRecentEvent() + assertThat(event).isEssentialPriority() + assertThat(event).hasAppInForegroundContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testLogAppInForeground_withoutInstallationId_logsEventWithoutInstallationId() { + learnerAnalyticsLogger.logAppInForeground(installationId = null, TEST_LEARNER_ID) + + assertThat(fakeEventLogger.getMostRecentEvent()).hasAppInForegroundContextThat { + hasLearnerIdThat().isNotEmpty() + hasInstallationIdThat().isEmpty() + } + } + + @Test + fun testLogAppInForeground_withoutLearnerId_logsEventWithoutLearnerId() { + learnerAnalyticsLogger.logAppInForeground(TEST_INSTALL_ID, learnerId = null) + + assertThat(fakeEventLogger.getMostRecentEvent()).hasAppInForegroundContextThat { + hasLearnerIdThat().isEmpty() + hasInstallationIdThat().isNotEmpty() + } + } + + @Test + fun testLogAppInForeground_withoutIds_logsEventWithoutIds() { + learnerAnalyticsLogger.logAppInForeground(installationId = null, learnerId = null) + + val event = fakeEventLogger.getMostRecentEvent() + assertThat(event).hasAppInForegroundContextThat().isEqualToDefaultInstance() + } + + @Test + fun testLogDeleteProfile_noOngoingSession_logsEventWithIds() { + learnerAnalyticsLogger.logDeleteProfile(TEST_INSTALL_ID, TEST_LEARNER_ID) + + val event = fakeEventLogger.getMostRecentEvent() + assertThat(event).isEssentialPriority() + assertThat(event).hasDeleteProfileContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testLogDeleteProfile_ongoingSession_logsEventWithIds() { + learnerAnalyticsLogger.beginExploration(loadExploration(TEST_EXPLORATION_ID_5)) + + learnerAnalyticsLogger.logDeleteProfile(TEST_INSTALL_ID, TEST_LEARNER_ID) + + val event = fakeEventLogger.getMostRecentEvent() + assertThat(event).isEssentialPriority() + assertThat(event).hasDeleteProfileContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testLogDeleteProfile_withoutInstallationId_logsEventWithoutInstallationId() { + learnerAnalyticsLogger.logDeleteProfile(installationId = null, TEST_LEARNER_ID) + + assertThat(fakeEventLogger.getMostRecentEvent()).hasDeleteProfileContextThat { + hasLearnerIdThat().isNotEmpty() + hasInstallationIdThat().isEmpty() + } + } + + @Test + fun testLogDeleteProfile_withoutLearnerId_logsEventWithoutLearnerId() { + learnerAnalyticsLogger.logDeleteProfile(TEST_INSTALL_ID, learnerId = null) + + assertThat(fakeEventLogger.getMostRecentEvent()).hasDeleteProfileContextThat { + hasLearnerIdThat().isEmpty() + hasInstallationIdThat().isNotEmpty() + } + } + + @Test + fun testLogDeleteProfile_withoutIds_logsEventWithoutIds() { + learnerAnalyticsLogger.logDeleteProfile(installationId = null, learnerId = null) + + val event = fakeEventLogger.getMostRecentEvent() + assertThat(event).hasDeleteProfileContextThat().isEqualToDefaultInstance() + } + + @Test + fun testExpLogger_afterStarting_stateLoggerIsNull() { + val expLogger = learnerAnalyticsLogger.beginExploration(loadExploration(TEST_EXPLORATION_ID_5)) + + assertThat(expLogger.stateAnalyticsLogger.value).isNull() + } + + @Test + fun testExpLogger_startCard_noOngoingState_returnsNewLogger() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val initState = exploration5.getStateByName(exploration5.initStateName) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + + val stateLogger = expLogger.startCard(initState) + + assertThat(stateLogger).isNotNull() + } + + @Test + fun testExpLogger_startCard_noOngoingState_doesNotLogEvent() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val initState = exploration5.getStateByName(exploration5.initStateName) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + + expLogger.startCard(initState) + + assertThat(fakeEventLogger.noEventsPresent()).isTrue() + } + + @Test + fun testExpLogger_startCard_noOngoingState_setsStateAnalyticsLogger() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val initState = exploration5.getStateByName(exploration5.initStateName) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + + val stateLogger = expLogger.startCard(initState) + + assertThat(expLogger.stateAnalyticsLogger.value).isEqualTo(stateLogger) + } + + @Test + fun testExpLogger_startCard_withOngoingState_returnsNewDifferentLogger() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val thirdState = exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME) + val fourthState = exploration5.getStateByName(TEST_EXP_5_STATE_FOUR_NAME) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger3 = expLogger.startCard(thirdState) + + val stateLogger4 = expLogger.startCard(fourthState) + + assertThat(stateLogger3).isNotEqualTo(stateLogger4) + } + + @Test + fun testExpLogger_startCard_withOngoingState_updatesStateAnalyticsLogger() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val thirdState = exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME) + val fourthState = exploration5.getStateByName(TEST_EXP_5_STATE_FOUR_NAME) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + expLogger.startCard(thirdState) + + val stateLogger = expLogger.startCard(fourthState) + + assertThat(expLogger.stateAnalyticsLogger.value).isEqualTo(stateLogger) + } + + @Test + fun testExpLogger_startCard_withOngoingState_logsConsoleWarning() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val thirdState = exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME) + val fourthState = exploration5.getStateByName(TEST_EXP_5_STATE_FOUR_NAME) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + expLogger.startCard(thirdState) + + expLogger.startCard(fourthState) + + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(log.msg).contains("Attempting to start a card without ending the previous") + assertThat(log.type).isEqualTo(Log.WARN) + } + + @Test + fun testExpLogger_endCard_noOngoingState_logsConsoleWarning() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + + expLogger.endCard() + + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(log.msg).contains("Attempting to end a card not yet started") + assertThat(log.type).isEqualTo(Log.WARN) + } + + @Test + fun testExpLogger_endCard_withOngoingState_setsStateAnalyticsLoggerToNull() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + expLogger.endCard() + + assertThat(expLogger.stateAnalyticsLogger.value).isNull() + } + + @Test + fun testExpLogger_endCard_withOngoingState_doesNotLogEvent() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + expLogger.endCard() + + assertThat(fakeEventLogger.noEventsPresent()).isTrue() + } + + @Test + fun testExpLogger_logResumeExploration_outsideCard_logsExpEvent() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + + expLogger.logResumeExploration() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasResumeExplorationContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testExpLogger_logResumeExploration_insideCard_logsExpEvent() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + expLogger.logResumeExploration() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasResumeExplorationContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testExpLogger_logStartExplorationOver_outsideCard_logsExpEvent() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + + expLogger.logStartExplorationOver() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasStartOverExplorationContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testExpLogger_logStartExplorationOver_insideCard_logsExpEvent() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + expLogger.logStartExplorationOver() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasStartOverExplorationContextThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + + @Test + fun testExpLogger_logExitExploration_outsideCard_logsConsoleWarningAndNoEvents() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + + expLogger.logExitExploration() + + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(log.msg).contains("Attempting to log a state event outside state") + assertThat(log.type).isEqualTo(Log.WARN) + assertThat(fakeEventLogger.noEventsPresent()).isTrue() + } + + @Test + fun testExpLogger_logExitExploration_insideCard_logsStateEventWithStateName() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + expLogger.logExitExploration() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasExitExplorationContextThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + } + + @Test + fun testExpLogger_logFinishExploration_outsideCard_logsConsoleWarningAndNoEvents() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + + expLogger.logFinishExploration() + + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(log.msg).contains("Attempting to log a state event outside state") + assertThat(log.type).isEqualTo(Log.WARN) + assertThat(fakeEventLogger.noEventsPresent()).isTrue() + } + + @Test + fun testExpLogger_logFinishExploration_insideCard_logsStateEventWithStateName() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + expLogger.logFinishExploration() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasFinishExplorationContextThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + } + + @Test + fun testStateAnalyticsLogger_logStartCard_logsStateEventWithSkillId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logStartCard() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasStartCardContextThat { + hasExplorationDetailsThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + hasSkillIdThat().isEqualTo("test_skill_id_2") + } + } + + @Test + fun testStateAnalyticsLogger_logStartCard_differentState_logsDifferentSkillId() { + val exploration2 = loadExploration(TEST_EXPLORATION_ID_2) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration2) + val stateLogger = expLogger.startCard(exploration2.getStateByName(exploration2.initStateName)) + + stateLogger.logStartCard() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasStartCardContextThat().hasSkillIdThat().isEqualTo("test_skill_id_0") + } + + @Test + fun testStateAnalyticsLogger_logEndCard_logsStateEventWithSkillId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logEndCard() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasEndCardContextThat { + hasExplorationDetailsThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + hasSkillIdThat().isEqualTo("test_skill_id_2") + } + } + + @Test + fun testStateAnalyticsLogger_logEndCard_differentState_logsDifferentSkillId() { + val exploration2 = loadExploration(TEST_EXPLORATION_ID_2) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration2) + val stateLogger = expLogger.startCard(exploration2.getStateByName(exploration2.initStateName)) + + stateLogger.logEndCard() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasEndCardContextThat().hasSkillIdThat().isEqualTo("test_skill_id_0") + } + + @Test + fun testStateAnalyticsLogger_logHintOffered_logsStateEventWithHintIndex() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logHintOffered(hintIndex = 1) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasHintOfferedContextThat { + hasExplorationDetailsThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + hasHintIndexThat().isEqualTo(1) + } + } + + @Test + fun testStateAnalyticsLogger_logHintOffered_diffIndex_logsStateEventWithHintIndex() { + val exploration2 = loadExploration(TEST_EXPLORATION_ID_2) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration2) + val stateLogger = expLogger.startCard(exploration2.getStateByName(exploration2.initStateName)) + + stateLogger.logHintOffered(hintIndex = 2) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasHintOfferedContextThat().hasHintIndexThat().isEqualTo(2) + } + + @Test + fun testStateAnalyticsLogger_logViewHint_logsStateEventWithHintIndex() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logViewHint(hintIndex = 1) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasAccessHintContextThat { + hasExplorationDetailsThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + hasHintIndexThat().isEqualTo(1) + } + } + + @Test + fun testStateAnalyticsLogger_logViewHint_diffIndex_logsStateEventWithHintIndex() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logViewHint(hintIndex = 2) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasAccessHintContextThat().hasHintIndexThat().isEqualTo(2) + } + + @Test + fun testStateAnalyticsLogger_logSolutionOffered_logsStateEvent() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logSolutionOffered() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasSolutionOfferedContextThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + } + + @Test + fun testStateAnalyticsLogger_logViewSolution_logsStateEvent() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logViewSolution() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasAccessSolutionContextThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + } + + @Test + fun testStateAnalyticsLogger_logSubmitAnswer_answerWrong_logsStateEventWithCorrectLabel() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logSubmitAnswer(isCorrect = false) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasSubmitAnswerContextThat { + hasExplorationDetailsThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + hasAnswerCorrectValueThat().isFalse() + } + } + + @Test + fun testStateAnalyticsLogger_logSubmitAnswer_answerCorrect_logsStateEventWithCorrectLabel() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logSubmitAnswer(isCorrect = true) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasSubmitAnswerContextThat().hasAnswerCorrectValueThat().isTrue() + } + + @Test + fun testStateAnalyticsLogger_logPlayVoiceOver_logsStateEventWithContentId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logPlayVoiceOver(contentId = "test_content_id_1") + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasPlayVoiceOverContextThat { + hasExplorationDetailsThat { + hasTopicIdThat().isEqualTo(TEST_TOPIC_ID) + hasStoryIdThat().isEqualTo(TEST_STORY_ID) + hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_5) + hasSessionIdThat().isEqualTo(DEFAULT_INITIAL_SESSION_ID) + hasVersionThat().isEqualTo(5) + hasStateNameThat().isEqualTo(TEST_EXP_5_STATE_THREE_NAME) + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(TEST_LEARNER_ID) + hasInstallationIdThat().isEqualTo(TEST_INSTALL_ID) + } + } + hasContentIdThat().isEqualTo("test_content_id_1") + } + } + + @Test + fun testStateAnalyticsLogger_logPlayVoiceOver_diffContentId_logsStateEventWithContentId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logPlayVoiceOver(contentId = "content_id_2") + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasPlayVoiceOverContextThat().hasContentIdThat().isEqualTo("content_id_2") + } + + @Test + fun testStateAnalyticsLogger_logPlayVoiceOver_nullContentId_logsStateEventWithoutContentId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) + val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) + + stateLogger.logPlayVoiceOver(contentId = null) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).isEssentialPriority() + assertThat(eventLog).hasPlayVoiceOverContextThat().hasContentIdThat().isEmpty() + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install"), + Iteration("missing_install_and_learner_ids", "lid=null", "iid=null", "elid=", "eid=") + ) + fun testExpLogger_logResumeExploration_missingOneOrMoreIds_logsEventWithMissingIds() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + + expLogger.logResumeExploration() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasResumeExplorationContextThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install"), + Iteration("missing_install_and_learner_ids", "lid=null", "iid=null", "elid=", "eid=") + ) + fun testExpLogger_logStartExplorationOver_missingOneOrMoreIds_logsEventWithMissingIds() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + + expLogger.logStartExplorationOver() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasStartOverExplorationContextThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testExpLogger_logExitExploration_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + expLogger.logExitExploration() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasExitExplorationContextThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + + @Test + fun testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + expLogger.logExitExploration() + + // Since both the learner & installation IDs are missing, the event logging fails since it would + // have no context. An unknown installation ID is used to indicate the installation ID was + // missing. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testExpLogger_logFinishExploration_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + expLogger.logFinishExploration() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasFinishExplorationContextThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + + @Test + fun testExpLogger_logFinishExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + expLogger.logFinishExploration() + + // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testStateAnalyticsLogger_logStartCard_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logStartCard() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasStartCardContextThat { + hasExplorationDetailsThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + } + + @Test + fun testStateAnalyticsLogger_logStartCard_noInstallOrLearnerIds_logsEventAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logStartCard() + + // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testStateAnalyticsLogger_logEndCard_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logEndCard() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasEndCardContextThat { + hasExplorationDetailsThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + } + + @Test + fun testStateAnalyticsLogger_logEndCard_noInstallOrLearnerIds_logsEventAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logEndCard() + + // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testStateAnalyticsLogger_logHintOffered_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logHintOffered(hintIndex = 1) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasHintOfferedContextThat { + hasExplorationDetailsThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + } + + @Test + fun testStateAnalyticsLogger_logHintOffered_noInstallOrLearnerIds_logsEventAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logHintOffered(hintIndex = 1) + + // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testStateAnalyticsLogger_logViewHint_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logViewHint(hintIndex = 1) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasAccessHintContextThat { + hasExplorationDetailsThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + } + + @Test + fun testStateAnalyticsLogger_logViewHint_noInstallOrLearnerIds_logsEventAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logViewHint(hintIndex = 1) + + // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testStateAnalyticsLogger_logSolutionOffered_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logSolutionOffered() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasSolutionOfferedContextThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + + @Test + fun testStateAnalyticsLogger_logSolutionOffered_noInstallOrLearnerIds_logsEvtAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logSolutionOffered() + + // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testStateAnalyticsLogger_logViewSolution_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logViewSolution() + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasAccessSolutionContextThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + + @Test + fun testStateAnalyticsLogger_logViewSolution_noInstallOrLearnerIds_logsEventAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logViewSolution() + + // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testStateAnalyticsLogger_logSubmitAnswer_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logSubmitAnswer(isCorrect = true) + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasSubmitAnswerContextThat { + hasExplorationDetailsThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + } + + @Test + fun testStateAnalyticsLogger_logSubmitAnswer_noInstallOrLearnerIds_logsEventAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logSubmitAnswer(isCorrect = true) + + // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + @Test + @RunParameterized( + Iteration("no_install_id", "lid=learn", "iid=null", "elid=learn", "eid="), + Iteration("no_learner_id", "lid=null", "iid=install", "elid=", "eid=install") + ) + fun testStateAnalyticsLogger_logPlayVoiceOver_missingOneId_logsEventWithMissingId() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration( + exploration5, learnerId = learnerIdParameter, installationId = installIdParameter + ) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logPlayVoiceOver(contentId = "test_content_id_1") + + val eventLog = fakeEventLogger.getMostRecentEvent() + assertThat(eventLog).hasPlayVoiceOverContextThat { + hasExplorationDetailsThat { + hasLearnerDetailsThat { + hasLearnerIdThat().isEqualTo(expectedLearnerIdParameter) + hasInstallationIdThat().isEqualTo(expectedInstallIdParameter) + } + } + } + } + + @Test + fun testStateAnalyticsLogger_logPlayVoiceOver_noInstallOrLearnerIds_logsEventAndConsoleErrors() { + val exploration5 = loadExploration(TEST_EXPLORATION_ID_5) + val expLogger = + learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) + val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) + + stateLogger.logPlayVoiceOver(contentId = "test_content_id_1") + + // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. + val eventLog = fakeEventLogger.getMostRecentEvent() + val log = ShadowLog.getLogs().getMostRecentWithTag("LearnerAnalyticsLogger") + assertThat(eventLog).hasInstallIdForAnalyticsLogFailureThat().isEqualTo(UNKNOWN_INSTALL_ID) + assertThat(log.msg).contains("Event is being dropped due to incomplete event") + assertThat(log.type).isEqualTo(Log.ERROR) + } + + private fun loadExploration(expId: String): Exploration { + return monitorFactory.waitForNextSuccessfulResult( + explorationDataController.getExplorationById(expId) + ) + } + + private fun Exploration.getStateByName(name: String) = statesMap.getValue(name) + + private fun LearnerAnalyticsLogger.beginExploration( + exploration: Exploration, + installationId: String? = TEST_INSTALL_ID, + learnerId: String? = TEST_LEARNER_ID, + topicId: String = TEST_TOPIC_ID, + storyId: String = TEST_STORY_ID + ) = beginExploration(installationId, learnerId, exploration, topicId, storyId) + + private fun List.getMostRecentWithTag(tag: String) = last { it.tag == tag } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + @Provides + @ApplicationIdSeed + fun provideFixedTestApplicationIdSeed(): Long = 123456789L + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, TestLogReportingModule::class, RobolectricModule::class, + TestDispatcherModule::class, LogStorageModule::class, NetworkConnectionUtilDebugModule::class, + LocaleProdModule::class, FakeOppiaClockModule::class, PlatformParameterModule::class, + PlatformParameterSingletonModule::class, SyncStatusTestModule::class, LoggerModule::class, + ExplorationStorageModule::class, ContinueModule::class, FractionInputModule::class, + ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, + DragDropSortInputModule::class, InteractionsModule::class, RatioInputModule::class, + NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class, + MathEquationInputModule::class, ImageClickInputModule::class, AssetModule::class, + HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class, + CachingTestModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(test: LearnerAnalyticsLoggerTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerLearnerAnalyticsLoggerTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(test: LearnerAnalyticsLoggerTest) { + component.inject(test) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkManagerInitializerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkManagerInitializerTest.kt index 9e8071d8ec1..23a2427fcbe 100644 --- a/domain/src/test/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkManagerInitializerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkManagerInitializerTest.kt @@ -22,8 +22,11 @@ import org.junit.Test import org.junit.runner.RunWith import org.oppia.android.domain.oppialogger.EventLogStorageCacheSize import org.oppia.android.domain.oppialogger.ExceptionLogStorageCacheSize +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.testing.oppialogger.loguploader.FakeLogUploader import org.oppia.android.testing.FakeEventLogger import org.oppia.android.testing.FakeExceptionLogger @@ -37,6 +40,7 @@ import org.oppia.android.util.data.DataProviders import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.LogUploader import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.logging.SyncStatusModule import org.oppia.android.util.networking.NetworkConnectionDebugUtil import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.robolectric.annotation.Config @@ -192,7 +196,9 @@ class LogUploadWorkManagerInitializerTest { TestLogStorageModule::class, TestDispatcherModule::class, LogUploadWorkerModule::class, TestFirebaseLogUploaderModule::class, FakeOppiaClockModule::class, NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, - LoggerModule::class, AssetModule::class, LoggerModule::class + LoggerModule::class, AssetModule::class, LoggerModule::class, PlatformParameterModule::class, + PlatformParameterSingletonModule::class, LoggingIdentifierModule::class, + SyncStatusModule::class ] ) interface TestApplicationComponent { diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkerTest.kt index 3ef5cbf63ce..8392af66923 100644 --- a/domain/src/test/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkerTest.kt @@ -25,19 +25,26 @@ import org.junit.runner.RunWith import org.oppia.android.app.model.EventLog import org.oppia.android.domain.oppialogger.EventLogStorageCacheSize import org.oppia.android.domain.oppialogger.ExceptionLogStorageCacheSize +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.oppialogger.analytics.AnalyticsController +import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.testing.oppialogger.loguploader.FakeLogUploader import org.oppia.android.testing.FakeEventLogger import org.oppia.android.testing.FakeExceptionLogger import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.logging.SyncStatusTestModule import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.data.DataProviders +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.LogUploader import org.oppia.android.util.logging.LoggerModule @@ -54,9 +61,8 @@ private const val TEST_TOPIC_ID = "test_topicId" @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) -@Config(manifest = Config.NONE) +@Config(application = LogUploadWorkerTest.TestApplication::class) class LogUploadWorkerTest { - @Inject lateinit var networkConnectionUtil: NetworkConnectionDebugUtil @@ -116,7 +122,7 @@ class LogUploadWorkerTest { @Test fun testWorker_logEvent_withoutNetwork_enqueueRequest_verifySuccess() { networkConnectionUtil.setCurrentConnectionStatus(NONE) - analyticsController.logTransitionEvent( + analyticsController.logImportantEvent( eventLogTopicContext.timestamp, oppiaLogger.createOpenInfoTabContext(TEST_TOPIC_ID) ) @@ -229,10 +235,12 @@ class LogUploadWorkerTest { TestLogStorageModule::class, TestDispatcherModule::class, LogUploadWorkerModule::class, TestFirebaseLogUploaderModule::class, FakeOppiaClockModule::class, NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, - LoggerModule::class, AssetModule::class, LoggerModule::class + LoggerModule::class, AssetModule::class, LoggerModule::class, PlatformParameterModule::class, + PlatformParameterSingletonModule::class, LoggingIdentifierModule::class, + SyncStatusTestModule::class, ApplicationLifecycleModule::class ] ) - interface TestApplicationComponent { + interface TestApplicationComponent : DataProvidersInjector { @Component.Builder interface Builder { @BindsInstance @@ -242,4 +250,18 @@ class LogUploadWorkerTest { fun inject(logUploadWorkerTest: LogUploadWorkerTest) } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerLogUploadWorkerTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(logUploadWorkerTest: LogUploadWorkerTest) { + component.inject(logUploadWorkerTest) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } } diff --git a/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt index 036b41444e6..5428a206a3c 100644 --- a/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt @@ -10,7 +10,12 @@ import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides -import org.junit.Before +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import org.junit.Test import org.junit.runner.RunWith import org.oppia.android.app.model.AppLanguage @@ -21,7 +26,9 @@ import org.oppia.android.app.model.Profile import org.oppia.android.app.model.ProfileDatabase import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.ReadingTextSize.MEDIUM_TEXT_SIZE +import org.oppia.android.domain.oppialogger.ApplicationIdSeed import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.data.DataProviderTestMonitor import org.oppia.android.testing.profile.ProfileTestHelper @@ -29,19 +36,28 @@ import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProvider import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.locale.OppiaLocale import org.oppia.android.util.logging.EnableConsoleLog import org.oppia.android.util.logging.EnableFileLog import org.oppia.android.util.logging.GlobalLogLevel import org.oppia.android.util.logging.LogLevel +import org.oppia.android.util.logging.SyncStatusModule import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.LearnerStudyAnalytics +import org.oppia.android.util.platformparameter.PlatformParameterValue +import org.oppia.android.util.threading.BackgroundDispatcher import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import java.io.File import java.io.FileInputStream +import java.lang.IllegalStateException +import java.util.Random import javax.inject.Inject import javax.inject.Singleton @@ -57,6 +73,8 @@ class ProfileManagementControllerTest { @Inject lateinit var profileManagementController: ProfileManagementController @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + @Inject lateinit var machineLocale: OppiaLocale.MachineLocale + @field:[BackgroundDispatcher Inject] lateinit var backgroundDispatcher: CoroutineDispatcher private companion object { private val PROFILES_LIST = listOf( @@ -79,17 +97,9 @@ class ProfileManagementControllerTest { private const val DEFAULT_AVATAR_COLOR_RGB = -10710042 } - @Before - fun setUp() { - setUpTestApplicationComponent() - } - - private fun setUpTestApplicationComponent() { - ApplicationProvider.getApplicationContext().inject(this) - } - @Test fun testAddProfile_addProfile_checkProfileIsAdded() { + setUpTestApplicationComponent() val dataProvider = addAdminProfile(name = "James", pin = "123") monitorFactory.waitForNextSuccessfulResult(dataProvider) @@ -106,8 +116,38 @@ class ProfileManagementControllerTest { assertThat(File(getAbsoluteDirPath("0")).isDirectory).isTrue() } + @Test + fun testAddProfile_addProfile_studyOff_checkProfileDoesNotIncludeLearnerId() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val dataProvider = addAdminProfile(name = "James", pin = "123") + + monitorFactory.waitForNextSuccessfulResult(dataProvider) + + // The learner ID should not be generated if there's no ongoing study. + val profileDatabase = readProfileDatabase() + val profile = profileDatabase.profilesMap[0]!! + assertThat(profile.learnerId).isEmpty() + } + + @Test + fun testAddProfile_addProfile_studyOn_checkProfileDoesNotIncludeLearnerId() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val expectedLearnerId = machineLocale.run { + "%08x".formatForMachines(createRandomReadyForProfileLearnerIds().nextInt()) + } + val dataProvider = addAdminProfile(name = "James", pin = "123") + + monitorFactory.waitForNextSuccessfulResult(dataProvider) + + // TODO(#4064): Ensure that the learner ID is correctly set here & update test title. + val profileDatabase = readProfileDatabase() + val profile = profileDatabase.profilesMap[0]!! + assertThat(profile.learnerId).isNotEqualTo(expectedLearnerId) + } + @Test fun testAddProfile_addProfileWithNotUniqueName_checkResultIsFailure() { + setUpTestApplicationComponent() addTestProfiles() val dataProvider = addAdminProfile(name = "JAMES", pin = "321") @@ -118,6 +158,7 @@ class ProfileManagementControllerTest { @Test fun testAddProfile_addProfileWithNumberInName_checkResultIsFailure() { + setUpTestApplicationComponent() addTestProfiles() val dataProvider = addAdminProfile(name = "James034", pin = "321") @@ -128,6 +169,7 @@ class ProfileManagementControllerTest { @Test fun testGetProfile_addManyProfiles_checkGetProfileIsCorrect() { + setUpTestApplicationComponent() addTestProfiles() val dataProvider = profileManagementController.getProfile(PROFILE_ID_3) @@ -144,6 +186,7 @@ class ProfileManagementControllerTest { @Test fun testGetProfiles_addManyProfiles_checkAllProfilesAreAdded() { + setUpTestApplicationComponent() addTestProfiles() val dataProvider = profileManagementController.getProfiles() @@ -157,6 +200,7 @@ class ProfileManagementControllerTest { @Test fun testGetProfiles_addManyProfiles_restartApplication_addProfile_checkAllProfilesAreAdded() { + setUpTestApplicationComponent() addTestProfiles() setUpTestApplicationComponent() @@ -170,8 +214,120 @@ class ProfileManagementControllerTest { checkTestProfilesArePresent(profiles) } + @Test + fun testUpdateLearnerId_addProfiles_updateLearnerIdWithSeed_withoutStudy_learnerIdIsUnchanged() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + addTestProfiles() + testCoroutineDispatchers.runCurrent() + + val profileId = ProfileId.newBuilder().setInternalId(2).build() + val updateProvider = profileManagementController.initializeLearnerId(profileId) + monitorFactory.ensureDataProviderExecutes(updateProvider) + val profileProvider = profileManagementController.getProfile(profileId) + + // The learner ID shouldn't be updated if there's no ongoing study. + val profile = monitorFactory.waitForNextSuccessfulResult(profileProvider) + assertThat(profile.learnerId).isEmpty() + } + + @Test + fun testUpdateLearnerId_addProfiles_updateLearnerIdWithSeed_withStudy_learnerIdIsUnchanged() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val expectedLearnerId = machineLocale.run { + val random = createRandomReadyForProfileLearnerIds().also { + it.advanceBy(learnerCount = PROFILES_LIST.size) + } + "%08x".formatForMachines(random.nextInt()) + } + addTestProfiles() + testCoroutineDispatchers.runCurrent() + + val profileId = ProfileId.newBuilder().setInternalId(2).build() + val updateProvider = profileManagementController.initializeLearnerId(profileId) + monitorFactory.ensureDataProviderExecutes(updateProvider) + val profileProvider = profileManagementController.getProfile(profileId) + + // TODO(#4064): Ensure that the learner ID is correctly set here & update test title. + val profile = monitorFactory.waitForNextSuccessfulResult(profileProvider) + assertThat(profile.learnerId).isNotEqualTo(expectedLearnerId) + } + + @Test + fun testFetchCurrentLearnerId_noLoggedInProfile_returnsNull() { + setUpTestApplicationComponent() + addTestProfiles() + + val learnerId = fetchSuccessfulAsyncValue(profileManagementController::fetchCurrentLearnerId) + + assertThat(learnerId).isNull() + } + + @Test + fun testFetchCurrentLearnerId_loggedInProfile_createdWithStudyOff_returnsEmptyString() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + addTestProfiles() + monitorFactory.ensureDataProviderExecutes( + profileManagementController.loginToProfile(PROFILE_ID_1) + ) + + val learnerId = fetchSuccessfulAsyncValue(profileManagementController::fetchCurrentLearnerId) + + assertThat(learnerId).isEmpty() + } + + @Test + fun testFetchCurrentLearnerId_loggedInProfile_createdWithStudyOn_returnsEmptyString() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + addTestProfiles() + monitorFactory.ensureDataProviderExecutes( + profileManagementController.loginToProfile(PROFILE_ID_1) + ) + + val learnerId = fetchSuccessfulAsyncValue(profileManagementController::fetchCurrentLearnerId) + + // TODO(#4064): Ensure that the learner ID is correctly set here & update test title. + assertThat(learnerId).isEmpty() + } + + @Test + fun testFetchLearnerId_nonExistentProfile_returnsNull() { + setUpTestApplicationComponent() + + val learnerId = fetchSuccessfulAsyncValue { + profileManagementController.fetchLearnerId(PROFILE_ID_2) + } + + assertThat(learnerId).isNull() + } + + @Test + fun testFetchLearnerId_createdProfileWithStudyOff_returnsEmptyString() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + addTestProfiles() + + val learnerId = fetchSuccessfulAsyncValue { + profileManagementController.fetchLearnerId(PROFILE_ID_2) + } + + assertThat(learnerId).isEmpty() + } + + @Test + fun testFetchLearnerId_createdProfileWithStudyOn_returnsEmptyString() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + addTestProfiles() + + val learnerId = fetchSuccessfulAsyncValue { + profileManagementController.fetchLearnerId(PROFILE_ID_2) + } + + // TODO(#4064): Ensure that the learner ID is correctly set here & update test title. + assertThat(learnerId).isEmpty() + } + @Test fun testUpdateName_addProfiles_updateWithUniqueName_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = profileManagementController.updateName(PROFILE_ID_2, "John") @@ -184,6 +340,7 @@ class ProfileManagementControllerTest { @Test fun testUpdateName_addProfiles_updateWithNotUniqueName_checkUpdatedFailed() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = profileManagementController.updateName(PROFILE_ID_2, "James") @@ -194,6 +351,7 @@ class ProfileManagementControllerTest { @Test fun testUpdateName_addProfiles_updateWithBadProfileId_checkUpdatedFailed() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = profileManagementController.updateName(PROFILE_ID_6, "John") @@ -204,6 +362,7 @@ class ProfileManagementControllerTest { @Test fun testUpdateName_addProfiles_updateProfileAvatar_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = profileManagementController @@ -221,6 +380,7 @@ class ProfileManagementControllerTest { @Test fun testUpdatePin_addProfiles_updatePin_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = profileManagementController.updatePin(PROFILE_ID_2, "321") @@ -233,6 +393,7 @@ class ProfileManagementControllerTest { @Test fun testUpdatePin_addProfiles_updateWithBadProfileId_checkUpdateFailed() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = profileManagementController.updatePin(PROFILE_ID_6, "321") @@ -244,6 +405,7 @@ class ProfileManagementControllerTest { @Test fun testUpdateAllowDownloadAccess_addProfiles_updateDownloadAccess_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = profileManagementController.updateAllowDownloadAccess(PROFILE_ID_2, false) @@ -256,6 +418,7 @@ class ProfileManagementControllerTest { @Test fun testUpdateAllowDownloadAccess_addProfiles_updateWithBadProfileId_checkUpdatedFailed() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = profileManagementController.updateAllowDownloadAccess(PROFILE_ID_6, false) @@ -267,6 +430,7 @@ class ProfileManagementControllerTest { @Test fun testUpdateReadingTextSize_addProfiles_updateWithFontSize18_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = @@ -280,6 +444,7 @@ class ProfileManagementControllerTest { @Test fun testUpdateAppLanguage_addProfiles_updateWithChineseLanguage_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = @@ -293,6 +458,7 @@ class ProfileManagementControllerTest { @Test fun testUpdateAudioLanguage_addProfiles_updateWithFrenchLanguage_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() addTestProfiles() val updateProvider = @@ -306,6 +472,7 @@ class ProfileManagementControllerTest { @Test fun testDeleteProfile_addProfiles_deleteProfile_checkDeletionIsSuccessful() { + setUpTestApplicationComponent() addTestProfiles() val deleteProvider = profileManagementController.deleteProfile(PROFILE_ID_2) @@ -318,6 +485,7 @@ class ProfileManagementControllerTest { @Test fun testDeleteProfile_addProfiles_deleteProfiles_addProfile_checkIdIsNotReused() { + setUpTestApplicationComponent() addTestProfiles() profileManagementController.deleteProfile(PROFILE_ID_3) @@ -338,6 +506,7 @@ class ProfileManagementControllerTest { @Test fun testDeleteProfile_addProfiles_deleteProfiles_restartApplication_checkDeletionIsSuccessful() { + setUpTestApplicationComponent() addTestProfiles() profileManagementController.deleteProfile(PROFILE_ID_1) @@ -358,6 +527,7 @@ class ProfileManagementControllerTest { @Test fun testLoginToProfile_addProfiles_loginToProfile_checkGetProfileIdAndLoginTimestampIsCorrect() { + setUpTestApplicationComponent() addTestProfiles() val loginProvider = profileManagementController.loginToProfile(PROFILE_ID_2) @@ -371,6 +541,7 @@ class ProfileManagementControllerTest { @Test fun testLoginToProfile_addProfiles_loginToProfileWithBadProfileId_checkLoginFailed() { + setUpTestApplicationComponent() addTestProfiles() val loginProvider = profileManagementController.loginToProfile(PROFILE_ID_6) @@ -386,6 +557,7 @@ class ProfileManagementControllerTest { @Test fun testWasProfileEverAdded_addAdminProfile_checkIfProfileEverAdded() { + setUpTestApplicationComponent() val addProvider = addAdminProfile(name = "James", pin = "123") monitorFactory.waitForNextSuccessfulResult(addProvider) @@ -396,6 +568,7 @@ class ProfileManagementControllerTest { @Test fun testWasProfileEverAdded_addAdminProfile_getWasProfileEverAdded() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") val wasProfileAddedProvider = profileManagementController.getWasProfileEverAdded() @@ -406,6 +579,7 @@ class ProfileManagementControllerTest { @Test fun testWasProfileEverAdded_addAdminProfile_addUserProfile_checkIfProfileEverAdded() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") addNonAdminProfileAndWait(name = "Rajat", pin = "01234") @@ -417,6 +591,7 @@ class ProfileManagementControllerTest { @Test fun testWasProfileEverAdded_addAdminProfile_addUserProfile_getWasProfileEverAdded() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") addNonAdminProfileAndWait(name = "Rajat", pin = "01234") @@ -429,6 +604,7 @@ class ProfileManagementControllerTest { @Test fun testWasProfileEverAdded_addAdminProfile_addUserProfile_deleteUserProfile_profileIsAdded() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") addNonAdminProfileAndWait(name = "Rajat", pin = "01234") @@ -441,6 +617,7 @@ class ProfileManagementControllerTest { @Test fun testWasProfileEverAdded_addAdminProfile_addUserProfile_deleteUserProfile_profileWasAdded() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") addNonAdminProfileAndWait(name = "Rajat", pin = "01234") profileManagementController.deleteProfile(PROFILE_ID_1) @@ -455,6 +632,7 @@ class ProfileManagementControllerTest { @Test fun testAddAdminProfile_addAnotherAdminProfile_checkSecondAdminProfileWasNotAdded() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "Rohit") val addProfile2 = addAdminProfile(name = "Ben") @@ -465,6 +643,7 @@ class ProfileManagementControllerTest { @Test fun testDeviceSettings_addAdminProfile_getDefaultDeviceSettings_isSuccessful() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") val deviceSettingsProvider = profileManagementController.getDeviceSettings() @@ -476,6 +655,7 @@ class ProfileManagementControllerTest { @Test fun testDeviceSettings_addAdminProfile_updateDeviceWifiSettings_getDeviceSettings_isSuccessful() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") val updateProvider = profileManagementController.updateWifiPermissionDeviceSettings( @@ -492,6 +672,7 @@ class ProfileManagementControllerTest { @Test fun testDeviceSettings_addAdminProfile_updateTopicsAutoDeviceSettings_isSuccessful() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") val updateProvider = @@ -508,6 +689,7 @@ class ProfileManagementControllerTest { @Test fun testDeviceSettings_addAdminProfile_updateDeviceWifiSettings_andTopicDevSettings_succeeds() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") val updateProvider1 = @@ -529,6 +711,7 @@ class ProfileManagementControllerTest { @Test fun testDeviceSettings_updateDeviceWifiSettings_fromUserProfile_isFailure() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") addNonAdminProfileAndWait(name = "Rajat", pin = "01234") @@ -542,6 +725,7 @@ class ProfileManagementControllerTest { @Test fun testDeviceSettings_updateTopicsAutomaticallyDeviceSettings_fromUserProfile_isFailure() { + setUpTestApplicationComponent() addAdminProfileAndWait(name = "James") addNonAdminProfileAndWait(name = "Rajat", pin = "01234") @@ -626,9 +810,65 @@ class ProfileManagementControllerTest { ) } + private fun createRandomReadyForProfileLearnerIds(): Random { + return Random(TestLoggingIdentifierModule.applicationIdSeed).also { + it.nextBytes(ByteArray(16)) + } + } + + private fun Random.advanceBy(learnerCount: Int) { + repeat(learnerCount) { nextInt() } + } + + private fun fetchSuccessfulAsyncValue(block: suspend () -> T) = + CoroutineScope(backgroundDispatcher).async { block() }.waitForSuccessfulResult() + + private fun Deferred.waitForSuccessfulResult(): T { + return when (val result = waitForResult()) { + is AsyncResult.Pending -> error("Deferred never finished.") + is AsyncResult.Success -> result.value + is AsyncResult.Failure -> throw IllegalStateException("Deferred failed", result.error) + } + } + + private fun Deferred.waitForResult() = toStateFlow().waitForLatestValue() + + private fun Deferred.toStateFlow(): StateFlow> { + val deferred = this + return MutableStateFlow>(value = AsyncResult.Pending()).also { flow -> + CoroutineScope(backgroundDispatcher).async { + flow.emit(AsyncResult.Success(deferred.await())) + }.invokeOnCompletion { + it?.let { flow.tryEmit(AsyncResult.Failure(it)) } + } + } + } + + private fun StateFlow.waitForLatestValue(): T = + also { testCoroutineDispatchers.runCurrent() }.value + + private fun setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() { + setUpTestApplicationComponent() + } + + private fun setUpTestApplicationComponentWithLearnerAnalyticsStudy() { + TestModule.enableLearnerStudyAnalytics = true + setUpTestApplicationComponent() + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + // TODO(#89): Move this to a common test application component. @Module class TestModule { + internal companion object { + // This is expected to be off by default, so this helps the tests above confirm that the + // feature's default value is, indeed, off. + var enableLearnerStudyAnalytics = LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE + } + @Provides @Singleton fun provideContext(application: Application): Context { @@ -648,6 +888,30 @@ class ProfileManagementControllerTest { @GlobalLogLevel @Provides fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + + // The scoping here is to ensure changes to the module value above don't change the parameter + // within the same application instance. + @Provides + @Singleton + @LearnerStudyAnalytics + fun provideLearnerStudyAnalytics(): PlatformParameterValue { + // Snapshot the value so that it doesn't change between injection and use. + val enableFeature = enableLearnerStudyAnalytics + return object : PlatformParameterValue { + override val value: Boolean = enableFeature + } + } + } + + @Module + class TestLoggingIdentifierModule { + companion object { + const val applicationIdSeed = 1L + } + + @Provides + @ApplicationIdSeed + fun provideApplicationIdSeed(): Long = applicationIdSeed } // TODO(#89): Move this to a common test application component. @@ -656,7 +920,8 @@ class ProfileManagementControllerTest { modules = [ TestModule::class, TestLogReportingModule::class, LogStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, - NetworkConnectionUtilDebugModule::class, LocaleProdModule::class + NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, + TestLoggingIdentifierModule::class, SyncStatusModule::class, ApplicationLifecycleModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel index c45786a6961..a7b8ee9fde7 100644 --- a/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel +++ b/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel @@ -28,7 +28,7 @@ oppia_android_test( "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericinput:numeric_input_rule_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/ratioinput:ratio_input_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module", - "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", "//testing", "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", @@ -70,7 +70,7 @@ oppia_android_test( "//domain/src/main/java/org/oppia/android/domain/classify/rules/numericinput:numeric_input_rule_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/ratioinput:ratio_input_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module", - "//domain/src/main/java/org/oppia/android/domain/oppialogger:storage_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", "//testing", "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", diff --git a/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt b/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt index 0a737fec093..3f499f34b02 100644 --- a/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt @@ -43,23 +43,24 @@ private const val TEST_EXPLORATION_ID_2 = "test_exp_id_2" private const val TEST_EXPLORATION_ID_4 = "test_exp_id_4" private const val TEST_EXPLORATION_ID_5 = "test_exp_id_5" private const val TEST_EXPLORATION_ID_13 = "13" +private const val FRACTIONS_EXPLORATION_ID_0 = "umPkwp0L1M0-" /** Tests for [StateRetriever]. */ -@Suppress("PrivatePropertyName") // Truly immutable constants can be named in CONSTANT_CASE. +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) @Config(manifest = Config.NONE) class StateRetrieverTest { - private val DRAG_DROP_CHOICE_CONTENT_ID_0 = createXlatableContentId(contentId = "ca_choices_0") - private val DRAG_DROP_CHOICE_CONTENT_ID_1 = createXlatableContentId(contentId = "ca_choices_1") - private val DRAG_DROP_CHOICE_CONTENT_ID_2 = createXlatableContentId(contentId = "ca_choices_2") - private val DRAG_DROP_CHOICE_CONTENT_ID_3 = createXlatableContentId(contentId = "ca_choices_3") - - @Inject - lateinit var stateRetriever: StateRetriever + private companion object { + private val DRAG_DROP_CHOICE_CONTENT_ID_0 = createXlatableContentId(contentId = "ca_choices_0") + private val DRAG_DROP_CHOICE_CONTENT_ID_1 = createXlatableContentId(contentId = "ca_choices_1") + private val DRAG_DROP_CHOICE_CONTENT_ID_2 = createXlatableContentId(contentId = "ca_choices_2") + private val DRAG_DROP_CHOICE_CONTENT_ID_3 = createXlatableContentId(contentId = "ca_choices_3") + } - @Inject - lateinit var jsonAssetRetriever: JsonAssetRetriever + @Inject lateinit var stateRetriever: StateRetriever + @Inject lateinit var jsonAssetRetriever: JsonAssetRetriever @Before fun setUp() { @@ -610,6 +611,26 @@ class StateRetrieverTest { assertThat(customArgs["customOskLetters"]?.objectTypeCase).isEqualTo(SCHEMA_OBJECT_LIST) } + @Test + fun testParseState_withoutLinkedSkillId_doesNotSetLinkedSkillId() { + val state = loadStateFromJson( + stateName = "Introduction", explorationName = FRACTIONS_EXPLORATION_ID_0 + ) + + // No linked skill ID should be set since there isn't one defined for this state. + assertThat(state.linkedSkillId).isEmpty() + } + + @Test + fun testParseState_withLinkedSkillId_setsLinkedSkillId() { + val state = loadStateFromJson( + stateName = "MathEquationInput.MatchesExactlyWith", explorationName = TEST_EXPLORATION_ID_5 + ) + + // The skill ID from the state should be parsed & included in its represented proto structure. + assertThat(state.linkedSkillId).isEqualTo("test_skill_id_2") + } + /** * Return the first [RuleSpec] in the specified [State] matching the specified rule type, or fails * if one cannot be found. @@ -620,11 +641,6 @@ class StateRetrieverTest { .find { it.ruleType == ruleType } ?: error("Failed to find rule type: $ruleType") } - private fun createXlatableContentId(contentId: String): TranslatableHtmlContentId = - TranslatableHtmlContentId.newBuilder().apply { - this.contentId = contentId - }.build() - private fun crateSetOfContentIds( vararg items: TranslatableHtmlContentId ): SetOfTranslatableHtmlContentIds { @@ -699,3 +715,8 @@ class StateRetrieverTest { fun inject(stateRetrieverTest: StateRetrieverTest) } } + +private fun createXlatableContentId(contentId: String): TranslatableHtmlContentId = + TranslatableHtmlContentId.newBuilder().apply { + this.contentId = contentId + }.build() diff --git a/model/src/main/proto/exploration.proto b/model/src/main/proto/exploration.proto index 463af6f4688..288834a02f1 100644 --- a/model/src/main/proto/exploration.proto +++ b/model/src/main/proto/exploration.proto @@ -69,6 +69,9 @@ message State { // from the learner about why they picked a particular answer while // playing the exploration. bool solicit_answer_details = 8; + + // Corresponds to the global skill ID being taught/evaluated by the interaction of this state. + string linked_skill_id = 9; } // Structure for customization args for ParamChange objects. @@ -252,6 +255,9 @@ message EphemeralState { // The current context that should be used for selecting written translations. WrittenTranslationContext written_translation_context = 8; + + // The identifier of the current session that'd be used for analytics logging. + string session_id = 9; } // Corresponds to an exploration state that hasn't yet had a correct answer filled in. diff --git a/model/src/main/proto/oppia_logger.proto b/model/src/main/proto/oppia_logger.proto index 3328ce0a7dd..42bf2b21f4c 100644 --- a/model/src/main/proto/oppia_logger.proto +++ b/model/src/main/proto/oppia_logger.proto @@ -20,12 +20,13 @@ message EventLog { // Structure of an activity context. message Context { - - // Deprecated exploration context. This is now handled via the open_exploration_activity context below. + // Deprecated exploration context. This is now handled via the open_exploration_activity context + // below. reserved 1; // Deprecated topic context. This is now broken down into 4 different contexts and is handled - // via the open_info_tab, open_lessons_tab, open_practice_tab and open_revision_tab contexts below. + // via the open_info_tab, open_lessons_tab, open_practice_tab and open_revision_tab contexts + // below. reserved 2; // Deprecated question context. This is now handled via the open_question_player context below. @@ -37,7 +38,8 @@ message EventLog { // Deprecated concept card context. This is now handled via the open_concept_card context below. reserved 5; - // Deprecated revision card context. This is now handled via the open_concept_card context below. + // Deprecated revision card context. This is now handled via the open_concept_card context + // below. reserved 6; oneof activity_context { @@ -113,13 +115,17 @@ message EventLog { // The event being logged is related to deleting a profile. LearnerDetailsContext delete_profile_context = 30; - // The event being logged is related to the opening of home activity. The boolean value here has no - // importance and is always 'true'. + // The event being logged is related to the opening of home activity. The boolean value here + // has no importance and is always 'true'. bool open_home = 31; - // The event being logged is related to the opening of profile chooser activity. The boolean value here - // has no importance and is always 'true'. + // The event being logged is related to the opening of profile chooser activity. The boolean + // value here has no importance and is always 'true'. bool open_profile_chooser = 32; + + // Indicates that something went wrong when trying to log a learner analytics even for the + // device corresponding to the specified device ID. + string install_id_for_failed_analytics_log = 33; } } @@ -173,12 +179,11 @@ message EventLog { // Defined attributes that are common among other exploration related event log contexts. ExplorationContext exploration_details = 1; - // Corresponds to the index of a hint or solution in a state. This structure is set up - // to properly account for variable numbers of hints, for cases when only a solution or no solution - // exists, and for when there are no hints or solutions. This structure represents the entire state - // needed to determine which hints can be shown, have been seen, whether the solution can be shown, - // and whether the solution has been seen. - HelpIndex help_index = 3; + // Corresponds to the index of the index being shown or last seen. + int32 hint_index = 4; + + reserved 2; + reserved 3; } // Represents the event context for submitting an answer within an exploration. @@ -199,13 +204,14 @@ message EventLog { string content_id = 3; } - // Represents event context which contains learner-specific details that are logged with every event. + // Represents event context which contains learner-specific details that are logged with every + // event. message LearnerDetailsContext { // The learner id of the current user. This id is profile-specific. string learner_id = 1; // The device id of the current device. This id is common across all profiles. - string device_id = 2; + string install_id = 2; } // Represents the event context for all exploration related events containing learner-specific and @@ -223,8 +229,10 @@ message EventLog { // The session id of the current learning session. string session_id = 4; + reserved 5; // Deprecated string version of exploration version. + // The version of the exploration. - string exploration_version = 5; + int32 exploration_version = 8; // The name of the current state of the exploration. string state_name = 6; @@ -240,7 +248,8 @@ message EventLog { // The priority of events whose logs should not be removed from the storage on most occasions // but can be removed if they're the only ones there and size limit exceeds. ESSENTIAL = 1; - // The priority of events whose logs can be removed from the storage if the size exceeds a certain limit + // The priority of events whose logs can be removed from the storage if the size exceeds a + // certain limit OPTIONAL = 2; } } @@ -289,3 +298,13 @@ message OppiaEventLogs { message OppiaExceptionLogs { repeated ExceptionLog exception_log = 1; } + +// Data structure of device & app context that's stored on disk to be unique to each app +// installation (or cross-installation if restoring from an app backup). +message DeviceContextDatabase { + // The per-installation UUID that can be used to generally uniquely identify this device among all + // others present in a user study. This shouldn't be relied on for anything other than analytics, + // and only for specific studies as the ID is not stable across app installations (but should + // survive app data backups & restores, as well as app upgrades). + string installation_id = 1; +} diff --git a/model/src/main/proto/profile.proto b/model/src/main/proto/profile.proto index 899a3aba8a9..6285450ede5 100644 --- a/model/src/main/proto/profile.proto +++ b/model/src/main/proto/profile.proto @@ -65,6 +65,11 @@ message Profile { // Represents user selected app-language. AppLanguage app_language = 12; + + // Represents a generally study-unique ID that helps identify this profile for analytics logs that + // are enabled as part of a user study. In non-user study cases this is expected to either be + // empty or a meaningless value. + string learner_id = 13; } // Represents a profile avatar image. diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index b152a5f4a7b..32022dd5c69 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -297,6 +297,7 @@ file_content_checks { exempted_file_name: "domain/src/test/java/org/oppia/android/domain/classify/rules/numericexpressioninput/NumericExpressionInputIsEquivalentToRuleClassifierProviderTest.kt" exempted_file_name: "domain/src/test/java/org/oppia/android/domain/classify/rules/numericexpressioninput/NumericExpressionInputMatchesExactlyWithRuleClassifierProviderTest.kt" exempted_file_name: "domain/src/test/java/org/oppia/android/domain/classify/rules/numericexpressioninput/NumericExpressionInputMatchesUpToTrivialManipulationsRuleClassifierProviderTest.kt" + exempted_file_name: "domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt" exempted_file_name: "scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt" exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/ExpressionToComparableOperationConverterTest.kt" exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/MathExpressionExtensionsTest.kt" @@ -306,3 +307,12 @@ file_content_checks { exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt" exempted_file_patterns: "testing/src/main/java/org/oppia/android/testing/junit/.+?\\.kt" } +file_content_checks { + file_path_regex: ".+?.kt" + prohibited_content_regex: "android\\.content\\.ClipboardManager" + failure_message: "Don't use Android's ClipboardManager directly. Instead, use ClipboardController." + exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdFragmentTest.kt" + exempted_file_name: "domain/src/main/java/org/oppia/android/domain/clipboard/ClipboardController.kt" + exempted_file_name: "domain/src/test/java/org/oppia/android/domain/clipboard/ClipboardControllerTest.kt" + exempted_file_name: "scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt" +} diff --git a/scripts/assets/kdoc_validity_exemptions.textproto b/scripts/assets/kdoc_validity_exemptions.textproto index 2bab77ce5ce..f7a21145d53 100644 --- a/scripts/assets/kdoc_validity_exemptions.textproto +++ b/scripts/assets/kdoc_validity_exemptions.textproto @@ -371,7 +371,6 @@ exempted_file_path: "testing/src/main/java/org/oppia/android/testing/threading/T exempted_file_path: "testing/src/main/java/org/oppia/android/testing/threading/TestCoroutineDispatchersEspressoImpl.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/threading/TestCoroutineDispatchersRobolectricImpl.kt" exempted_file_path: "testing/src/test/java/org/oppia/android/testing/threading/TestCoroutineDispatcherTestBase.kt" -exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/LogLevel.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/html/ConceptCardTagHandler.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/html/CustomBulletSpan.kt" diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index ad1c19e7db2..06271eb1457 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -597,8 +597,10 @@ exempted_file_path: "domain/src/main/java/org/oppia/android/domain/onboarding/Ex exempted_file_path: "domain/src/main/java/org/oppia/android/domain/onboarding/ExpirationMetaDataRetrieverModule.kt" exempted_file_path: "domain/src/main/java/org/oppia/android/domain/onboarding/testing/ExpirationMetaDataRetrieverTestModule.kt" exempted_file_path: "domain/src/main/java/org/oppia/android/domain/onboarding/testing/FakeExpirationMetaDataRetriever.kt" +exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/ApplicationIdSeed.kt" exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/ApplicationStartupListener.kt" exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/LogStorageModule.kt" +exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsInactivityLimitMillis.kt" exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions/UncaughtExceptionLoggerModule.kt" exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkerFactory.kt" exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorkerModule.kt" @@ -667,6 +669,7 @@ exempted_file_path: "testing/src/main/java/org/oppia/android/testing/junit/Param exempted_file_path: "testing/src/main/java/org/oppia/android/testing/junit/ParameterizedRunnerDelegate.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/junit/ParameterizedRunnerOverrideMethods.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/junit/ParameterValue.kt" +exempted_file_path: "testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/ComparableOperationSubject.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/FractionSubject.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/MathExpressionSubject.kt" @@ -731,6 +734,8 @@ exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/LogLev exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/LogUploader.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/LoggerModule.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/LoggingAnnotations.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/SyncStatusManager.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/SyncStatusModule.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/firebase/DebugEventLogger.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/firebase/DebugLogReportingModule.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/firebase/FirebaseEventLogger.kt" diff --git a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt index 0ecea9fbee5..3df0f8cfa12 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt @@ -131,6 +131,8 @@ class RegexPatternValidationCheckTest { " PR description. Note that parameterized tests should only be used in special" + " circumstances where a single behavior can be tested across multiple inputs, or for" + " especially large test suites that can be trivially reduced." + private val doNotUseClipboardManager = + "Don't use Android's ClipboardManager directly. Instead, use ClipboardController." private val wikiReferenceNote = "Refer to https://github.com/oppia/oppia-android/wiki/Static-Analysis-Checks" + "#regexpatternvalidation-check for more details on how to fix this." @@ -1664,6 +1666,27 @@ class RegexPatternValidationCheckTest { ) } + @Test + fun testFileContent_clipboardManagerImport_fileContentIsNotCorrect() { + val prohibitedContent = "import android.content.ClipboardManager" + tempFolder.newFolder("testfiles", "domain", "src", "main") + val stringFilePath = "domain/src/main/SomeController.kt" + tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) + + val exception = assertThrows(Exception::class) { + runScript() + } + + assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) + assertThat(outContent.toString().trim()) + .isEqualTo( + """ + $stringFilePath:1: $doNotUseClipboardManager + $wikiReferenceNote + """.trimIndent() + ) + } + @Test fun testFilenameAndContent_useProhibitedFileName_useProhibitedFileContent_multipleFailures() { tempFolder.newFolder("testfiles", "data", "src", "main") diff --git a/testing/src/main/java/org/oppia/android/testing/FakeEventLogger.kt b/testing/src/main/java/org/oppia/android/testing/FakeEventLogger.kt index 542f2337393..ef3a35c90c2 100644 --- a/testing/src/main/java/org/oppia/android/testing/FakeEventLogger.kt +++ b/testing/src/main/java/org/oppia/android/testing/FakeEventLogger.kt @@ -17,7 +17,7 @@ class FakeEventLogger @Inject constructor() : EventLogger { /** Returns the most recently logged event. */ fun getMostRecentEvent(): EventLog = eventList.last() - /** Clears all the events that are currently logged.. */ + /** Clears all the events that are currently logged. */ fun clearAllEvents() = eventList.clear() /** Checks if a certain event has been logged or not. */ diff --git a/testing/src/main/java/org/oppia/android/testing/logging/BUILD.bazel b/testing/src/main/java/org/oppia/android/testing/logging/BUILD.bazel new file mode 100644 index 00000000000..851b774df8e --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/logging/BUILD.bazel @@ -0,0 +1,50 @@ +""" +Test utilities for broad logging functionality. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +kt_android_library( + name = "event_log_subject", + testonly = True, + srcs = [ + "EventLogSubject.kt", + ], + visibility = ["//:oppia_testing_visibility"], + deps = [ + "//model/src/main/proto:event_logger_java_proto_lite", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + ], +) + +kt_android_library( + name = "fake_sync_status_manager", + testonly = True, + srcs = [ + "FakeSyncStatusManager.kt", + ], + visibility = ["//:oppia_testing_visibility"], + deps = [ + ":dagger", + "//utility/src/main/java/org/oppia/android/util/logging:sync_status_manager", + "//utility/src/main/java/org/oppia/android/util/logging:sync_status_manager_impl", + ], +) + +kt_android_library( + name = "sync_status_test_module", + testonly = True, + srcs = [ + "SyncStatusTestModule.kt", + ], + visibility = ["//:oppia_testing_visibility"], + deps = [ + ":dagger", + ":fake_sync_status_manager", + "//utility/src/main/java/org/oppia/android/util/logging:sync_status_manager", + ], +) + +dagger_rules() diff --git a/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt b/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt new file mode 100644 index 00000000000..a36ee70eb06 --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt @@ -0,0 +1,1271 @@ +package org.oppia.android.testing.logging + +import com.google.common.truth.BooleanSubject +import com.google.common.truth.FailureMetadata +import com.google.common.truth.IntegerSubject +import com.google.common.truth.IterableSubject +import com.google.common.truth.LongSubject +import com.google.common.truth.StringSubject +import com.google.common.truth.Truth.assertAbout +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.extensions.proto.LiteProtoSubject +import org.oppia.android.app.model.EventLog +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_HINT_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_SOLUTION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_BACKGROUND_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_FOREGROUND_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.DELETE_PROFILE_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.END_CARD_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.EXIT_EXPLORATION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.FINISH_EXPLORATION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.HINT_OFFERED_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_CONCEPT_CARD +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_EXPLORATION_ACTIVITY +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_HOME +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_INFO_TAB +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_LESSONS_TAB +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_PRACTICE_TAB +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_PROFILE_CHOOSER +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_QUESTION_PLAYER +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_CARD +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_TAB +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_STORY_ACTIVITY +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.PLAY_VOICE_OVER_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.RESUME_EXPLORATION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SOLUTION_OFFERED_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_CARD_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_OVER_EXPLORATION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SUBMIT_ANSWER_CONTEXT + +// TODO(#4272): Add tests for this class. + +/** + * Truth subject for verifying properties of [EventLog]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying [EventLog] + * proto can be verified through inherited methods. + * + * Call [assertThat] to create the subject. + */ +@Suppress("unused", "MemberVisibilityCanBePrivate") // TODO(#4272): Remove suppression when tested. +class EventLogSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog +) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [LongSubject] to test [EventLog.getTimestamp]. + * + * This method never fails since the underlying property defaults to 0 if it's not defined in the + * log. + */ + fun hasTimestampThat(): LongSubject = assertThat(actual.timestamp) + + /** + * Verifies that the [EventLog] under test has priority [EventLog.Priority.ESSENTIAL] per + * [EventLog.getPriority]. + */ + fun isEssentialPriority() { + assertThat(actual.priority).isEqualTo(EventLog.Priority.ESSENTIAL) + } + + /** + * Verifies that the [EventLog] under test has priority [EventLog.Priority.OPTIONAL] per + * [EventLog.getPriority]. + */ + fun isOptionalPriority() { + assertThat(actual.priority).isEqualTo(EventLog.Priority.OPTIONAL) + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [OPEN_EXPLORATION_ACTIVITY] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenExplorationActivityContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_EXPLORATION_ACTIVITY) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenExplorationActivityContext] and returns an + * [ExplorationContextSubject] to test the corresponding context. + * + * See the documentation for this method's other overload for details on a variant of this method + * that allows for easier verification of constituent properties of the log's context. + */ + fun hasOpenExplorationActivityContextThat(): ExplorationContextSubject { + hasOpenExplorationActivityContext() + return ExplorationContextSubject.assertThat(actual.context.openExplorationActivity) + } + + /** + * Verifies the [EventLog]'s context in the same way as [hasOpenExplorationActivityContextThat] + * and executes the specified [block] with the resulting test subject as the receiver. + * + * This can be useful to verify multiple underlying properties of the context, e.g.: + * + * ```kotlin + * assertThat(someEventLog).hasOpenExplorationActivityContextThat { + * hasTopicIdThat().isEqualTo("expected_topic_id") + * hasStateNameThat().isEqualTo("expected_state_name") + * ... + * } + * ``` + * + * This is logically equivalent to the following (but is meant as a more Truthy & readable + * alternative): + * + * ```kotlin + * assertThat(someEventLog).apply { + * hasTopicIdThat().isEqualTo("expected_topic_id") + * ... + * } + * ``` + */ + fun hasOpenExplorationActivityContextThat(block: ExplorationContextSubject.() -> Unit) { + hasOpenExplorationActivityContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_INFO_TAB] (per + * [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenInfoTabContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_INFO_TAB) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenInfoTabContext] and returns a + * [TopicContextSubject] to test the corresponding context. + */ + fun hasOpenInfoTabContextThat(): TopicContextSubject { + hasOpenInfoTabContext() + return TopicContextSubject.assertThat(actual.context.openInfoTab) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasOpenInfoTabContextThat]. + */ + fun hasOpenInfoTabContextThat(block: TopicContextSubject.() -> Unit) { + hasOpenInfoTabContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_LESSONS_TAB] (per + * [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenLessonsTabContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_LESSONS_TAB) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenLessonsTabContext] and returns a + * [TopicContextSubject] to test the corresponding context. + */ + fun hasOpenLessonsTabContextThat(): TopicContextSubject { + hasOpenLessonsTabContext() + return TopicContextSubject.assertThat(actual.context.openLessonsTab) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasOpenLessonsTabContextThat]. + */ + fun hasOpenLessonsTabContextThat(block: TopicContextSubject.() -> Unit) { + hasOpenLessonsTabContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_PRACTICE_TAB] (per + * [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenPracticeTabContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_PRACTICE_TAB) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenPracticeTabContext] and returns a + * [TopicContextSubject] to test the corresponding context. + */ + fun hasOpenPracticeTabContextThat(): TopicContextSubject { + hasOpenPracticeTabContext() + return TopicContextSubject.assertThat(actual.context.openPracticeTab) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasOpenPracticeTabContextThat]. + */ + fun hasOpenPracticeTabContextThat(block: TopicContextSubject.() -> Unit) { + hasOpenPracticeTabContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_REVISION_TAB] (per + * [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenRevisionTabContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_REVISION_TAB) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenRevisionTabContext] and returns a + * [TopicContextSubject] to test the corresponding context. + */ + fun hasOpenRevisionTabContextThat(): TopicContextSubject { + hasOpenRevisionTabContext() + return TopicContextSubject.assertThat(actual.context.openRevisionTab) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasOpenRevisionTabContextThat]. + */ + fun hasOpenRevisionTabContextThat(block: TopicContextSubject.() -> Unit) { + hasOpenRevisionTabContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_QUESTION_PLAYER] + * (per [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenQuestionPlayerContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_QUESTION_PLAYER) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenQuestionPlayerContext] and returns a + * [QuestionContextSubject] to test the corresponding context. + */ + fun hasOpenQuestionPlayerContextThat(): QuestionContextSubject { + hasOpenQuestionPlayerContext() + return QuestionContextSubject.assertThat(actual.context.openQuestionPlayer) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasOpenQuestionPlayerContextThat]. + */ + fun hasOpenQuestionPlayerContextThat(block: QuestionContextSubject.() -> Unit) { + hasOpenQuestionPlayerContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_STORY_ACTIVITY] + * (per [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenStoryActivityContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_STORY_ACTIVITY) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenStoryActivityContext] and returns a + * [StoryContextSubject] to test the corresponding context. + */ + fun hasOpenStoryActivityContextThat(): StoryContextSubject { + hasOpenStoryActivityContext() + return StoryContextSubject.assertThat(actual.context.openStoryActivity) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasOpenStoryActivityContextThat]. + */ + fun hasOpenStoryActivityContextThat(block: StoryContextSubject.() -> Unit) { + hasOpenStoryActivityContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_CONCEPT_CARD] (per + * [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenConceptCardContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_CONCEPT_CARD) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenConceptCardContext] and returns a + * [ConceptCardContextSubject] to test the corresponding context. + */ + fun hasOpenConceptCardContextThat(): ConceptCardContextSubject { + hasOpenConceptCardContext() + return ConceptCardContextSubject.assertThat(actual.context.openConceptCard) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasOpenConceptCardContextThat]. + */ + fun hasOpenConceptCardContextThat(block: ConceptCardContextSubject.() -> Unit) { + hasOpenConceptCardContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_REVISION_CARD] + * (per [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenRevisionCardContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_REVISION_CARD) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenRevisionCardContext] and returns a + * [RevisionCardContextSubject] to test the corresponding context. + */ + fun hasOpenRevisionCardContextThat(): RevisionCardContextSubject { + hasOpenRevisionCardContext() + return RevisionCardContextSubject.assertThat(actual.context.openRevisionCard) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasOpenRevisionCardContextThat]. + */ + fun hasOpenRevisionCardContextThat(block: RevisionCardContextSubject.() -> Unit) { + hasOpenRevisionCardContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [START_CARD_CONTEXT] + * (per [EventLog.Context.getActivityContextCase]). + */ + fun hasStartCardContext() { + assertThat(actual.context.activityContextCase).isEqualTo(START_CARD_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasStartCardContext] and returns a [CardContextSubject] + * to test the corresponding context. + */ + fun hasStartCardContextThat(): CardContextSubject { + hasStartCardContext() + return CardContextSubject.assertThat(actual.context.startCardContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasStartCardContextThat]. + */ + fun hasStartCardContextThat(block: CardContextSubject.() -> Unit) { + hasStartCardContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [END_CARD_CONTEXT] (per + * [EventLog.Context.getActivityContextCase]). + */ + fun hasEndCardContext() { + assertThat(actual.context.activityContextCase).isEqualTo(END_CARD_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasEndCardContext] and returns a [CardContextSubject] to + * test the corresponding context. + */ + fun hasEndCardContextThat(): CardContextSubject { + hasEndCardContext() + return CardContextSubject.assertThat(actual.context.endCardContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasEndCardContextThat]. + */ + fun hasEndCardContextThat(block: CardContextSubject.() -> Unit) { + hasEndCardContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [HINT_OFFERED_CONTEXT] + * (per [EventLog.Context.getActivityContextCase]). + */ + fun hasHintOfferedContext() { + assertThat(actual.context.activityContextCase).isEqualTo(HINT_OFFERED_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasHintOfferedContext] and returns a + * [HintContextSubject] to test the corresponding context. + */ + fun hasHintOfferedContextThat(): HintContextSubject { + hasHintOfferedContext() + return HintContextSubject.assertThat(actual.context.hintOfferedContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasHintOfferedContextThat]. + */ + fun hasHintOfferedContextThat(block: HintContextSubject.() -> Unit) { + hasHintOfferedContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [ACCESS_HINT_CONTEXT] + * (per [EventLog.Context.getActivityContextCase]). + */ + fun hasAccessHintContext() { + assertThat(actual.context.activityContextCase).isEqualTo(ACCESS_HINT_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasAccessHintContext] and returns a [HintContextSubject] + * to test the corresponding context. + */ + fun hasAccessHintContextThat(): HintContextSubject { + hasAccessHintContext() + return HintContextSubject.assertThat(actual.context.accessHintContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasAccessHintContextThat]. + */ + fun hasAccessHintContextThat(block: HintContextSubject.() -> Unit) { + hasAccessHintContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [SOLUTION_OFFERED_CONTEXT] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasSolutionOfferedContext() { + assertThat(actual.context.activityContextCase).isEqualTo(SOLUTION_OFFERED_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasSolutionOfferedContext] and returns an + * [ExplorationContextSubject] to test the corresponding context. + */ + fun hasSolutionOfferedContextThat(): ExplorationContextSubject { + hasSolutionOfferedContext() + return ExplorationContextSubject.assertThat(actual.context.solutionOfferedContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasSolutionOfferedContextThat]. + */ + fun hasSolutionOfferedContextThat(block: ExplorationContextSubject.() -> Unit) { + hasSolutionOfferedContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [ACCESS_SOLUTION_CONTEXT] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasAccessSolutionContext() { + assertThat(actual.context.activityContextCase).isEqualTo(ACCESS_SOLUTION_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasAccessSolutionContext] and returns an + * [ExplorationContextSubject] to test the corresponding context. + */ + fun hasAccessSolutionContextThat(): ExplorationContextSubject { + hasAccessSolutionContext() + return ExplorationContextSubject.assertThat(actual.context.accessSolutionContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasAccessSolutionContextThat]. + */ + fun hasAccessSolutionContextThat(block: ExplorationContextSubject.() -> Unit) { + hasAccessSolutionContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [SUBMIT_ANSWER_CONTEXT] + * (per [EventLog.Context.getActivityContextCase]). + */ + fun hasSubmitAnswerContext() { + assertThat(actual.context.activityContextCase).isEqualTo(SUBMIT_ANSWER_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasSubmitAnswerContext] and returns a + * [SubmitAnswerContextSubject] to test the corresponding context. + */ + fun hasSubmitAnswerContextThat(): SubmitAnswerContextSubject { + hasSubmitAnswerContext() + return SubmitAnswerContextSubject.assertThat(actual.context.submitAnswerContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasSubmitAnswerContextThat]. + */ + fun hasSubmitAnswerContextThat(block: SubmitAnswerContextSubject.() -> Unit) { + hasSubmitAnswerContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [PLAY_VOICE_OVER_CONTEXT] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasPlayVoiceOverContext() { + assertThat(actual.context.activityContextCase).isEqualTo(PLAY_VOICE_OVER_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasPlayVoiceOverContext] and returns a + * [PlayVoiceOverContextSubject] to test the corresponding context. + */ + fun hasPlayVoiceOverContextThat(): PlayVoiceOverContextSubject { + hasPlayVoiceOverContext() + return PlayVoiceOverContextSubject.assertThat(actual.context.playVoiceOverContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasPlayVoiceOverContextThat]. + */ + fun hasPlayVoiceOverContextThat(block: PlayVoiceOverContextSubject.() -> Unit) { + hasPlayVoiceOverContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [APP_IN_BACKGROUND_CONTEXT] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasAppInBackgroundContext() { + assertThat(actual.context.activityContextCase).isEqualTo(APP_IN_BACKGROUND_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasAppInBackgroundContext] and returns a + * [LearnerDetailsContextSubject] to test the corresponding context. + */ + fun hasAppInBackgroundContextThat(): LearnerDetailsContextSubject { + hasAppInBackgroundContext() + return LearnerDetailsContextSubject.assertThat(actual.context.appInBackgroundContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasAppInBackgroundContextThat]. + */ + fun hasAppInBackgroundContextThat(block: LearnerDetailsContextSubject.() -> Unit) { + hasAppInBackgroundContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [APP_IN_FOREGROUND_CONTEXT] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasAppInForegroundContext() { + assertThat(actual.context.activityContextCase).isEqualTo(APP_IN_FOREGROUND_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasAppInForegroundContext] and returns a + * [LearnerDetailsContextSubject] to test the corresponding context. + */ + fun hasAppInForegroundContextThat(): LearnerDetailsContextSubject { + hasAppInForegroundContext() + return LearnerDetailsContextSubject.assertThat(actual.context.appInForegroundContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasAppInForegroundContextThat]. + */ + fun hasAppInForegroundContextThat(block: LearnerDetailsContextSubject.() -> Unit) { + hasAppInForegroundContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [EXIT_EXPLORATION_CONTEXT] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasExitExplorationContext() { + assertThat(actual.context.activityContextCase).isEqualTo(EXIT_EXPLORATION_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasExitExplorationContext] and returns an + * [ExplorationContextSubject] to test the corresponding context. + */ + fun hasExitExplorationContextThat(): ExplorationContextSubject { + hasExitExplorationContext() + return ExplorationContextSubject.assertThat(actual.context.exitExplorationContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasExitExplorationContextThat]. + */ + fun hasExitExplorationContextThat(block: ExplorationContextSubject.() -> Unit) { + hasExitExplorationContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [FINISH_EXPLORATION_CONTEXT] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasFinishExplorationContext() { + assertThat(actual.context.activityContextCase).isEqualTo(FINISH_EXPLORATION_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasFinishExplorationContext] and returns an + * [ExplorationContextSubject] to test the corresponding context. + */ + fun hasFinishExplorationContextThat(): ExplorationContextSubject { + hasFinishExplorationContext() + return ExplorationContextSubject.assertThat(actual.context.finishExplorationContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasFinishExplorationContextThat]. + */ + fun hasFinishExplorationContextThat(block: ExplorationContextSubject.() -> Unit) { + hasFinishExplorationContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [RESUME_EXPLORATION_CONTEXT] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasResumeExplorationContext() { + assertThat(actual.context.activityContextCase).isEqualTo(RESUME_EXPLORATION_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasResumeExplorationContext] and returns a + * [LearnerDetailsContextSubject] to test the corresponding context. + */ + fun hasResumeExplorationContextThat(): LearnerDetailsContextSubject { + hasResumeExplorationContext() + return LearnerDetailsContextSubject.assertThat(actual.context.resumeExplorationContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasResumeExplorationContextThat]. + */ + fun hasResumeExplorationContextThat(block: LearnerDetailsContextSubject.() -> Unit) { + hasResumeExplorationContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [START_OVER_EXPLORATION_CONTEXT] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasStartOverExplorationContext() { + assertThat(actual.context.activityContextCase).isEqualTo(START_OVER_EXPLORATION_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasStartOverExplorationContext] and returns a + * [LearnerDetailsContextSubject] to test the corresponding context. + */ + fun hasStartOverExplorationContextThat(): LearnerDetailsContextSubject { + hasStartOverExplorationContext() + return LearnerDetailsContextSubject.assertThat(actual.context.startOverExplorationContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasStartOverExplorationContextThat]. + */ + fun hasStartOverExplorationContextThat(block: LearnerDetailsContextSubject.() -> Unit) { + hasStartOverExplorationContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [DELETE_PROFILE_CONTEXT] + * (per [EventLog.Context.getActivityContextCase]). + */ + fun hasDeleteProfileContext() { + assertThat(actual.context.activityContextCase).isEqualTo(DELETE_PROFILE_CONTEXT) + } + + /** + * Verifies the [EventLog]'s context per [hasDeleteProfileContext] and returns a + * [LearnerDetailsContextSubject] to test the corresponding context. + */ + fun hasDeleteProfileContextThat(): LearnerDetailsContextSubject { + hasDeleteProfileContext() + return LearnerDetailsContextSubject.assertThat(actual.context.deleteProfileContext) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same was as + * [hasOpenExplorationActivityContextThat] except for the conditions of, and subject returned by, + * [hasDeleteProfileContextThat]. + */ + fun hasDeleteProfileContextThat(block: LearnerDetailsContextSubject.() -> Unit) { + hasDeleteProfileContextThat().block() + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_HOME] (per + * [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenHomeContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_HOME) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenHomeContext] and returns a [BooleanSubject] to + * test the corresponding context. + */ + fun hasOpenHomeContextThat(): BooleanSubject { + hasOpenHomeContext() + return assertThat(actual.context.openHome) + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to [OPEN_PROFILE_CHOOSER] + * (per [EventLog.Context.getActivityContextCase]). + */ + fun hasOpenProfileChooserContext() { + assertThat(actual.context.activityContextCase).isEqualTo(OPEN_PROFILE_CHOOSER) + } + + /** + * Verifies the [EventLog]'s context per [hasOpenProfileChooserContext] and returns a + * [BooleanSubject] to test the corresponding context. + */ + fun hasOpenProfileChooserContextThat(): BooleanSubject { + hasOpenProfileChooserContext() + return assertThat(actual.context.openProfileChooser) + } + + /** + * Verifies that the [EventLog] under test has a context corresponding to + * [INSTALL_ID_FOR_FAILED_ANALYTICS_LOG] (per [EventLog.Context.getActivityContextCase]). + */ + fun hasInstallIdForAnalyticsLogFailure() { + assertThat(actual.context.activityContextCase).isEqualTo(INSTALL_ID_FOR_FAILED_ANALYTICS_LOG) + } + + /** + * Verifies the [EventLog]'s context per [hasInstallIdForAnalyticsLogFailure] and returns a + * [StringSubject] to test the corresponding context. + */ + fun hasInstallIdForAnalyticsLogFailureThat(): StringSubject { + hasInstallIdForAnalyticsLogFailure() + return assertThat(actual.context.installIdForFailedAnalyticsLog) + } + + /** + * Truth subject for verifying properties of [EventLog.CardContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.CardContext] proto can be verified through inherited methods. + * + * Call [CardContextSubject.assertThat] to create the subject. + */ + class CardContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.CardContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [ExplorationContextSubject] to test [EventLog.CardContext.getExplorationDetails]. + * + * This method never fails since the underlying property defaults to empty proto if it's not + * defined in the context. + */ + fun hasExplorationDetailsThat(): ExplorationContextSubject = + ExplorationContextSubject.assertThat(actual.explorationDetails) + + /** + * Executes [block] in the context returned by [hasExplorationDetailsThat], similar to + * [hasOpenExplorationActivityContextThat]. + */ + fun hasExplorationDetailsThat(block: ExplorationContextSubject.() -> Unit) { + hasExplorationDetailsThat().block() + } + + /** + * Returns a [StringSubject] to test [EventLog.CardContext.getSkillId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasSkillIdThat(): StringSubject = assertThat(actual.skillId) + + companion object { + /** + * Returns a new [CardContextSubject] to verify aspects of the specified + * [EventLog.CardContext] value. + */ + fun assertThat(actual: EventLog.CardContext): CardContextSubject = + assertAbout(::CardContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.ConceptCardContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.ConceptCardContext] proto can be verified through inherited methods. + * + * Call [ConceptCardContextSubject.assertThat] to create the subject. + */ + class ConceptCardContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.ConceptCardContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test [EventLog.ConceptCardContext.getSkillId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasSkillIdThat(): StringSubject = assertThat(actual.skillId) + + companion object { + /** + * Returns a new [ConceptCardContextSubject] to verify aspects of the specified + * [EventLog.ConceptCardContext] value. + */ + fun assertThat(actual: EventLog.ConceptCardContext): ConceptCardContextSubject = + assertAbout(::ConceptCardContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.ExplorationContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.ExplorationContext] proto can be verified through inherited methods. + * + * Call [ExplorationContextSubject.assertThat] to create the subject. + */ + class ExplorationContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.ExplorationContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test [EventLog.ExplorationContext.getTopicId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasTopicIdThat(): StringSubject = assertThat(actual.topicId) + + /** + * Returns a [StringSubject] to test [EventLog.ExplorationContext.getStoryId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasStoryIdThat(): StringSubject = assertThat(actual.storyId) + + /** + * Returns a [StringSubject] to test [EventLog.ExplorationContext.getExplorationId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasExplorationIdThat(): StringSubject = assertThat(actual.explorationId) + + /** + * Returns a [StringSubject] to test [EventLog.ExplorationContext.getSessionId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasSessionIdThat(): StringSubject = assertThat(actual.sessionId) + + /** + * Returns a [IntegerSubject] to test [EventLog.ExplorationContext.getExplorationVersion]. + * + * This method never fails since the underlying property defaults to 0 if it's not defined in + * the context. + */ + fun hasVersionThat(): IntegerSubject = assertThat(actual.explorationVersion) + + /** + * Returns a [StringSubject] to test [EventLog.ExplorationContext.getStateName]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasStateNameThat(): StringSubject = assertThat(actual.stateName) + + /** + * Returns a [LearnerDetailsContextSubject] to test + * [EventLog.ExplorationContext.getLearnerDetails]. + * + * This method never fails since the underlying property defaults to empty proto if it's not + * defined in the context. + */ + fun hasLearnerDetailsThat(): LearnerDetailsContextSubject = + LearnerDetailsContextSubject.assertThat(actual.learnerDetails) + + /** + * Executes [block] in the context returned by [hasLearnerDetailsThat], similar to + * [hasOpenExplorationActivityContextThat]. + */ + fun hasLearnerDetailsThat(block: LearnerDetailsContextSubject.() -> Unit) { + hasLearnerDetailsThat().block() + } + + companion object { + /** + * Returns a new [ExplorationContextSubject] to verify aspects of the specified + * [EventLog.ExplorationContext] value. + */ + fun assertThat(actual: EventLog.ExplorationContext): ExplorationContextSubject = + assertAbout(::ExplorationContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.HintContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.HintContext] proto can be verified through inherited methods. + * + * Call [HintContextSubject.assertThat] to create the subject. + */ + class HintContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.HintContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [ExplorationContextSubject] to test [EventLog.HintContext.getExplorationDetails]. + * + * This method never fails since the underlying property defaults to empty proto if it's not + * defined in the context. + */ + fun hasExplorationDetailsThat(): ExplorationContextSubject = + ExplorationContextSubject.assertThat(actual.explorationDetails) + + /** + * Executes [block] in the context returned by [hasExplorationDetailsThat], similar to + * [hasOpenExplorationActivityContextThat]. + */ + fun hasExplorationDetailsThat(block: ExplorationContextSubject.() -> Unit) { + hasExplorationDetailsThat().block() + } + + /** + * Returns a [IntegerSubject] to test [EventLog.HintContext.getHintIndex]. + * + * This method never fails since the underlying property defaults to 0 if it's not defined in + * the context. + */ + fun hasHintIndexThat(): IntegerSubject = assertThat(actual.hintIndex) + + companion object { + /** + * Returns a new [HintContextSubject] to verify aspects of the specified + * [EventLog.HintContext] value. + */ + fun assertThat(actual: EventLog.HintContext): HintContextSubject = + assertAbout(::HintContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.LearnerDetailsContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.LearnerDetailsContext] proto can be verified through inherited methods. + * + * Call [LearnerDetailsContextSubject.assertThat] to create the subject. + */ + class LearnerDetailsContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.LearnerDetailsContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test [EventLog.LearnerDetailsContext.getLearnerId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasLearnerIdThat(): StringSubject = assertThat(actual.learnerId) + + /** + * Returns a [StringSubject] to test [EventLog.LearnerDetailsContext.getInstallId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasInstallationIdThat(): StringSubject = assertThat(actual.installId) + + companion object { + /** + * Returns a new [LearnerDetailsContextSubject] to verify aspects of the specified + * [EventLog.LearnerDetailsContext] value. + */ + fun assertThat(actual: EventLog.LearnerDetailsContext): LearnerDetailsContextSubject = + assertAbout(::LearnerDetailsContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.PlayVoiceOverContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.PlayVoiceOverContext] proto can be verified through inherited methods. + * + * Call [PlayVoiceOverContextSubject.assertThat] to create the subject. + */ + class PlayVoiceOverContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.PlayVoiceOverContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [ExplorationContextSubject] to test + * [EventLog.PlayVoiceOverContext.getExplorationDetails]. + * + * This method never fails since the underlying property defaults to empty proto if it's not + * defined in the context. + */ + fun hasExplorationDetailsThat(): ExplorationContextSubject = + ExplorationContextSubject.assertThat(actual.explorationDetails) + + /** + * Executes [block] in the context returned by [hasExplorationDetailsThat], similar to + * [hasOpenExplorationActivityContextThat]. + */ + fun hasExplorationDetailsThat(block: ExplorationContextSubject.() -> Unit) { + hasExplorationDetailsThat().block() + } + + /** + * Returns a [StringSubject] to test [EventLog.PlayVoiceOverContext.getContentId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasContentIdThat(): StringSubject = assertThat(actual.contentId) + + companion object { + /** + * Returns a new [PlayVoiceOverContextSubject] to verify aspects of the specified + * [EventLog.PlayVoiceOverContext] value. + */ + fun assertThat(actual: EventLog.PlayVoiceOverContext): PlayVoiceOverContextSubject = + assertAbout(::PlayVoiceOverContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.QuestionContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.QuestionContext] proto can be verified through inherited methods. + * + * Call [QuestionContextSubject.assertThat] to create the subject. + */ + class QuestionContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.QuestionContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test [EventLog.QuestionContext.getQuestionId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasQuestionIdThat(): StringSubject = assertThat(actual.questionId) + + /** + * Returns a [IterableSubject] to test [EventLog.QuestionContext.getSkillIdList]. + * + * This method never fails since the underlying property defaults to an empty list if it's not + * defined in the context. + */ + fun hasSkillIdListThat(): IterableSubject = assertThat(actual.skillIdList) + + companion object { + /** + * Returns a new [QuestionContextSubject] to verify aspects of the specified + * [EventLog.QuestionContext] value. + */ + fun assertThat(actual: EventLog.QuestionContext): QuestionContextSubject = + assertAbout(::QuestionContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.RevisionCardContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.RevisionCardContext] proto can be verified through inherited methods. + * + * Call [RevisionCardContextSubject.assertThat] to create the subject. + */ + class RevisionCardContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.RevisionCardContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test [EventLog.RevisionCardContext.getTopicId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasTopicIdThat(): StringSubject = assertThat(actual.topicId) + + /** + * Returns a [IntegerSubject] to test [EventLog.RevisionCardContext.getSubTopicId]. + * + * This method never fails since the underlying property defaults to 0 if it's not defined in + * the context. + */ + fun hasSubtopicIndexThat(): IntegerSubject = assertThat(actual.subTopicId) + + companion object { + /** + * Returns a new [RevisionCardContextSubject] to verify aspects of the specified + * [EventLog.RevisionCardContext] value. + */ + fun assertThat(actual: EventLog.RevisionCardContext): RevisionCardContextSubject = + assertAbout(::RevisionCardContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.StoryContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.StoryContext] proto can be verified through inherited methods. + * + * Call [StoryContextSubject.assertThat] to create the subject. + */ + class StoryContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.StoryContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test [EventLog.StoryContext.getTopicId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasTopicIdThat(): StringSubject = assertThat(actual.topicId) + + /** + * Returns a [StringSubject] to test [EventLog.StoryContext.getStoryId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasStoryIdThat(): StringSubject = assertThat(actual.storyId) + + companion object { + /** + * Returns a new [StoryContextSubject] to verify aspects of the specified + * [EventLog.StoryContext] value. + */ + fun assertThat(actual: EventLog.StoryContext): StoryContextSubject = + assertAbout(::StoryContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.SubmitAnswerContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.SubmitAnswerContext] proto can be verified through inherited methods. + * + * Call [SubmitAnswerContextSubject.assertThat] to create the subject. + */ + class SubmitAnswerContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.SubmitAnswerContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [ExplorationContextSubject] to test + * [EventLog.SubmitAnswerContext.getExplorationDetails]. + * + * This method never fails since the underlying property defaults to empty proto if it's not + * defined in the context. + */ + fun hasExplorationDetailsThat(): ExplorationContextSubject = + ExplorationContextSubject.assertThat(actual.explorationDetails) + + /** + * Executes [block] in the context returned by [hasExplorationDetailsThat], similar to + * [hasOpenExplorationActivityContextThat]. + */ + fun hasExplorationDetailsThat(block: ExplorationContextSubject.() -> Unit) { + hasExplorationDetailsThat().block() + } + + /** + * Returns a [BooleanSubject] to test [EventLog.SubmitAnswerContext.getIsAnswerCorrect]. + * + * This method never fails since the underlying property defaults to false if it's not defined + * in the context. + */ + fun hasAnswerCorrectValueThat(): BooleanSubject = assertThat(actual.isAnswerCorrect) + + companion object { + /** + * Returns a new [SubmitAnswerContextSubject] to verify aspects of the specified + * [EventLog.SubmitAnswerContext] value. + */ + fun assertThat(actual: EventLog.SubmitAnswerContext): SubmitAnswerContextSubject = + assertAbout(::SubmitAnswerContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.TopicContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.TopicContext] proto can be verified through inherited methods. + * + * Call [TopicContextSubject.assertThat] to create the subject. + */ + class TopicContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.TopicContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test [EventLog.TopicContext.getTopicId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasTopicIdThat(): StringSubject = assertThat(actual.topicId) + + companion object { + /** + * Returns a new [TopicContextSubject] to verify aspects of the specified + * [EventLog.TopicContext] value. + */ + fun assertThat(actual: EventLog.TopicContext): TopicContextSubject = + assertAbout(::TopicContextSubject).that(actual) + } + } + + companion object { + /** Returns a new [EventLogSubject] to verify aspects of the specified [EventLog] value. */ + fun assertThat(actual: EventLog): EventLogSubject = assertAbout(::EventLogSubject).that(actual) + } +} diff --git a/testing/src/main/java/org/oppia/android/testing/logging/FakeSyncStatusManager.kt b/testing/src/main/java/org/oppia/android/testing/logging/FakeSyncStatusManager.kt new file mode 100644 index 00000000000..9b8bdc4b402 --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/logging/FakeSyncStatusManager.kt @@ -0,0 +1,37 @@ +package org.oppia.android.testing.logging + +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.logging.SyncStatusManager +import org.oppia.android.util.logging.SyncStatusManagerImpl +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A test-only implementation of [SyncStatusManager] that uses a real implementation internally, but + * tracks all sync status set via [setSyncStatus]. + */ +@Singleton +class FakeSyncStatusManager @Inject constructor( + private val syncStatusManagerImpl: SyncStatusManagerImpl +) : SyncStatusManager { + private val trackedSyncStatuses = CopyOnWriteArrayList() + + override fun getSyncStatus(): DataProvider = + syncStatusManagerImpl.getSyncStatus() + + override fun setSyncStatus(syncStatus: SyncStatusManager.SyncStatus) { + trackedSyncStatuses += syncStatus + syncStatusManagerImpl.setSyncStatus(syncStatus) + } + + /** + * Returns the list of all [SyncStatusManager.SyncStatus]s that were set via [setSyncStatus]. + * + * Note that the order of values in the returned list will match the order [setSyncStatus] was + * called but may not match the exact order or presence of values propagated to the [DataProvider] + * returned by [getSyncStatus] (since [DataProvider]s guarantee eventual consistency and may skip + * values). + */ + fun getSyncStatuses(): List = trackedSyncStatuses +} diff --git a/testing/src/main/java/org/oppia/android/testing/logging/SyncStatusTestModule.kt b/testing/src/main/java/org/oppia/android/testing/logging/SyncStatusTestModule.kt new file mode 100644 index 00000000000..ae881074596 --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/logging/SyncStatusTestModule.kt @@ -0,0 +1,12 @@ +package org.oppia.android.testing.logging + +import dagger.Binds +import dagger.Module +import org.oppia.android.util.logging.SyncStatusManager + +/** Module for providing test-only sync status utilities. */ +@Module +interface SyncStatusTestModule { + @Binds + fun bindSyncStatusManager(impl: FakeSyncStatusManager): SyncStatusManager +} diff --git a/testing/src/test/java/org/oppia/android/testing/FakeEventLoggerTest.kt b/testing/src/test/java/org/oppia/android/testing/FakeEventLoggerTest.kt index 1399d4b9cf5..8c545be3087 100644 --- a/testing/src/test/java/org/oppia/android/testing/FakeEventLoggerTest.kt +++ b/testing/src/test/java/org/oppia/android/testing/FakeEventLoggerTest.kt @@ -14,22 +14,26 @@ import org.junit.Test import org.junit.runner.RunWith import org.oppia.android.app.model.EventLog import org.oppia.android.app.model.EventLog.Priority +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.logging.EventLogger import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import javax.inject.Inject import javax.inject.Singleton +/** Tests for [FakeEventLogger]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) @Config(manifest = Config.NONE) class FakeEventLoggerTest { - @Inject - lateinit var fakeEventLogger: FakeEventLogger - - @Inject - lateinit var eventLogger: EventLogger + @Inject lateinit var fakeEventLogger: FakeEventLogger + @Inject lateinit var eventLogger: EventLogger private val eventLog1 = EventLog.newBuilder().setPriority(Priority.ESSENTIAL).build() private val eventLog2 = EventLog.newBuilder().setPriority(Priority.OPTIONAL).build() @@ -158,7 +162,12 @@ class FakeEventLoggerTest { // TODO(#89): Move this to a common test application component. @Singleton - @Component(modules = [TestModule::class, TestLogReportingModule::class]) + @Component( + modules = [ + TestModule::class, TestLogReportingModule::class, RobolectricModule::class, + TestDispatcherModule::class, LogStorageModule::class, FakeOppiaClockModule::class + ] + ) interface TestApplicationComponent { @Component.Builder interface Builder { diff --git a/testing/src/test/java/org/oppia/android/testing/logging/BUILD.bazel b/testing/src/test/java/org/oppia/android/testing/logging/BUILD.bazel new file mode 100644 index 00000000000..df54586c2f8 --- /dev/null +++ b/testing/src/test/java/org/oppia/android/testing/logging/BUILD.bazel @@ -0,0 +1,60 @@ +""" +Tests for broad logging functionality test utilities. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "FakeSyncStatusManagerTest", + srcs = ["FakeSyncStatusManagerTest.kt"], + custom_package = "org.oppia.android.testing.logging", + test_class = "org.oppia.android.testing.logging.FakeSyncStatusManagerTest", + test_manifest = "//testing:test_manifest", + deps = [ + ":dagger", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/logging:fake_sync_status_manager", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +oppia_android_test( + name = "SyncStatusTestModuleTest", + srcs = ["SyncStatusTestModuleTest.kt"], + custom_package = "org.oppia.android.testing.logging", + test_class = "org.oppia.android.testing.logging.SyncStatusTestModuleTest", + test_manifest = "//testing:test_manifest", + deps = [ + ":dagger", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/logging:fake_sync_status_manager", + "//testing/src/main/java/org/oppia/android/testing/logging:sync_status_test_module", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/logging:sync_status_manager", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +dagger_rules() diff --git a/testing/src/test/java/org/oppia/android/testing/logging/FakeSyncStatusManagerTest.kt b/testing/src/test/java/org/oppia/android/testing/logging/FakeSyncStatusManagerTest.kt new file mode 100644 index 00000000000..d5e64b1b129 --- /dev/null +++ b/testing/src/test/java/org/oppia/android/testing/logging/FakeSyncStatusManagerTest.kt @@ -0,0 +1,210 @@ +package org.oppia.android.testing.logging + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.data.DataProviderTestMonitor +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus.DATA_UPLOADED +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus.DATA_UPLOADING +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus.INITIAL_UNKNOWN +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus.NETWORK_ERROR +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [FakeSyncStatusManager]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = FakeSyncStatusManagerTest.TestApplication::class) +class FakeSyncStatusManagerTest { + @Inject lateinit var fakeSyncStatusManager: FakeSyncStatusManager + @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testGetSyncStatus_initialState_returnsDefaultValue() { + val syncStatusProvider = fakeSyncStatusManager.getSyncStatus() + + val state = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(state).isEqualTo(INITIAL_UNKNOWN) + } + + @Test + fun testGetSyncStatuses_initialState_returnsEmptyList() { + val syncStatuses = fakeSyncStatusManager.getSyncStatuses() + + assertThat(syncStatuses).isEmpty() + } + + @Test + fun testGetSyncStatus_changeToUploading_returnsUploading() { + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + + val syncStatusProvider = fakeSyncStatusManager.getSyncStatus() + + // The retrieved state should be updated, similar to the production implementation. + val state = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(state).isEqualTo(DATA_UPLOADING) + } + + @Test + fun testGetSyncStatus_changeToUploading_existingSubscription_updatesSubscriptionToUploading() { + val syncStatusProvider = fakeSyncStatusManager.getSyncStatus() + val monitor = monitorFactory.createMonitor(syncStatusProvider) + monitor.waitForNextResult() + + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + + // The existing subscription should be updated due to a notification (similar to the production + // implementation). + val state = monitor.waitForNextSuccessResult() + assertThat(state).isEqualTo(DATA_UPLOADING) + } + + @Test + fun testGetSyncStatuses_changeToUploading_returnsUploading() { + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + + val syncStatuses = fakeSyncStatusManager.getSyncStatuses() + + // The new status value should be captured. + assertThat(syncStatuses).containsExactly(DATA_UPLOADING) + } + + @Test + fun testGetSyncStatuses_changeToUploadingTwice_returnsDuplicatedUploadingStatuses() { + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + + val syncStatuses = fakeSyncStatusManager.getSyncStatuses() + + // Multiple changes to sync status should be captured. + assertThat(syncStatuses).containsExactly(DATA_UPLOADING, DATA_UPLOADING) + } + + @Test + fun testGetSyncStatus_changeToUploadingThenNetworkError_returnsNetworkError() { + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + fakeSyncStatusManager.setSyncStatus(NETWORK_ERROR) + + val syncStatusProvider = fakeSyncStatusManager.getSyncStatus() + + // The latest retrieved state should be updated, similar to the production implementation. + val state = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(state).isEqualTo(NETWORK_ERROR) + } + + @Test + fun testGetSyncStatuses_changeToUploadingThenNetworkError_returnsUploadingAndNetworkErrors() { + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + fakeSyncStatusManager.setSyncStatus(NETWORK_ERROR) + + val syncStatuses = fakeSyncStatusManager.getSyncStatuses() + + // Both status values should be captured. + assertThat(syncStatuses).containsExactly(DATA_UPLOADING, NETWORK_ERROR) + } + + @Test + fun testGetSyncStatus_changeBetweenSeveralStates_returnsLatest() { + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + fakeSyncStatusManager.setSyncStatus(NETWORK_ERROR) + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADED) + + val syncStatusProvider = fakeSyncStatusManager.getSyncStatus() + + // The latest retrieved state should be updated, similar to the production implementation. + val state = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(state).isEqualTo(DATA_UPLOADED) + } + + @Test + fun testGetSyncStatuses_changeBetweenSeveralStates_returnsAllStatusesInOrder() { + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + fakeSyncStatusManager.setSyncStatus(NETWORK_ERROR) + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADING) + fakeSyncStatusManager.setSyncStatus(DATA_UPLOADED) + + val syncStatuses = fakeSyncStatusManager.getSyncStatuses() + + // All status values should be captured, in order, including repeats. + assertThat(syncStatuses).containsExactly( + DATA_UPLOADING, NETWORK_ERROR, DATA_UPLOADING, DATA_UPLOADED + ).inOrder() + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LogStorageModule::class, NetworkConnectionUtilDebugModule::class, + TestLogReportingModule::class, LoggerModule::class, TestDispatcherModule::class, + LocaleProdModule::class, FakeOppiaClockModule::class, RobolectricModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(test: FakeSyncStatusManagerTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerFakeSyncStatusManagerTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(test: FakeSyncStatusManagerTest) { + component.inject(test) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +} diff --git a/testing/src/test/java/org/oppia/android/testing/logging/SyncStatusTestModuleTest.kt b/testing/src/test/java/org/oppia/android/testing/logging/SyncStatusTestModuleTest.kt new file mode 100644 index 00000000000..9bb9bedf26c --- /dev/null +++ b/testing/src/test/java/org/oppia/android/testing/logging/SyncStatusTestModuleTest.kt @@ -0,0 +1,85 @@ +package org.oppia.android.testing.logging + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.logging.SyncStatusManager +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [SyncStatusTestModule]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class SyncStatusTestModuleTest { + @Inject lateinit var syncStatusManager: SyncStatusManager + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testInjectSyncStatusManager_isInstanceOfFakeSyncStatusManager() { + assertThat(syncStatusManager).isInstanceOf(FakeSyncStatusManager::class.java) + } + + private fun setUpTestApplicationComponent() { + DaggerSyncStatusTestModuleTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, SyncStatusTestModule::class, LogStorageModule::class, + NetworkConnectionUtilDebugModule::class, TestLogReportingModule::class, LoggerModule::class, + TestDispatcherModule::class, LocaleProdModule::class, FakeOppiaClockModule::class, + RobolectricModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(test: SyncStatusTestModuleTest) + } +} diff --git a/utility/BUILD.bazel b/utility/BUILD.bazel index ea0aac5c42a..fba2af1f42f 100644 --- a/utility/BUILD.bazel +++ b/utility/BUILD.bazel @@ -94,6 +94,7 @@ TEST_DEPS = [ "//app:crashlytics_deps", "//model/src/main/proto:test_models", "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", "//testing/src/main/java/org/oppia/android/testing/mockito", "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", "//testing/src/main/java/org/oppia/android/testing/threading:test_module", @@ -111,6 +112,7 @@ TEST_DEPS = [ "//utility/src/main/java/org/oppia/android/util/extensions:bundle_extensions", "//utility/src/main/java/org/oppia/android/util/locale:prod_module", "//utility/src/main/java/org/oppia/android/util/logging:event_bundle_creator", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", "//utility/src/main/java/org/oppia/android/util/parser/html:tag_handlers", "//utility/src/main/java/org/oppia/android/util/parser/image:glide_image_loader", "//utility/src/main/java/org/oppia/android/util/parser/image:url_image_parser", diff --git a/utility/src/main/java/org/oppia/android/util/logging/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/logging/BUILD.bazel index 4e9ed4bd036..0ee93a1bd5c 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/logging/BUILD.bazel @@ -66,6 +66,7 @@ kt_android_library( visibility = ["//:oppia_api_visibility"], deps = [ "//model/src/main/proto:event_logger_java_proto_lite", + "//third_party:javax_inject_javax_inject", ], ) @@ -100,12 +101,14 @@ kt_android_library( name = "prod_module", srcs = [ "LoggerModule.kt", + "SyncStatusModule.kt", ], visibility = ["//:oppia_prod_module_visibility"], deps = [ ":annotations", ":dagger", ":log_level", + ":sync_status_manager_impl", "//third_party:javax_inject_javax_inject", ], ) @@ -122,4 +125,30 @@ kt_android_library( ], ) +kt_android_library( + name = "sync_status_manager", + srcs = [ + "SyncStatusManager.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + "//utility/src/main/java/org/oppia/android/util/data:data_provider", + ], +) + +kt_android_library( + name = "sync_status_manager_impl", + srcs = [ + "SyncStatusManagerImpl.kt", + ], + visibility = [ + "//testing/src/main/java/org/oppia/android/testing/logging:__pkg__", + ], + deps = [ + ":sync_status_manager", + "//utility/src/main/java/org/oppia/android/util/data:async_data_subscription_manager", + "//utility/src/main/java/org/oppia/android/util/data:data_providers", + ], +) + dagger_rules() diff --git a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt index 6880cf5834e..0d63140a643 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt @@ -2,7 +2,17 @@ package org.oppia.android.util.logging import android.os.Bundle import org.oppia.android.app.model.EventLog +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_HINT_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_SOLUTION_CONTEXT import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACTIVITYCONTEXT_NOT_SET +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_BACKGROUND_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_FOREGROUND_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.DELETE_PROFILE_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.END_CARD_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.EXIT_EXPLORATION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.FINISH_EXPLORATION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.HINT_OFFERED_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_CONCEPT_CARD import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_EXPLORATION_ACTIVITY import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_HOME @@ -14,134 +24,352 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_QUE import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_CARD import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_TAB import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_STORY_ACTIVITY +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.PLAY_VOICE_OVER_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.RESUME_EXPLORATION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SOLUTION_OFFERED_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_CARD_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_OVER_EXPLORATION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SUBMIT_ANSWER_CONTEXT +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.CardContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.ConceptCardContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.EmptyContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.ExplorationContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.HintContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.LearnerDetailsContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.PlayVoiceOverContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.QuestionContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.RevisionCardContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.SensitiveStringContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.StoryContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.SubmitAnswerContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.TopicContext +import javax.inject.Inject +import org.oppia.android.app.model.EventLog.CardContext as CardEventContext +import org.oppia.android.app.model.EventLog.ConceptCardContext as ConceptCardEventContext +import org.oppia.android.app.model.EventLog.ExplorationContext as ExplorationEventContext +import org.oppia.android.app.model.EventLog.HintContext as HintEventContext +import org.oppia.android.app.model.EventLog.LearnerDetailsContext as LearnerDetailsEventContext +import org.oppia.android.app.model.EventLog.PlayVoiceOverContext as PlayVoiceOverEventContext +import org.oppia.android.app.model.EventLog.QuestionContext as QuestionEventContext +import org.oppia.android.app.model.EventLog.RevisionCardContext as RevisionCardEventContext +import org.oppia.android.app.model.EventLog.StoryContext as StoryEventContext +import org.oppia.android.app.model.EventLog.SubmitAnswerContext as SubmitAnswerEventContext +import org.oppia.android.app.model.EventLog.TopicContext as TopicEventContext -const val TIMESTAMP_KEY = "timestamp" -const val TOPIC_ID_KEY = "topicId" -const val STORY_ID_KEY = "storyId" -const val SKILL_ID_KEY = "skillId" -const val SUB_TOPIC_ID_KEY = "subTopicId" -const val QUESTION_ID_KEY = "questionId" -const val EXPLORATION_ID_KEY = "explorationId" -const val PRIORITY_KEY = "priority" +// See https://firebase.google.com/docs/reference/cpp/group/parameter-names for context. +private const val MAX_CHARACTERS_IN_PARAMETER_NAME = 40 /** - * Utility for creating bundles from [EventLog] objects. - * Note that this utility may later upload them to remote services. + * Utility for creating [Bundle]s from [EventLog] objects. + * + * This class is only expected to be used by internal logging mechanisms and should not be called + * directly. */ -class EventBundleCreator { - private var bundle = Bundle() - - fun createEventBundle(eventLog: EventLog): Bundle { - bundle = - when (eventLog.context.activityContextCase) { - OPEN_EXPLORATION_ACTIVITY -> createOpenExplorationActivityContextBundle(eventLog) - OPEN_QUESTION_PLAYER -> createOpenQuestionPlayerContextBundle(eventLog) - OPEN_STORY_ACTIVITY -> createOpenStoryActivityContextBundle(eventLog) - OPEN_INFO_TAB -> createOpenInfoTabContextBundle(eventLog) - OPEN_LESSONS_TAB -> createOpenLessonsTabContextBundle(eventLog) - OPEN_PRACTICE_TAB -> createOpenPracticeTabContextBundle(eventLog) - OPEN_REVISION_TAB -> createOpenRevisionTabContextBundle(eventLog) - OPEN_CONCEPT_CARD -> createOpenConceptCardContextBundle(eventLog) - OPEN_REVISION_CARD -> createOpenRevisionCardContextBundle(eventLog) - OPEN_HOME, OPEN_PROFILE_CHOOSER, ACTIVITYCONTEXT_NOT_SET -> createNoContextBundle(eventLog) - // TODO(#4064): Create bundle creator functions for new events and replace this with them. - else -> createNoContextBundle(eventLog) - } - return bundle +class EventBundleCreator @Inject constructor() { + /** + * Fills the specified [bundle] with a logging-ready representation of [eventLog] and returns a + * string representation of the high-level type of event logged (per + * [EventLog.Context.getActivityContextCase]). + */ + fun fillEventBundle(eventLog: EventLog, bundle: Bundle): String { + bundle.putLong("timestamp", eventLog.timestamp) + bundle.putString("priority", eventLog.priority.toAnalyticsName()) + return eventLog.context.convertToActivityContext()?.also { eventContext -> + // Only allow user IDs to be logged when the learner study feature is enabled. + // TODO(#4064): Enable allowing user IDs if the study parameter is enabled. + eventContext.storeValue(PropertyStore(bundle, allowUserIds = false)) + }?.activityName ?: "unknown_activity_context" } - /** Returns a bundle from event having open_exploration_activity context. */ - private fun createOpenExplorationActivityContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(TOPIC_ID_KEY, eventLog.context.openExplorationActivity.topicId) - bundle.putString(STORY_ID_KEY, eventLog.context.openExplorationActivity.storyId) - bundle.putString(EXPLORATION_ID_KEY, eventLog.context.openExplorationActivity.explorationId) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle + private fun EventLog.Context.convertToActivityContext(): EventActivityContext<*>? { + return when (activityContextCase) { + OPEN_EXPLORATION_ACTIVITY -> + ExplorationContext("open_exploration_activity", openExplorationActivity) + OPEN_INFO_TAB -> TopicContext("open_info_tab", openInfoTab) + OPEN_LESSONS_TAB -> TopicContext("open_lessons_tab", openLessonsTab) + OPEN_PRACTICE_TAB -> TopicContext("open_practice_tab", openPracticeTab) + OPEN_REVISION_TAB -> TopicContext("open_revision_tab", openRevisionTab) + OPEN_QUESTION_PLAYER -> QuestionContext("open_question_player", openQuestionPlayer) + OPEN_STORY_ACTIVITY -> StoryContext("open_story_activity", openStoryActivity) + OPEN_CONCEPT_CARD -> ConceptCardContext("open_concept_card", openConceptCard) + OPEN_REVISION_CARD -> RevisionCardContext("open_revision_card", openRevisionCard) + START_CARD_CONTEXT -> CardContext("start_card_context", startCardContext) + END_CARD_CONTEXT -> CardContext("end_card_context", endCardContext) + HINT_OFFERED_CONTEXT -> HintContext("hint_offered_context", hintOfferedContext) + ACCESS_HINT_CONTEXT -> HintContext("access_hint_context", accessHintContext) + SOLUTION_OFFERED_CONTEXT -> + ExplorationContext("solution_offered_context", solutionOfferedContext) + ACCESS_SOLUTION_CONTEXT -> + ExplorationContext("access_solution_context", accessSolutionContext) + SUBMIT_ANSWER_CONTEXT -> SubmitAnswerContext("submit_answer_context", submitAnswerContext) + PLAY_VOICE_OVER_CONTEXT -> + PlayVoiceOverContext("play_voice_over_context", playVoiceOverContext) + APP_IN_BACKGROUND_CONTEXT -> + LearnerDetailsContext("app_in_background_context", appInBackgroundContext) + APP_IN_FOREGROUND_CONTEXT -> + LearnerDetailsContext("app_in_foreground_context", appInForegroundContext) + EXIT_EXPLORATION_CONTEXT -> + ExplorationContext("exit_exploration_context", exitExplorationContext) + FINISH_EXPLORATION_CONTEXT -> + ExplorationContext("finish_exploration_context", finishExplorationContext) + RESUME_EXPLORATION_CONTEXT -> + LearnerDetailsContext("resume_exploration_context", resumeExplorationContext) + START_OVER_EXPLORATION_CONTEXT -> + LearnerDetailsContext("start_over_exploration_context", startOverExplorationContext) + DELETE_PROFILE_CONTEXT -> + LearnerDetailsContext("delete_profile_context", deleteProfileContext) + OPEN_HOME -> EmptyContext("open_home") + OPEN_PROFILE_CHOOSER -> EmptyContext("open_profile_chooser") + INSTALL_ID_FOR_FAILED_ANALYTICS_LOG -> + SensitiveStringContext("failed_analytics_log", installIdForFailedAnalyticsLog, "install_id") + ACTIVITYCONTEXT_NOT_SET, null -> null // No context to create here. + } } - /** Returns a bundle from event having open_question_player context. */ - private fun createOpenQuestionPlayerContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - val skillIdList = eventLog.context.openQuestionPlayer.skillIdList - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(QUESTION_ID_KEY, eventLog.context.openQuestionPlayer.questionId) - bundle.putString(SKILL_ID_KEY, skillIdList.joinToString()) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle - } + /** + * Utility for storing properties within a [Bundle] (indicated by [bundle]), omitting those which + * contain sensitive information (if they should be per [allowUserIds]. + */ + private class PropertyStore(private val bundle: Bundle, private val allowUserIds: Boolean) { + private val namespaces = mutableListOf() - /** Returns a bundle from event having open_info_tab context. */ - private fun createOpenInfoTabContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(TOPIC_ID_KEY, eventLog.context.openInfoTab.topicId) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle - } + /** + * Indicates a new contextual namespace has been started for logging, as given by [name]. + * + * The namespace's name will be summarized in the final key representation of logged properties. + * + * [exitNamespace] should be called when the namespace is no longer used. + */ + fun enterNamespace(name: String) { + namespaces.add(name) + } - /** Returns a bundle from event having open_lessons_tab context. */ - private fun createOpenLessonsTabContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(TOPIC_ID_KEY, eventLog.context.openLessonsTab.topicId) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle - } + /** Indicates a namespace previously started by [enterNamespace] has ended. */ + fun exitNamespace() { + namespaces.removeLastOrNull() + } - /** Returns a bundle from event having open_practice_tab context. */ - private fun createOpenPracticeTabContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(TOPIC_ID_KEY, eventLog.context.openPracticeTab.topicId) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle - } + /** Save a non-sensitive property with name [valueName] and value [value]. */ + fun putNonSensitiveValue(valueName: String, value: T) = + putValue(valueName, value, isSensitive = false) - /** Returns a bundle from event having open_revision_tab context. */ - private fun createOpenRevisionTabContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(TOPIC_ID_KEY, eventLog.context.openRevisionTab.topicId) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle - } + /** + * Saves a value in the same way as [putNonSensitiveValue] except this property will be ignored + * if sensitive property logging is currently disabled. + */ + fun putSensitiveValue(valueName: String, value: T) = + putValue(valueName, value, isSensitive = true) - /** Returns a bundle from event having open_story_activity context. */ - private fun createOpenStoryActivityContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(TOPIC_ID_KEY, eventLog.context.openStoryActivity.topicId) - bundle.putString(STORY_ID_KEY, eventLog.context.openStoryActivity.storyId) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle - } + private fun putValue(valueName: String, value: T, isSensitive: Boolean) { + if (!isSensitive || allowUserIds) { + val propertyName = computePropertyName(valueName) + when (value) { + is Long -> bundle.putLong(propertyName, value) + is Iterable<*> -> bundle.putString(propertyName, value.joinToString(separator = ",")) + else -> bundle.putString(propertyName, value.toString()) + } + } + } + + private fun computePropertyName(valueName: String): String { + val validValueName = valueName.takeUnless(String::isEmpty) ?: "missing_prop_name" + + // Namespaces are reduced to their first letters and combined into a single word to simplify + // them and reduce the number of characters that need to be removed. + val qualifiers = namespaces.joinToString(separator = "_", transform = ::abbreviateNamespace) - /** Returns a bundle from event having open_concept_card context. */ - private fun createOpenConceptCardContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(SKILL_ID_KEY, eventLog.context.openConceptCard.skillId) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle + // Ensure that property names don't exceed the max allowed length (otherwise they'll be + // dropped). + val sizedName = "${qualifiers}_$validValueName".takeLast(MAX_CHARACTERS_IN_PARAMETER_NAME) + + // Ensure that the property never begins with '_' since that's not valid in Firebase. + return sizedName.dropWhile { it == '_' } + } + + private fun abbreviateNamespace(namespace: String): String = + namespace.split('_').map(String::first).joinToString(separator = "") } - /** Returns a bundle from event having open_revision_card context. */ - private fun createOpenRevisionCardContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(TOPIC_ID_KEY, eventLog.context.openRevisionCard.topicId) - bundle.putInt(SUB_TOPIC_ID_KEY, eventLog.context.openRevisionCard.subTopicId) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle + /** + * Represents an [EventLog] activity context (denoted by + * [EventLog.Context.getActivityContextCase]). + */ + private sealed class EventActivityContext(val activityName: String, private val value: T) { + /** + * Stores the value of this context (i.e. its constituent properties which may correspond to + * other [EventActivityContext]s). + */ + fun storeValue(store: PropertyStore) = value.storeValue(store) + + /** Method that should be overridden by base classes to satisfy the contract of [storeValue]. */ + protected abstract fun T.storeValue(store: PropertyStore) + + /** + * Helper function for child classes to easily store all of the constituent properties of an + * [EventActivityContext] property. + */ + protected fun , V> PropertyStore.putProperties( + propertyName: String, + value: V, + factory: (String, V) -> T + ) { + factory(propertyName, value).run { + try { + // Namespaces are only considered for nested properties since the outermost context is + // already contextualized via the top-level context parameter. + enterNamespace(propertyName) + value.storeValue(this@putProperties) + } finally { + exitNamespace() + } + } + } + + /** The [EventActivityContext] corresponding to [CardEventContext]s. */ + class CardContext( + activityName: String, + value: CardEventContext + ) : EventActivityContext(activityName, value) { + override fun CardEventContext.storeValue(store: PropertyStore) { + store.putProperties("exploration_details", explorationDetails, ::ExplorationContext) + store.putNonSensitiveValue("skill_id", skillId) + } + } + + /** The [EventActivityContext] corresponding to [ConceptCardEventContext]s. */ + class ConceptCardContext( + activityName: String, + value: ConceptCardEventContext + ) : EventActivityContext(activityName, value) { + override fun ConceptCardEventContext.storeValue(store: PropertyStore) { + store.putNonSensitiveValue("skill_id", skillId) + } + } + + /** The [EventActivityContext] corresponding to [ExplorationContext]s. */ + class ExplorationContext( + activityName: String, + value: ExplorationEventContext + ) : EventActivityContext(activityName, value) { + override fun ExplorationEventContext.storeValue(store: PropertyStore) { + store.putNonSensitiveValue("topic_id", topicId) + store.putNonSensitiveValue("story_id", storyId) + store.putNonSensitiveValue("exploration_id", explorationId) + store.putNonSensitiveValue("session_id", sessionId) + store.putNonSensitiveValue("exploration_version", explorationVersion.toString()) + store.putNonSensitiveValue("state_name", stateName) + store.putProperties("learner_details", learnerDetails, ::LearnerDetailsContext) + } + } + + /** The [EventActivityContext] corresponding to [HintEventContext]s. */ + class HintContext( + activityName: String, + value: HintEventContext + ) : EventActivityContext(activityName, value) { + override fun HintEventContext.storeValue(store: PropertyStore) { + store.putProperties("exploration_details", explorationDetails, ::ExplorationContext) + store.putNonSensitiveValue("hint_index", hintIndex.toString()) + } + } + + /** The [EventActivityContext] corresponding to [LearnerDetailsEventContext]s. */ + class LearnerDetailsContext( + activityName: String, + value: LearnerDetailsEventContext + ) : EventActivityContext(activityName, value) { + override fun LearnerDetailsEventContext.storeValue(store: PropertyStore) { + store.putSensitiveValue("learner_id", learnerId) + store.putSensitiveValue("install_id", installId) + } + } + + /** The [EventActivityContext] corresponding to [PlayVoiceOverEventContext]s. */ + class PlayVoiceOverContext( + activityName: String, + value: PlayVoiceOverEventContext + ) : EventActivityContext(activityName, value) { + override fun PlayVoiceOverEventContext.storeValue(store: PropertyStore) { + store.putProperties("exploration_details", explorationDetails, ::ExplorationContext) + store.putNonSensitiveValue("content_id", contentId) + } + } + + /** The [EventActivityContext] corresponding to [QuestionEventContext]s. */ + class QuestionContext( + activityName: String, + value: QuestionEventContext + ) : EventActivityContext(activityName, value) { + override fun QuestionEventContext.storeValue(store: PropertyStore) { + store.putNonSensitiveValue("question_id", questionId) + store.putNonSensitiveValue("skill_ids", skillIdList) + } + } + + /** The [EventActivityContext] corresponding to [RevisionCardEventContext]s. */ + class RevisionCardContext( + activityName: String, + value: RevisionCardEventContext + ) : EventActivityContext(activityName, value) { + override fun RevisionCardEventContext.storeValue(store: PropertyStore) { + store.putNonSensitiveValue("topic_id", topicId) + store.putNonSensitiveValue("subtopic_index", subTopicId.toString()) + } + } + + /** The [EventActivityContext] corresponding to [StoryEventContext]s. */ + class StoryContext( + activityName: String, + value: StoryEventContext + ) : EventActivityContext(activityName, value) { + override fun StoryEventContext.storeValue(store: PropertyStore) { + store.putNonSensitiveValue("topic_id", topicId) + store.putNonSensitiveValue("story_id", storyId) + } + } + + /** The [EventActivityContext] corresponding to [SubmitAnswerEventContext]s. */ + class SubmitAnswerContext( + activityName: String, + value: SubmitAnswerEventContext + ) : EventActivityContext(activityName, value) { + override fun SubmitAnswerEventContext.storeValue(store: PropertyStore) { + store.putProperties("exploration_details", explorationDetails, ::ExplorationContext) + store.putNonSensitiveValue("is_answer_correct", isAnswerCorrect.toString()) + } + } + + /** The [EventActivityContext] corresponding to [TopicEventContext]s. */ + class TopicContext( + activityName: String, + value: TopicEventContext + ) : EventActivityContext(activityName, value) { + override fun TopicEventContext.storeValue(store: PropertyStore) { + store.putNonSensitiveValue("topic_id", topicId) + } + } + + /** [EventActivityContext] corresponding to sensitive string properties. */ + class SensitiveStringContext( + activityName: String, + value: String, + private val propertyName: String + ) : EventActivityContext(activityName, value) { + override fun String.storeValue(store: PropertyStore) { + store.putSensitiveValue(propertyName, this) + } + } + + /** [EventActivityContext] corresponding to events with no constituent properties. */ + class EmptyContext(activityName: String) : EventActivityContext(activityName, Unit) { + override fun Unit.storeValue(store: PropertyStore) {} + } } - /** Returns a bundle from event having no context. */ - private fun createNoContextBundle(eventLog: EventLog): Bundle { - val bundle = Bundle() - bundle.putLong(TIMESTAMP_KEY, eventLog.timestamp) - bundle.putString(PRIORITY_KEY, eventLog.priority.toString()) - return bundle + private fun EventLog.Priority.toAnalyticsName() = when (this) { + EventLog.Priority.PRIORITY_UNSPECIFIED -> "unspecified_priority" + EventLog.Priority.ESSENTIAL -> "essential" + EventLog.Priority.OPTIONAL -> "optional" + EventLog.Priority.UNRECOGNIZED -> "unknown_priority" } } diff --git a/utility/src/main/java/org/oppia/android/util/logging/EventLogger.kt b/utility/src/main/java/org/oppia/android/util/logging/EventLogger.kt index b01657321e3..9137877c35b 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/EventLogger.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/EventLogger.kt @@ -2,16 +2,12 @@ package org.oppia.android.util.logging import org.oppia.android.app.model.EventLog -/** - * Logger for tracking events. - * Note that this utility may later upload them to remote services - */ +/** Logger for uploading analytics events to remote services. */ interface EventLogger { - /** - * Logs events to remote services. + * Logs an event to remote services. * - * @param eventLog: refers to the log object which contains all the relevant data to be reported. + * @param eventLog refers to the log object which contains all the relevant data to be reported */ fun logEvent(eventLog: EventLog) } diff --git a/utility/src/main/java/org/oppia/android/util/logging/SyncStatusManager.kt b/utility/src/main/java/org/oppia/android/util/logging/SyncStatusManager.kt new file mode 100644 index 00000000000..6807860cc06 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/logging/SyncStatusManager.kt @@ -0,0 +1,51 @@ +package org.oppia.android.util.logging + +import org.oppia.android.util.data.DataProvider + +/** + * Manager for handling the sync status of the device during analytics log uploads. + * + * Implementations of this class are safe to use across threads. + */ +interface SyncStatusManager { + /** + * Returns the current [SyncStatus] of the device. + * + * This returns a different [DataProvider] for each call, but they will be updated due to changes + * to the sync status via [setSyncStatus]. + */ + fun getSyncStatus(): DataProvider + + /** + * Changes the current [SyncStatusManager.SyncStatus] of the device to [syncStatus] and notifies + * the data provider returned by [getSyncStatus] of this change. + */ + fun setSyncStatus(syncStatus: SyncStatus) + + /** The sync status values corresponding to different stages of uploading logging analytics. */ + enum class SyncStatus { + /** The initial state where the current upload state is unknown. */ + INITIAL_UNKNOWN, + + /** Indicates that analytics are currently being uploaded. */ + DATA_UPLOADING, + + /** + * Indicates that analytics have recently successfully finished uploading, and that no new + * analytics are pending upload. + */ + DATA_UPLOADED, + + /** + * Indicates that the network is currently unavailable and that logs will be attempted to be + * uploaded once connectivity resumes. + */ + NO_CONNECTIVITY, + + /** + * Indicates a network error was encountered during analytics upload, and the logs may be + * attempted to be re-uploaded at a later time. + */ + NETWORK_ERROR + } +} diff --git a/utility/src/main/java/org/oppia/android/util/logging/SyncStatusManagerImpl.kt b/utility/src/main/java/org/oppia/android/util/logging/SyncStatusManagerImpl.kt new file mode 100644 index 00000000000..91f4edb8251 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/logging/SyncStatusManagerImpl.kt @@ -0,0 +1,26 @@ +package org.oppia.android.util.logging + +import kotlinx.coroutines.flow.MutableStateFlow +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus +import javax.inject.Inject +import javax.inject.Singleton + +private const val SYNC_STATUS_PROVIDER_ID = "SyncStatusManagerImpl.sync_status" + +/** Manager for handling the sync status of the device during log upload to the remote service.*/ +@Singleton +class SyncStatusManagerImpl @Inject constructor( + private val dataProviders: DataProviders +) : SyncStatusManager { + private val syncStatusFlow = MutableStateFlow(SyncStatus.INITIAL_UNKNOWN) + + override fun getSyncStatus(): DataProvider = dataProviders.run { + syncStatusFlow.convertToAutomaticDataProvider(SYNC_STATUS_PROVIDER_ID) + } + + override fun setSyncStatus(syncStatus: SyncStatus) { + check(syncStatusFlow.tryEmit(syncStatus)) { "Failed to update sync status to $syncStatus" } + } +} diff --git a/utility/src/main/java/org/oppia/android/util/logging/SyncStatusModule.kt b/utility/src/main/java/org/oppia/android/util/logging/SyncStatusModule.kt new file mode 100644 index 00000000000..10eb98087b3 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/logging/SyncStatusModule.kt @@ -0,0 +1,11 @@ +package org.oppia.android.util.logging + +import dagger.Binds +import dagger.Module + +/** Provides production-specific sync status mechanism related dependencies. */ +@Module +interface SyncStatusModule { + @Binds + fun provideSyncStatusManager(impl: SyncStatusManagerImpl): SyncStatusManager +} diff --git a/utility/src/main/java/org/oppia/android/util/logging/firebase/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/logging/firebase/BUILD.bazel index f1f21fb67b0..66454426511 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/firebase/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/logging/firebase/BUILD.bazel @@ -31,6 +31,7 @@ kt_android_library( "//utility/src/main/java/org/oppia/android/util/logging:event_bundle_creator", "//utility/src/main/java/org/oppia/android/util/logging:event_logger", "//utility/src/main/java/org/oppia/android/util/logging:log_uploader", + "//utility/src/main/java/org/oppia/android/util/logging:sync_status_manager", "//utility/src/main/java/org/oppia/android/util/networking:network_connection_util", ], ) @@ -63,6 +64,7 @@ kt_android_library( "//app:__pkg__", ], deps = [ + ":prod_impl", "//model/src/main/proto:event_logger_java_proto_lite", "//third_party:javax_inject_javax_inject", "//utility/src/main/java/org/oppia/android/util/logging:event_logger", diff --git a/utility/src/main/java/org/oppia/android/util/logging/firebase/DebugEventLogger.kt b/utility/src/main/java/org/oppia/android/util/logging/firebase/DebugEventLogger.kt index e38546610d0..8838a3e2ca8 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/firebase/DebugEventLogger.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/firebase/DebugEventLogger.kt @@ -2,22 +2,28 @@ package org.oppia.android.util.logging.firebase import org.oppia.android.app.model.EventLog import org.oppia.android.util.logging.EventLogger +import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import javax.inject.Singleton /** - * A debug specific implementation for the event logger. It stores all the event logs in a list - * instead of pushing them to Firebase. + * A debug implementation of [EventLogger] used in developer-only builds of the event. + * + * It forwards events to a production [EventLogger] for real logging, but it also records logged + * events for later retrieval (e.g. via [getEventList]). */ @Singleton -class DebugEventLogger @Inject constructor() : EventLogger { - - private val eventList = mutableListOf() +class DebugEventLogger @Inject constructor( + factory: FirebaseEventLogger.Factory +) : EventLogger { + private val realEventLogger by lazy { factory.create() } + private val eventList = CopyOnWriteArrayList() override fun logEvent(eventLog: EventLog) { eventList.add(eventLog) + realEventLogger.logEvent(eventLog) } - /** Returns list of event logs. */ + /** Returns the list of all [EventLog]s logged since the app opened. */ fun getEventList(): List = eventList } diff --git a/utility/src/main/java/org/oppia/android/util/logging/firebase/DebugLogReportingModule.kt b/utility/src/main/java/org/oppia/android/util/logging/firebase/DebugLogReportingModule.kt index 931ab2c6d2b..0b628a3190e 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/firebase/DebugLogReportingModule.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/firebase/DebugLogReportingModule.kt @@ -10,13 +10,12 @@ import javax.inject.Singleton /** Provides debug log reporting dependencies. */ @Module class DebugLogReportingModule { - @Singleton @Provides - fun provideExceptionLogger(): ExceptionLogger { - return FirebaseExceptionLogger(FirebaseCrashlytics.getInstance()) - } - @Singleton + fun provideExceptionLogger(): ExceptionLogger = + FirebaseExceptionLogger(FirebaseCrashlytics.getInstance()) + @Provides + @Singleton fun provideDebugEventLogger(debugEventLogger: DebugEventLogger): EventLogger = debugEventLogger } diff --git a/utility/src/main/java/org/oppia/android/util/logging/firebase/FirebaseEventLogger.kt b/utility/src/main/java/org/oppia/android/util/logging/firebase/FirebaseEventLogger.kt index 8eaffaea4ea..a4f61516e54 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/firebase/FirebaseEventLogger.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/firebase/FirebaseEventLogger.kt @@ -1,5 +1,7 @@ package org.oppia.android.util.logging.firebase +import android.annotation.SuppressLint +import android.content.Context import android.os.Bundle import com.google.firebase.analytics.FirebaseAnalytics import org.oppia.android.app.model.EventLog @@ -7,24 +9,24 @@ import org.oppia.android.util.logging.EventBundleCreator import org.oppia.android.util.logging.EventLogger import org.oppia.android.util.networking.NetworkConnectionUtil import java.util.Locale -import javax.inject.Singleton +import javax.inject.Inject private const val NETWORK_USER_PROPERTY = "NETWORK" private const val COUNTRY_USER_PROPERTY = "COUNTRY" /** Logger for event logging to Firebase Analytics. */ -@Singleton -class FirebaseEventLogger( +class FirebaseEventLogger private constructor( private val firebaseAnalytics: FirebaseAnalytics, - private val eventBundleCreator: EventBundleCreator, - private val networkConnectionUtil: NetworkConnectionUtil + private val networkConnectionUtil: NetworkConnectionUtil, + private val eventBundleCreator: EventBundleCreator ) : EventLogger { - private var bundle = Bundle() - - /** Logs an event to Firebase Analytics with [NETWORK_USER_PROPERTY] and [COUNTRY_USER_PROPERTY]. */ + /** + * Logs an event to Firebase Analytics with [NETWORK_USER_PROPERTY] and [COUNTRY_USER_PROPERTY]. + */ override fun logEvent(eventLog: EventLog) { - bundle = eventBundleCreator.createEventBundle(eventLog) - firebaseAnalytics.logEvent(eventLog.context.activityContextCase.name, bundle) + Bundle().let { + firebaseAnalytics.logEvent(eventBundleCreator.fillEventBundle(eventLog, it), it) + } // TODO(#3792): Remove this usage of Locale. firebaseAnalytics.setUserProperty(COUNTRY_USER_PROPERTY, Locale.getDefault().displayCountry) firebaseAnalytics.setUserProperty(NETWORK_USER_PROPERTY, getNetworkStatus()) @@ -39,4 +41,24 @@ class FirebaseEventLogger( else -> NetworkConnectionUtil.ProdConnectionStatus.NONE.logName } } + + /** Application-scoped injectable factory for creating new [FirebaseEventLogger]s. */ + @SuppressLint("MissingPermission") // This is a false warning probably due to the IJwB plugin. + class Factory @Inject constructor( + private val context: Context, + private val networkConnectionUtil: NetworkConnectionUtil, + private val eventBundleCreator: EventBundleCreator + ) { + private val firebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(context.applicationContext) + } + + /** + * Returns a new [FirebaseEventLogger] for the current application context. + * + * Generally, only one of these needs to be created per application. + */ + fun create(): EventLogger = + FirebaseEventLogger(firebaseAnalytics, networkConnectionUtil, eventBundleCreator) + } } diff --git a/utility/src/main/java/org/oppia/android/util/logging/firebase/LogReportingModule.kt b/utility/src/main/java/org/oppia/android/util/logging/firebase/LogReportingModule.kt index 35d7333f622..21d8fd20345 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/firebase/LogReportingModule.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/firebase/LogReportingModule.kt @@ -1,32 +1,21 @@ package org.oppia.android.util.logging.firebase -import android.app.Application -import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.Module import dagger.Provides -import org.oppia.android.util.logging.EventBundleCreator import org.oppia.android.util.logging.EventLogger import org.oppia.android.util.logging.ExceptionLogger -import org.oppia.android.util.networking.NetworkConnectionUtil import javax.inject.Singleton /** Provides Firebase-specific logging implementations. */ @Module class LogReportingModule { - @Singleton @Provides - fun provideCrashLogger(): ExceptionLogger { - return FirebaseExceptionLogger(FirebaseCrashlytics.getInstance()) - } - @Singleton + fun provideCrashLogger(): ExceptionLogger = + FirebaseExceptionLogger(FirebaseCrashlytics.getInstance()) + @Provides - fun provideEventLogger(networkConnectionUtil: NetworkConnectionUtil): EventLogger { - return FirebaseEventLogger( - FirebaseAnalytics.getInstance(Application()), - EventBundleCreator(), - networkConnectionUtil - ) - } + @Singleton + fun provideEventLogger(factory: FirebaseEventLogger.Factory): EventLogger = factory.create() } diff --git a/utility/src/test/java/org/oppia/android/util/logging/EventBundleCreatorTest.kt b/utility/src/test/java/org/oppia/android/util/logging/EventBundleCreatorTest.kt index 67785fee38e..9e2aea5ad67 100644 --- a/utility/src/test/java/org/oppia/android/util/logging/EventBundleCreatorTest.kt +++ b/utility/src/test/java/org/oppia/android/util/logging/EventBundleCreatorTest.kt @@ -2,218 +2,1147 @@ package org.oppia.android.util.logging import android.app.Application import android.content.Context +import android.os.Bundle import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.ext.truth.os.BundleSubject.assertThat import com.google.common.truth.Truth.assertThat import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides -import org.junit.Before +import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.oppia.android.app.model.EventLog +import org.oppia.android.app.model.EventLog.CardContext +import org.oppia.android.app.model.EventLog.ConceptCardContext +import org.oppia.android.app.model.EventLog.ExplorationContext +import org.oppia.android.app.model.EventLog.HintContext +import org.oppia.android.app.model.EventLog.LearnerDetailsContext +import org.oppia.android.app.model.EventLog.PlayVoiceOverContext +import org.oppia.android.app.model.EventLog.Priority.ESSENTIAL +import org.oppia.android.app.model.EventLog.Priority.OPTIONAL +import org.oppia.android.app.model.EventLog.QuestionContext +import org.oppia.android.app.model.EventLog.RevisionCardContext +import org.oppia.android.app.model.EventLog.StoryContext +import org.oppia.android.app.model.EventLog.SubmitAnswerContext +import org.oppia.android.app.model.EventLog.TopicContext +import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.LearnerStudyAnalytics +import org.oppia.android.util.platformparameter.PlatformParameterValue import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import javax.inject.Inject import javax.inject.Singleton +import org.oppia.android.app.model.EventLog.Context.Builder as EventContextBuilder -const val TEST_TIMESTAMP = 1556094120000 -const val TEST_TOPIC_ID = "test_topicId" -const val TEST_STORY_ID = "test_storyId" -const val TEST_EXPLORATION_ID = "test_explorationId" -const val TEST_QUESTION_ID = "test_questionId" -const val TEST_SKILL_ID_ONE = "test_skillId_one" -const val TEST_SKILL_ID_TWO = "test_skillId_two" -const val TEST_SUB_TOPIC_ID = 1 - +/** + * Tests for [EventBundleCreator]. + * + * Note that some of the properties of [EventLog]s logged via [EventBundleCreator] will 'namespace' + * their property names (for cases when properties are nested). The tests of this suite include + * verification for property names (based on the fact that certain properties need to be present in + * the filled [Bundle] in order for the test to pass since it's verifying values corresponding to + * those property names). + */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) -@Config(manifest = Config.NONE) +@Config(application = EventBundleCreatorTest.TestApplication::class) class EventBundleCreatorTest { + private companion object { + private const val TEST_TIMESTAMP_1 = 1556094120000 + private const val TEST_TIMESTAMP_2 = 1234567898765 + private const val TEST_TOPIC_ID = "test_topic_id" + private const val TEST_STORY_ID = "test_story_id" + private const val TEST_EXPLORATION_ID = "test_exploration_id" + private const val TEST_QUESTION_ID = "test_question_id" + private const val TEST_SKILL_ID_1 = "test_skill_id_1" + private const val TEST_SKILL_ID_2 = "test_skill_id_2" + private const val TEST_SUB_TOPIC_INDEX = 1 + private const val TEST_SUB_TOPIC_INDEX_STR = "1" + private const val TEST_LEARNER_ID = "test_learner_id" + private const val TEST_INSTALLATION_ID = "test_installation_id" + private const val TEST_LEARNER_SESSION_ID = "test_session_id" + private const val TEST_EXPLORATION_VERSION = 5 + private const val TEST_EXPLORATION_VERSION_STR = "5" + private const val TEST_STATE_NAME = "test_state_name" + private const val TEST_HINT_INDEX = 1 + private const val TEST_HINT_INDEX_STR = "1" + private const val TEST_IS_ANSWER_CORRECT = true + private const val TEST_IS_ANSWER_CORRECT_STR = "true" + private const val TEST_CONTENT_ID = "test_content_id" + } - private val eventBundleCreator = EventBundleCreator() - private val eventLogExplorationContext = EventLog.newBuilder() - .setContext( - EventLog.Context.newBuilder() - .setOpenExplorationActivity( - EventLog.ExplorationContext.newBuilder() - .setTopicId(TEST_TOPIC_ID) - .setExplorationId(TEST_EXPLORATION_ID) - .setStoryId(TEST_STORY_ID) - .build() - ) - .build() - ) - .setTimestamp(TEST_TIMESTAMP) - .setPriority(EventLog.Priority.ESSENTIAL) - .build() - - private val eventLogQuestionContext = EventLog.newBuilder() - .setContext( - EventLog.Context.newBuilder() - .setOpenQuestionPlayer( - EventLog.QuestionContext.newBuilder() - .setQuestionId(TEST_QUESTION_ID) - .addAllSkillId(listOf(TEST_SKILL_ID_ONE, TEST_SKILL_ID_TWO)) - .build() - ) - .build() - ) - .setTimestamp(TEST_TIMESTAMP) - .setPriority(EventLog.Priority.ESSENTIAL) - .build() - - private val eventLogTopicContext = EventLog.newBuilder() - .setContext( - EventLog.Context.newBuilder() - .setOpenInfoTab( - EventLog.TopicContext.newBuilder() - .setTopicId(TEST_TOPIC_ID) - .build() - ) - .build() - ) - .setTimestamp(TEST_TIMESTAMP) - .setPriority(EventLog.Priority.ESSENTIAL) - .build() - - private val eventLogStoryContext = EventLog.newBuilder() - .setContext( - EventLog.Context.newBuilder() - .setOpenStoryActivity( - EventLog.StoryContext.newBuilder() - .setTopicId(TEST_TOPIC_ID) - .setStoryId(TEST_STORY_ID) - .build() - ) - .build() - ) - .setTimestamp(TEST_TIMESTAMP) - .setPriority(EventLog.Priority.ESSENTIAL) - .build() - - private val eventLogConceptCardContext = EventLog.newBuilder() - .setContext( - EventLog.Context.newBuilder() - .setOpenConceptCard( - EventLog.ConceptCardContext.newBuilder() - .setSkillId(TEST_SKILL_ID_ONE) - .build() - ) - .build() - ) - .setTimestamp(TEST_TIMESTAMP) - .setPriority(EventLog.Priority.ESSENTIAL) - .build() - - private val eventLogRevisionCardContext = EventLog.newBuilder() - .setContext( - EventLog.Context.newBuilder() - .setOpenRevisionCard( - EventLog.RevisionCardContext.newBuilder() - .setTopicId(TEST_TOPIC_ID) - .setSubTopicId(TEST_SUB_TOPIC_ID) - .build() - ) - .build() - ) - .setTimestamp(TEST_TIMESTAMP) - .setPriority(EventLog.Priority.ESSENTIAL) - .build() + @Inject + lateinit var eventBundleCreator: EventBundleCreator - private val eventLogNoContext = EventLog.newBuilder() - .setTimestamp(TEST_TIMESTAMP) - .setPriority(EventLog.Priority.ESSENTIAL) - .build() + @After + fun tearDown() { + TestModule.enableLearnerStudyAnalytics = LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE + } - @Before - fun setUp() { + @Test + fun testFillEventBundle_defaultEvent_defaultsBundleAndReturnsUnknownActivityContext() { setUpTestApplicationComponent() + val bundle = Bundle() + + val typeName = eventBundleCreator.fillEventBundle(EventLog.getDefaultInstance(), bundle) + + assertThat(typeName).isEqualTo("unknown_activity_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(0) + assertThat(bundle).string("priority").isEqualTo("unspecified_priority") + } + + @Test + fun testFillEventBundle_eventWithDefaultedContext_fillsPriorityAndTimeAndRetsUnknownContext() { + setUpTestApplicationComponent() + val bundle = Bundle() + val eventLog = createEventLog(timestamp = TEST_TIMESTAMP_1, priority = ESSENTIAL) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + + assertThat(typeName).isEqualTo("unknown_activity_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_eventWithDifferentTimestamp_savesDifferentTimestampInBundle() { + setUpTestApplicationComponent() + val bundle = Bundle() + val eventLog = createEventLog(timestamp = TEST_TIMESTAMP_2) + + eventBundleCreator.fillEventBundle(eventLog, bundle) + + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_2) + } + + @Test + fun testFillEventBundle_eventWithDifferentPriority_savesDifferentPriorityInBundle() { + setUpTestApplicationComponent() + val bundle = Bundle() + val eventLog = createEventLog(priority = OPTIONAL) + + eventBundleCreator.fillEventBundle(eventLog, bundle) + + assertThat(bundle).string("priority").isEqualTo("optional") + } + + @Test + fun testFillEventBundle_openExpActivityEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenExplorationActivity()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_exploration_activity") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) + } + + @Test + fun testFillEventBundle_openExpActivityEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenExplorationActivity()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_exploration_activity") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) + } + + @Test + fun testFillEventBundle_openInfoTabContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenInfoTab()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_info_tab") + assertThat(bundle).hasSize(3) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + } + + @Test + fun testFillEventBundle_openLessonsTabContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenLessonsTab()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_lessons_tab") + assertThat(bundle).hasSize(3) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + } + + @Test + fun testFillEventBundle_openPracticeTabContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenPracticeTab()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_practice_tab") + assertThat(bundle).hasSize(3) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + } + + @Test + fun testFillEventBundle_openRevisionTabContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenRevisionTab()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_revision_tab") + assertThat(bundle).hasSize(3) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + } + + @Test + fun testFillEventBundle_openQuestionPlayerContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenQuestionPlayer()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_question_player") + assertThat(bundle).hasSize(4) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("question_id").isEqualTo(TEST_QUESTION_ID) + assertThat(bundle).string("skill_ids").isEqualTo("$TEST_SKILL_ID_1,$TEST_SKILL_ID_2") + } + + @Test + fun testFillEventBundle_openStoryActivityContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenStoryActivity()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_story_activity") + assertThat(bundle).hasSize(4) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + } + + @Test + fun testFillEventBundle_openConceptCardContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenConceptCard()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_concept_card") + assertThat(bundle).hasSize(3) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("skill_id").isEqualTo(TEST_SKILL_ID_1) + } + + @Test + fun testFillEventBundle_openRevisionCardContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenRevisionCard()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_revision_card") + assertThat(bundle).hasSize(4) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("subtopic_index").isEqualTo(TEST_SUB_TOPIC_INDEX_STR) + } + + @Test + fun testFillEventBundle_startCardContextEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createStartCardContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("start_card_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("skill_id").isEqualTo(TEST_SKILL_ID_1) + } + + @Test + fun testFillEventBundle_startCardContextEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createStartCardContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("start_card_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("skill_id").isEqualTo(TEST_SKILL_ID_1) + } + + @Test + fun testFillEventBundle_endCardContextEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createEndCardContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("end_card_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("skill_id").isEqualTo(TEST_SKILL_ID_1) + } + + @Test + fun testFillEventBundle_endCardContextEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createEndCardContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("end_card_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("skill_id").isEqualTo(TEST_SKILL_ID_1) + } + + @Test + fun testFillEventBundle_hintOfferedEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createHintOfferedContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("hint_offered_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("hint_index").isEqualTo(TEST_HINT_INDEX_STR) + } + + @Test + fun testFillEventBundle_hintOfferedEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createHintOfferedContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("hint_offered_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("hint_index").isEqualTo(TEST_HINT_INDEX_STR) + } + + @Test + fun testFillEventBundle_accessHintContextEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createAccessHintContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("access_hint_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("hint_index").isEqualTo(TEST_HINT_INDEX_STR) + } + + @Test + fun testFillEventBundle_accessHintContextEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createAccessHintContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("access_hint_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("hint_index").isEqualTo(TEST_HINT_INDEX_STR) + } + + @Test + fun testFillEventBundle_solutionOfferedEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createSolutionOfferedContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("solution_offered_context") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) + } + + @Test + fun testFillEventBundle_solutionOfferedEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createSolutionOfferedContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("solution_offered_context") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) + } + + @Test + fun testFillEventBundle_accessSolutionEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createAccessSolutionContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("access_solution_context") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) + } + + @Test + fun testFillEventBundle_accessSolutionEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createAccessSolutionContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("access_solution_context") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) } @Test - fun testBundleCreation_logEvent_withExplorationContext_isSuccessful() { - val eventBundle = EventBundleCreator().createEventBundle(eventLogExplorationContext) + fun testFillEventBundle_submitAnswerEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() - assertThat(eventBundle.get(TIMESTAMP_KEY)).isEqualTo(TEST_TIMESTAMP) - assertThat(eventBundle.get(PRIORITY_KEY)).isEqualTo(EventLog.Priority.ESSENTIAL.toString()) - assertThat(eventBundle.get(TOPIC_ID_KEY)).isEqualTo(TEST_TOPIC_ID) - assertThat(eventBundle.get(STORY_ID_KEY)).isEqualTo(TEST_STORY_ID) - assertThat(eventBundle.get(EXPLORATION_ID_KEY)).isEqualTo(TEST_EXPLORATION_ID) + val eventLog = createEventLog(context = createSubmitAnswerContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("submit_answer_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("is_answer_correct").isEqualTo(TEST_IS_ANSWER_CORRECT_STR) } @Test - fun testBundleCreation_logEvent_withQuestionContext_isSuccessful() { - val eventBundle = eventBundleCreator.createEventBundle(eventLogQuestionContext) + fun testFillEventBundle_submitAnswerEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createSubmitAnswerContext()) - assertThat(eventBundle.get(TIMESTAMP_KEY)).isEqualTo(TEST_TIMESTAMP) - assertThat(eventBundle.get(PRIORITY_KEY)).isEqualTo(EventLog.Priority.ESSENTIAL.toString()) - assertThat(eventBundle.get(QUESTION_ID_KEY)).isEqualTo(TEST_QUESTION_ID) - assertThat(eventBundle.get(SKILL_ID_KEY)) - .isEqualTo(listOf(TEST_SKILL_ID_ONE, TEST_SKILL_ID_TWO).joinToString()) + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("submit_answer_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("is_answer_correct").isEqualTo(TEST_IS_ANSWER_CORRECT_STR) } @Test - fun testBundleCreation_logEvent_withTopicContext_isSuccessful() { - val eventBundle = eventBundleCreator.createEventBundle(eventLogTopicContext) + fun testFillEventBundle_playVoiceOverEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() - assertThat(eventBundle.get(TIMESTAMP_KEY)).isEqualTo(TEST_TIMESTAMP) - assertThat(eventBundle.get(PRIORITY_KEY)).isEqualTo(EventLog.Priority.ESSENTIAL.toString()) - assertThat(eventBundle.get(TOPIC_ID_KEY)).isEqualTo(TEST_TOPIC_ID) + val eventLog = createEventLog(context = createPlayVoiceOverContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("play_voice_over_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("content_id").isEqualTo(TEST_CONTENT_ID) } @Test - fun testBundleCreation_logEvent_withStoryContext_isSuccessful() { - val eventBundle = eventBundleCreator.createEventBundle(eventLogStoryContext) + fun testFillEventBundle_playVoiceOverEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createPlayVoiceOverContext()) - assertThat(eventBundle.get(TIMESTAMP_KEY)).isEqualTo(TEST_TIMESTAMP) - assertThat(eventBundle.get(PRIORITY_KEY)).isEqualTo(EventLog.Priority.ESSENTIAL.toString()) - assertThat(eventBundle.get(TOPIC_ID_KEY)).isEqualTo(TEST_TOPIC_ID) - assertThat(eventBundle.get(STORY_ID_KEY)).isEqualTo(TEST_STORY_ID) + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("play_voice_over_context") + assertThat(bundle).hasSize(9) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("ed_story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("ed_exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("content_id").isEqualTo(TEST_CONTENT_ID) } @Test - fun testBundleCreation_logEvent_withConceptCardContext_isSuccessful() { - val eventBundle = eventBundleCreator.createEventBundle(eventLogConceptCardContext) + fun testFillEventBundle_appInBackgroundEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createAppInBackgroundContext()) - assertThat(eventBundle.get(TIMESTAMP_KEY)).isEqualTo(TEST_TIMESTAMP) - assertThat(eventBundle.get(PRIORITY_KEY)).isEqualTo(EventLog.Priority.ESSENTIAL.toString()) - assertThat(eventBundle.get(SKILL_ID_KEY)).isEqualTo(TEST_SKILL_ID_ONE) + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("app_in_background_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") } @Test - fun testBundleCreation_logEvent_withRevisionCardContext_isSuccessful() { - val eventBundle = eventBundleCreator.createEventBundle(eventLogRevisionCardContext) + fun testFillEventBundle_appInBackgroundEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() - assertThat(eventBundle.get(TIMESTAMP_KEY)).isEqualTo(TEST_TIMESTAMP) - assertThat(eventBundle.get(PRIORITY_KEY)).isEqualTo(EventLog.Priority.ESSENTIAL.toString()) - assertThat(eventBundle.get(TOPIC_ID_KEY)).isEqualTo(TEST_TOPIC_ID) - assertThat(eventBundle.get(SUB_TOPIC_ID_KEY)).isEqualTo(TEST_SUB_TOPIC_ID) + val eventLog = createEventLog(context = createAppInBackgroundContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("app_in_background_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") } @Test - fun testBundleCreation_logEvent_withNoContext_isSuccessful() { - val eventBundle = eventBundleCreator.createEventBundle(eventLogNoContext) + fun testFillEventBundle_appInForegroundEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createAppInForegroundContext()) - assertThat(eventBundle.get(TIMESTAMP_KEY)).isEqualTo(TEST_TIMESTAMP) - assertThat(eventBundle.get(PRIORITY_KEY)).isEqualTo(EventLog.Priority.ESSENTIAL.toString()) + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("app_in_foreground_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_appInForegroundEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createAppInForegroundContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("app_in_foreground_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_exitExplorationEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createExitExplorationContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("exit_exploration_context") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) + } + + @Test + fun testFillEventBundle_exitExplorationEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createExitExplorationContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("exit_exploration_context") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) + } + + @Test + fun testFillEventBundle_finishExplorationEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createFinishExplorationContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("finish_exploration_context") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) + } + + @Test + fun testFillEventBundle_finishExplorationEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createFinishExplorationContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("finish_exploration_context") + assertThat(bundle).hasSize(8) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + assertThat(bundle).string("topic_id").isEqualTo(TEST_TOPIC_ID) + assertThat(bundle).string("story_id").isEqualTo(TEST_STORY_ID) + assertThat(bundle).string("exploration_id").isEqualTo(TEST_EXPLORATION_ID) + assertThat(bundle).string("session_id").isEqualTo(TEST_LEARNER_SESSION_ID) + assertThat(bundle).string("exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) + assertThat(bundle).string("state_name").isEqualTo(TEST_STATE_NAME) + } + + @Test + fun testFillEventBundle_resumeExplorationEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createResumeExplorationContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("resume_exploration_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_resumeExplorationEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createResumeExplorationContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("resume_exploration_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_startOverExpEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createStartOverExplorationContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("start_over_exploration_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_startOverExpEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createStartOverExplorationContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("start_over_exploration_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_deleteProfileEvent_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createDeleteProfileContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("delete_profile_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_deleteProfileEvent_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createDeleteProfileContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("delete_profile_context") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_openHomeContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenHomeContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_home") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_openProfileChooserContextEvent_fillsAllFieldsInBundleAndReturnsName() { + setUpTestApplicationComponent() + val bundle = Bundle() + + val eventLog = createEventLog(context = createOpenProfileChooserContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("open_profile_chooser") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_failedEventInstallId_studyOff_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createInstallationIdForFailedAnalyticsLogContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("failed_analytics_log") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + @Test + fun testFillEventBundle_failedEventInstallId_studyOn_fillsOnlyNonSensitiveFieldsAndRetsName() { + setUpTestApplicationComponentWithLearnerAnalyticsStudy() + val bundle = Bundle() + + val eventLog = createEventLog(context = createInstallationIdForFailedAnalyticsLogContext()) + + val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) + assertThat(typeName).isEqualTo("failed_analytics_log") + assertThat(bundle).hasSize(2) + assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) + assertThat(bundle).string("priority").isEqualTo("essential") + } + + private fun createEventLog( + timestamp: Long = TEST_TIMESTAMP_1, + priority: EventLog.Priority = ESSENTIAL, + context: EventLog.Context = EventLog.Context.getDefaultInstance() + ) = EventLog.newBuilder().apply { + this.timestamp = timestamp + this.priority = priority + this.context = context + }.build() + + private fun createOpenExplorationActivity( + explorationContext: ExplorationContext = createExplorationContext() + ) = createEventContext(explorationContext, EventContextBuilder::setOpenExplorationActivity) + + private fun createOpenInfoTab(topicContext: TopicContext = createTopicContext()) = + createEventContext(topicContext, EventContextBuilder::setOpenInfoTab) + + private fun createOpenLessonsTab(topicContext: TopicContext = createTopicContext()) = + createEventContext(topicContext, EventContextBuilder::setOpenLessonsTab) + + private fun createOpenPracticeTab(topicContext: TopicContext = createTopicContext()) = + createEventContext(topicContext, EventContextBuilder::setOpenPracticeTab) + + private fun createOpenRevisionTab(topicContext: TopicContext = createTopicContext()) = + createEventContext(topicContext, EventContextBuilder::setOpenRevisionTab) + + private fun createOpenQuestionPlayer(questionContext: QuestionContext = createQuestionContext()) = + createEventContext(questionContext, EventContextBuilder::setOpenQuestionPlayer) + + private fun createOpenStoryActivity(storyContext: StoryContext = createStoryContext()) = + createEventContext(storyContext, EventContextBuilder::setOpenStoryActivity) + + private fun createOpenConceptCard( + conceptCardContext: ConceptCardContext = createConceptCardContext() + ) = createEventContext(conceptCardContext, EventContextBuilder::setOpenConceptCard) + + private fun createOpenRevisionCard( + revisionCardContext: RevisionCardContext = createRevisionCardContext() + ) = createEventContext(revisionCardContext, EventContextBuilder::setOpenRevisionCard) + + private fun createStartCardContext(cardContext: CardContext = createCardContext()) = + createEventContext(cardContext, EventContextBuilder::setStartCardContext) + + private fun createEndCardContext(cardContext: CardContext = createCardContext()) = + createEventContext(cardContext, EventContextBuilder::setEndCardContext) + + private fun createHintOfferedContext(hintContext: HintContext = createHintContext()) = + createEventContext(hintContext, EventContextBuilder::setHintOfferedContext) + + private fun createAccessHintContext(hintContext: HintContext = createHintContext()) = + createEventContext(hintContext, EventContextBuilder::setAccessHintContext) + + private fun createSolutionOfferedContext( + explorationContext: ExplorationContext = createExplorationContext() + ) = createEventContext(explorationContext, EventContextBuilder::setSolutionOfferedContext) + + private fun createAccessSolutionContext( + explorationContext: ExplorationContext = createExplorationContext() + ) = createEventContext(explorationContext, EventContextBuilder::setAccessSolutionContext) + + private fun createSubmitAnswerContext( + submitAnswerContext: SubmitAnswerContext = createSubmitAnswerContextDetails() + ) = createEventContext(submitAnswerContext, EventContextBuilder::setSubmitAnswerContext) + + private fun createPlayVoiceOverContext( + playVoiceOverContext: PlayVoiceOverContext = createPlayVoiceOverContextDetails() + ) = createEventContext(playVoiceOverContext, EventContextBuilder::setPlayVoiceOverContext) + + private fun createAppInBackgroundContext( + learnerDetails: LearnerDetailsContext = createLearnerDetailsContext() + ) = createEventContext(learnerDetails, EventContextBuilder::setAppInBackgroundContext) + + private fun createAppInForegroundContext( + learnerDetails: LearnerDetailsContext = createLearnerDetailsContext() + ) = createEventContext(learnerDetails, EventContextBuilder::setAppInForegroundContext) + + private fun createExitExplorationContext( + explorationContext: ExplorationContext = createExplorationContext() + ) = createEventContext(explorationContext, EventContextBuilder::setExitExplorationContext) + + private fun createFinishExplorationContext( + explorationContext: ExplorationContext = createExplorationContext() + ) = createEventContext(explorationContext, EventContextBuilder::setFinishExplorationContext) + + private fun createResumeExplorationContext( + learnerDetails: LearnerDetailsContext = createLearnerDetailsContext() + ) = createEventContext(learnerDetails, EventContextBuilder::setResumeExplorationContext) + + private fun createStartOverExplorationContext( + learnerDetails: LearnerDetailsContext = createLearnerDetailsContext() + ) = createEventContext(learnerDetails, EventContextBuilder::setStartOverExplorationContext) + + private fun createDeleteProfileContext( + learnerDetails: LearnerDetailsContext = createLearnerDetailsContext() + ) = createEventContext(learnerDetails, EventContextBuilder::setDeleteProfileContext) + + private fun createOpenHomeContext() = + createEventContext(value = true, EventContextBuilder::setOpenHome) + + private fun createOpenProfileChooserContext() = + createEventContext(value = true, EventContextBuilder::setOpenProfileChooser) + + private fun createInstallationIdForFailedAnalyticsLogContext( + installationId: String = TEST_INSTALLATION_ID + ) = createEventContext(installationId, EventContextBuilder::setInstallIdForFailedAnalyticsLog) + + private fun createEventContext( + value: T, + setter: EventContextBuilder.(T) -> EventContextBuilder + ) = EventLog.Context.newBuilder().setter(value).build() + + private fun createExplorationContext( + topicId: String = TEST_TOPIC_ID, + storyId: String = TEST_STORY_ID, + explorationId: String = TEST_EXPLORATION_ID, + sessionId: String = TEST_LEARNER_SESSION_ID, + explorationVersion: Int = TEST_EXPLORATION_VERSION, + stateName: String = TEST_STATE_NAME, + learnerDetails: LearnerDetailsContext = createLearnerDetailsContext() + ) = ExplorationContext.newBuilder().apply { + this.topicId = topicId + this.storyId = storyId + this.explorationId = explorationId + this.sessionId = sessionId + this.explorationVersion = explorationVersion + this.stateName = stateName + this.learnerDetails = learnerDetails + }.build() + + private fun createLearnerDetailsContext( + learnerId: String = TEST_LEARNER_ID, + installId: String = TEST_INSTALLATION_ID + ) = LearnerDetailsContext.newBuilder().apply { + this.learnerId = learnerId + this.installId = installId + }.build() + + private fun createTopicContext(topicId: String = TEST_TOPIC_ID) = + TopicContext.newBuilder().apply { this.topicId = topicId }.build() + + private fun createQuestionContext( + questionId: String = TEST_QUESTION_ID, + skillIds: List = listOf(TEST_SKILL_ID_1, TEST_SKILL_ID_2) + ) = QuestionContext.newBuilder().apply { + this.questionId = questionId + addAllSkillId(skillIds) + }.build() + + private fun createStoryContext( + topicId: String = TEST_TOPIC_ID, + storyId: String = TEST_STORY_ID + ) = StoryContext.newBuilder().apply { + this.topicId = topicId + this.storyId = storyId + }.build() + + private fun createConceptCardContext(skillId: String = TEST_SKILL_ID_1) = + ConceptCardContext.newBuilder().apply { this.skillId = skillId }.build() + + private fun createRevisionCardContext( + topicId: String = TEST_TOPIC_ID, + subTopicIndex: Int = TEST_SUB_TOPIC_INDEX + ) = RevisionCardContext.newBuilder().apply { + this.topicId = topicId + subTopicId = subTopicIndex + }.build() + + private fun createCardContext( + explorationDetails: ExplorationContext = createExplorationContext(), + skillId: String = TEST_SKILL_ID_1 + ) = CardContext.newBuilder().apply { + this.explorationDetails = explorationDetails + this.skillId = skillId + }.build() + + private fun createHintContext( + explorationDetails: ExplorationContext = createExplorationContext(), + hintIndex: Int = TEST_HINT_INDEX + ) = HintContext.newBuilder().apply { + this.explorationDetails = explorationDetails + this.hintIndex = hintIndex + }.build() + + private fun createSubmitAnswerContextDetails( + explorationDetails: ExplorationContext = createExplorationContext(), + isAnswerCorrect: Boolean = TEST_IS_ANSWER_CORRECT + ) = SubmitAnswerContext.newBuilder().apply { + this.explorationDetails = explorationDetails + this.isAnswerCorrect = isAnswerCorrect + }.build() + + private fun createPlayVoiceOverContextDetails( + explorationDetails: ExplorationContext = createExplorationContext(), + contentId: String = TEST_CONTENT_ID + ) = PlayVoiceOverContext.newBuilder().apply { + this.explorationDetails = explorationDetails + this.contentId = contentId + }.build() + + private fun setUpTestApplicationComponentWithoutLearnerAnalyticsStudy() { + setUpTestApplicationComponent() + } + + private fun setUpTestApplicationComponentWithLearnerAnalyticsStudy() { + TestModule.enableLearnerStudyAnalytics = true + setUpTestApplicationComponent() } private fun setUpTestApplicationComponent() { - DaggerEventBundleCreatorTest_TestApplicationComponent.builder() - .setApplication(ApplicationProvider.getApplicationContext()) - .build() - .inject(this) + ApplicationProvider.getApplicationContext().inject(this) } // TODO(#89): Move this to a common test application component. @Module class TestModule { + internal companion object { + // This is expected to be off by default, so this helps the tests above confirm that the + // feature's default value is, indeed, off. + var enableLearnerStudyAnalytics = LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE + } + @Provides @Singleton fun provideContext(application: Application): Context { return application } + + // The scoping here is to ensure changes to the module value above don't change the parameter + // within the same application instance. + @Provides + @Singleton + @LearnerStudyAnalytics + fun provideLearnerStudyAnalytics(): PlatformParameterValue { + // Snapshot the value so that it doesn't change between injection and use. + val enableFeature = enableLearnerStudyAnalytics + return object : PlatformParameterValue { + override val value: Boolean = enableFeature + } + } } // TODO(#89): Move this to a common test application component. @@ -228,6 +1157,18 @@ class EventBundleCreatorTest { fun build(): TestApplicationComponent } - fun inject(eventBundleCreatorTest: EventBundleCreatorTest) + fun inject(test: EventBundleCreatorTest) + } + + class TestApplication : Application() { + private val component: TestApplicationComponent by lazy { + DaggerEventBundleCreatorTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(test: EventBundleCreatorTest) { + component.inject(test) + } } } diff --git a/utility/src/test/java/org/oppia/android/util/logging/SyncStatusManagerImplTest.kt b/utility/src/test/java/org/oppia/android/util/logging/SyncStatusManagerImplTest.kt new file mode 100644 index 00000000000..8c8d5257947 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/logging/SyncStatusManagerImplTest.kt @@ -0,0 +1,228 @@ +package org.oppia.android.util.logging + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.data.DataProviderTestMonitor +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus.DATA_UPLOADED +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus.DATA_UPLOADING +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus.INITIAL_UNKNOWN +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus.NETWORK_ERROR +import org.oppia.android.util.logging.SyncStatusManager.SyncStatus.NO_CONNECTIVITY +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi +import org.oppia.android.util.platformparameter.LearnerStudyAnalytics +import org.oppia.android.util.platformparameter.PlatformParameterValue +import org.oppia.android.util.platformparameter.SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.SplashScreenWelcomeMsg +import org.oppia.android.util.platformparameter.SyncUpWorkerTimePeriodHours +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [SyncStatusManagerImpl]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = SyncStatusManagerImplTest.TestApplication::class) +class SyncStatusManagerImplTest { + @Inject lateinit var syncStatusManager: SyncStatusManager + @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testGetSyncStatus_initialState_returnsUnknown() { + val syncStatusProvider = syncStatusManager.getSyncStatus() + + val syncStatus = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(syncStatus).isEqualTo(INITIAL_UNKNOWN) + } + + @Test + fun testGetSyncStatus_setSyncStatus_toDataUploading_returnsUploading() { + val syncStatusProvider = syncStatusManager.getSyncStatus() + + syncStatusManager.setSyncStatus(DATA_UPLOADING) + + val syncStatus = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(syncStatus).isEqualTo(DATA_UPLOADING) + } + + @Test + fun testGetSyncStatus_setSyncStatus_toDataUploaded_returnsUploaded() { + val syncStatusProvider = syncStatusManager.getSyncStatus() + + syncStatusManager.setSyncStatus(DATA_UPLOADED) + + val syncStatus = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(syncStatus).isEqualTo(DATA_UPLOADED) + } + + @Test + fun testGetSyncStatus_setSyncStatus_toNoConnectivity_returnsNoConnectivity() { + val syncStatusProvider = syncStatusManager.getSyncStatus() + + syncStatusManager.setSyncStatus(NO_CONNECTIVITY) + + val syncStatus = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(syncStatus).isEqualTo(NO_CONNECTIVITY) + } + + @Test + fun testGetSyncStatus_setSyncStatus_toNetworkError_returnsNetworkError() { + val syncStatusProvider = syncStatusManager.getSyncStatus() + + syncStatusManager.setSyncStatus(NETWORK_ERROR) + + val syncStatus = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(syncStatus).isEqualTo(NETWORK_ERROR) + } + + @Test + fun testGetSyncStatus_setSyncStatus_toDataUploading_thenNetworkError_returnsNetworkError() { + val syncStatusProvider = syncStatusManager.getSyncStatus() + syncStatusManager.setSyncStatus(DATA_UPLOADING) + + syncStatusManager.setSyncStatus(NETWORK_ERROR) + + val syncStatus = monitorFactory.waitForNextSuccessfulResult(syncStatusProvider) + assertThat(syncStatus).isEqualTo(NETWORK_ERROR) + } + + @Test + fun testGetSyncStatus_setSyncStatus_toUploading_thenNetworkError_existingSub_notifiesChange() { + val syncStatusProvider = syncStatusManager.getSyncStatus() + val monitor = monitorFactory.createMonitor(syncStatusProvider) + syncStatusManager.setSyncStatus(DATA_UPLOADING) + monitor.waitForNextResult() + + syncStatusManager.setSyncStatus(NETWORK_ERROR) + + // The latest value should be sent to the existing observer as a notification. + val syncStatus = monitor.waitForNextSuccessResult() + assertThat(syncStatus).isEqualTo(NETWORK_ERROR) + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + // TODO(#59): Either isolate these to their own shared test module, or use the real logging + // module in tests to avoid needing to specify these settings for tests. + @EnableConsoleLog + @Provides + fun provideEnableConsoleLog(): Boolean = true + + @EnableFileLog + @Provides + fun provideEnableFileLog(): Boolean = false + + @GlobalLogLevel + @Provides + fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + } + + @Module + class TestPlatformParameterModule { + + companion object { + var forceLearnerAnalyticsStudy: Boolean = false + } + + @Provides + @SplashScreenWelcomeMsg + fun provideSplashScreenWelcomeMsgParam(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE) + } + + @Provides + @SyncUpWorkerTimePeriodHours + fun provideSyncUpWorkerTimePeriod(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE + ) + } + + @Provides + @EnableLanguageSelectionUi + fun provideEnableLanguageSelectionUi(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE + ) + } + + @Provides + @LearnerStudyAnalytics + fun provideLearnerStudyAnalytics(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter(forceLearnerAnalyticsStudy) + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, TestLogReportingModule::class, + TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, + NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, + TestPlatformParameterModule::class, SyncStatusModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(syncStatusControllerTest: SyncStatusManagerImplTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerSyncStatusManagerImplTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(syncStatusControllerTest: SyncStatusManagerImplTest) { + component.inject(syncStatusControllerTest) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +}