-
Notifications
You must be signed in to change notification settings - Fork 528
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
* Initial introduction of StoryProgressController & tests. * Address review comments.
- Loading branch information
1 parent
e74c827
commit 4e0d577
Showing
3 changed files
with
440 additions
and
0 deletions.
There are no files selected for viewing
170 changes: 170 additions & 0 deletions
170
domain/src/main/java/org/oppia/domain/topic/StoryProgressController.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
package org.oppia.domain.topic | ||
|
||
import androidx.lifecycle.LiveData | ||
import androidx.lifecycle.MutableLiveData | ||
import javax.inject.Inject | ||
import javax.inject.Singleton | ||
import org.oppia.app.model.ChapterPlayState | ||
import org.oppia.app.model.ChapterProgress | ||
import org.oppia.app.model.StoryProgress | ||
import org.oppia.util.data.AsyncResult | ||
|
||
const val TEST_STORY_ID_0 = "test_story_id_0" | ||
const val TEST_STORY_ID_1 = "test_story_id_1" | ||
const val TEST_STORY_ID_2 = "test_story_id_2" | ||
const val TEST_EXPLORATION_ID_0 = "test_exp_id_0" | ||
const val TEST_EXPLORATION_ID_1 = "test_exp_id_1" | ||
const val TEST_EXPLORATION_ID_2 = "test_exp_id_2" | ||
const val TEST_EXPLORATION_ID_3 = "test_exp_id_3" | ||
const val TEST_EXPLORATION_ID_4 = "test_exp_id_4" | ||
|
||
/** Controller that records and provides completion statuses of chapters within the context of a story. */ | ||
@Singleton | ||
class StoryProgressController @Inject constructor() { | ||
// TODO(#21): Determine whether chapters can have missing prerequisites in the initial prototype, or if that just | ||
// indicates that they can't be started due to previous chapter not yet being completed. | ||
|
||
private val trackedStoriesProgress: Map<String, TrackedStoryProgress> by lazy { createInitialStoryProgressState() } | ||
|
||
/** | ||
* Records the specified chapter completed within the context of the specified story. Returns a [LiveData] that | ||
* provides exactly one [AsyncResult] to indicate whether this operation has succeeded. This method will never return | ||
* a pending result. | ||
*/ | ||
fun recordCompletedChapter(storyId: String, explorationId: String): LiveData<AsyncResult<Nothing?>> { | ||
return try { | ||
trackCompletedChapter(storyId, explorationId) | ||
MutableLiveData(AsyncResult.success(null)) | ||
} catch (e: Exception) { | ||
MutableLiveData(AsyncResult.failed(e)) | ||
} | ||
} | ||
|
||
// TODO(#21): Implement notifying story progress changes when a chapter is recorded as complete, and add tests for | ||
// this case. | ||
|
||
/** | ||
* Returns a [LiveData] corresponding to the story progress of the specified story, or a failure if no such story can | ||
* be identified. This [LiveData] will update as the story's progress changes. | ||
*/ | ||
fun getStoryProgress(storyId: String): LiveData<AsyncResult<StoryProgress>> { | ||
return try { | ||
MutableLiveData(AsyncResult.success(createStoryProgressSnapshot(storyId))) | ||
} catch (e: Exception) { | ||
MutableLiveData(AsyncResult.failed(e)) | ||
} | ||
} | ||
|
||
private fun trackCompletedChapter(storyId: String, explorationId: String) { | ||
check(storyId in trackedStoriesProgress) { "No story found with ID: $storyId" } | ||
trackedStoriesProgress.getValue(storyId).markChapterCompleted(explorationId) | ||
} | ||
|
||
private fun createStoryProgressSnapshot(storyId: String): StoryProgress { | ||
check(storyId in trackedStoriesProgress) { "No story found with ID: $storyId" } | ||
return trackedStoriesProgress.getValue(storyId).toStoryProgress() | ||
} | ||
|
||
private fun createInitialStoryProgressState(): Map<String, TrackedStoryProgress> { | ||
return mapOf( | ||
TEST_STORY_ID_0 to createStoryProgress0(), | ||
TEST_STORY_ID_1 to createStoryProgress1(), | ||
TEST_STORY_ID_2 to createStoryProgress2() | ||
) | ||
} | ||
|
||
private fun createStoryProgress0(): TrackedStoryProgress { | ||
return TrackedStoryProgress( | ||
chapterList = listOf(TEST_EXPLORATION_ID_0), | ||
completedChapters = setOf(TEST_EXPLORATION_ID_0) | ||
) | ||
} | ||
|
||
private fun createStoryProgress1(): TrackedStoryProgress { | ||
return TrackedStoryProgress( | ||
chapterList = listOf(TEST_EXPLORATION_ID_1, TEST_EXPLORATION_ID_2, TEST_EXPLORATION_ID_3), | ||
completedChapters = setOf(TEST_EXPLORATION_ID_1) | ||
) | ||
} | ||
|
||
private fun createStoryProgress2(): TrackedStoryProgress { | ||
return TrackedStoryProgress( | ||
chapterList = listOf(TEST_EXPLORATION_ID_4), | ||
completedChapters = setOf() | ||
) | ||
} | ||
|
||
/** | ||
* Mutable container for [StoryProgress] that provides support for determining whether a specific chapter can be | ||
* played in the context of this story, marking a chapter as played, and converting to a [StoryProgress] object for | ||
* reporting to the UI. | ||
*/ | ||
private class TrackedStoryProgress(private val chapterList: List<String>, completedChapters: Set<String>) { | ||
private val trackedCompletedChapters: MutableSet<String> = completedChapters.toMutableSet() | ||
|
||
// TODO(#21): Implement tests for the following invariant checking logic, if possible. | ||
init { | ||
// Verify that the progress object is well-defined by ensuring that the invariant where lessons must be played in | ||
// order holds. | ||
var expectedCompleted: Boolean? = null | ||
chapterList.reversed().forEach { explorationId -> | ||
val completedChapter = explorationId in trackedCompletedChapters | ||
val expectedCompletedSnapshot = expectedCompleted | ||
if (expectedCompletedSnapshot == null) { | ||
// This should always be initialized for the last lesson. If it's completed, all previous lessons must be | ||
// completed. If it's not, then previous lessons may be completed or incomplete. | ||
expectedCompleted = completedChapter | ||
} else if (completedChapter != expectedCompletedSnapshot) { | ||
// There's exactly one case where the expectation can change: if the next lesson is not completed. This means | ||
// the current lesson is the most recent one completed in the list, and all previous lessons must also be | ||
// completed. | ||
check(!expectedCompletedSnapshot) { | ||
"Expected lessons to be completed in order with no holes between them, and starting from the beginning " + | ||
"of the story. Encountered uncompleted chapter right before a completed chapter: $explorationId" | ||
} | ||
// This is the first lesson that was completed after encountering one or more lessons that are not completed. | ||
// All previous lessons in the list (the lessons next to be iterated) must be completed in order for the | ||
// in-order invariant to hold. | ||
expectedCompleted = true | ||
} | ||
// Otherwise, the invariant holds. Continue on to the previous lesson. | ||
} | ||
} | ||
|
||
/** | ||
* Returns whether the specified exploration ID can be played, or if it's missing prerequisites. Fails if the | ||
* specified exploration ID is not contained in this story. | ||
*/ | ||
fun canPlayChapter(explorationId: String): Boolean { | ||
// The chapter can be played only if it's the first one, or the chapter before it has been completed. | ||
check(explorationId in chapterList) { "Chapter not found in story: $explorationId" } | ||
val chapterIndex = chapterList.indexOf(explorationId) | ||
return if (chapterIndex == 0) true else chapterList[chapterIndex - 1] in trackedCompletedChapters | ||
} | ||
|
||
/** Marks the specified exploration ID as completed, or fails if the exploration is not contained in this story. */ | ||
fun markChapterCompleted(explorationId: String) { | ||
check(canPlayChapter(explorationId)) { "Cannot mark chapter as completed, missing prerequisites: $explorationId" } | ||
trackedCompletedChapters.add(explorationId) | ||
} | ||
|
||
/** Returns an immutable [StoryProgress] representation of this progress object. */ | ||
fun toStoryProgress(): StoryProgress { | ||
return StoryProgress.newBuilder() | ||
.addAllChapterProgress(chapterList.map(this::buildChapterProgress)) | ||
.build() | ||
} | ||
|
||
private fun buildChapterProgress(explorationId: String): ChapterProgress { | ||
val chapterPlayState = when { | ||
explorationId in trackedCompletedChapters -> ChapterPlayState.COMPLETED | ||
canPlayChapter(explorationId) -> ChapterPlayState.NOT_STARTED | ||
else -> ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES /* Assume only reason is missing prerequisites. */ | ||
} | ||
return ChapterProgress.newBuilder() | ||
.setExplorationId(explorationId) | ||
.setPlayState(chapterPlayState) | ||
.build() | ||
} | ||
} | ||
} |
Oops, something went wrong.