Skip to content

Commit

Permalink
Fix #119: Introduce interface for StoryProgressController (#177)
Browse files Browse the repository at this point in the history
* Initial introduction of StoryProgressController & tests.

* Address review comments.
  • Loading branch information
BenHenning authored Sep 24, 2019
1 parent e74c827 commit 4e0d577
Show file tree
Hide file tree
Showing 3 changed files with 440 additions and 0 deletions.
170 changes: 170 additions & 0 deletions domain/src/main/java/org/oppia/domain/topic/StoryProgressController.kt
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()
}
}
}
Loading

0 comments on commit 4e0d577

Please sign in to comment.