Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.instructure.canvas.espresso.mockcanvas.MockCanvas
import com.instructure.canvasapi2.GetCoursesQuery
import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations
import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress
import com.instructure.canvasapi2.managers.graphql.horizon.DashboardEnrollment
import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager
import com.instructure.canvasapi2.type.EnrollmentWorkflowState
import com.instructure.canvasapi2.utils.DataResult
Expand Down Expand Up @@ -89,6 +90,33 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager {
)
}

override suspend fun getDashboardEnrollments(
userId: Long,
forceNetwork: Boolean
): DataResult<List<DashboardEnrollment>> {
val enrollments = MockCanvas.data.enrollments.values.toList()
val courses = getCourses()
val dashboardEnrollments = courses.mapIndexedNotNull { index, course ->
val enrollmentId = enrollments.getOrNull(index)?.id ?: return@mapIndexedNotNull null
val state = when (index) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This index-based pairing of courses and enrollments is brittle. If the two lists ever differ in length (or ordering changes), unrelated courses/enrollments get silently paired — or enrollments are dropped with no error.

Consider mapping enrollments by a stable key (e.g. enrollmentId tied to courseId) rather than relying on positional alignment:

val enrollmentByCourseId = enrollments.associateBy { it.courseId }
val dashboardEnrollments = courses.mapNotNull { course ->
    val enrollment = enrollmentByCourseId[course.id] ?: return@mapNotNull null
    ...
}

1 -> DashboardEnrollment.STATE_COMPLETED
2 -> DashboardEnrollment.STATE_INVITED
else -> DashboardEnrollment.STATE_ACTIVE
}
DashboardEnrollment(
enrollmentId = enrollmentId,
enrollmentState = state,
courseId = course.courseId,
courseName = course.courseName,
courseImageUrl = course.courseImageUrl,
courseSyllabus = course.courseSyllabus,
institutionName = null,
completionPercentage = course.progress,
)
}
return DataResult.Success(dashboardEnrollments)
}

override suspend fun getProgramCourses(
courseId: Long,
forceNetwork: Boolean
Expand All @@ -112,7 +140,7 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager {
courseName = courses[0].name,
courseImageUrl = null,
courseSyllabus = "Syllabus for Course 1",
progress = 0.25
progress = 25.0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The progress values changed from fractions (0.25, 1.0) to percentages (25.0, 100.0). If the real API contract uses a 0–1 scale this mismatch will cause test assertions to diverge from production behaviour (and vice-versa). Please confirm the expected unit and ensure both the mock data and the display logic use the same scale consistently.

)
} else { null }
val completedCourse = if (courses.size > 1) {
Expand All @@ -121,7 +149,7 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager {
courseName = courses[1].name,
courseImageUrl = null,
courseSyllabus = "Syllabus for Course 2",
progress = 1.0
progress = 100.0
)
} else { null }
val invitedCourse = if (courses.size > 2) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ interface HorizonGetCoursesManager {

suspend fun getEnrollments(userId: Long, forceNetwork: Boolean = false): DataResult<List<GetCoursesQuery.Enrollment>>

suspend fun getDashboardEnrollments(userId: Long, forceNetwork: Boolean = false): DataResult<List<DashboardEnrollment>>

suspend fun getProgramCourses(courseId: Long, forceNetwork: Boolean = false): DataResult<CourseWithModuleItemDurations>
}

Expand Down Expand Up @@ -101,6 +103,33 @@ class HorizonGetCoursesManagerImpl(private val apolloClient: ApolloClient): Hori
}
}

override suspend fun getDashboardEnrollments(userId: Long, forceNetwork: Boolean): DataResult<List<DashboardEnrollment>> {
return try {
val query = GetCoursesQuery(userId.toString())
val result = apolloClient.enqueueQuery(query, forceNetwork).dataAssertNoErrors
val enrollments = result.legacyNode?.onUser?.enrollments.orEmpty().mapNotNull { enrollment ->
val course = enrollment.course ?: return@mapNotNull null
val courseId = course.id.toLongOrNull() ?: return@mapNotNull null
val enrollmentId = enrollment.id?.toLongOrNull() ?: return@mapNotNull null
val progress = course.usersConnection?.nodes?.firstOrNull()
?.courseProgression?.requirements?.completionPercentage ?: 0.0
DashboardEnrollment(
enrollmentId = enrollmentId,
enrollmentState = enrollment.state.rawValue,
courseId = courseId,
courseName = course.name,
courseImageUrl = course.image_download_url,
courseSyllabus = course.syllabus_body,
institutionName = course.account?.name,
completionPercentage = progress,
)
}
DataResult.Success(enrollments)
} catch (e: Exception) {
DataResult.Fail(Failure.Exception(e))
}
}

override suspend fun getProgramCourses(courseId: Long, forceNetwork: Boolean): DataResult<CourseWithModuleItemDurations> {
var hasNextPage = true
var nextCursor: String? = null
Expand Down Expand Up @@ -137,6 +166,23 @@ class HorizonGetCoursesManagerImpl(private val apolloClient: ApolloClient): Hori
}
}

data class DashboardEnrollment(
val enrollmentId: Long,
val enrollmentState: String,
val courseId: Long,
val courseName: String,
val courseImageUrl: String?,
val courseSyllabus: String?,
val institutionName: String?,
val completionPercentage: Double,
) {
companion object {
const val STATE_ACTIVE = "active"
const val STATE_INVITED = "invited"
const val STATE_COMPLETED = "completed"
}
}

data class CourseWithProgress(
val courseId: Long,
val courseName: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ enum class CollectionItemType {
EXTERNAL_URL,
EXTERNAL_TOOL,
FILE,
PROGRAM
PROGRAM;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safeValueOf returning null for unknown types is good defensive practice, but callers that use mapNotNull { it.toModel() } will silently drop collection items the server sends with a type the client doesn't recognise yet. This can make future API additions invisible to users without any indication something was skipped.

Consider adding a Crashlytics log (non-fatal) or an UNKNOWN fallback entry so unknown items surface during QA rather than disappearing silently in production.


companion object {
fun safeValueOf(name: String): CollectionItemType? = entries.find { it.name == name }
}
}

fun ApolloCollectionItemType.toModel(): CollectionItemType {
Expand Down
6 changes: 6 additions & 0 deletions libs/horizon/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ dependencies {
isTransitive = true
}

/* Room */
implementation(Libs.ROOM)
implementation(Libs.ROOM_COROUTINES)
ksp(Libs.ROOM_COMPILER)
testImplementation(Libs.ROOM_TEST)

/* Android Test Dependencies */
androidTestImplementation(project(":espresso"))
androidTestImplementation(project(":dataseedingapi"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (C) 2026 - present Instructure, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.instructure.horizon.espresso

import android.content.Context
import androidx.room.Room
import com.instructure.horizon.database.HorizonDatabase
import com.instructure.horizon.database.dao.HorizonCourseDao
import com.instructure.horizon.database.dao.HorizonCourseModuleDao
import com.instructure.horizon.database.dao.HorizonCourseScoreDao
import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao
import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao
import com.instructure.horizon.database.dao.HorizonFileFolderDao
import com.instructure.horizon.database.dao.HorizonLearnCollectionDao
import com.instructure.horizon.database.dao.HorizonLearnItemDao
import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao
import com.instructure.horizon.database.dao.HorizonLocalFileDao
import com.instructure.horizon.database.dao.HorizonProgramDao
import com.instructure.horizon.database.dao.HorizonSyncMetadataDao
import com.instructure.horizon.di.HorizonOfflineModule
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton

@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [HorizonOfflineModule::class])
object HorizonOfflineTestModule {

@Provides
@Singleton
fun provideHorizonDatabase(@ApplicationContext context: Context): HorizonDatabase {
return Room.inMemoryDatabaseBuilder(context, HorizonDatabase::class.java)
.allowMainThreadQueries()
.build()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.allowMainThreadQueries() disables Room's main-thread safety guard. This is fine for keeping tests simple, but it means any accidental main-thread DB access in production code won't be caught here. If threading correctness is important for offline sync, consider running at least a subset of the new local datasource tests without this flag so regressions are caught earlier.

}

@Provides
fun provideHorizonDashboardEnrollmentDao(db: HorizonDatabase): HorizonDashboardEnrollmentDao = db.dashboardEnrollmentDao()

@Provides
fun provideHorizonProgramDao(db: HorizonDatabase): HorizonProgramDao = db.programDao()

@Provides
fun provideHorizonDashboardModuleItemDao(db: HorizonDatabase): HorizonDashboardModuleItemDao = db.dashboardModuleItemDao()

@Provides
fun provideHorizonSyncMetadataDao(db: HorizonDatabase): HorizonSyncMetadataDao = db.syncMetadataDao()

@Provides
fun provideHorizonLearnItemDao(db: HorizonDatabase): HorizonLearnItemDao = db.learnItemDao()

@Provides
fun provideHorizonLearnCollectionDao(db: HorizonDatabase): HorizonLearnCollectionDao = db.learnCollectionDao()

@Provides
fun provideHorizonLearnSavedItemDao(db: HorizonDatabase): HorizonLearnSavedItemDao = db.learnSavedItemDao()

@Provides
fun provideHorizonCourseDao(db: HorizonDatabase): HorizonCourseDao = db.courseDao()

@Provides
fun provideHorizonCourseModuleDao(db: HorizonDatabase): HorizonCourseModuleDao = db.courseModuleDao()

@Provides
fun provideHorizonCourseScoreDao(db: HorizonDatabase): HorizonCourseScoreDao = db.courseScoreDao()

@Provides
fun provideHorizonLocalFileDao(db: HorizonDatabase): HorizonLocalFileDao = db.localFileDao()

@Provides
fun provideHorizonFileFolderDao(db: HorizonDatabase): HorizonFileFolderDao = db.fileFolderDao()
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.instructure.horizon.espresso

import android.content.Context
import android.content.Intent
import androidx.room.Room
import com.instructure.canvasapi2.LoginRouter
import com.instructure.canvasapi2.utils.pageview.PandataInfo
import dagger.hilt.android.qualifiers.ApplicationContext
import com.instructure.pandautils.features.about.AboutRepository
import com.instructure.pandautils.features.assignments.details.AssignmentDetailsBehaviour
import com.instructure.pandautils.features.assignments.details.AssignmentDetailsColorProvider
Expand Down Expand Up @@ -98,8 +101,11 @@ object HorizonTestModule {
}

@Provides
fun provideAppDatabase(): AppDatabase {
throw NotImplementedError("This is a test module. Implementation not required.")
@javax.inject.Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
.build()
}

@Provides
Expand Down
Loading
Loading