Skip to content

[CLXR-57][Horizon] Learn program screen#3502

Open
domonkosadam wants to merge 12 commits intoCLX-3743-learn-course-screenfrom
CLXR-57-learn-program-screen
Open

[CLXR-57][Horizon] Learn program screen#3502
domonkosadam wants to merge 12 commits intoCLX-3743-learn-course-screenfrom
CLXR-57-learn-program-screen

Conversation

@domonkosadam
Copy link
Contributor

refs: CLXR-57
affects: Student
release note: none

domonkosadam and others added 8 commits February 3, 2026 11:09
Implemented unit and UI tests for Learn Program features following the
Learn Course test patterns:

Unit Tests:
- LearnProgramListRepositoryTest (5 tests)
- LearnProgramListViewModelTest (24 tests)
- ProgramDetailsRepositoryTest (6 tests)
- ProgramDetailsViewModelTest (28 tests)

UI Tests:
- LearnProgramListUiTest (6 tests)
- ProgramDetailsUiTest (8 tests)

Tests use real data objects instead of mocks for data classes, following
best practices. All tests cover filtering, search, pagination, progress
calculation, course enrollment, and UI rendering.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Feb 4, 2026

🧪 Unit Test Results

✅ 📱 Student App

  • Tests: 1229 total, 0 failed, 0 skipped
  • Duration: 0.000s
  • Success Rate: 100%

✅ 🌅 Horizon

  • Tests: 623 total, 0 failed, 0 skipped
  • Duration: 39.392s
  • Success Rate: 100%

✅ 📦 Submodules

  • Tests: 2888 total, 0 failed, 0 skipped
  • Duration: 53.627s
  • Success Rate: 100%

📊 Summary

  • Total Tests: 4740
  • Failed: 0
  • Skipped: 0
  • Status: ✅ All tests passed!

Last updated: Thu, 05 Feb 2026 15:40:06 GMT

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Review Summary

This PR implements a comprehensive Learn Programs feature for the Horizon module, including program listing and detailed views with excellent test coverage. The implementation follows the project's MVVM architecture and Compose patterns well.

Positive Highlights ✅

  • Excellent test coverage: Both unit tests and UI tests are comprehensive, covering edge cases and various states
  • Clean architecture: Proper separation of concerns with Repository, ViewModel, and UI layers
  • Good use of Compose: Leverages modern Compose features like HorizontalPager, LaunchedEffect, and state management
  • Reusable components: Well-structured components like ProgramCourseCard, ProgramProgress, and progress indicators
  • Consistent patterns: Follows existing project conventions for feature organization and naming

Issues to Address 🔧

  • libs/horizon/.../ProgramCourseCard.kt:76 - Code style: Remove space before colon in enabled : Boolean
  • libs/horizon/.../ProgramCourseCard.kt:108 - Architecture: Consider passing a lambda instead of NavHostController for better testability
  • libs/pandautils/.../DateExtensions.kt:189 - Edge case: toFormattedString() returns empty string for zero duration
  • libs/horizon/.../LearnScreen.kt:59 - Potential infinite loop: Add guard condition in LaunchedEffect(state.selectedTab)
  • libs/horizon/.../LearnProgramListRepository.kt:36 - Error handling: getCoursesById lacks exception handling for failed async calls

Test Coverage ✅

The PR includes comprehensive testing:

  • UI Tests: LearnProgramListUiTest and ProgramDetailsUiTest cover loading states, content display, filtering, and edge cases
  • Unit Tests: LearnProgramListViewModelTest and ProgramDetailsViewModelTest thoroughly test business logic with 20+ test cases each
  • Repository Tests: Both list and details repositories have proper test coverage

All tests follow the project's testing patterns using Mockk and Compose testing utilities.

Security & Performance

No security concerns identified. The code properly handles network calls asynchronously and uses appropriate loading states. Consider the error handling improvement suggested for parallel course fetching to prevent failures from affecting the entire list.

Overall, this is a well-implemented feature that maintains high code quality standards. The inline comments address minor improvements that would enhance robustness and maintainability.

🤖 Generated with Claude Code

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ProgramCourseCard(state: ProgramCourseCardState, modifier: Modifier = Modifier) {
fun ProgramCourseCard(
Copy link

Choose a reason for hiding this comment

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

Consider extracting the navigation logic from the composable. Rather than passing NavHostController directly, pass a lambda like onCourseClicked: (Long) -> Unit to maintain better separation of concerns and make the component more testable and reusable.

This would change the signature to:

fun ProgramCourseCard(
    state: ProgramCourseCardState,
    onCourseClicked: (Long) -> Unit,
    modifier: Modifier = Modifier
)

Then in the clickable:

.conditional(state.enabled) {
    clickable { onCourseClicked(state.id) }
}

This approach keeps the composable independent of navigation framework details.

val courseClicked: (() -> Unit)? = null,
val onEnrollClicked: (() -> Unit)? = null,
val enrollLoading: Boolean = false,
val enabled : Boolean = false,
Copy link

Choose a reason for hiding this comment

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

Spacing issue: Remove the space before the colon. Kotlin style guide recommends no space before colons in property declarations.

val enabled: Boolean = false,

null
}
}

Copy link

Choose a reason for hiding this comment

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

The toFormattedString function could return an empty string if both hours and minutes are 0. Consider handling this edge case explicitly to provide a meaningful return value (e.g., "0 mins") or document this behavior.

fun Duration.toFormattedString(
    resources: Resources,
    @PluralsRes hoursLabelRes: Int = R.plurals.durationHours,
    @PluralsRes minutesLabelRes: Int = R.plurals.durationMins,
    separator: String = " "
): String {
    val hours = inWholeHours.toInt()
    val minutes = (inWholeMinutes % 60).toInt()

    val formattedStringParts = mutableListOf<String>()
    if (hours > 0) {
        formattedStringParts.add(resources.getQuantityString(hoursLabelRes, hours, hours))
    }
    if (minutes > 0) {
        formattedStringParts.add(resources.getQuantityString(minutesLabelRes, minutes, minutes))
    }
    
    // Handle zero duration case
    if (formattedStringParts.isEmpty()) {
        return resources.getQuantityString(minutesLabelRes, 0, 0)
    }
    
    return formattedStringParts.joinToString(separator)
}

}
}

LaunchedEffect(state.selectedTab) {
Copy link

Choose a reason for hiding this comment

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

The LaunchedEffect with state.selectedTab could cause an infinite loop if pagerState.animateScrollToPage triggers a state change that updates selectedTab. Consider adding a check to prevent unnecessary animations:

LaunchedEffect(state.selectedTab) {
    val pageIndex = LearnTab.entries.indexOf(state.selectedTab)
    if (pagerState.currentPage != pageIndex) {
        pagerState.animateScrollToPage(pageIndex)
    }
}

return getProgramsManager.getPrograms(forceRefresh)
}

suspend fun getCoursesById(courseIds: List<Long>, forceNetwork: Boolean = false): List<CourseWithModuleItemDurations> = coroutineScope {
Copy link

Choose a reason for hiding this comment

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

The getCoursesById function could throw an exception if any of the async calls fail. Consider adding error handling to make this more robust:

suspend fun getCoursesById(courseIds: List<Long>, forceNetwork: Boolean = false): List<CourseWithModuleItemDurations> = coroutineScope {
    courseIds.mapNotNull { id ->
        async { 
            try {
                getCoursesManager.getProgramCourses(id, forceNetwork).dataOrThrow
            } catch (e: Exception) {
                // Log error or handle appropriately
                null
            }
        }
    }.awaitAll().filterNotNull()
}

Or document that callers should handle exceptions from this method.

@github-actions
Copy link

github-actions bot commented Feb 5, 2026

Student Install Page

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant