From d4dd86b663d2f8fd71d8e2e02f4ee6de86363db9 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 4 Oct 2026 13:29:39 -0700 Subject: [PATCH 1/3] ci: run instrumented tests on an emulator; add HomeScreen UI tests - build.yml: new `instrumented` job runs connectedDebugAndroidTest on an API 34 x86_64 emulator (reactivecircus/android-emulator-runner, KVM enabled) and uploads the report. First time these tests run anywhere. - HomeScreenTest: blueprint toggle, search by formula, empty search state, locked cards expose "Locked" and ignore taps (covers the manual checks left over from the v2.4 device smoke test). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 42 +++++++ .../app/ui/screens/HomeScreenTest.kt | 116 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 app/src/androidTest/java/com/circuitqueest/app/ui/screens/HomeScreenTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87898ab..0725390 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,3 +47,45 @@ jobs: name: app-debug path: app/build/outputs/apk/debug/app-debug.apk retention-days: 7 + + instrumented: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Hardware acceleration for the emulator on GitHub's Linux runners. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run instrumented tests on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + target: google_apis + disable-animations: true + script: ./gradlew connectedDebugAndroidTest + + - name: Upload instrumented test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: instrumented-test-report + path: app/build/reports/androidTests/connected/ + retention-days: 7 + diff --git a/app/src/androidTest/java/com/circuitqueest/app/ui/screens/HomeScreenTest.kt b/app/src/androidTest/java/com/circuitqueest/app/ui/screens/HomeScreenTest.kt new file mode 100644 index 0000000..17c5d0b --- /dev/null +++ b/app/src/androidTest/java/com/circuitqueest/app/ui/screens/HomeScreenTest.kt @@ -0,0 +1,116 @@ +package com.circuitqueest.app.ui.screens + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.circuitqueest.app.data.content.TopicsService +import com.circuitqueest.app.data.db.AppDatabase +import com.circuitqueest.app.data.repository.ProgressRepository +import com.circuitqueest.app.ui.theme.CircuitQueestTheme +import com.circuitqueest.app.viewmodel.HomeViewModel +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class HomeScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var database: AppDatabase + private lateinit var viewModel: HomeViewModel + + private val ohmsLaw = TopicsService.allTopics.first { it.id == "ohms_law" } + private val mosfets = TopicsService.allTopics.first { it.id == "mosfets" } + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + viewModel = HomeViewModel(ProgressRepository(database.progressDao(), database.quizResultDao())) + } + + @After + fun tearDown() { + database.close() + } + + /** Mirrors MainActivity: blueprint state hoisted above the theme. */ + private fun showHome(onTopicClick: (String) -> Unit = {}) { + composeTestRule.setContent { + var blueprint by remember { mutableStateOf(false) } + CircuitQueestTheme(blueprintMode = blueprint) { + HomeScreen( + viewModel = viewModel, + onTopicClick = onTopicClick, + onToggleBlueprint = { blueprint = !blueprint }, + blueprintMode = blueprint + ) + } + } + } + + @Test + fun blueprintToggle_switchesChipState() { + showHome() + + composeTestRule.onNodeWithText("⊞ BP").assertIsDisplayed().performClick() + composeTestRule.onNodeWithText("⊟ BP").assertIsDisplayed().performClick() + composeTestRule.onNodeWithText("⊞ BP").assertIsDisplayed() + } + + @Test + fun search_byFormula_findsTopicAndHidesOthers() { + showHome() + val formula = ohmsLaw.lesson.sections.firstNotNullOf { it.formula } + + composeTestRule.onNode(hasSetTextAction()).performTextInput(formula) + + composeTestRule.onNodeWithText(ohmsLaw.title).assertIsDisplayed() + composeTestRule.onNodeWithText(mosfets.title).assertDoesNotExist() + } + + @Test + fun search_withNoMatches_showsEmptyState() { + showHome() + + composeTestRule.onNode(hasSetTextAction()).performTextInput("zzz-no-such-quest") + + composeTestRule.onNodeWithText("No quests found").assertIsDisplayed() + } + + @Test + fun lockedCards_announceLockedState_andIgnoreTaps() { + val clicked = mutableListOf() + showHome(onTopicClick = { clicked += it }) + val second = TopicsService.allTopics[1] + + val locked = composeTestRule.onAllNodes( + SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "Locked") + ).fetchSemanticsNodes() + assertTrue(locked.isNotEmpty(), "expected locked cards to expose a Locked state") + + composeTestRule.onAllNodesWithText(second.title)[0].performClick() + composeTestRule.onNodeWithText(ohmsLaw.title).performClick() + + composeTestRule.runOnIdle { assertEquals(listOf(ohmsLaw.id), clicked) } + } +} From 54d4e41fc247d99c5ea377394ea6685d36017d61 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 4 Oct 2026 13:31:50 -0700 Subject: [PATCH 2/3] docs: rewrite API.md from the actual code The old API.md described APIs that never existed (a Progress entity, allTopics/userProgress/currentQuiz flows, clearError(), custom exception types). Rewritten from the current sources: content model, QuizScoring rules and constants, ProgressRepository (recordQuizResult returns XP), Room schema/DAOs/DATABASE_NAME, Hilt module, ViewModel state and behavior, Routes, screen and component signatures, theming rules. Notes which components are legacy/unused. Co-Authored-By: Claude Opus 5.5 --- docs/API.md | 744 +++++++++++++++++----------------------------------- 1 file changed, 236 insertions(+), 508 deletions(-) diff --git a/docs/API.md b/docs/API.md index 8550693..e84f61f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,630 +1,358 @@ # API Documentation -## Public APIs Reference +Reference for the app's internal Kotlin APIs, as of v2.4. Everything lives under +`app/src/main/java/com/circuitqueest/app/`. The app is offline-only: there is no network API. -Complete documentation of public interfaces, classes, and methods available for developers extending CircuitQuEEst. - -## ViewModel APIs - -### HomeViewModel -Manages the home screen state including topic list and user progress. - -```kotlin -@HiltViewModel -class HomeViewModel @Inject constructor( - private val repository: ProgressRepository, - private val topicsService: TopicsService, - savedStateHandle: SavedStateHandle -) : ViewModel() +``` +UI (Compose screens) ──collectAsStateWithLifecycle──▶ ViewModels (StateFlow) + │ + TopicsService (static content) ◀─┤ + ▼ + ProgressRepository ──▶ Room DAOs ──▶ circuitqueest_db ``` -#### Properties -```kotlin -// All topics available for learning -val allTopics: StateFlow> - -// User progress for each topic -val userProgress: StateFlow> - -// Total XP earned by user -val totalXP: StateFlow +--- -// Currently selected topic -val selectedTopic: StateFlow +## Content -// Loading state -val isLoading: StateFlow +Lesson and quiz content is compiled into the app as Kotlin singleton objects +(`data/content/*Content.kt`), one per topic. -// Error messages -val errorMessage: StateFlow -``` +### Data model (`data/content/TopicContent.kt`) -#### Methods ```kotlin -// Select a topic to view details -fun selectTopic(topicId: String) +data class Topic( + val id: String, // stable key, e.g. "ohms_law" — used in routes and the database + val title: String, + val subtitle: String, + val icon: String, // emoji shown in the card badge + val order: Int, // quest number = position in the unlock path (see TopicCategories) + val lesson: Lesson, + val quiz: Quiz +) -// Refresh progress data -suspend fun refreshProgress() +data class Lesson(val title: String, val sections: List) -// Clear error message -fun clearError() -``` +data class LessonSection( + val heading: String, + val content: String, + val formula: String? = null, // rendered in a FormulaTile + val keyPoint: String? = null // rendered as a "KEY INSIGHT" callout +) -#### Example Usage -```kotlin -@Composable -fun HomeScreen(viewModel: HomeViewModel) { - val topics by viewModel.allTopics.collectAsState() - val progress by viewModel.userProgress.collectAsState() - - LazyColumn { - items(topics) { topic -> - TopicCard( - topic = topic, - progress = progress[topic.id], - onClick = { viewModel.selectTopic(topic.id) } - ) - } - } +data class Quiz(val title: String, val questions: List) + +sealed class Question { + abstract val id: String + abstract val questionText: String + abstract val explanation: String + abstract val points: Int // default 1 + + data class MultipleChoice( + /* id, questionText, */ val options: List, val correctIndex: Int, + /* explanation, points = 1 */ + ) : Question() + + data class NumericInput( + /* id, questionText, */ val correctAnswer: Double, + val tolerance: Double = 0.01, // absolute: |answer - correctAnswer| <= tolerance + val unit: String = "", // shown next to the input, e.g. "Ω" + /* explanation, points = 1 */ + ) : Question() } ``` ---- - -### QuizViewModel -Manages quiz state and scoring. +### `TopicsService` (`data/content/TopicsService.kt`) ```kotlin -@HiltViewModel -class QuizViewModel @Inject constructor( - private val repository: ProgressRepository, - private val topicsService: TopicsService, - savedStateHandle: SavedStateHandle -) : ViewModel() +object TopicsService { + val allTopics: List // all 42 topics, sorted by `order` +} ``` -#### Properties -```kotlin -// Current quiz questions -val currentQuiz: StateFlow - -// Current question index -val currentQuestionIndex: StateFlow +Look a topic up with `TopicsService.allTopics.find { it.id == topicId }`. -// User's selected answer for current question -val selectedAnswer: StateFlow +### `TopicCategories` (`data/content/TopicCategories.kt`) -// Total score so far -val currentScore: StateFlow - -// Quiz completion state -val isQuizComplete: StateFlow +```kotlin +data class TopicCategory(val name: String, val icon: String, val topicIds: List) -// Percentage of quiz completed -val progressPercentage: StateFlow +object TopicCategories { + val categories: List // 9 categories, in quest-map order + fun categoryFor(topicId: String): TopicCategory? +} ``` -#### Methods -```kotlin -// Start quiz for given topic -suspend fun startQuiz(topicId: String) +The categories, top to bottom, define the **unlock order**. Each topic's `order` must equal its +index in `categories.flatMap { it.topicIds }`; `HomeViewModelTest.unlockOrder_followsCategoryLayout` +fails otherwise. -// Select an answer for current question -fun selectAnswer(answer: String) +--- -// Move to next question -suspend fun nextQuestion() +## Progress rules — `QuizScoring` (`util/QuizScoring.kt`) -// Submit quiz and calculate score -suspend fun submitQuiz(): QuizResult +All scoring and XP rules live here; nothing else hard-codes them. -// Restart current quiz -suspend fun restartQuiz() -``` - -#### Example Usage ```kotlin -@Composable -fun QuizScreen(viewModel: QuizViewModel) { - val quiz by viewModel.currentQuiz.collectAsState() - val currentIndex by viewModel.currentQuestionIndex.collectAsState() - val selected by viewModel.selectedAnswer.collectAsState() - - val question = quiz.questions[currentIndex] - - Column { - Text(question.text) - question.options.forEach { option -> - Button( - onClick = { viewModel.selectAnswer(option) }, - selected = selected == option - ) { - Text(option) - } - } - } +object QuizScoring { + const val PASS_PERCENT = 60 // minimum % to pass and unlock the next topic + const val XP_PER_POINT = 10 + const val FIRST_PASS_BONUS = 100 + const val LESSON_XP = 50 + + fun checkAnswer(question: Question, answer: Any?): Boolean + fun percentage(score: Int, totalQuestions: Int): Int // 0 when totalQuestions == 0 + fun isPassing(score: Int, totalQuestions: Int): Boolean + fun calculateXp(score: Int, previousBest: Int, isFirstPass: Boolean): Int } ``` ---- - -### LessonViewModel -Manages lesson content display. +- `checkAnswer`: `MultipleChoice` expects an `Int` index; `NumericInput` accepts any `Number` + within `tolerance`. Anything else returns `false`. +- `calculateXp` pays only for points **above the previous best**, plus `FIRST_PASS_BONUS` on the + first passing attempt. Retakes at or below the best score earn 0, so a topic's lifetime quiz XP is + `best × 10 (+100 once passed)`. ```kotlin -@HiltViewModel -class LessonViewModel @Inject constructor( - private val repository: ProgressRepository, - private val topicsService: TopicsService, - savedStateHandle: SavedStateHandle -) : ViewModel() +QuizScoring.calculateXp(score = 7, previousBest = 0, isFirstPass = true) // 170 +QuizScoring.calculateXp(score = 8, previousBest = 7, isFirstPass = false) // 10 +QuizScoring.calculateXp(score = 7, previousBest = 7, isFirstPass = false) // 0 ``` -#### Properties -```kotlin -// Current lesson being displayed -val currentLesson: StateFlow - -// Lesson content sections -val sections: StateFlow> +--- -// Current section index -val currentSectionIndex: StateFlow +## Repository — `ProgressRepository` (`data/repository/ProgressRepository.kt`) -// Is lesson content loaded -val isLoaded: StateFlow -``` +The only class that writes progress. Hilt provides a singleton; inject it, don't construct it +(tests construct it directly with DAOs from an in-memory database). -#### Methods ```kotlin -// Load lesson for topic -suspend fun loadLesson(topicId: String) +class ProgressRepository(progressDao: ProgressDao, quizResultDao: QuizResultDao) { + fun getAllProgress(): Flow> + fun getProgress(topicId: String): Flow + fun getTotalXp(): Flow + fun getQuizResults(topicId: String): Flow> // newest first -// Move to next section -fun nextSection() + suspend fun markLessonCompleted(topicId: String) // +LESSON_XP the first time only + suspend fun recordQuizResult(topicId: String, score: Int, totalQuestions: Int): Int + suspend fun saveQuizResult(topicId: String, score: Int, totalQuestions: Int) // = recordQuizResult, ignoring the XP +} +``` -// Move to previous section -fun previousSection() +`recordQuizResult` inserts a `QuizResult`, then updates the topic's `TopicProgress`: +`bestScore = max(best, score)`; `quizCompleted` becomes `true` only on a passing attempt (and never +reverts); XP is added per `QuizScoring.calculateXp`. It **returns the XP awarded**, which the quiz +passes to the result screen. -// Start quiz for this lesson -suspend fun startQuiz() -``` +Writes are serialized with a `Mutex`, so concurrent updates to the same topic can't lose XP. --- -## Repository APIs +## Database -### ProgressRepository -Central data access point for user progress. +### `AppDatabase` (`data/db/AppDatabase.kt`) ```kotlin -@Inject -class ProgressRepository( - private val progressDao: ProgressDao, - private val quizResultDao: QuizResultDao -) +@Database(entities = [TopicProgress::class, QuizResult::class], version = 1, exportSchema = true) +abstract class AppDatabase : RoomDatabase() { + abstract fun progressDao(): ProgressDao + abstract fun quizResultDao(): QuizResultDao + companion object { const val DATABASE_NAME = "circuitqueest_db" } +} ``` -#### Methods -```kotlin -// Get progress for specific topic -suspend fun getProgress(topicId: String): Progress? - -// Get all user progress across topics -suspend fun getAllProgress(): List - -// Save quiz completion -suspend fun saveQuizResult(result: QuizResult) - -// Get all quiz attempts for topic -suspend fun getQuizResults(topicId: String): List +- **Never change `DATABASE_NAME`**: it would orphan every player's saved progress. +- Schemas are exported to `app/schemas/`. To change an entity, bump `version`, add a `Migration`, + and test it with `MigrationTestHelper` (`room-testing` is already an `androidTest` dependency). -// Update best score for topic -suspend fun updateBestScore(topicId: String, score: Int) +### Entities (`data/db/entity/`) -// Get total XP earned -suspend fun getTotalXP(): Int - -// Reset all progress (dangerous!) -suspend fun resetProgress() -``` - -#### Data Classes ```kotlin -data class Progress( - val topicId: String, // PK - val bestScore: Int, // 0-100 - val totalQuestions: Int, // Total questions attempted - val lastAttempt: Long = 0 // Timestamp of last quiz +@Entity(tableName = "topic_progress") +data class TopicProgress( + @PrimaryKey val topicId: String, + val lessonCompleted: Boolean = false, + val quizCompleted: Boolean = false, // true once any attempt passed — drives unlocking + val bestScore: Int = 0, + val totalQuestions: Int = 0, + val xpEarned: Int = 0, // lesson + quiz XP for this topic + val lastAccessedTimestamp: Long = 0L ) +@Entity(tableName = "quiz_results") data class QuizResult( - val id: Int = 0, // Auto-incremented PK - val topicId: String, // FK to topic - val score: Int, // 0-100 - val totalQuestions: Int, // Number of questions - val timestamp: Long // When quiz was completed + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val topicId: String, + val score: Int, + val totalQuestions: Int, + val timestamp: Long = System.currentTimeMillis() ) ``` -#### Example Usage -```kotlin -// In ViewModel -viewModelScope.launch { - val progress = repository.getProgress("ohms_law") - if (progress != null) { - updateUI(progress) - } -} - -// Save quiz results -val result = QuizResult( - topicId = "ohms_law", - score = 85, - totalQuestions = 10, - timestamp = System.currentTimeMillis() -) -repository.saveQuizResult(result) -``` - ---- - -## DAO APIs - -### ProgressDao -Direct database access for Progress entity. +### DAOs (`data/db/dao/`) ```kotlin -@Dao -interface ProgressDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertOrUpdate(progress: Progress) - - @Query("SELECT * FROM Progress WHERE topicId = :topicId") - suspend fun getProgress(topicId: String): Progress? - - @Query("SELECT * FROM Progress") - suspend fun getAllProgress(): List - - @Query("DELETE FROM Progress WHERE topicId = :topicId") - suspend fun deleteProgress(topicId: String) - - @Query("DELETE FROM Progress") - suspend fun deleteAll() +@Dao interface ProgressDao { + fun getProgress(topicId: String): Flow + suspend fun getProgressOnce(topicId: String): TopicProgress? + fun getAllProgress(): Flow> + @Insert(onConflict = REPLACE) suspend fun upsertProgress(progress: TopicProgress) + fun getTotalXp(): Flow // SUM(xpEarned), 0 when empty } -``` - ---- -### QuizResultDao -Direct database access for QuizResult entity. - -```kotlin -@Dao -interface QuizResultDao { - @Insert - suspend fun insert(result: QuizResult) - - @Query("SELECT * FROM QuizResult WHERE topicId = :topicId") - suspend fun getResults(topicId: String): List - - @Query("SELECT AVG(score) FROM QuizResult WHERE topicId = :topicId") - suspend fun getAverageScore(topicId: String): Int? - - @Query("DELETE FROM QuizResult WHERE topicId = :topicId") - suspend fun deleteResults(topicId: String) +@Dao interface QuizResultDao { + @Insert suspend fun insertResult(result: QuizResult) + fun getResultsForTopic(topicId: String): Flow> // ORDER BY timestamp DESC + fun getBestScore(topicId: String): Flow // MAX(score) } ``` --- -## Service APIs +## Dependency injection -### TopicsService -Provides access to all educational content. - -```kotlin -@Inject -class TopicsService { - // Get all topics sorted by order - fun getAllTopics(): List - - // Get specific topic - fun getTopic(topicId: String): Topic? - - // Get topics by category - fun getTopicsByCategory(category: String): List -} -``` +`CircuitQuestApplication` is annotated `@HiltAndroidApp`; `MainActivity` is `@AndroidEntryPoint`. +`data/di/RepositoryModule.kt` (installed in `SingletonComponent`) provides: -#### Topic Data Structure ```kotlin -data class Topic( - val id: String, // Unique identifier - val name: String, // Display name - val description: String, // Brief description - val icon: String, // Icon reference - val lessons: List, // Course material - val quiz: Quiz, // Quiz questions - val category: String // Topic category -) - -data class Lesson( - val title: String, - val sections: List
-) - -data class Section( - val title: String, - val content: String // HTML or markdown -) - -data class Quiz( - val questions: List -) - -data class Question( - val text: String, - val options: List, // Answer choices - val correctAnswer: String // For validation -) +@Provides @Singleton fun provideDatabase(@ApplicationContext context: Context): AppDatabase +@Provides @Singleton fun provideProgressRepository(database: AppDatabase): ProgressRepository ``` -#### Example Usage -```kotlin -val topicsService: TopicsService = get() // Via DI -val allTopics = topicsService.getAllTopics() -val topic = topicsService.getTopic("ohms_law") - -// Use in UI -Column { - allTopics.forEach { topic -> - Text(topic.name) - Text(topic.description) - } -} -``` +ViewModels are `@HiltViewModel` and obtained in composables with `hiltViewModel()`. --- -## Navigation APIs +## ViewModels (`viewmodel/`) -### NavGraph Routes -Type-safe navigation routes. +All state is exposed as `StateFlow`; screens read it with `collectAsStateWithLifecycle()`. +`LessonViewModel` and `QuizViewModel` read `topicId` from the navigation arguments via +`SavedStateHandle`. -```kotlin -// Home screen - no arguments -val HOME = "home" - -// Lesson screen - requires topicId -val LESSON = "lesson/{topicId}" - -// Quiz screen - requires topicId -val QUIZ = "quiz/{topicId}" - -// Result screen - topicId, score, question count, and XP awarded -val RESULT = "result/{topicId}/{score}/{total}/{xp}" -``` +### `HomeViewModel` -#### Navigation Example ```kotlin -val navController = rememberNavController() +data class TopicState(val topic: Topic, val progress: TopicProgress?, val isLocked: Boolean) +data class CategoryState(val category: TopicCategory, val topics: List) -Button(onClick = { - navController.navigate("lesson/ohms_law") -}) { - Text("Start Lesson") -} - -// In NavHost -composable("lesson/{topicId}") { backStackEntry -> - val topicId = backStackEntry.arguments?.getString("topicId") ?: "" - LessonScreen(topicId = topicId) -} +val topicStates: StateFlow> // in unlock order +val categorizedTopics: StateFlow> // grouped for the quest map +val totalXp: StateFlow ``` ---- - -## Utility Functions +Lock rule: the first topic is always open; any other topic is locked unless the previous topic's +quiz was passed, **or** the topic itself already has progress (so saves made under the pre-2.4 +unlock order are never re-locked). -### QuizScoring -```kotlin -object QuizScoring { - // Calculate score percentage - fun calculateScore(correctAnswers: Int, totalQuestions: Int): Int { - return if (totalQuestions > 0) { - (correctAnswers * 100) / totalQuestions - } else { - 0 - } - } - - // Calculate XP reward based on score - fun calculateXP(score: Int): Int { - return when { - score >= 90 -> 100 - score >= 80 -> 75 - score >= 70 -> 50 - score >= 60 -> 25 - else -> 10 - } - } -} -``` +### `LessonViewModel` -#### Example ```kotlin -val score = QuizScoring.calculateScore(8, 10) // Returns 80 -val xp = QuizScoring.calculateXP(score) // Returns 75 -``` - ---- - -## Composable Components - -### TopicCard -Reusable component for displaying topic in list. - -```kotlin -@Composable -fun TopicCard( - topic: Topic, - progress: Progress?, - onClick: () -> Unit, - modifier: Modifier = Modifier -) +val topic: StateFlow // null for an unknown id → LessonScreen shows QuestNotFound +val lessonCompleted: StateFlow +fun markLessonComplete() ``` -**Parameters:** -- `topic` - Topic to display -- `progress` - User progress for topic (nullable) -- `onClick` - Callback when tapped -- `modifier` - Compose modifier (optional) - ---- - -### QuestionCard -Display quiz question with answer options. +### `QuizViewModel` ```kotlin -@Composable -fun QuestionCard( - question: Question, - selectedAnswer: String?, - onAnswerSelected: (String) -> Unit, - modifier: Modifier = Modifier +data class QuizState( + val topicId: String = "", val quizTitle: String = "", + val currentIndex: Int = 0, val score: Int = 0, val totalQuestions: Int = 0, + val xpEarned: Int = 0 // set when the attempt is saved ) -``` +data class QuizFeedback(val isCorrect: Boolean, val explanation: String) ---- +val quizState: StateFlow +val currentQuestion: StateFlow +val feedback: StateFlow // non-null after answering, until nextQuestion() +val quizComplete: StateFlow // true after the attempt is saved -### ProgressBar -Display topic progress visualization. - -```kotlin -@Composable -fun ProgressBar( - progress: Progress, - modifier: Modifier = Modifier -) +fun answerMultipleChoice(selectedIndex: Int) +fun answerNumeric(value: Double?) // null (unparseable input) counts as wrong +fun nextQuestion() ``` -Shows: -- Best score percentage -- XP earned -- Quiz attempts count +- Answers are ignored once `feedback` is set; `nextQuestion()` is ignored until it is. +- On the last question, `nextQuestion()` saves via `ProgressRepository.recordQuizResult` exactly + once (repeat taps are ignored), stores the awarded XP in `quizState.xpEarned`, then sets + `quizComplete`. +- `totalQuestions == 0` (unknown topic) → `QuizScreen` shows `QuestNotFound`. --- -## Dependency Injection - -### Using Hilt -All dependencies are injected via Hilt. No manual factory needed. +## Navigation (`navigation/NavGraph.kt`) ```kotlin -@HiltViewModel -class MyViewModel @Inject constructor( - private val repository: ProgressRepository, - private val topicsService: TopicsService -) : ViewModel() -``` +object Routes { + const val HOME = "home" + const val LESSON = "lesson/{topicId}" + const val QUIZ = "quiz/{topicId}" + const val RESULT = "result/{topicId}/{score}/{total}/{xp}" -### Module Providers -```kotlin -@Module -@InstallIn(SingletonComponent::class) -object RepositoryModule { - @Provides - @Singleton - fun provideProgressRepository( - progressDao: ProgressDao, - quizResultDao: QuizResultDao - ): ProgressRepository = ProgressRepository(progressDao, quizResultDao) + fun lesson(topicId: String): String + fun quiz(topicId: String): String + fun result(topicId: String, score: Int, total: Int, xp: Int): String } -``` ---- +@Composable fun CircuitQueestNavGraph(onToggleBlueprint: () -> Unit = {}, blueprintMode: Boolean = false) +``` -## Database Access +Always build routes with the `Routes` helpers: -### Room Database ```kotlin -@Database( - entities = [TopicProgress::class, QuizResult::class], - version = 1, - exportSchema = true // JSON written to app/schemas/ -) -abstract class AppDatabase : RoomDatabase() { - abstract fun progressDao(): ProgressDao - abstract fun quizResultDao(): QuizResultDao - - companion object { - const val DATABASE_NAME = "circuitqueest_db" - } -} +navController.navigate(Routes.lesson("ohms_law")) +navController.navigate(Routes.result(topicId, score, total, xp)) { popUpTo(Routes.HOME) } ``` -### Creating Instance -Hilt provides the single instance (`data/di/RepositoryModule.kt`); inject `ProgressRepository` -rather than building the database yourself: -```kotlin -@Provides @Singleton -fun provideDatabase(@ApplicationContext context: Context): AppDatabase = - Room.databaseBuilder(context, AppDatabase::class.java, AppDatabase.DATABASE_NAME).build() -``` +The graph is wrapped in `SharedTransitionLayout`. Destinations expose +`LocalNavSharedTransitionScope` / `LocalNavAnimatedVisibilityScope` so `TopicCard` and the lesson +hero can share the bounds key `"topic_card_$topicId"`. --- -## Error Handling +## Screens (`ui/screens/`) -### Exception Types -```kotlin -// Thrown when topic not found -class TopicNotFoundException(topicId: String) : Exception() - -// Thrown when quiz validation fails -class QuizValidationException(message: String) : Exception() - -// Generic app exception -class CircuitQuestException(message: String) : Exception() -``` - -### Handling Errors -```kotlin -try { - val topic = topicsService.getTopic(topicId) -} catch (e: TopicNotFoundException) { - showError("Topic not found") -} catch (e: Exception) { - showError("An error occurred: ${e.message}") -} -``` +| Screen | Signature | Notes | +|---|---|---| +| `HomeScreen` | `(viewModel: HomeViewModel, onTopicClick: (String) -> Unit, onToggleBlueprint: () -> Unit = {}, blueprintMode: Boolean = false)` | Collapsible categories (expanded set survives rotation), search via `Topic.matchesSearch(query)`: title, subtitle, section headings, formulas | +| `LessonScreen` | `(viewModel: LessonViewModel, onBack: () -> Unit, onStartQuiz: (String) -> Unit)` | Sticky CTA fades in past the hero (or immediately if nothing scrolls); inset above the nav bar | +| `QuizScreen` | `(viewModel: QuizViewModel, onBack: () -> Unit, onQuizComplete: (topicId: String, score: Int, total: Int, xpEarned: Int) -> Unit)` | Confirms before leaving once an answer exists (close button and system back) | +| `ResultScreen` | `(topicId: String, score: Int, totalQuestions: Int, xpEarned: Int, onRetry: (String) -> Unit, onHome: () -> Unit, onNextLesson: ((String) -> Unit)? = null)` | Shows the XP passed in, never recomputes it; "Up next" only when passed | --- -## Best Practices - -1. **Use Hilt for DI** - Never instantiate services/repositories manually -2. **Use StateFlow** - For observable state in ViewModels -3. **Use Coroutines** - For async operations in viewModelScope -4. **Use Type-Safe Navigation** - Leverage NavGraph route definitions -5. **Handle Null Safety** - Always check nullable Progress/Topic values -6. **Test Your Code** - Write tests for repository and ViewModel changes +## Components (`ui/components/`) + +| Composable | Signature | +|---|---| +| `TopicCard` | `(topicId, topicNumber: Int, title, subtitle, topicIcon, isLocked, isCurrent, lessonCompleted, quizCompleted, quizScore: Int?, totalQuestions: Int?, accentColor: Color, onClick, modifier)`. Locked cards shake instead of calling `onClick` and expose a "Locked" state description | +| `CategoryHeader` | `(imageVector, name, completedCount, totalCount, categoryXp, accentColor, isExpanded, onClick, modifier)` | +| `XpBar` | `(totalXp: Int, modifier)`. Level = `totalXp / 500 + 1` | +| `StatusChip` | `(status: ChipStatus, label: String? = null, modifier)` with `ChipStatus { DONE, IN_PROGRESS, LOCKED }` | +| `TopicGlyphBadge` | `(imageVector: ImageVector? = null, label: String = "", accentColor = CqBlue, size = 40.dp, modifier)` | +| `FormulaTile` | `(formula: String, modifier)`. Gold-bracketed formula, used by lesson sections | +| `MultipleChoiceQuestion` | `(questionText, options, questionNumber, correctIndex, isSubmitted, onAnswer: (Int) -> Unit, modifier)` | +| `NumericInputQuestion` | `(questionText, unit, questionNumber, isSubmitted, onAnswer: (Double?) -> Unit, modifier)`. Parses with `parseNumericAnswer`, which accepts `,` as the decimal separator | +| `QuestionCard` | `(questionNumber, questionText, modifier)` | +| `AnswerFeedback` | `(isCorrect, explanation, onNext, modifier)` | +| `QuestNotFound` | `(onBack: () -> Unit)` | +| `XpProgressBar`, `QuizScoreDisplay`, `FormulaDisplay` | `(currentXp, label = "Total XP", modifier)`, `(score, total, modifier)`, `(formula, modifier)`. Legacy, not used by current screens (`FormulaDisplay` just wraps `FormulaTile`) | + +Modifiers (`ModifierExtensions.kt`): `Modifier.goldBrackets()` (public) and +`Modifier.dashedBorder(width, color, cornerRadius)` (internal). + +### Theming (`ui/theme/`) + +`CircuitQueestTheme(blueprintMode: Boolean = false, content)` provides the Material color scheme and +`LocalCqPalette`. Use `LocalCqPalette.current` (`bg`, `bg2`, `surface`, `surface2`, `border`, +`borderStrong`, `track`) for structural colors and the `Cq*` constants (`CqBlue`, `CqGold`, +`CqGreen`, `CqRed`, `CqText*`) for accents. Never hard-code colors; the PR template checks this. +Fonts: `SpaceGrotesk`, `JetBrainsMono`, `MonoLabel` (`Type.kt`); spacing and radii: `Spacing.s4…s64`, +`Radius.sm…xl`. --- ## Versioning -This API documentation matches CircuitQuEEst v1.0.0 - -For updates, check: -- [CHANGELOG.md](../CHANGELOG.md) -- GitHub releases - ---- - -**Related Documentation:** -- [ARCHITECTURE.md](ARCHITECTURE.md) - System design details -- [DEVELOPMENT.md](DEVELOPMENT.md) - Development setup -- [TESTING.md](TESTING.md) - Testing guide +This document tracks the code on `master`. When you change a public signature above, update it in +the same PR. From 7132d27158f68020ad8062722a50c2ca1cb87463 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 4 Oct 2026 13:38:01 -0700 Subject: [PATCH 3/3] test: make the instrumented suite pass on its first real run First emulator run: the new HomeScreen/QuizScreen/ResultScreen tests passed; 10 failures were all in inherited tests. - ComponentsTest was placeholders (component calls commented out, tests asserting empty roots or local literals). Replaced with real tests: TopicCard locked/unlocked, XpBar level text, AnswerFeedback, multiple choice submit gating, numeric comma input, StatusChip, QuestNotFound. - AppDatabaseIntegrationTest failed to instantiate: a `= runBlocking` test ended in kotlin.test.assertNotNull (returns a value), making the test method non-void. All runBlocking tests now declare `: Unit`. - QuizResultDao multipleInserts asserted insertion order, but results are ORDER BY timestamp DESC and same-millisecond inserts tie; compare sorted. - Docs/workflow comment: CI now runs instrumented tests, not just compiles. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 2 +- .../app/data/db/AppDatabaseIntegrationTest.kt | 12 +- .../data/db/ProgressDaoInstrumentedTest.kt | 16 +- .../data/db/QuizResultDaoInstrumentedTest.kt | 23 +- .../app/ui/components/ComponentsTest.kt | 282 ++++++------------ docs/DEPLOYMENT.md | 2 +- docs/TESTING.md | 3 +- 7 files changed, 120 insertions(+), 220 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0725390..a8379ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: - name: Build debug APK run: ./gradlew assembleDebug - # No emulator in CI, but compiling keeps the instrumented suite from rotting. + # Fast compile check; the `instrumented` job below runs them on an emulator. - name: Compile instrumented tests run: ./gradlew assembleDebugAndroidTest diff --git a/app/src/androidTest/java/com/circuitqueest/app/data/db/AppDatabaseIntegrationTest.kt b/app/src/androidTest/java/com/circuitqueest/app/data/db/AppDatabaseIntegrationTest.kt index be6f3ec..75d8b80 100644 --- a/app/src/androidTest/java/com/circuitqueest/app/data/db/AppDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/com/circuitqueest/app/data/db/AppDatabaseIntegrationTest.kt @@ -42,7 +42,7 @@ class AppDatabaseIntegrationTest { } @Test - fun completeWorkflow_saveQuizAndProgress() = runBlocking { + fun completeWorkflow_saveQuizAndProgress(): Unit = runBlocking { val topicId = "ohms-law" // Create initial progress @@ -87,7 +87,7 @@ class AppDatabaseIntegrationTest { } @Test - fun multipleTopics_isolation() = runBlocking { + fun multipleTopics_isolation(): Unit = runBlocking { // Insert data for two topics database.progressDao().upsertProgress(TopicProgress("topic1", xpEarned = 100)) database.progressDao().upsertProgress(TopicProgress("topic2", xpEarned = 200)) @@ -110,7 +110,7 @@ class AppDatabaseIntegrationTest { } @Test - fun totalXp_aggregation_acrossTopics() = runBlocking { + fun totalXp_aggregation_acrossTopics(): Unit = runBlocking { database.progressDao().upsertProgress(TopicProgress("topic1", xpEarned = 100)) database.progressDao().upsertProgress(TopicProgress("topic2", xpEarned = 150)) database.progressDao().upsertProgress(TopicProgress("topic3", xpEarned = 250)) @@ -121,7 +121,7 @@ class AppDatabaseIntegrationTest { } @Test - fun progressionFlow_unlocking() = runBlocking { + fun progressionFlow_unlocking(): Unit = runBlocking { val topic1 = "ohms-law" val topic2 = "series-parallel" @@ -152,7 +152,7 @@ class AppDatabaseIntegrationTest { } @Test - fun bestScoreTracking_workflow() = runBlocking { + fun bestScoreTracking_workflow(): Unit = runBlocking { val topicId = "test-topic" // First attempt @@ -175,7 +175,7 @@ class AppDatabaseIntegrationTest { } @Test - fun reactiveUpdates_progressFlow() = runBlocking { + fun reactiveUpdates_progressFlow(): Unit = runBlocking { val topicId = "reactive-test" // Insert initial diff --git a/app/src/androidTest/java/com/circuitqueest/app/data/db/ProgressDaoInstrumentedTest.kt b/app/src/androidTest/java/com/circuitqueest/app/data/db/ProgressDaoInstrumentedTest.kt index e165aa6..4ccacf3 100644 --- a/app/src/androidTest/java/com/circuitqueest/app/data/db/ProgressDaoInstrumentedTest.kt +++ b/app/src/androidTest/java/com/circuitqueest/app/data/db/ProgressDaoInstrumentedTest.kt @@ -37,7 +37,7 @@ class ProgressDaoInstrumentedTest { } @Test - fun insertAndRetrieve_success() = runBlocking { + fun insertAndRetrieve_success(): Unit = runBlocking { val progress = TopicProgress( topicId = "ohms-law", lessonCompleted = true, @@ -55,7 +55,7 @@ class ProgressDaoInstrumentedTest { } @Test - fun upsert_updates_existingProgress() = runBlocking { + fun upsert_updates_existingProgress(): Unit = runBlocking { val initial = TopicProgress( topicId = "series-parallel", lessonCompleted = false, @@ -81,7 +81,7 @@ class ProgressDaoInstrumentedTest { } @Test - fun getAllProgress_returnsAll() = runBlocking { + fun getAllProgress_returnsAll(): Unit = runBlocking { progressDao.upsertProgress(TopicProgress("topic1", lessonCompleted = true)) progressDao.upsertProgress(TopicProgress("topic2", lessonCompleted = false)) progressDao.upsertProgress(TopicProgress("topic3", lessonCompleted = true)) @@ -95,7 +95,7 @@ class ProgressDaoInstrumentedTest { } @Test - fun getTotalXp_aggregatesCorrectly() = runBlocking { + fun getTotalXp_aggregatesCorrectly(): Unit = runBlocking { progressDao.upsertProgress(TopicProgress("topic1", xpEarned = 100)) progressDao.upsertProgress(TopicProgress("topic2", xpEarned = 150)) progressDao.upsertProgress(TopicProgress("topic3", xpEarned = 250)) @@ -106,14 +106,14 @@ class ProgressDaoInstrumentedTest { } @Test - fun getTotalXp_emptyDatabase_returnsZero() = runBlocking { + fun getTotalXp_emptyDatabase_returnsZero(): Unit = runBlocking { val total = progressDao.getTotalXp().first() assertEquals(0, total) } @Test - fun flowReactivity_updatesOnChange() = runBlocking { + fun flowReactivity_updatesOnChange(): Unit = runBlocking { val topicId = "kirchhoffs" progressDao.upsertProgress(TopicProgress(topicId, xpEarned = 100)) @@ -127,7 +127,7 @@ class ProgressDaoInstrumentedTest { } @Test - fun multipleUpdates_bestScoreTracking() = runBlocking { + fun multipleUpdates_bestScoreTracking(): Unit = runBlocking { val topicId = "capacitors" progressDao.upsertProgress(TopicProgress(topicId, bestScore = 6, xpEarned = 100)) @@ -141,7 +141,7 @@ class ProgressDaoInstrumentedTest { } @Test - fun nonExistent_returnsNull() = runBlocking { + fun nonExistent_returnsNull(): Unit = runBlocking { val result = progressDao.getProgressOnce("non-existent-topic") assertNull(result) diff --git a/app/src/androidTest/java/com/circuitqueest/app/data/db/QuizResultDaoInstrumentedTest.kt b/app/src/androidTest/java/com/circuitqueest/app/data/db/QuizResultDaoInstrumentedTest.kt index 483aa76..df9493d 100644 --- a/app/src/androidTest/java/com/circuitqueest/app/data/db/QuizResultDaoInstrumentedTest.kt +++ b/app/src/androidTest/java/com/circuitqueest/app/data/db/QuizResultDaoInstrumentedTest.kt @@ -37,7 +37,7 @@ class QuizResultDaoInstrumentedTest { } @Test - fun insertResult_success() = runBlocking { + fun insertResult_success(): Unit = runBlocking { val result = QuizResult( topicId = "ohms-law", score = 9, @@ -53,21 +53,20 @@ class QuizResultDaoInstrumentedTest { } @Test - fun multipleInserts_allRetrieved() = runBlocking { + fun multipleInserts_allRetrieved(): Unit = runBlocking { quizResultDao.insertResult(QuizResult(topicId = "topic1", score = 8, totalQuestions = 10)) quizResultDao.insertResult(QuizResult(topicId = "topic1", score = 9, totalQuestions = 10)) quizResultDao.insertResult(QuizResult(topicId = "topic1", score = 7, totalQuestions = 10)) val results = quizResultDao.getResultsForTopic("topic1").first() + // Inserts in the same millisecond share a timestamp, so order is unspecified. assertEquals(3, results.size) - assertEquals(8, results[0].score) - assertEquals(9, results[1].score) - assertEquals(7, results[2].score) + assertEquals(listOf(7, 8, 9), results.map { it.score }.sorted()) } @Test - fun getResultsForTopic_orderedByTimestampDesc() = runBlocking { + fun getResultsForTopic_orderedByTimestampDesc(): Unit = runBlocking { val timestamp1 = System.currentTimeMillis() quizResultDao.insertResult(QuizResult(topicId = "topic1", score = 8, totalQuestions = 10, timestamp = timestamp1)) @@ -87,7 +86,7 @@ class QuizResultDaoInstrumentedTest { } @Test - fun getBestScore_returnsMax() = runBlocking { + fun getBestScore_returnsMax(): Unit = runBlocking { quizResultDao.insertResult(QuizResult(topicId = "topic1", score = 6, totalQuestions = 10)) quizResultDao.insertResult(QuizResult(topicId = "topic1", score = 9, totalQuestions = 10)) quizResultDao.insertResult(QuizResult(topicId = "topic1", score = 8, totalQuestions = 10)) @@ -98,14 +97,14 @@ class QuizResultDaoInstrumentedTest { } @Test - fun getBestScore_noResults_returnsNull() = runBlocking { + fun getBestScore_noResults_returnsNull(): Unit = runBlocking { val best = quizResultDao.getBestScore("non-existent").first() assertNull(best) } @Test - fun getResultsForTopic_differentTopics_isolated() = runBlocking { + fun getResultsForTopic_differentTopics_isolated(): Unit = runBlocking { quizResultDao.insertResult(QuizResult(topicId = "topic1", score = 8, totalQuestions = 10)) quizResultDao.insertResult(QuizResult(topicId = "topic1", score = 9, totalQuestions = 10)) quizResultDao.insertResult(QuizResult(topicId = "topic2", score = 7, totalQuestions = 10)) @@ -121,7 +120,7 @@ class QuizResultDaoInstrumentedTest { } @Test - fun autoIncrement_id_generated() = runBlocking { + fun autoIncrement_id_generated(): Unit = runBlocking { val result1 = QuizResult(topicId = "topic1", score = 8, totalQuestions = 10) val result2 = QuizResult(topicId = "topic1", score = 9, totalQuestions = 10) @@ -137,14 +136,14 @@ class QuizResultDaoInstrumentedTest { } @Test - fun getResults_emptyTopic_returnsEmpty() = runBlocking { + fun getResults_emptyTopic_returnsEmpty(): Unit = runBlocking { val results = quizResultDao.getResultsForTopic("empty-topic").first() assertEquals(0, results.size) } @Test - fun insertResult_preservesData_integrity() = runBlocking { + fun insertResult_preservesData_integrity(): Unit = runBlocking { val result = QuizResult( topicId = "test-topic", score = 95, diff --git a/app/src/androidTest/java/com/circuitqueest/app/ui/components/ComponentsTest.kt b/app/src/androidTest/java/com/circuitqueest/app/ui/components/ComponentsTest.kt index 9d2c17b..4e5bb5e 100644 --- a/app/src/androidTest/java/com/circuitqueest/app/ui/components/ComponentsTest.kt +++ b/app/src/androidTest/java/com/circuitqueest/app/ui/components/ComponentsTest.kt @@ -1,254 +1,154 @@ package com.circuitqueest.app.ui.components +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.circuitqueest.app.ui.theme.CqBlue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) -class ComposableComponentsTest { +class ComponentsTest { @get:Rule val composeTestRule = createComposeRule() - @Test - fun topicCard_renders() { - // Act - components render without crashing + private fun topicCard(isLocked: Boolean, onClick: () -> Unit = {}) { composeTestRule.setContent { - // TopicCard(topic = mockTopic, onClick = {}) + TopicCard( + topicId = "ohms_law", + topicNumber = 0, + title = "Ohm's Law", + subtitle = "The foundation of circuit analysis", + topicIcon = "⚡", + isLocked = isLocked, + isCurrent = !isLocked, + lessonCompleted = false, + quizCompleted = false, + quizScore = null, + totalQuestions = null, + accentColor = CqBlue, + onClick = onClick + ) } - - // Assert - composeTestRule.onRoot().assertIsDisplayed() } @Test - fun categoryHeader_displays() { - // Act - composeTestRule.setContent { - // CategoryHeader(category = "Basics") - } - - // Assert - composeTestRule.onRoot().assertIsDisplayed() - } + fun topicCard_unlocked_showsSubtitleAndClicks() { + var clicks = 0 + topicCard(isLocked = false, onClick = { clicks++ }) - @Test - fun xpProgressBar_shows() { - // Act - composeTestRule.setContent { - // XpProgressBar(currentXp = 250, maxXp = 1000) - } + composeTestRule.onNodeWithText("The foundation of circuit analysis").assertIsDisplayed() + composeTestRule.onNodeWithText("Ohm's Law").performClick() - // Assert - composeTestRule.onRoot().assertIsDisplayed() + composeTestRule.runOnIdle { assertEquals(1, clicks) } } @Test - fun answerFeedback_displays() { - // Act - composeTestRule.setContent { - // AnswerFeedback(isCorrect = true, message = "Correct!") - } + fun topicCard_locked_explainsAndIgnoresClicks() { + var clicks = 0 + topicCard(isLocked = true, onClick = { clicks++ }) + + composeTestRule.onNodeWithText("Pass the previous quest to unlock").assertIsDisplayed() + composeTestRule.onNodeWithText("Ohm's Law").performClick() - // Assert - composeTestRule.onRoot().assertIsDisplayed() + composeTestRule.runOnIdle { assertEquals(0, clicks) } } @Test - fun resultCard_shows() { - // Act - composeTestRule.setContent { - // ResultCard(score = 80, totalQuestions = 10) - } + fun xpBar_showsLevelAndXpToNext() { + composeTestRule.setContent { XpBar(totalXp = 600) } - // Assert - composeTestRule.onRoot().assertIsDisplayed() + composeTestRule.onNodeWithText("Level 2 · 600 XP").assertIsDisplayed() + composeTestRule.onNodeWithText("→ 400 to Level 3").assertIsDisplayed() } @Test - fun progressIndicator_renders() { - // Act + fun answerFeedback_correct_showsExplanationAndNext() { + var next = 0 composeTestRule.setContent { - // LinearProgressIndicator(progress = 0.8f) + AnswerFeedback(isCorrect = true, explanation = "V = IR", onNext = { next++ }) } - // Assert - composeTestRule.onRoot().assertIsDisplayed() - } - - @Test - fun xpProgressBar_correctCalculation() { - // Arrange - val currentXp = 500 - val maxXp = 1000 - val expectedProgress = 0.5f - - // Act & Assert - val progress = currentXp.toFloat() / maxXp - assert(progress == expectedProgress) - } - - @Test - fun components_handleNullData() { - // Arrange - components should handle null gracefully - val title: String? = null - - // Assert - assert(title == null || title.isEmpty()) - } - - @Test - fun xpProgressBar_boundaryValues() { - // Assert - test min/max boundaries - val minProgress = 0f - val maxProgress = 1f - - assert(minProgress >= 0f && maxProgress <= 1f) - } - - @Test - fun resultCard_perfectScoreDisplay() { - // Arrange - val score = 100 - val total = 100 - - // Act - val percentage = (score * 100) / total + composeTestRule.onNodeWithText("Circuit closed!").assertIsDisplayed() + composeTestRule.onNodeWithText("V = IR").assertIsDisplayed() + composeTestRule.onNodeWithText("Next →").performClick() - // Assert - assert(percentage == 100) + composeTestRule.runOnIdle { assertEquals(1, next) } } @Test - fun resultCard_zeroScoreDisplay() { - // Arrange - val score = 0 - val total = 100 - - // Act - val percentage = (score * 100) / total - - // Assert - assert(percentage == 0) - } - - @Test - fun answerFeedback_correctState() { - // Arrange - val isCorrect = true - val message = "Well done!" - - // Assert - assert(isCorrect) - assert(message.isNotEmpty()) - } - - @Test - fun answerFeedback_incorrectState() { - // Arrange - val isCorrect = false - val message = "Try again" - - // Assert - assert(!isCorrect) - assert(message.isNotEmpty()) - } -} - -@RunWith(AndroidJUnit4::class) -class CustomComponentsTest { - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun multipleChoiceQuestion_renders() { - // Act + fun answerFeedback_incorrect_showsOpenCircuit() { composeTestRule.setContent { - // MultipleChoiceQuestion(question = mockQuestion, onAnswer = {}) + AnswerFeedback(isCorrect = false, explanation = "Try again", onNext = {}) } - // Assert - composeTestRule.onRoot().assertIsDisplayed() + composeTestRule.onNodeWithText("Open circuit").assertIsDisplayed() } @Test - fun numericInputQuestion_renders() { - // Act + fun multipleChoice_submitEnabledOnlyAfterSelection() { + var answered: Int? = null composeTestRule.setContent { - // NumericInputQuestion(question = mockQuestion, onAnswer = {}) + MultipleChoiceQuestion( + questionText = "What does Ohm's Law state?", + options = listOf("V = I × R", "V = I / R"), + questionNumber = 1, + correctIndex = 0, + isSubmitted = false, + onAnswer = { answered = it } + ) } - // Assert - composeTestRule.onRoot().assertIsDisplayed() - } - - @Test - fun progressDisplay_updates() { - // Arrange - val progress = 0.75f - - // Assert - assert(progress in 0f..1f) - } + composeTestRule.onNodeWithText("Submit Answer").assertIsNotEnabled() + composeTestRule.onNodeWithText("V = I / R").performClick() + composeTestRule.onNodeWithText("Submit Answer").assertIsEnabled().performClick() - @Test - fun customButton_clickable() { - // Act - button should be interactive - var clicked = false - val onClick: () -> Unit = { clicked = true } - - // Assert - onClick() - assert(clicked) + composeTestRule.runOnIdle { assertEquals(1, answered) } } @Test - fun animatedFeedback_triggers() { - // Arrange - var animated = false - - // Act - simulate animation trigger - animated = true + fun numericInput_acceptsCommaDecimal() { + var answered: Double? = null + composeTestRule.setContent { + NumericInputQuestion( + questionText = "Current in amps?", + unit = "A", + questionNumber = 7, + isSubmitted = false, + onAnswer = { answered = it } + ) + } - // Assert - assert(animated) - } + composeTestRule.onNodeWithText("Submit Answer").assertIsNotEnabled() + composeTestRule.onNode(hasSetTextAction()).performTextInput("0,5") + composeTestRule.onNodeWithText("Submit Answer").performClick() - @Test - fun scoreDisplay_formatting() { - // Arrange - val score = 8 - val total = 10 - val display = "$score / $total" - - // Assert - assertEquals("8 / 10", display) + composeTestRule.runOnIdle { assertEquals(0.5, answered) } } @Test - fun customTextField_input() { - // Arrange - var inputValue = "" + fun statusChip_lockedDefaultLabel() { + composeTestRule.setContent { StatusChip(status = ChipStatus.LOCKED) } - // Act - inputValue = "42" - - // Assert - assertEquals("42", inputValue) + composeTestRule.onNodeWithText("⌗ Locked").assertIsDisplayed() } @Test - fun componentTheme_applies() { - // Assert - theme should apply correctly - val themeColor = "#6200EE" - assert(themeColor.isNotEmpty()) - } + fun questNotFound_backInvokesCallback() { + var back = 0 + composeTestRule.setContent { QuestNotFound(onBack = { back++ }) } + + composeTestRule.onNodeWithText("Quest not found").assertIsDisplayed() + composeTestRule.onNodeWithText("Back").performClick() - private fun assertEquals(expected: String, actual: String) { - assert(expected == actual) + composeTestRule.runOnIdle { assertEquals(1, back) } } } diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 6c808ab..07371c5 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -184,7 +184,7 @@ If issues detected post-release: ## CI/CD Integration ### GitHub Actions Workflows -- **build.yml** - Push/PR to `master`: unit tests, debug APK, compiles instrumented tests +- **build.yml** - Push/PR to `master`: unit tests, debug APK, compiles instrumented tests; `instrumented` job runs them on an API 34 emulator (report uploaded as an artifact) - **quality.yml** - Push/PR to `master`: detekt ### Manual Release Steps diff --git a/docs/TESTING.md b/docs/TESTING.md index 6d702a5..8d2514e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -96,7 +96,8 @@ class ProgressDaoInstrumentedTest { ```bash ./gradlew test # Unit tests only ./gradlew connectedAndroidTest # Instrumented tests (requires device/emulator; uninstalls the app afterwards — use an emulator, not a phone with real progress) -./gradlew test connectedAndroidTest # Both (recommended for CI/CD) +./gradlew test connectedAndroidTest # Both +# CI runs connectedDebugAndroidTest on an API 34 emulator (build.yml, `instrumented` job) ``` ### Specific Tests