feat(testing): add core:testing module with fakes, fixtures, and TestTags (#3059)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Rajan Maurya 2026-01-06 12:13:35 +05:30 committed by GitHub
parent cd556ffc99
commit b351148ac2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 13192 additions and 964 deletions

View File

@ -47,6 +47,13 @@ GAP PLANNING (What needs work?)
│ └── /gap-planning platform web │ └── /gap-planning platform web
└── /gap-planning [feature] # Plan specific feature (all layers) └── /gap-planning [feature] # Plan specific feature (all layers)
GAP STATUS (Track plan progress)
├── /gap-status # Show all active plans
├── /gap-status [plan-name] # Show specific plan progress
├── /gap-status complete [plan] # Mark plan as complete
├── /gap-status pause [plan] # Pause a plan
└── /gap-status resume [plan] # Resume a paused plan
DESIGN LAYER (Specifications & Mockups) DESIGN LAYER (Specifications & Mockups)
├── /design # Show feature list ├── /design # Show feature list
├── /design [feature] # Full spec review/create ├── /design [feature] # Full spec review/create
@ -94,6 +101,16 @@ VERIFICATION
| `/gap-planning client network` | Network services plan | Service implementation tasks | | `/gap-planning client network` | Network services plan | Service implementation tasks |
| `/gap-planning feature [name]` | Feature implementation plan | v2.0 UI update tasks | | `/gap-planning feature [name]` | Feature implementation plan | v2.0 UI update tasks |
### Gap Status
| Command | Purpose | Output |
|---------|---------|--------|
| `/gap-status` | Show all active plans | Summary table with progress bars |
| `/gap-status [plan]` | Show specific plan | Detailed steps, current step, progress log |
| `/gap-status complete [plan]` | Mark plan done | Move to completed, update index |
| `/gap-status pause [plan]` | Pause a plan | Mark as paused with reason |
| `/gap-status resume [plan]` | Resume plan | Mark as active again |
### Design Layer ### Design Layer
| Command | Purpose | Output | | Command | Purpose | Output |

View File

@ -1,131 +1,351 @@
# /client - Client Layer Implementation # /client - Client Layer Implementation
## Purpose ## Purpose
Implement the client layer (Network + Data) for a feature. This includes DTOs, Services, and Repositories.
Implement the client layer (Network + Data) using O(1) lookup and pattern detection. Creates Services, Repositories, and DI registration with code matching existing codebase conventions.
--- ---
## Workflow ## Command Variants
``` ```
┌───────────────────────────────────────────────────────────────────┐ /client # Show client layer status
│ /client [Feature] WORKFLOW │ /client [Feature] # Implement client layer for feature
├───────────────────────────────────────────────────────────────────┤ /client [Feature] --network # Network layer only (Service)
│ │ /client [Feature] --data # Data layer only (Repository)
│ STEP 1: READ SPEC │
│ ├─→ Read features/[feature]/SPEC.md │
│ ├─→ Read features/[feature]/API.md │
│ └─→ Read server-layer/FINERACT_API.md │
│ │
│ STEP 2: CHECK EXISTING CODE │
│ ├─→ Check core/network/services/ for existing service │
│ ├─→ Check core/data/repository/ for existing repository │
│ └─→ Identify what needs to be created/updated │
│ │
│ STEP 3: NETWORK LAYER │
│ ├─→ Create/update DTOs in core/network/model/ (if needed) │
│ ├─→ Create/update Service interface in core/network/services/ │
│ └─→ Register in NetworkModule │
│ │
│ STEP 4: DATA LAYER │
│ ├─→ Create/update Repository interface │
│ ├─→ Create/update RepositoryImpl │
│ └─→ Register in DataModule │
│ │
│ STEP 5: BUILD & VERIFY │
│ ├─→ ./gradlew :core:network:build │
│ ├─→ ./gradlew :core:data:build │
│ └─→ ./gradlew spotlessApply │
│ │
└───────────────────────────────────────────────────────────────────┘
``` ```
--- ---
## File Locations ## Workflow with O(1) Optimization
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ /client [Feature] - O(1) OPTIMIZED WORKFLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PHASE 0: O(1) CONTEXT LOADING │
│ ├─→ Read FEATURE_MAP.md → Check if service/repo exist │
│ ├─→ Read API_INDEX.md → Get endpoint definitions │
│ ├─→ Read features/[name]/API.md → Get feature-specific endpoints │
│ └─→ Read features/[name]/SPEC.md → Get data requirements │
│ │
│ PHASE 1: PATTERN DETECTION │
│ ├─→ Read existing Service → Extract interface pattern │
│ ├─→ Read existing Repository → Extract implementation pattern │
│ └─→ Read NetworkModule/DataModule → Extract DI pattern │
│ │
│ PHASE 2: NETWORK LAYER (if needed) │
│ ├─→ Check FEATURE_MAP for existing → Skip if exists │
│ ├─→ Create Service interface → Pattern-matched code │
│ └─→ Register in NetworkModule → DI registration │
│ │
│ PHASE 3: DATA LAYER (if needed) │
│ ├─→ Check FEATURE_MAP for existing → Skip if exists │
│ ├─→ Create Repository interface → Pattern-matched code │
│ ├─→ Create RepositoryImpl → Pattern-matched code │
│ └─→ Register in RepositoryModule → DI registration │
│ │
│ PHASE 4: BUILD & VERIFY │
│ ├─→ ./gradlew :core:network:build │
│ ├─→ ./gradlew :core:data:build │
│ ├─→ ./gradlew spotlessApply │
│ └─→ Update FEATURE_MAP.md → Maintain O(1) index │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## PHASE 0: O(1) Context Loading
### Files to Read
| File | Purpose | Data Extracted |
|------|---------|----------------|
| `client-layer/FEATURE_MAP.md` | Service/Repo inventory | existingServices[], existingRepos[] |
| `server-layer/API_INDEX.md` | All API endpoints | endpoints[], dtos[] |
| `design-spec-layer/features/[name]/API.md` | Feature endpoints | featureEndpoints[] |
| `design-spec-layer/features/[name]/SPEC.md` | Data requirements | models[], fields[] |
### Decision Matrix (from FEATURE_MAP.md lookup)
```markdown
| Component | Exists | Action |
|-----------|:------:|--------|
| ${Feature}Service | ✅/❌ | SKIP/CREATE |
| ${Feature}Repository | ✅/❌ | SKIP/CREATE |
```
---
## PHASE 1: Pattern Detection
### Reference Files
```
1. Service Reference:
core/network/src/commonMain/.../services/BeneficiaryService.kt
2. Repository Reference:
core/data/src/commonMain/.../repository/BeneficiaryRepository.kt
core/data/src/commonMain/.../repository/BeneficiaryRepositoryImp.kt
3. DI Reference:
core/network/src/commonMain/.../di/NetworkModule.kt
core/data/src/commonMain/.../di/RepositoryModule.kt
```
### Extracted Patterns
```kotlin
// Service Pattern
val servicePattern = ServicePattern(
returnFlow = "Flow<Type>", // GET returns Flow
returnSuspend = "HttpResponse", // POST/PUT/DELETE returns HttpResponse
pathAnnotation = "@Path(\"id\")",
bodyAnnotation = "@Body",
endpointConstant = "ApiEndPoints.CONSTANT"
)
// Repository Pattern
val repoPattern = RepositoryPattern(
interfaceReturn = "Flow<DataState<T>>",
implUsesFlow = "= flow { emit(...) }",
loadingEmit = "emit(DataState.Loading)",
successEmit = "emit(DataState.Success(data))",
errorEmit = "emit(DataState.Error(e.message ?: \"Unknown error\"))"
)
// DI Pattern
val diPattern = DiPattern(
serviceDeclaration = "single<Service> { get<Ktorfit>().create<Service>() }",
repoDeclaration = "single<Repository> { RepositoryImp(get()) }"
)
```
---
## PHASE 2: Network Layer
### File Locations
| Component | Location | | Component | Location |
|-----------|----------| |-----------|----------|
| DTOs | `core/network/model/` | | Service Interface | `core/network/src/commonMain/kotlin/org/mifos/mobile/core/network/services/` |
| Service Interface | `core/network/services/` | | API Endpoints | `core/network/src/commonMain/kotlin/org/mifos/mobile/core/network/ApiEndPoints.kt` |
| Repository Interface | `core/data/repository/` | | Network DI | `core/network/src/commonMain/kotlin/org/mifos/mobile/core/network/di/NetworkModule.kt` |
| Repository Impl | `core/data/repositoryImpl/` |
| Network DI | `core/network/di/NetworkModule.kt` |
| Data DI | `core/data/di/DataModule.kt` |
--- ### Service Template (Pattern-Matched)
## Service Pattern
```kotlin ```kotlin
// core/network/services/[Feature]Service.kt /*
interface [Feature]Service { * Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package org.mifos.mobile.core.network.services
@GET(ApiEndPoints.[ENDPOINT]) import de.jensklingenberg.ktorfit.http.Body
fun getData(): Flow<DataType> import de.jensklingenberg.ktorfit.http.DELETE
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.POST
import de.jensklingenberg.ktorfit.http.PUT
import de.jensklingenberg.ktorfit.http.Path
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.flow.Flow
import org.mifos.mobile.core.network.ApiEndPoints
import org.mifos.mobile.core.network.model.${Dto}
@GET(ApiEndPoints.[ENDPOINT] + "/{id}") interface ${Feature}Service {
fun getById(@Path("id") id: Long): Flow<DataType>
@POST(ApiEndPoints.[ENDPOINT]) @GET(ApiEndPoints.${ENDPOINT_CONSTANT})
suspend fun create(@Body payload: PayloadType): HttpResponse fun get${Feature}List(): Flow<List<${Dto}>>
@PUT(ApiEndPoints.[ENDPOINT] + "/{id}") @GET(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}")
suspend fun update( fun get${Feature}ById(@Path("id") id: Long): Flow<${Dto}>
@POST(ApiEndPoints.${ENDPOINT_CONSTANT})
suspend fun create${Feature}(@Body payload: ${Payload}): HttpResponse
@PUT(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}")
suspend fun update${Feature}(
@Path("id") id: Long, @Path("id") id: Long,
@Body payload: PayloadType, @Body payload: ${Payload},
): HttpResponse ): HttpResponse
@DELETE(ApiEndPoints.[ENDPOINT] + "/{id}") @DELETE(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}")
suspend fun delete(@Path("id") id: Long): HttpResponse suspend fun delete${Feature}(@Path("id") id: Long): HttpResponse
}
```
### Add Endpoint Constant (if needed)
```kotlin
// ApiEndPoints.kt
object ApiEndPoints {
// ... existing constants
const val ${ENDPOINT_CONSTANT} = "${endpoint_path}"
}
```
### Register in NetworkModule
```kotlin
// NetworkModule.kt
val networkModule = module {
// ... existing registrations
single<${Feature}Service> { get<Ktorfit>().create<${Feature}Service>() }
} }
``` ```
--- ---
## Repository Pattern ## PHASE 3: Data Layer
### File Locations
| Component | Location |
|-----------|----------|
| Repository Interface | `core/data/src/commonMain/kotlin/org/mifos/mobile/core/data/repository/` |
| Repository Impl | `core/data/src/commonMain/kotlin/org/mifos/mobile/core/data/repository/` |
| Data DI | `core/data/src/commonMain/kotlin/org/mifos/mobile/core/data/di/RepositoryModule.kt` |
### Repository Interface Template
```kotlin ```kotlin
// core/data/repository/[Feature]Repository.kt /*
interface [Feature]Repository { * Copyright 2024 Mifos Initiative
fun getData(): Flow<DataState<List<Data>>> *
fun getById(id: Long): Flow<DataState<Data>> * This Source Code Form is subject to the terms of the Mozilla Public
suspend fun create(data: Data): DataState<Unit> * License, v. 2.0. If a copy of the MPL was not distributed with this
suspend fun update(id: Long, data: Data): DataState<Unit> * file, You can obtain one at https://mozilla.org/MPL/2.0/.
suspend fun delete(id: Long): DataState<Unit> */
package org.mifos.mobile.core.data.repository
import kotlinx.coroutines.flow.Flow
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.model.${Model}
interface ${Feature}Repository {
fun get${Feature}List(): Flow<DataState<List<${Model}>>>
fun get${Feature}ById(id: Long): Flow<DataState<${Model}>>
suspend fun create${Feature}(data: ${Model}): DataState<Unit>
suspend fun update${Feature}(id: Long, data: ${Model}): DataState<Unit>
suspend fun delete${Feature}(id: Long): DataState<Unit>
} }
```
// core/data/repositoryImpl/[Feature]RepositoryImpl.kt ### Repository Implementation Template
class [Feature]RepositoryImpl(
private val service: [Feature]Service,
) : [Feature]Repository {
override fun getData(): Flow<DataState<List<Data>>> = flow { ```kotlin
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package org.mifos.mobile.core.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.model.${Model}
import org.mifos.mobile.core.network.services.${Feature}Service
class ${Feature}RepositoryImp(
private val ${feature}Service: ${Feature}Service,
) : ${Feature}Repository {
override fun get${Feature}List(): Flow<DataState<List<${Model}>>> = flow {
emit(DataState.Loading) emit(DataState.Loading)
try { try {
val result = service.getData().first() val result = ${feature}Service.get${Feature}List().first()
emit(DataState.Success(result)) emit(DataState.Success(result))
} catch (e: Exception) { } catch (e: Exception) {
emit(DataState.Error(e.message ?: "Unknown error")) emit(DataState.Error(e.message ?: "Unknown error"))
} }
} }
override fun get${Feature}ById(id: Long): Flow<DataState<${Model}>> = flow {
emit(DataState.Loading)
try {
val result = ${feature}Service.get${Feature}ById(id).first()
emit(DataState.Success(result))
} catch (e: Exception) {
emit(DataState.Error(e.message ?: "Unknown error"))
}
}
override suspend fun create${Feature}(data: ${Model}): DataState<Unit> {
return try {
${feature}Service.create${Feature}(data.toPayload())
DataState.Success(Unit)
} catch (e: Exception) {
DataState.Error(e.message ?: "Unknown error")
}
}
override suspend fun update${Feature}(id: Long, data: ${Model}): DataState<Unit> {
return try {
${feature}Service.update${Feature}(id, data.toPayload())
DataState.Success(Unit)
} catch (e: Exception) {
DataState.Error(e.message ?: "Unknown error")
}
}
override suspend fun delete${Feature}(id: Long): DataState<Unit> {
return try {
${feature}Service.delete${Feature}(id)
DataState.Success(Unit)
} catch (e: Exception) {
DataState.Error(e.message ?: "Unknown error")
}
}
}
```
### Register in RepositoryModule
```kotlin
// RepositoryModule.kt
val repositoryModule = module {
// ... existing registrations
single<${Feature}Repository> { ${Feature}RepositoryImp(get()) }
} }
``` ```
--- ---
## DI Registration ## PHASE 4: Build & Verify
```kotlin ### Build Commands
// core/network/di/NetworkModule.kt
val networkModule = module {
single<[Feature]Service> { get<Ktorfit>().create<[Feature]Service>() }
}
// core/data/di/DataModule.kt ```bash
val dataModule = module { # Build network module
single<[Feature]Repository> { [Feature]RepositoryImpl(get()) } ./gradlew :core:network:build
}
# Build data module
./gradlew :core:data:build
# Format code
./gradlew spotlessApply --no-configuration-cache
# Run detekt
./gradlew detekt
```
### Update FEATURE_MAP.md
Add new entry to maintain O(1) lookup:
```markdown
| ${feature} | ${Feature}Service | ${Feature}Repository | ${Notes} |
``` ```
--- ---
@ -133,24 +353,150 @@ val dataModule = module {
## Output Template ## Output Template
``` ```
┌──────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│ ✅ CLIENT LAYER COMPLETE │ │ ✅ CLIENT LAYER COMPLETE │
├──────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────────────┤
│ │ │ │
│ Created/Updated: │ │ 📚 O(1) Context Used: │
│ ├─ core/network/services/[Feature]Service.kt │ │ ├─ FEATURE_MAP.md → Checked existing: [existing services/repos] │
│ ├─ core/data/repository/[Feature]Repository.kt │ │ ├─ API_INDEX.md → Mapped [n] endpoints │
│ └─ core/data/repositoryImpl/[Feature]RepositoryImpl.kt │ │ └─ API.md → Feature endpoints: [list] │
│ │ │ │
│ Registered in DI: │ │ 📊 Pattern Matching: │
│ ├─ NetworkModule: [Feature]Service ✅ │ │ ├─ Service pattern from: BeneficiaryService.kt │
│ └─ DataModule: [Feature]Repository ✅ │ │ └─ Repository pattern from: BeneficiaryRepositoryImp.kt │
│ │ │ │
│ 🔨 BUILD: :core:network ✅ :core:data ✅ │ │ 🔧 Network Layer: │
│ 🧹 LINT: spotlessApply ✅ │ │ ├─ ${Feature}Service.kt [CREATED|SKIPPED] │
│ │ │ ├─ ApiEndPoints.${CONSTANT} [ADDED|EXISTS] │
├──────────────────────────────────────────────────────────────────────┤ │ └─ NetworkModule registration [ADDED|EXISTS] │
│ NEXT STEP: │ │ │
│ Run: /feature [Feature] │ │ 🔧 Data Layer: │
└──────────────────────────────────────────────────────────────────────┘ │ ├─ ${Feature}Repository.kt [CREATED|SKIPPED] │
│ ├─ ${Feature}RepositoryImp.kt [CREATED|SKIPPED] │
│ └─ RepositoryModule registration [ADDED|EXISTS] │
│ │
│ 📋 Index Updated: │
│ └─ FEATURE_MAP.md [UPDATED] │
│ │
│ 🔨 BUILD: │
│ ├─ :core:network ✅ │
│ └─ :core:data ✅ │
│ │
│ 🧹 LINT: spotlessApply ✅ │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ NEXT STEP: │
│ Run: /feature ${Feature} │
└──────────────────────────────────────────────────────────────────────────────┘
``` ```
---
## Client Status (No Argument)
When `/client` called without arguments, read FEATURE_MAP.md:
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ 📋 CLIENT LAYER STATUS (from FEATURE_MAP.md) │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ Summary: 13 services | 17 repositories | 2 DI modules │
│ │
│ | Feature | Service | Repository | Status │ │
│ |-----------------|-------------------|--------------------|-----------│ │
│ | auth | AuthenticationSvc | UserAuthRepository | ✅ Complete│ │
│ | home | ClientService | HomeRepository | ✅ Complete│ │
│ | accounts | ClientService | AccountsRepository | ✅ Complete│ │
│ | beneficiary | BeneficiaryService| BeneficiaryRepo | ✅ Complete│ │
│ | ... │
│ │
│ Commands: │
│ • /client [feature] → Implement client layer │
│ • /gap-analysis client → Check for gaps │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## Error Handling
### Missing API Endpoint
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ⚠️ MISSING API ENDPOINT │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ Feature: ${feature} │
│ Expected: API.md with endpoint definitions │
│ Found: File missing or empty │
│ │
│ Options: │
│ • d / design → Run /design ${feature} api first │
│ • m / manual → Enter endpoints manually │
│ • a / abort → Cancel implementation │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Build Failure
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ❌ BUILD FAILED: :core:network │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ Error: Unresolved reference: ${Dto} │
│ │
│ 📍 Auto-Fix Suggestion: │
│ Create DTO in core/network/model/: │
│ │
│ ```kotlin │
@Serializable
│ data class ${Dto}( │
│ val id: Long, │
│ // ... fields from API.md │
│ ) │
│ ``` │
│ │
│ Options: │
│ • f / fix → Create DTO and rebuild │
│ • m / manual → Show full DTO template │
│ • a / abort → Stop implementation │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## Related Files
### O(1) Index Files
| File | Purpose |
|------|---------|
| `client-layer/FEATURE_MAP.md` | Service/Repository inventory |
| `server-layer/API_INDEX.md` | All API endpoints |
| `client-layer/LAYER_STATUS.md` | Implementation status |
### Reference Code
| Component | Reference File |
|-----------|----------------|
| Service | `BeneficiaryService.kt` |
| Repository | `BeneficiaryRepositoryImp.kt` |
| DI | `NetworkModule.kt`, `RepositoryModule.kt` |
---
## Related Commands
| Command | Purpose |
|---------|---------|
| `/feature [Feature]` | Feature layer (ViewModel + Screen) |
| `/implement [Feature]` | Full E2E (Client + Feature) |
| `/gap-analysis client` | Check client layer gaps |
| `/verify [Feature]` | Verify implementation |

View File

@ -1,4 +1,4 @@
# /design - Feature Specification # /design - Feature Specification (O(1) Enhanced)
## Purpose ## Purpose
Create or update feature specifications (SPEC.md + API.md) that define what to build and how to build it. Create or update feature specifications (SPEC.md + API.md) that define what to build and how to build it.
@ -8,52 +8,196 @@ Create or update feature specifications (SPEC.md + API.md) that define what to b
## Command Variants ## Command Variants
``` ```
/design → Show feature list /design # Show feature list with status (O(1)
/design [Feature] Full spec review/create /design [Feature] # Full spec review/create
/design [Feature] add [section] Add specific section /design [Feature] add [section] # Add specific section
/design [Feature] improve Suggest improvements /design [Feature] improve # Suggest improvements
/design [Feature] mockup → Generate Figma mockups for feature (NEW) /design [Feature] mockup # Generate Figma mockups for feature
/design mockup → Generate Figma mockups for all features (NEW) /design mockup # Generate Figma mockups for all features
``` ```
--- ---
## Mockup Sub-Command ## O(1) Workflow
```
+-------------------------------------------------------------------------+
| /design WORKFLOW (O(1) ENHANCED) |
+-------------------------------------------------------------------------+
| |
| PHASE 0: O(1) CONTEXT LOADING (~300 lines total) |
| +--> Read FEATURES_INDEX.md --> Feature exists? SPEC/API status? |
| +--> Read MOCKUPS_INDEX.md --> Mockup status (4 file types) |
| +--> Read API_INDEX.md --> All endpoints for reference |
| +--> O(1) path: features/[name]/ --> Direct file access |
| |
| PHASE 1: FEATURE STATUS (From Index) |
| +--> Check if feature exists in FEATURES_INDEX |
| +--> Get SPEC/API/STATUS/Mockups status from index |
| +--> Determine: Create new vs Update existing |
| |
| PHASE 2: GATHER CONTEXT (O(1) Paths) |
| +--> Read features/[feature]/SPEC.md (if exists) |
| +--> Read features/[feature]/API.md (if exists) |
| +--> Read features/[feature]/STATUS.md (if exists) |
| +--> Lookup API endpoints from API_INDEX.md |
| |
| PHASE 3: ANALYZE & UPDATE |
| +--> Compare current spec vs requirements |
| +--> Identify gaps, outdated sections |
| +--> Update/create spec files |
| |
| PHASE 4: INDEX UPDATE (Mandatory) |
| +--> Update FEATURES_INDEX.md (if new feature) |
| +--> Update STATUS.md (layer status) |
| +--> Update feature STATUS.md |
| |
+-------------------------------------------------------------------------+
```
---
## Phase 0: O(1) Context Loading
### Index Files to Read
| File | Purpose | Lines |
|------|---------|:-----:|
| `design-spec-layer/FEATURES_INDEX.md` | All features + SPEC/API status | ~120 |
| `design-spec-layer/MOCKUPS_INDEX.md` | Mockup completion matrix | ~150 |
| `server-layer/API_INDEX.md` | All API endpoints | ~400 |
### O(1) Path Pattern
```
features/[name]/SPEC.md # Specification
features/[name]/API.md # API requirements
features/[name]/STATUS.md # Feature status
features/[name]/MOCKUP.md # v2.0 ASCII mockup
features/[name]/mockups/ # Generated mockup files
```
---
## If No Feature Name Provided
Read from FEATURES_INDEX.md and show:
```
+========================================================================+
| DESIGN LAYER - FEATURE STATUS (O(1) Lookup) |
+========================================================================+
| # | Feature | SPEC | API | STATUS | Mockups | Command |
|:-:|---------|:----:|:---:|:------:|:-------:|---------|
| 1 | accounts | [s] | [a] | [st] | [m] | /design accounts |
| 2 | auth | [s] | [a] | [st] | [m] | /design auth |
| ... (all from FEATURES_INDEX.md)
Legend: [s]=SPEC [a]=API [st]=STATUS [m]=Mockups
**Design Progress**: {complete}/{total} features ({percentage}%)
+------------------------------------------------------------------------+
| QUICK ACTIONS |
+------------------------------------------------------------------------+
| Create/Update Spec | /design [feature] |
| Generate Mockups | /design [feature] mockup |
| All Mockups | /design mockup |
| Improve Feature | /design [feature] improve |
+------------------------------------------------------------------------+
```
---
## Mockup Sub-Command (O(1) Enhanced)
### `/design [Feature] mockup` ### `/design [Feature] mockup`
Generates Figma-ready mockups from the feature's MOCKUP.md specification. ```
+-------------------------------------------------------------------------+
| /design [Feature] mockup WORKFLOW |
+-------------------------------------------------------------------------+
| |
| PHASE 0: O(1) STATUS CHECK |
| +--> Read MOCKUPS_INDEX.md |
| +--> Check feature row: FIGMA | PROMPTS_FIGMA | PROMPTS_STITCH | tokens|
| +--> Identify: What exists? What's missing? |
| |
| PHASE 1: MCP & TOOL CHECK |
| +--> Check MCP: claude mcp list |
| +--> If stitch-ai configured: Use Google Stitch |
| +--> If figma configured: Use Figma MCP |
| +--> Otherwise: Ask user to select tool |
| |
| PHASE 2: READ MOCKUP.md |
| +--> Read features/[feature]/MOCKUP.md (v2.0 ASCII design) |
| +--> Parse screen layouts, components, colors |
| +--> Identify all screens and UI elements |
| |
| PHASE 3: GENERATE OUTPUTS |
| +--> If missing: Generate PROMPTS_FIGMA.md |
| +--> If missing: Generate PROMPTS_STITCH.md |
| +--> If missing: Generate design-tokens.json |
| +--> Skip files that already exist (from MOCKUPS_INDEX) |
| |
| PHASE 4: INDEX UPDATE |
| +--> Update MOCKUPS_INDEX.md with new status |
| +--> Update FEATURES_INDEX.md Mockups column |
| |
+-------------------------------------------------------------------------+
```
**Before Running**: Check MCP connections and select AI tool: ### `/design mockup` (All Features)
#### Step 0: Check MCP & Select Tool Uses O(1) lookup from MOCKUPS_INDEX.md to identify all gaps:
```
+-------------------------------------------------------------------------+
| MOCKUP GENERATION STATUS (from MOCKUPS_INDEX.md) |
+-------------------------------------------------------------------------+
| Feature | FIGMA | PROMPTS_F | PROMPTS_S | Tokens | Status |
|---------|:-----:|:---------:|:---------:|:------:|--------|
| auth | [x] | [x] | [x] | [x] | Complete |
| dashboard | [ ] | [x] | [x] | [x] | Need FIGMA |
| accounts | [ ] | [x] | [x] | [ ] | Need FIGMA, tokens |
| ... (from MOCKUPS_INDEX)
**Summary**:
- Complete: {n} features
- Need FIGMA_LINKS: {n} features
- Need Prompts: {n} features
- Need Tokens: {n} features
**Next Step**: Generate missing files for [first-incomplete-feature]
```
---
## Tool Selection
### Check MCP First
**Check MCP Status**:
```bash ```bash
claude mcp list claude mcp list
``` ```
**AI Design Tools Available**: ### AI Design Tools
| Tool | MCP | Best For | Setup Command | | Tool | MCP | Best For | Setup |
|------|:---:|----------|---------------| |------|:---:|----------|-------|
| **Google Stitch** | ✅ | Material Design 3, Android/KMP | `claude mcp add stitch-ai -- npx -y stitch-ai-mcp` | | **Google Stitch** | YES | Material Design 3, Android/KMP | `claude mcp add stitch-ai -- npx -y stitch-ai-mcp` |
| **Figma** | ✅ | Team collaboration, custom designs | `claude mcp add figma -- npx -y figma-mcp --token TOKEN` | | **Figma** | YES | Team collaboration | `claude mcp add figma -- npx -y figma-mcp --token TOKEN` |
| Uizard | ❌ | Quick prototypes | Manual (web only) | | Uizard | NO | Quick prototypes | Manual (web) |
| Visily | ❌ | Component-focused | Manual (web only) | | Visily | NO | Component-focused | Manual (web) |
**Recommended**: Google Stitch (MD3 native, has MCP) **Recommended**: Google Stitch (MD3 native, has MCP)
**MCP Resources**: ### Tool Selection Prompt (If Not Configured)
- Google Stitch MCP: [github.com/StitchAI/stitch-ai-mcp](https://github.com/StitchAI/stitch-ai-mcp)
- Stitch Web: [stitch.withgoogle.com](https://stitch.withgoogle.com/)
#### Tool Selection (Ask User)
When running `/design [feature] mockup`, prompt user to select tool:
``` ```
🎨 Select AI Design Tool: Select AI Design Tool:
1. Google Stitch (Recommended) - Material Design 3 native 1. Google Stitch (Recommended) - Material Design 3 native
MCP: claude mcp add stitch-ai -- npx -y stitch-ai-mcp MCP: claude mcp add stitch-ai -- npx -y stitch-ai-mcp
@ -71,72 +215,28 @@ When running `/design [feature] mockup`, prompt user to select tool:
Which tool? (1-4, default: 1) Which tool? (1-4, default: 1)
``` ```
#### Workflow ---
1. Check MCP connection status ## Output Files Structure
2. Ask user to select AI design tool (or use configured default)
3. Read `features/[Feature]/MOCKUP.md` (v2.0 ASCII design)
4. Generate `features/[Feature]/mockups/PROMPTS.md` (tool-specific prompts)
5. Generate `features/[Feature]/mockups/design-tokens.json` (structured tokens)
6. If MCP connected: Offer to send directly to tool
7. Output next steps for user
**Output Files**:
``` ```
features/[Feature]/mockups/ features/[Feature]/mockups/
├── PROMPTS.md # AI tool prompts (format based on selection) +-- PROMPTS_FIGMA.md # Figma-specific prompts
├── design-tokens.json # Structured design tokens +-- PROMPTS_STITCH.md # Google Stitch prompts
└── FIGMA_LINKS.md # Figma URLs (user fills after export) +-- design-tokens.json # Structured design tokens
+-- FIGMA_LINKS.md # Figma URLs (user fills after export)
``` ```
### `/design mockup` ---
Generates mockups for ALL features that don't have mockups/ directory yet. ## PROMPTS_STITCH.md Format
Shows progress and allows resuming where left off.
**First Run**: Will ask to select AI tool and configure MCP if not already done.
### Mockup Generation Workflow
```
┌───────────────────────────────────────────────────────────────────┐
│ /design [Feature] mockup WORKFLOW │
├───────────────────────────────────────────────────────────────────┤
│ │
│ STEP 1: READ MOCKUP.md │
│ ├─→ Read features/[feature]/MOCKUP.md (v2.0 ASCII design) │
│ ├─→ Parse screen layouts, components, colors │
│ └─→ Identify all screens and UI elements │
│ │
│ STEP 2: GENERATE PROMPTS.md │
│ ├─→ Create Google Stitch prompts for each screen │
│ ├─→ Include: colors, typography, spacing, components │
│ ├─→ Follow Material Design 3 guidelines │
│ └─→ Write to features/[feature]/mockups/PROMPTS.md │
│ │
│ STEP 3: GENERATE design-tokens.json │
│ ├─→ Extract color tokens (primary, surface, error, success) │
│ ├─→ Extract typography tokens │
│ ├─→ Extract spacing and radius tokens │
│ ├─→ List components and screens │
│ └─→ Write to features/[feature]/mockups/design-tokens.json │
│ │
│ STEP 4: OUTPUT NEXT STEPS │
│ ├─→ Instructions to use Google Stitch │
│ ├─→ How to export to Figma │
│ └─→ Remind to update FIGMA_LINKS.md │
│ │
└───────────────────────────────────────────────────────────────────┘
```
### PROMPTS.md Format (Google Stitch)
```markdown ```markdown
# [Feature] - AI Mockup Prompts # [Feature] - Google Stitch Prompts
> **Generated from**: features/[feature]/MOCKUP.md > **Generated from**: features/[feature]/MOCKUP.md
> **Generated on**: [DATE] > **Generated on**: [DATE]
> **AI Tool**: Google Stitch (recommended) > **AI Tool**: Google Stitch
## Screen 1: [Screen Name] ## Screen 1: [Screen Name]
@ -156,7 +256,7 @@ Mifos Mobile - Self-service banking app for viewing accounts and transactions.
- [Section details from MOCKUP.md] - [Section details from MOCKUP.md]
**Style Guidelines:** **Style Guidelines:**
- Primary Gradient: #667EEA #764BA2 - Primary Gradient: #667EEA -> #764BA2
- Surface: #FFFBFE - Surface: #FFFBFE
- Typography: Inter font family - Typography: Inter font family
- Spacing: 16px standard padding - Spacing: 16px standard padding
@ -164,67 +264,51 @@ Mifos Mobile - Self-service banking app for viewing accounts and transactions.
--- ---
## Model Recommendation ## Main Workflow: `/design [Feature]`
**This command is optimized for Opus** for complex architectural decisions and comprehensive specification writing.
---
## Key Files
``` ```
claude-product-cycle/design-spec-layer/ +-------------------------------------------------------------------------+
├── STATUS.md # All features status | /design [Feature] WORKFLOW |
├── _shared/ +-------------------------------------------------------------------------+
│ ├── PATTERNS.md # Implementation patterns | |
│ └── API_REFERENCE.md # Fineract API reference | PHASE 0: O(1) CONTEXT LOADING |
└── features/[feature]/ | +--> Read FEATURES_INDEX.md --> Feature exists? Status? |
├── SPEC.md # What to build (UI, flows) | +--> Read MOCKUPS_INDEX.md --> Mockup status |
├── API.md # APIs needed | +--> Read API_INDEX.md --> Related endpoints |
└── STATUS.md # Feature implementation status | |
``` | PHASE 1: DETERMINE ACTION |
| +--> If feature NOT in index: Create new feature |
--- | +--> If SPEC missing: Create SPEC.md |
| +--> If API missing: Create API.md |
## Workflow | +--> If exists: Update/improve existing |
| |
``` | PHASE 2: GATHER CONTEXT (O(1) Paths) |
┌───────────────────────────────────────────────────────────────────┐ | +--> Read features/[feature]/SPEC.md |
│ /design [Feature] WORKFLOW │ | +--> Read features/[feature]/API.md |
├───────────────────────────────────────────────────────────────────┤ | +--> Read features/[feature]/STATUS.md |
│ │ | +--> Lookup endpoints from API_INDEX.md |
│ STEP 1: GATHER CONTEXT │ | +--> Read actual code: feature/[feature]/ (if exists) |
│ ├─→ Read claude-product-cycle/design-spec-layer/STATUS.md │ | |
│ ├─→ Read features/[feature]/SPEC.md (if exists) │ | PHASE 3: ANALYZE |
│ ├─→ Read features/[feature]/API.md (if exists) │ | +--> Compare current spec vs implementation |
│ ├─→ Read actual code in feature/[feature]/ │ | +--> Identify gaps, outdated sections |
│ └─→ Read server-layer/FINERACT_API.md │ | +--> Check API availability in API_INDEX |
│ │ | +--> Report findings to user |
│ STEP 2: ANALYZE │ | |
│ ├─→ Compare current spec vs implementation │ | PHASE 4: UPDATE FILES |
│ ├─→ Identify gaps, outdated sections, missing features │ | +--> Update/create SPEC.md with ASCII mockups |
│ ├─→ Research best practices for similar apps │ | +--> Update/create API.md with endpoints |
│ └─→ Report findings to user │ | +--> Update feature STATUS.md |
│ │ | |
│ STEP 3: UPDATE SPEC.md │ | PHASE 5: INDEX UPDATE (Mandatory) |
│ ├─→ Update/add sections with ASCII mockups │ | +--> Update FEATURES_INDEX.md (status columns) |
│ ├─→ Define state model │ | +--> Update design-spec-layer/STATUS.md |
│ ├─→ Define user actions │ | |
│ └─→ Add changelog entry │ | PHASE 6: OUTPUT SUMMARY |
│ │ | +--> Implementation requirements |
│ STEP 4: UPDATE API.md │ | +--> Next command suggestion |
│ ├─→ List all required endpoints │ | |
│ ├─→ Define request/response structures │ +-------------------------------------------------------------------------+
│ └─→ Note any missing endpoints │
│ │
│ STEP 5: CROSS-UPDATE (MANDATORY) │
│ ├─→ features/[feature]/STATUS.md │
│ └─→ claude-product-cycle/design-spec-layer/STATUS.md │
│ │
│ STEP 6: GENERATE IMPLEMENTATION SUMMARY │
│ └─→ Output clear requirements for /implement │
│ │
└───────────────────────────────────────────────────────────────────┘
``` ```
--- ---
@ -254,17 +338,15 @@ claude-product-cycle/design-spec-layer/
### 2.1 ASCII Mockup ### 2.1 ASCII Mockup
``` +-------------------------------------------+
┌─────────────────────────────────────────┐ | <- Back [Title] : | <- TopBar
│ ← Back [Title] ⋮ │ ← TopBar +-------------------------------------------+
├─────────────────────────────────────────┤ | |
│ │ | +-----------------------------------+ |
│ ┌─────────────────────────────────┐ │ | | Section 1 | |
│ │ Section 1 │ │ | +-----------------------------------+ |
│ └─────────────────────────────────┘ │ | |
│ │ +-------------------------------------------+
└─────────────────────────────────────────┘
```
### 2.2 Sections Table ### 2.2 Sections Table
@ -285,7 +367,6 @@ claude-product-cycle/design-spec-layer/
## 4. State Model ## 4. State Model
```kotlin
@Immutable @Immutable
data class [Feature]State( data class [Feature]State(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@ -298,7 +379,6 @@ sealed interface [Feature]ScreenState {
data object Success : [Feature]ScreenState data object Success : [Feature]ScreenState
data class Error(val message: StringResource) : [Feature]ScreenState data class Error(val message: StringResource) : [Feature]ScreenState
} }
```
--- ---
@ -306,7 +386,7 @@ sealed interface [Feature]ScreenState {
| Endpoint | Method | Purpose | Status | | Endpoint | Method | Purpose | Status |
|----------|--------|---------|--------| |----------|--------|---------|--------|
| /self/[path] | GET | [Description] | Exists | | /self/[path] | GET | [Description] | Exists |
--- ---
@ -343,28 +423,22 @@ sealed interface [Feature]ScreenState {
**Description**: [What this endpoint does] **Description**: [What this endpoint does]
**Request**: **Request**:
```
Headers: Headers:
Authorization: Basic {token} Authorization: Basic {token}
Fineract-Platform-TenantId: {tenant} Fineract-Platform-TenantId: {tenant}
```
**Response**: **Response**:
```json
{ {
"field": "value" "field": "value"
} }
```
**Kotlin DTO**: **Kotlin DTO**:
```kotlin
@Serializable @Serializable
data class [Name]Dto( data class [Name]Dto(
@SerialName("field") val field: String, @SerialName("field") val field: String,
) )
```
**Status**: Implemented / Missing **Status**: Implemented / Missing
--- ---
@ -372,7 +446,7 @@ data class [Name]Dto(
| Endpoint | Service | Repository | Status | | Endpoint | Service | Repository | Status |
|----------|---------|------------|--------| |----------|---------|------------|--------|
| /self/[path] | [Name]Service | [Name]Repository | | | /self/[path] | [Name]Service | [Name]Repository | Done |
``` ```
--- ---
@ -382,60 +456,130 @@ data class [Name]Dto(
After completing design, output: After completing design, output:
``` ```
┌───────────────────────────────────────────────────────────────────┐ +=========================================================================+
│ IMPLEMENTATION REQUIREMENTS │ | IMPLEMENTATION REQUIREMENTS |
│ Ready for /implement in Sonnet session │ | Ready for /implement in Sonnet session |
├───────────────────────────────────────────────────────────────────┤ +=========================================================================+
│ │ | |
│ FEATURE: [Feature Name] │ | FEATURE: [Feature Name] |
│ SPEC UPDATED: features/[feature]/SPEC.md │ | SPEC UPDATED: features/[feature]/SPEC.md |
│ │ | |
│ ════════════════════════════════════════════════════════════════ │ | ================================================================ |
│ │ | |
│ CLIENT WORK NEEDED: │ | CLIENT WORK NEEDED: |
│ [ ] Network: [DTO/Service changes] │ | [ ] Network: [DTO/Service changes] |
│ [ ] Data: [Repository changes] │ | [ ] Data: [Repository changes] |
│ │ | |
│ FEATURE WORK NEEDED: │ | FEATURE WORK NEEDED: |
│ [ ] ViewModel: [changes] │ | [ ] ViewModel: [changes] |
│ [ ] Screen: [changes] │ | [ ] Screen: [changes] |
│ [ ] Components: [new components] │ | [ ] Components: [new components] |
│ │ | |
│ ════════════════════════════════════════════════════════════════ │ | ================================================================ |
│ │ | |
│ NEXT STEP: │ | INDEXES UPDATED: |
│ Run: /implement [Feature] │ | [x] FEATURES_INDEX.md - Status updated |
│ │ | [x] design-spec-layer/STATUS.md - Layer status |
└───────────────────────────────────────────────────────────────────┘ | [x] features/[feature]/STATUS.md - Feature status |
| |
| ================================================================ |
| |
| NEXT STEP: |
| Run: /implement [Feature] |
| |
+=========================================================================+
``` ```
--- ---
## If No Feature Name Provided ## Feature Reference (From FEATURES_INDEX.md)
Show feature list: | # | Feature | Design Dir | Feature Dir |
|:-:|---------|------------|-------------|
| 1 | accounts | features/accounts/ | feature/account/ |
| 2 | auth | features/auth/ | feature/auth/ |
| 3 | beneficiary | features/beneficiary/ | feature/beneficiary/ |
| 4 | client-charge | features/client-charge/ | feature/user-profile/ |
| 5 | dashboard | features/dashboard/ | feature/dashboard/ |
| 6 | guarantor | features/guarantor/ | feature/guarantor/ |
| 7 | home | features/home/ | feature/home/ |
| 8 | loan-account | features/loan-account/ | feature/loan-account/ |
| 9 | location | features/location/ | feature/location/ |
| 10 | notification | features/notification/ | feature/notification/ |
| 11 | passcode | features/passcode/ | libs/mifos-passcode/ |
| 12 | qr | features/qr/ | feature/qr-code/ |
| 13 | recent-transaction | features/recent-transaction/ | feature/recent-transaction/ |
| 14 | savings-account | features/savings-account/ | feature/savings-account/ |
| 15 | settings | features/settings/ | feature/settings/ |
| 16 | share-account | features/share-account/ | feature/share-account/ |
| 17 | transfer | features/transfer/ | feature/transfer-process/ |
---
## Error Handling
### Feature Not Found
``` ```
📋 FEATURES AVAILABLE FOR DESIGN: +-------------------------------------------------------------------------+
| ERROR: Feature '[name]' not found |
| Feature | Status | Last Updated | Command | +-------------------------------------------------------------------------+
|---------|--------|--------------|---------| | |
| auth | ✅ Done | - | /design auth | | The feature '[name]' does not exist in FEATURES_INDEX.md |
| home | ✅ Done | - | /design home | | |
| accounts | ✅ Done | - | /design accounts | | OPTIONS: |
| loan-account | ✅ Done | - | /design loan-account | | 1. Create new feature: /design [name] |
| savings-account | ✅ Done | - | /design savings-account | | 2. Check available features: /design |
| share-account | ✅ Done | - | /design share-account | | 3. Similar features: [suggestions based on name] |
| beneficiary | ✅ Done | - | /design beneficiary | | |
| transfer | ✅ Done | - | /design transfer | +-------------------------------------------------------------------------+
| recent-transaction | ✅ Done | - | /design recent-transaction | ```
| notification | ✅ Done | - | /design notification |
| settings | ✅ Done | - | /design settings | ### Invalid Sub-command
| passcode | ✅ Done | - | /design passcode |
| guarantor | ✅ Done | - | /design guarantor | ```
| qr | ✅ Done | - | /design qr | +-------------------------------------------------------------------------+
| location | ✅ Done | - | /design location | | ERROR: Invalid sub-command '[sub]' |
| client-charge | ✅ Done | - | /design client-charge | +-------------------------------------------------------------------------+
| |
Which feature do you want to design? | Valid sub-commands: |
| - mockup : Generate mockup prompts |
| - improve : Suggest improvements |
| - add [x] : Add specific section |
| |
+-------------------------------------------------------------------------+
```
---
## Model Recommendation
**This command is optimized for Opus** for complex architectural decisions and comprehensive specification writing.
---
## Related Commands
| Command | Purpose |
|---------|---------|
| `/gap-analysis design` | See design layer gaps |
| `/gap-analysis design mockup` | See mockup gaps specifically |
| `/implement [feature]` | Implement the designed feature |
| `/verify [feature]` | Verify implementation vs spec |
---
## Key Files
```
claude-product-cycle/design-spec-layer/
+-- FEATURES_INDEX.md # O(1) feature lookup
+-- MOCKUPS_INDEX.md # O(1) mockup status
+-- STATUS.md # Layer status
+-- features/[feature]/
+-- SPEC.md # What to build (UI, flows)
+-- API.md # APIs needed
+-- STATUS.md # Feature implementation status
+-- MOCKUP.md # v2.0 ASCII mockup
+-- mockups/ # Generated mockup files
``` ```

File diff suppressed because it is too large Load Diff

View File

@ -344,3 +344,105 @@ For each gap found:
5. **Prioritize** - P0 → P1 → P2 5. **Prioritize** - P0 → P1 → P2
6. **Provide verification** - Checklist for each plan 6. **Provide verification** - Checklist for each plan
7. **NO interactive questions** - Show everything, user decides 7. **NO interactive questions** - Show everything, user decides
8. **Save plan to file** - Persist for tracking (see below)
---
## Plan Persistence
When creating a detailed plan (with parameters), **save it to a file** for tracking:
### Save Location
```
claude-product-cycle/plans/active/[target]-[type].md
```
Examples:
- `/gap-planning design mockup``plans/active/design-mockup.md`
- `/gap-planning testing auth``plans/active/testing-auth.md`
- `/gap-planning feature beneficiary``plans/active/feature-beneficiary.md`
- `/gap-planning platform web``plans/active/platform-web.md`
### Plan File Format
```markdown
# Plan: [Target Description]
**Created**: YYYY-MM-DD
**Status**: 🔄 Active
**Command**: /gap-planning [args]
**Progress**: 0/N steps (0%)
---
## Overview
[Brief description of what this plan accomplishes]
---
## Steps
- [ ] **Step 1**: [Description]
- Sub-task 1
- Sub-task 2
- Command: `[execution command]`
- Files: `path/to/expected/files`
- [ ] **Step 2**: [Description]
- Sub-task 1
- Command: `[execution command]`
[... more steps ...]
---
## Verification
- [ ] All expected files exist
- [ ] Tests pass (if applicable)
- [ ] Index files updated
---
## Progress Log
| Date | Step | Action | Notes |
|------|:----:|--------|-------|
| YYYY-MM-DD | 0 | Created | Plan initialized |
```
### Update PLANS_INDEX.md
After creating a plan file, also update `plans/PLANS_INDEX.md`:
```markdown
## Active Plans
| # | Plan | Target | Progress | Current Step | Created |
|:-:|------|--------|:--------:|--------------|---------|
| 1 | design-mockup | Design mockups | [░░░░░░░░░░] 0% (0/10) | Step 1 | 2026-01-05 |
```
### Check Progress
After plan is saved, show:
```
✅ Plan saved to: plans/active/[name].md
Track progress with: /gap-status [name]
```
---
## Related Commands
| Command | Purpose |
|---------|---------|
| `/gap-analysis` | Identify gaps (run first) |
| `/gap-planning` | Create implementation plans (this command) |
| `/gap-status` | Track plan progress |
| `/implement` | Execute implementation |
| `/verify` | Confirm completion |

View File

@ -0,0 +1,339 @@
# /gap-status - Plan Progress Tracking
## Purpose
Track progress on implementation plans created by `/gap-planning`. Shows current step, completed steps, and what's next.
---
## Usage
```
/gap-status # Show all active plans summary
/gap-status [plan-name] # Show detailed progress for plan
/gap-status design # Show design layer plans
/gap-status testing # Show testing layer plans
/gap-status feature [name] # Show feature-specific plan
/gap-status complete [plan] # Mark plan as complete
/gap-status pause [plan] # Pause a plan
/gap-status resume [plan] # Resume a paused plan
```
---
## Workflow
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ /gap-status WORKFLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PHASE 0: O(1) CONTEXT LOADING │
│ ├─→ Read plans/PLANS_INDEX.md → Get all plans overview │
│ ├─→ Read plans/active/*.md → Get active plan details │
│ └─→ Count completed steps → Calculate progress │
│ │
│ PHASE 1: DETERMINE OUTPUT │
│ ├─→ If no args: Show all active plans summary │
│ ├─→ If [plan-name]: Show detailed plan progress │
│ └─→ If action (complete/pause/resume): Update plan status │
│ │
│ PHASE 2: GENERATE REPORT │
│ ├─→ Progress bars for each plan │
│ ├─→ Current step highlight │
│ ├─→ Next steps preview │
│ └─→ Suggested commands │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Output: All Plans Summary (No Args)
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ MIFOS MOBILE - PLAN STATUS ║
╠══════════════════════════════════════════════════════════════════════════════╣
## 🔄 Active Plans
| # | Plan | Progress | Current Step | Last Updated |
|:-:|------|:--------:|--------------|--------------|
| 1 | design-mockup | [████████░░] 80% (8/10) | Step 9: transfer mockups | 2026-01-05 |
| 2 | testing-auth | [████░░░░░░] 40% (4/10) | Step 5: LoginViewModel tests | 2026-01-05 |
| 3 | feature-dashboard | [██░░░░░░░░] 20% (2/10) | Step 3: Create DashboardViewModel | 2026-01-04 |
## ⏸️ Paused Plans
| # | Plan | Progress | Paused At | Reason |
|:-:|------|:--------:|-----------|--------|
| - | (none) | - | - | - |
## ✅ Recently Completed
| # | Plan | Steps | Completed |
|:-:|------|:-----:|-----------|
| 1 | client-layer | 12/12 | 2026-01-03 |
---
## Commands
| Action | Command |
|--------|---------|
| View plan details | `/gap-status [plan-name]` |
| Continue implementation | `/implement [target]` |
| Mark complete | `/gap-status complete [plan]` |
| Create new plan | `/gap-planning [target]` |
╚══════════════════════════════════════════════════════════════════════════════╝
```
---
## Output: Specific Plan (With Args)
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ PLAN: design-mockup ║
╠══════════════════════════════════════════════════════════════════════════════╣
**Status**: 🔄 Active
**Progress**: [████████░░] 80% (8/10 steps)
**Created**: 2026-01-03
**Last Updated**: 2026-01-05
---
## Steps
| # | Step | Status | Description |
|:-:|------|:------:|-------------|
| 1 | ✅ | Done | Generate auth mockups |
| 2 | ✅ | Done | Generate home mockups |
| 3 | ✅ | Done | Generate accounts mockups |
| 4 | ✅ | Done | Generate beneficiary mockups |
| 5 | ✅ | Done | Generate loan-account mockups |
| 6 | ✅ | Done | Generate savings-account mockups |
| 7 | ✅ | Done | Generate share-account mockups |
| 8 | ✅ | Done | Generate notification mockups |
| 9 | 🔄 | **Current** | Generate transfer mockups |
| 10 | ⬜ | Pending | Generate recent-transaction mockups |
---
## Current Step Details
### Step 9: Generate transfer mockups
**Target**: `design-spec-layer/features/transfer/mockups/`
**Tasks**:
- [ ] Run `/design transfer mockup`
- [ ] Review generated PROMPTS.md
- [ ] Execute prompts in Google Stitch
- [ ] Save design-tokens.json
- [ ] Update MOCKUPS_INDEX.md
**Expected Files**:
```
features/transfer/mockups/
├── PROMPTS.md
├── PROMPTS_FIGMA.md (if MCP)
├── design-tokens.json
└── screenshots/ (optional)
```
---
## Next Step Preview
### Step 10: Generate recent-transaction mockups
**Target**: `design-spec-layer/features/recent-transaction/mockups/`
Same process as Step 9 but for recent-transaction feature.
---
## Progress Log
| Date | Step | Action |
|------|:----:|--------|
| 2026-01-05 | 8 | Completed notification mockups |
| 2026-01-05 | 9 | Started transfer mockups |
| 2026-01-04 | 5-7 | Completed account mockups |
| 2026-01-03 | 1-4 | Initial mockups complete |
---
## Commands
| Action | Command |
|--------|---------|
| Execute current step | `/design transfer mockup` |
| Mark step complete | Update plan file, re-run `/gap-status` |
| Pause plan | `/gap-status pause design-mockup` |
| Mark plan complete | `/gap-status complete design-mockup` |
╚══════════════════════════════════════════════════════════════════════════════╝
```
---
## Phase 0: O(1) Context Loading
### Files to Read
| File | Purpose | Data Extracted |
|------|---------|----------------|
| `plans/PLANS_INDEX.md` | Plan inventory | activePlans[], completedPlans[] |
| `plans/active/[plan].md` | Plan details | steps[], currentStep, progress |
---
## Plan File Structure
When `/gap-planning` creates a plan, it saves to `plans/active/[name].md`:
```markdown
# Plan: Design Layer - Mockups
**Created**: 2026-01-03
**Status**: 🔄 Active
**Command**: /gap-planning design mockup
**Progress**: 8/10 steps (80%)
---
## Overview
Generate mockups for all features missing UI designs.
---
## Steps
- [x] **Step 1**: Generate auth mockups
- Run `/design auth mockup`
- Files: `features/auth/mockups/`
- Completed: 2026-01-03
- [x] **Step 2**: Generate home mockups
- Run `/design home mockup`
- Files: `features/home/mockups/`
- Completed: 2026-01-04
- [ ] **Step 9**: Generate transfer mockups ← CURRENT
- Run `/design transfer mockup`
- Files: `features/transfer/mockups/`
- [ ] **Step 10**: Generate recent-transaction mockups
- Run `/design recent-transaction mockup`
- Files: `features/recent-transaction/mockups/`
---
## Progress Log
| Date | Step | Action | Notes |
|------|:----:|--------|-------|
| 2026-01-05 | 8 | ✅ Completed | notification mockups done |
| 2026-01-05 | 9 | 🔄 Started | transfer in progress |
```
---
## Step Status Icons
| Icon | Status | Meaning |
|:----:|--------|---------|
| ✅ | Done | Step completed |
| 🔄 | Current | Currently working on |
| ⬜ | Pending | Not started |
| ⏸️ | Blocked | Waiting on dependency |
| ❌ | Failed | Step failed, needs retry |
---
## Instructions
### When `/gap-status` is called (no args):
1. Read `plans/PLANS_INDEX.md`
2. For each active plan, read `plans/active/[plan].md`
3. Count `[x]` vs `[ ]` checkboxes to calculate progress
4. Display summary table with progress bars
### When `/gap-status [plan]` is called:
1. Read `plans/active/[plan].md`
2. Find current step (first `[ ]` after last `[x]`)
3. Display detailed view with:
- All steps with status
- Current step details
- Next step preview
- Progress log
### When `/gap-status complete [plan]` is called:
1. Read `plans/active/[plan].md`
2. Update status to "✅ Completed"
3. Move file to `plans/completed/[plan].md`
4. Update `plans/PLANS_INDEX.md`
---
## Integration with Other Commands
| When This Runs | Update Plan |
|----------------|-------------|
| `/gap-planning [target]` | Create new plan file |
| `/implement` checkpoint | Update related plan step |
| `/design [feature] mockup` | Update mockup plan step |
| `/verify [feature]` | Update verification plan |
---
## Progress Bar Reference
```
100% = [██████████] | 50% = [█████░░░░░]
90% = [█████████░] | 40% = [████░░░░░░]
80% = [████████░░] | 30% = [███░░░░░░░]
70% = [███████░░░] | 20% = [██░░░░░░░░]
60% = [██████░░░░] | 10% = [█░░░░░░░░░]
```
---
## Example: Creating and Tracking a Plan
```bash
# 1. Create plan
/gap-planning design mockup
# → Creates plans/active/design-mockup.md with 10 steps
# 2. Check status
/gap-status
# → Shows design-mockup at 0% (0/10)
# 3. Work on step 1
/design auth mockup
# → Completes auth mockups
# 4. Update plan (manual or via command)
# Edit plans/active/design-mockup.md, mark step 1 as [x]
# 5. Check status again
/gap-status design-mockup
# → Shows 10% (1/10), current step is now step 2
# 6. Continue until done
# ...
# 7. Mark complete
/gap-status complete design-mockup
# → Moves to plans/completed/
```

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,16 @@
# Verify Tests Command # /verify-tests - Test Verification (O(1) Enhanced)
Run and verify tests for features across the project. Run and verify tests for features across the project with O(1) status lookups.
## Usage ## Usage
``` ```
/verify-tests # Run all tests, show status /verify-tests # Show test status dashboard (O(1))
/verify-tests auth # Run auth feature tests /verify-tests [feature] # Run all tests for feature
/verify-tests auth unit # Run auth ViewModel tests only /verify-tests [feature] unit # Run ViewModel tests only
/verify-tests auth ui # Run auth UI tests only /verify-tests [feature] ui # Run UI tests only
/verify-tests auth integration # Run auth integration tests /verify-tests [feature] integration # Run integration tests
/verify-tests auth screenshot # Run auth screenshot tests /verify-tests [feature] screenshot # Run screenshot tests
/verify-tests client # Run all client layer tests /verify-tests client # Run all client layer tests
/verify-tests feature # Run all feature layer tests /verify-tests feature # Run all feature layer tests
/verify-tests platform # Run all platform tests /verify-tests platform # Run all platform tests
@ -18,21 +18,226 @@ Run and verify tests for features across the project.
--- ---
## Output Format ## O(1) Workflow
``` ```
╔══════════════════════════════════════════════════════════════════════════════╗ +-------------------------------------------------------------------------+
║ VERIFY TESTS - [target] ║ | /verify-tests WORKFLOW (O(1) ENHANCED) |
╠══════════════════════════════════════════════════════════════════════════════╣ +-------------------------------------------------------------------------+
| |
| PHASE 0: O(1) CONTEXT LOADING |
| +--> Read feature-layer/TESTING_STATUS.md --> VM/Screen test status|
| +--> Read client-layer/TESTING_STATUS.md --> Repository test status|
| +--> Read platform-layer/TESTING_STATUS.md --> E2E/Screenshot status|
| +--> Read feature-layer/MODULES_INDEX.md --> Feature paths |
| |
| PHASE 1: DETERMINE TEST SCOPE |
| +--> If no args: Show test dashboard from indexes |
| +--> If [feature]: Get paths from MODULES_INDEX |
| +--> If [layer]: Get layer test config |
| |
| PHASE 2: EXECUTE TESTS |
| +--> Build appropriate Gradle command |
| +--> Run tests via Bash |
| +--> Capture output |
| |
| PHASE 3: PARSE RESULTS |
| +--> Extract: passed, failed, skipped |
| +--> Extract: failure details |
| +--> Calculate coverage (if available) |
| |
| PHASE 4: UPDATE STATUS |
| +--> Update TESTING_STATUS.md with new results |
| +--> Log test run timestamp |
| |
| PHASE 5: REPORT |
| +--> Show results with next steps |
| |
+-------------------------------------------------------------------------+
```
---
## Phase 0: O(1) Context Loading
### Index Files to Read
| File | Purpose | Lines |
|------|---------|:-----:|
| `testing-layer/LAYER_STATUS.md` | **Primary** test dashboard | ~200 |
| `testing-layer/TEST_PATTERNS.md` | Test patterns & conventions | ~300 |
| `testing-layer/TEST_TAGS_INDEX.md` | TestTag specifications | ~350 |
| `testing-layer/TEST_FIXTURES_INDEX.md` | Test fixtures inventory | ~250 |
| `testing-layer/FAKE_REPOS_INDEX.md` | Fake repositories status | ~200 |
| `feature-layer/MODULES_INDEX.md` | Feature → Path mapping | ~115 |
### O(1) Path Pattern
```
feature/[module]/src/commonTest/ # Unit tests (ViewModel)
feature/[module]/src/androidInstrumentedTest/ # UI tests (Screen)
core/data/src/commonTest/ # Repository tests
cmp-android/src/androidTest/ # E2E integration tests
core/designsystem/src/test/ # Screenshot tests
```
---
## If No Arguments: Test Dashboard
Read from TESTING_STATUS.md files and show:
```
+=========================================================================+
| TEST STATUS DASHBOARD (O(1)) |
+=========================================================================+
## Layer Summary
| Layer | Tests | Passed | Failed | Coverage | Status |
|-------|:-----:|:------:|:------:|:--------:|:------:|
| Client | 14 | 14 | 0 | 82% | [======= ] |
| Feature | 0 | 0 | 0 | 0% | [ ] |
| Platform | 0 | 0 | 0 | 0% | [ ] |
## Feature Testing Matrix (from TESTING_STATUS.md)
| Feature | VMs | VM Tests | Screens | UI Tests | Status |
|---------|:---:|:--------:|:-------:|:--------:|:------:|
| auth | 5 | 0 | 6 | 0 | [ ] Not Started |
| home | 1 | 0 | 1 | 0 | [ ] Not Started |
| accounts | 3 | 0 | 3 | 0 | [ ] Not Started |
| ... (from feature-layer/TESTING_STATUS.md)
## Repository Testing (from client-layer/TESTING_STATUS.md)
| Repository | Tests | Success | Error | Empty | Status |
|------------|:-----:|:-------:|:-----:|:-----:|:------:|
| AccountsRepository | 2 | [x] | [ ] | [ ] | Partial |
| UserAuthRepository | 0 | [ ] | [ ] | [ ] | Not Started |
| ... (from client-layer/TESTING_STATUS.md)
## Quick Commands
| Action | Command |
|--------|---------|
| Run all tests | `./gradlew test` |
| Run feature tests | `/verify-tests [feature]` |
| Run client tests | `/verify-tests client` |
| Check gaps | `/gap-analysis testing` |
+=========================================================================+
```
---
## Feature Test Mapping (from MODULES_INDEX.md)
| # | Feature | Module Path | Unit Test Path | UI Test Path |
|:-:|---------|-------------|----------------|--------------|
| 1 | auth | feature/auth | feature/auth/src/commonTest/ | feature/auth/src/androidInstrumentedTest/ |
| 2 | home | feature/home | feature/home/src/commonTest/ | feature/home/src/androidInstrumentedTest/ |
| 3 | accounts | feature/accounts | feature/accounts/src/commonTest/ | feature/accounts/src/androidInstrumentedTest/ |
| 4 | beneficiary | feature/beneficiary | feature/beneficiary/src/commonTest/ | feature/beneficiary/src/androidInstrumentedTest/ |
| 5 | loan-account | feature/loan-account | feature/loan-account/src/commonTest/ | feature/loan-account/src/androidInstrumentedTest/ |
| 6 | savings-account | feature/savings-account | feature/savings-account/src/commonTest/ | feature/savings-account/src/androidInstrumentedTest/ |
| 7 | share-account | feature/share-account | feature/share-account/src/commonTest/ | feature/share-account/src/androidInstrumentedTest/ |
| 8 | transfer | feature/transfer-process | feature/transfer-process/src/commonTest/ | feature/transfer-process/src/androidInstrumentedTest/ |
| 9 | recent-transaction | feature/recent-transaction | feature/recent-transaction/src/commonTest/ | feature/recent-transaction/src/androidInstrumentedTest/ |
| 10 | notification | feature/notification | feature/notification/src/commonTest/ | feature/notification/src/androidInstrumentedTest/ |
| 11 | settings | feature/settings | feature/settings/src/commonTest/ | feature/settings/src/androidInstrumentedTest/ |
| 12 | passcode | libs/mifos-passcode | libs/mifos-passcode/src/commonTest/ | libs/mifos-passcode/src/androidInstrumentedTest/ |
| 13 | guarantor | feature/guarantor | feature/guarantor/src/commonTest/ | feature/guarantor/src/androidInstrumentedTest/ |
| 14 | qr | feature/qr-code | feature/qr-code/src/commonTest/ | feature/qr-code/src/androidInstrumentedTest/ |
| 15 | location | feature/location | feature/location/src/commonTest/ | feature/location/src/androidInstrumentedTest/ |
| 16 | user-profile | feature/user-profile | feature/user-profile/src/commonTest/ | feature/user-profile/src/androidInstrumentedTest/ |
---
## Test Type Commands
### `/verify-tests [feature]` - All Tests
```bash
# Unit tests (ViewModel)
./gradlew :feature:[module]:test
# UI tests (Screen) - requires emulator
./gradlew :feature:[module]:connectedDebugAndroidTest
```
### `/verify-tests [feature] unit` - ViewModel Only
```bash
./gradlew :feature:[module]:test
```
### `/verify-tests [feature] ui` - Screen Only
```bash
./gradlew :feature:[module]:connectedDebugAndroidTest
```
### `/verify-tests [feature] integration` - E2E Flow
```bash
./gradlew :cmp-android:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=org.mifos.mobile.[Feature]FlowTest
```
### `/verify-tests [feature] screenshot` - Visual
```bash
# Compare against golden images
./gradlew :core:designsystem:compareRoborazziDebug
# Record new golden images
./gradlew :core:designsystem:recordRoborazziDebug
```
---
## Layer Test Commands
### `/verify-tests client` - Repository Tests
```bash
./gradlew :core:data:test
```
### `/verify-tests feature` - All Feature Tests
```bash
./gradlew feature:test
```
### `/verify-tests platform` - Platform Tests
```bash
# E2E tests
./gradlew :cmp-android:connectedDebugAndroidTest
# Screenshot tests
./gradlew :core:designsystem:compareRoborazziDebug
```
---
## Output Format
### After Running Tests
```
+=========================================================================+
| VERIFY TESTS - [target] |
+=========================================================================+
## Test Execution ## Test Execution
| Type | Command | Tests | Passed | Failed | Status | | Type | Command | Tests | Passed | Failed | Status |
|------|---------|:-----:|:------:|:------:|:------:| |------|---------|:-----:|:------:|:------:|:------:|
| Unit | `./gradlew :feature:auth:test` | 45 | 45 | 0 | ✅ | | Unit | `./gradlew :feature:auth:test` | 45 | 45 | 0 | [x] |
| UI | `./gradlew :feature:auth:connectedDebugAndroidTest` | 25 | 23 | 2 | ⚠️ | | UI | `./gradlew :feature:auth:connectedDebugAndroidTest` | 25 | 23 | 2 | [!] |
| Integration | `./gradlew :cmp-android:connectedDebugAndroidTest` | 8 | 8 | 0 | ✅ |
| Screenshot | `./gradlew :core:designsystem:compareRoborazziDebug` | 12 | 12 | 0 | ✅ |
## Failed Tests ## Failed Tests
@ -45,137 +250,87 @@ Run and verify tests for features across the project.
| Component | Coverage | Target | Status | | Component | Coverage | Target | Status |
|-----------|:--------:|:------:|:------:| |-----------|:--------:|:------:|:------:|
| ViewModel | 85% | 80% | | | ViewModel | 85% | 80% | [x] Pass |
| Screen | 72% | 60% | | | Screen | 72% | 60% | [x] Pass |
| Repository | 90% | 80% | | | Repository | 90% | 80% | [x] Pass |
--- ## Index Updated
## Next Steps [x] feature-layer/TESTING_STATUS.md - Updated test counts
[x] Last run: [timestamp]
1. Fix failing tests: `/gap-planning auth testing` +---------+----------------------------------------------------------+
2. Increase coverage: Add tests for uncovered paths | NEXT STEPS |
3. Re-run: `/verify-tests auth` +---------+----------------------------------------------------------+
| 1 | Fix failing tests: LoginScreenTest.kt:45, :32 |
╚══════════════════════════════════════════════════════════════════════════════╝ | 2 | Increase coverage: Add tests for uncovered paths |
| 3 | Re-run: /verify-tests auth |
+---------+----------------------------------------------------------+
``` ```
--- ---
## Test Commands Reference ## Error Handling
### Unit Tests (ViewModel + Repository) ### Feature Not Found
```bash ```
# All unit tests +-------------------------------------------------------------------------+
./gradlew test | ERROR: Feature '[name]' not found |
+-------------------------------------------------------------------------+
# Specific module | |
./gradlew :feature:auth:test | The feature '[name]' does not exist in MODULES_INDEX.md |
./gradlew :core:data:test | |
| Available features: |
# With coverage | auth, home, accounts, beneficiary, loan-account, savings-account, |
./gradlew test jacocoTestReport | share-account, transfer, recent-transaction, notification, settings, |
| passcode, guarantor, qr, location, user-profile |
| |
| Did you mean: [closest match]? |
| |
+-------------------------------------------------------------------------+
``` ```
### UI Tests (Compose) ### No Tests Found
```bash ```
# All UI tests (requires emulator/device) +-------------------------------------------------------------------------+
./gradlew connectedDebugAndroidTest | WARNING: No tests found for '[feature]' |
+-------------------------------------------------------------------------+
# Specific feature | |
./gradlew :feature:auth:connectedDebugAndroidTest | Test directory: feature/[module]/src/commonTest/ |
| Status: Empty |
| |
| To create tests: |
| 1. Run /gap-planning [feature] testing |
| 2. Follow TDD pattern in TESTING_STATUS.md |
| |
+-------------------------------------------------------------------------+
``` ```
### Integration Tests (E2E) ### Gradle Error
```bash
# Full E2E tests
./gradlew :cmp-android:connectedDebugAndroidTest
# Specific test class
./gradlew :cmp-android:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=org.mifos.mobile.AuthFlowTest
``` ```
+-------------------------------------------------------------------------+
### Screenshot Tests (Roborazzi) | ERROR: Gradle build failed |
+-------------------------------------------------------------------------+
```bash | |
# Record golden images | Command: ./gradlew :feature:[module]:test |
./gradlew :core:designsystem:recordRoborazziDebug | Exit code: 1 |
| |
# Compare against golden images | Error output: |
./gradlew :core:designsystem:compareRoborazziDebug | [Gradle error message] |
| |
# View differences | Suggestions: |
open build/reports/roborazzi/ | 1. Check compilation errors: ./gradlew :feature:[module]:compileKotlin |
| 2. Clean build: ./gradlew clean |
| 3. Check dependencies: ./gradlew :feature:[module]:dependencies |
| |
+-------------------------------------------------------------------------+
``` ```
--- ---
## Instructions for Claude
### Step 1: Determine Test Scope
| Parameter | Test Type | Gradle Command |
|-----------|-----------|----------------|
| (none) | All tests | `./gradlew test connectedDebugAndroidTest` |
| `[feature]` | Feature tests | `./gradlew :feature:[name]:test` |
| `[feature] unit` | ViewModel only | `./gradlew :feature:[name]:test` |
| `[feature] ui` | Screen tests | `./gradlew :feature:[name]:connectedDebugAndroidTest` |
| `[feature] integration` | E2E flow | `./gradlew :cmp-android:connectedDebugAndroidTest` |
| `[feature] screenshot` | Visual | `./gradlew :core:designsystem:compareRoborazziDebug` |
| `client` | Repositories | `./gradlew :core:data:test` |
| `feature` | All ViewModels | `./gradlew feature:test` |
| `platform` | All E2E | `./gradlew :cmp-android:connectedDebugAndroidTest` |
### Step 2: Execute Tests
Run the appropriate Gradle command and capture output.
### Step 3: Parse Results
From Gradle output, extract:
- Total tests run
- Tests passed
- Tests failed
- Failed test names and errors
- Coverage percentages (if available)
### Step 4: Generate Report
Display results in the formatted output above.
### Step 5: Suggest Next Steps
Based on results:
- If all pass: "All tests passing. Coverage: X%"
- If failures: List fixes needed with file paths
- If low coverage: Suggest adding tests
---
## Feature Test Mapping
| Feature | Unit Test Path | UI Test Path |
|---------|----------------|--------------|
| auth | `feature/auth/src/commonTest/` | `feature/auth/src/androidInstrumentedTest/` |
| home | `feature/home/src/commonTest/` | `feature/home/src/androidInstrumentedTest/` |
| accounts | `feature/account/src/commonTest/` | `feature/account/src/androidInstrumentedTest/` |
| beneficiary | `feature/beneficiary/src/commonTest/` | `feature/beneficiary/src/androidInstrumentedTest/` |
| loan-account | `feature/loan-account/src/commonTest/` | `feature/loan-account/src/androidInstrumentedTest/` |
| savings-account | `feature/savings-account/src/commonTest/` | `feature/savings-account/src/androidInstrumentedTest/` |
| transfer | `feature/transfer-process/src/commonTest/` | `feature/transfer-process/src/androidInstrumentedTest/` |
| notification | `feature/notification/src/commonTest/` | `feature/notification/src/androidInstrumentedTest/` |
| settings | `feature/settings/src/commonTest/` | `feature/settings/src/androidInstrumentedTest/` |
| qr | `feature/qr-code/src/commonTest/` | `feature/qr-code/src/androidInstrumentedTest/` |
| guarantor | `feature/guarantor/src/commonTest/` | `feature/guarantor/src/androidInstrumentedTest/` |
| passcode | `libs/mifos-passcode/src/commonTest/` | `libs/mifos-passcode/src/androidInstrumentedTest/` |
| location | `feature/location/src/commonTest/` | `feature/location/src/androidInstrumentedTest/` |
| user-profile | `feature/user-profile/src/commonTest/` | `feature/user-profile/src/androidInstrumentedTest/` |
---
## Coverage Targets ## Coverage Targets
| Component | Minimum | Target | Excellent | | Component | Minimum | Target | Excellent |
@ -188,10 +343,104 @@ Based on results:
--- ---
## TestTag System (from TESTING_STATUS.md)
### Pattern: `feature:component:element`
```kotlin
object TestTags {
object Auth {
const val SCREEN = "auth:screen"
const val USERNAME_FIELD = "auth:username"
const val PASSWORD_FIELD = "auth:password"
const val LOGIN_BUTTON = "auth:loginButton"
const val ERROR_MESSAGE = "auth:error"
const val LOADING_INDICATOR = "auth:loading"
}
// ... for all features
}
```
---
## Integration Test Flows (from platform-layer/TESTING_STATUS.md)
| # | Flow | Screens | Tests | Status |
|:-:|------|:-------:|:-----:|:------:|
| 1 | Login -> Passcode -> Home | 3 | 0 | [ ] |
| 2 | Registration -> OTP -> Login | 4 | 0 | [ ] |
| 3 | Home -> Account Details | 2 | 0 | [ ] |
| 4 | Home -> Transfer -> Confirm | 3 | 0 | [ ] |
| 5 | Home -> Beneficiary -> Add | 2 | 0 | [ ] |
| 6 | Settings -> Change Password | 2 | 0 | [ ] |
| 7 | Loan -> Schedule -> Summary | 3 | 0 | [ ] |
| 8 | QR -> Scan -> Transfer | 3 | 0 | [ ] |
---
## Related Commands ## Related Commands
- `/gap-analysis testing` - View testing status | Command | Purpose |
- `/gap-planning testing [layer]` - Plan test implementation |---------|---------|
- `/verify [feature]` - Verify implementation vs spec | `/gap-analysis testing` | View all testing gaps |
| `/gap-analysis [layer] testing` | Layer-specific test gaps |
| `/gap-planning [feature] testing` | Plan test implementation |
| `/verify [feature]` | Verify implementation vs spec |
---
## Key Files
```
claude-product-cycle/
+-- feature-layer/
| +-- TESTING_STATUS.md # O(1) ViewModel/Screen test status
| +-- MODULES_INDEX.md # Feature -> path mapping
+-- client-layer/
| +-- TESTING_STATUS.md # O(1) Repository test status
+-- platform-layer/
| +-- TESTING_STATUS.md # O(1) E2E/Screenshot status
```
---
## Gradle Commands Reference
### Unit Tests
```bash
# All unit tests
./gradlew test
# Specific module
./gradlew :feature:auth:test
./gradlew :core:data:test
# With coverage
./gradlew test jacocoTestReport
```
### UI Tests
```bash
# All UI tests (requires emulator/device)
./gradlew connectedDebugAndroidTest
# Specific feature
./gradlew :feature:auth:connectedDebugAndroidTest
```
### Screenshot Tests (Roborazzi)
```bash
# Record golden images
./gradlew :core:designsystem:recordRoborazziDebug
# Compare against golden images
./gradlew :core:designsystem:compareRoborazziDebug
# View differences
open build/reports/roborazzi/
```
ARGUMENTS: $ARGUMENTS ARGUMENTS: $ARGUMENTS

View File

@ -1,169 +1,736 @@
# /verify - Implementation Verification # /verify - Implementation Verification
## Purpose ## Purpose
Validate that the implementation matches the specification. Identify gaps between SPEC.md and actual code.
Validate implementation matches specification using O(1) lookup. Compares SPEC.md requirements against actual code and identifies gaps with actionable fixes.
--- ---
## Workflow ## Command Variants
``` ```
┌───────────────────────────────────────────────────────────────────┐ /verify # Show all features verification status
│ /verify [Feature] WORKFLOW │ /verify [Feature] # Full verification for feature
├───────────────────────────────────────────────────────────────────┤ /verify [Feature] --quick # Skip detailed code analysis
│ │ /verify [Feature] --spec # Verify spec completeness only
│ PHASE 1: READ SPEC │ /verify [Feature] --code # Verify code completeness only
│ ├─→ Read features/[feature]/SPEC.md │ /verify all # Verify all features (summary)
│ ├─→ Extract all UI sections │
│ ├─→ Extract all user actions │
│ ├─→ Extract state model │
│ └─→ Extract API requirements │
│ │
│ PHASE 2: CHECK ACTUAL CODE │
│ ├─→ Read feature/[name]/*ViewModel.kt │
│ ├─→ Read feature/[name]/*Screen.kt │
│ ├─→ Read feature/[name]/components/*.kt │
│ ├─→ Read core/network/services/*Service.kt │
│ └─→ Read core/data/repository/*Repository.kt │
│ │
│ PHASE 3: COMPARE SPEC VS CODE │
│ ├─→ All sections from spec implemented? │
│ ├─→ All user actions handled? │
│ ├─→ State model matches? │
│ ├─→ All API calls present? │
│ └─→ DI registration complete? │
│ │
│ PHASE 4: CHECK LAYER INTEGRITY │
│ ├─→ Network → Data → Feature flow correct? │
│ ├─→ No layer violations? │
│ └─→ Navigation configured? │
│ │
│ PHASE 5: GENERATE REPORT │
│ ├─→ List all gaps found │
│ ├─→ List suggestions for improvement │
│ └─→ Output: Gap report or "✅ Feature verified" │
│ │
└───────────────────────────────────────────────────────────────────┘
``` ```
--- ---
## Verification Checklist ## Verification Pipeline with O(1) Optimization
### UI Sections
- [ ] All sections from SPEC.md ASCII mockup present in Screen
- [ ] Loading state handled
- [ ] Error state handled
- [ ] Empty state handled (if applicable)
### User Actions
- [ ] All actions from SPEC.md handled in ViewModel
- [ ] Actions trigger correct events/state changes
- [ ] Navigation works correctly
### State Model
- [ ] State class matches SPEC.md definition
- [ ] All required fields present
- [ ] Correct default values
### API Integration
- [ ] All required endpoints called
- [ ] Error handling for API failures
- [ ] Loading states during API calls
### DI Registration
- [ ] ViewModel registered in module
- [ ] Repository registered in module
- [ ] Service registered in module
### Navigation
- [ ] Route defined
- [ ] Screen registered in nav graph
- [ ] Navigation parameters correct
---
## Output Templates
### All Good:
``` ```
╔══════════════════════════════════════════════════════════════════════╗ ┌─────────────────────────────────────────────────────────────────────────────┐
║ ✅ VERIFICATION COMPLETE - [Feature] ║ │ /verify [Feature] - O(1) OPTIMIZED PIPELINE │
╠══════════════════════════════════════════════════════════════════════╣ ├─────────────────────────────────────────────────────────────────────────────┤
║ ║ │ │
║ UI SECTIONS: ✅ All 5 sections implemented ║ │ PHASE 0: O(1) CONTEXT LOADING │
║ USER ACTIONS: ✅ All 8 actions handled ║ │ ├─→ Read FEATURES_INDEX.md → Feature exists? Spec status? │
║ STATE MODEL: ✅ Matches specification ║ │ ├─→ Read FEATURE_MAP.md → Expected services/repos │
║ API INTEGRATION: ✅ All 3 endpoints called ║ │ ├─→ Read MODULES_INDEX.md → Expected VMs/Screens │
║ DI REGISTRATION: ✅ Complete ║ │ ├─→ Read SCREENS_INDEX.md → Screen-ViewModel mapping │
║ NAVIGATION: ✅ Configured ║ │ └─→ Read API_INDEX.md → Expected endpoints │
║ ║ │ │
║ RESULT: Feature fully implements specification ║ │ PHASE 1: SPEC ANALYSIS │
║ ║ │ ├─→ Read features/[name]/SPEC.md → Extract requirements │
╚══════════════════════════════════════════════════════════════════════╝ │ ├─→ Read features/[name]/API.md → Extract API requirements │
``` │ ├─→ Read features/[name]/STATUS.md → Current status claims │
│ └─→ Build requirement checklist → What SHOULD exist │
### Gaps Found: │ │
│ PHASE 2: CODE ANALYSIS (O(1) paths from indexes) │
``` │ ├─→ Check ViewModel exists → From SCREENS_INDEX.md path │
╔══════════════════════════════════════════════════════════════════════╗ │ ├─→ Check Screen exists → From SCREENS_INDEX.md path │
║ ⚠️ VERIFICATION COMPLETE - GAPS FOUND ║ │ ├─→ Check Service exists → From FEATURE_MAP.md path │
╠══════════════════════════════════════════════════════════════════════╣ │ ├─→ Check Repository exists → From FEATURE_MAP.md path │
║ ║ │ └─→ Build implementation checklist → What DOES exist │
║ FEATURE: [Feature] ║ │ │
║ SPEC: claude-product-cycle/design-spec-layer/features/[feature]/SPEC.md║ │ PHASE 3: DEEP VERIFICATION (if not --quick) │
║ ║ │ ├─→ Read ViewModel code → Check State/Event/Action │
╠══════════════════════════════════════════════════════════════════════╣ │ ├─→ Read Screen code → Check UI states, TestTags │
║ GAPS IDENTIFIED ║ │ ├─→ Compare SPEC actions vs code → All actions handled? │
╠══════════════════════════════════════════════════════════════════════╣ │ ├─→ Compare SPEC states vs code → All states rendered? │
║ ║ │ └─→ Check DI registration → Koin modules complete? │
║ UI SECTIONS (2 gaps): ║ │ │
║ ├─ ❌ Empty state not implemented ║ │ PHASE 4: GAP DETECTION │
║ └─ ❌ Pull-to-refresh missing ║ │ ├─→ Compare requirement vs impl → Identify missing items │
║ ║ │ ├─→ Categorize gaps by severity → P0 (critical) → P2 (polish) │
║ USER ACTIONS (1 gap): ║ │ ├─→ Generate fix suggestions → Actionable steps │
║ └─ ❌ Filter action not handled ║ │ └─→ Calculate verification score → Percentage complete │
║ ║ │ │
║ API INTEGRATION (1 gap): ║ │ PHASE 5: REPORT & UPDATE │
║ └─ ❌ /self/endpoint not called ║ │ ├─→ Generate verification report → Structured output │
║ ║ │ ├─→ Update STATUS.md (optional) → If user approves │
║ TOTAL GAPS: 4 ║ │ └─→ Suggest next command → /implement or /gap-planning │
║ ║ │ │
╠══════════════════════════════════════════════════════════════════════╣ └─────────────────────────────────────────────────────────────────────────────┘
║ SUGGESTED FIXES ║
╠══════════════════════════════════════════════════════════════════════╣
║ ║
║ 1. Add EmptyContent composable in Screen ║
║ 2. Add SwipeRefresh wrapper in Screen ║
║ 3. Add FilterAction and handleFilter() in ViewModel ║
║ 4. Add endpoint call in Repository ║
║ ║
╠══════════════════════════════════════════════════════════════════════╣
║ NEXT STEP ║
║ ║
║ Run: /implement [Feature] ║
║ Or fix gaps manually and run: /verify [Feature] ║
║ ║
╚══════════════════════════════════════════════════════════════════════╝
``` ```
--- ---
## Key Files to Compare ## PHASE 0: O(1) Context Loading
| Spec File | Code Files | ### Files to Read (~500 lines total instead of scanning)
|-----------|------------|
| features/[feature]/SPEC.md | feature/[name]/*ViewModel.kt | | File | Purpose | Data Extracted |
| | feature/[name]/*Screen.kt | |------|---------|----------------|
| | feature/[name]/components/*.kt | | `design-spec-layer/FEATURES_INDEX.md` | Feature inventory | featureExists, specStatus |
| features/[feature]/API.md | core/network/services/*Service.kt | | `client-layer/FEATURE_MAP.md` | Service/Repo mapping | expectedServices[], expectedRepos[] |
| | core/data/repository/*Repository.kt | | `feature-layer/MODULES_INDEX.md` | Module structure | expectedVMs, expectedScreens |
| `feature-layer/SCREENS_INDEX.md` | Screen details | screenPaths[], vmPaths[] |
| `server-layer/API_INDEX.md` | Endpoint inventory | expectedEndpoints[] |
| `testing-layer/TEST_TAGS_INDEX.md` | TestTag specs | expectedTags[], namingPattern |
| `testing-layer/LAYER_STATUS.md` | Test coverage | testCoverage, fakeRepos |
### Context Object Built
```kotlin
val context = VerifyContext(
feature = "beneficiary",
// From FEATURES_INDEX.md
specExists = true,
specStatus = "✅ Complete",
// From FEATURE_MAP.md
expectedServices = ["BeneficiaryService"],
expectedRepositories = ["BeneficiaryRepository"],
// From MODULES_INDEX.md
expectedVMs = 4,
expectedScreens = 4,
// From SCREENS_INDEX.md
screens = [
Screen("BeneficiaryListScreen", "BeneficiaryListViewModel"),
Screen("BeneficiaryDetailScreen", "BeneficiaryDetailViewModel"),
Screen("BeneficiaryApplicationScreen", "BeneficiaryApplicationViewModel"),
Screen("BeneficiaryApplicationConfirmationScreen", "BeneficiaryApplicationConfirmationViewModel")
],
// From API_INDEX.md
expectedEndpoints = [
"GET /beneficiaries",
"POST /beneficiaries",
"PUT /beneficiaries/{id}",
"DELETE /beneficiaries/{id}"
]
)
```
--- ---
## Status Update ## PHASE 1: Spec Analysis
After verification, update: ### Read Specification Files
1. `features/[feature]/STATUS.md` - Feature status
2. `claude-product-cycle/design-spec-layer/STATUS.md` - Main tracker ```
design-spec-layer/features/[feature]/
├── SPEC.md → UI sections, user actions, state model
├── API.md → Required endpoints, DTOs
└── STATUS.md → Claimed implementation status
```
### Extract Requirements from SPEC.md
```kotlin
val specRequirements = SpecRequirements(
// From SPEC.md Section 2: Screen Layout
uiSections = ["Header", "List", "EmptyState", "ErrorState", "LoadingState"],
// From SPEC.md Section 3: User Interactions
userActions = [
Action("Retry", "Reload data on error"),
Action("PullRefresh", "Refresh list"),
Action("ItemClick", "Navigate to detail"),
Action("AddClick", "Navigate to add form"),
Action("DeleteClick", "Delete with confirmation")
],
// From SPEC.md Section 4: State Model
stateFields = ["data", "uiState", "isRefreshing", "selectedItem"],
screenStates = ["Loading", "Success", "Error", "Empty"],
// From SPEC.md Section 5: API Requirements
apiEndpoints = ["GET /beneficiaries", "POST /beneficiaries", ...]
)
```
---
## PHASE 2: Code Analysis (O(1) Paths)
### File Paths from Index Files
| Component | Path Source | Example Path |
|-----------|-------------|--------------|
| ViewModel | SCREENS_INDEX.md | `feature/beneficiary/.../viewmodel/BeneficiaryListViewModel.kt` |
| Screen | SCREENS_INDEX.md | `feature/beneficiary/.../ui/BeneficiaryListScreen.kt` |
| Service | FEATURE_MAP.md | `core/network/.../services/BeneficiaryService.kt` |
| Repository | FEATURE_MAP.md | `core/data/.../repository/BeneficiaryRepository.kt` |
| DI Module | MODULES_INDEX.md | `feature/beneficiary/.../di/BeneficiaryModule.kt` |
### Check File Existence
```kotlin
val codeAnalysis = CodeAnalysis(
// File existence checks
viewModelsExist = [true, true, true, true], // 4/4
screensExist = [true, true, true, true], // 4/4
serviceExists = true,
repositoryExists = true,
diModuleExists = true,
// Navigation check
navigationRegistered = true,
// TestTags check
testTagsExist = false // Gap detected!
)
```
---
## PHASE 3: Deep Verification
### ViewModel Verification
```kotlin
// Read ViewModel and check:
val vmVerification = ViewModelVerification(
// State class
hasStateClass = true,
stateFieldsMatch = compareFields(spec.stateFields, vm.stateFields),
missingStateFields = ["selectedItem"], // Gap!
// Screen states
hasScreenStates = true,
screenStatesMatch = compareStates(spec.screenStates, vm.screenStates),
missingScreenStates = [],
// Actions
hasActionInterface = true,
actionsMatch = compareActions(spec.userActions, vm.actions),
missingActions = ["DeleteClick"], // Gap!
// Events
hasEventInterface = true,
eventsImplemented = true
)
```
### Screen Verification
```kotlin
// Read Screen and check:
val screenVerification = ScreenVerification(
// UI states rendered
hasLoadingState = true,
hasSuccessState = true,
hasErrorState = true,
hasEmptyState = false, // Gap!
// TestTags
hasTestTags = false, // Gap!
testTagsObject = null,
// Event collection
collectsEvents = true,
// Content separation
hasContentComposable = true
)
```
### DI Verification
```kotlin
val diVerification = DiVerification(
viewModelRegistered = true,
repositoryRegistered = true,
serviceRegistered = true
)
```
---
## PHASE 4: Gap Detection
### Gap Categories
| Severity | Description | Examples |
|:--------:|-------------|----------|
| P0 | Critical - App won't work | Missing ViewModel, Service not registered |
| P1 | Major - Feature incomplete | Missing action handler, Empty state |
| P2 | Minor - Polish needed | Missing TestTags, Missing Preview |
### Gap Report Structure
```kotlin
val gaps = GapReport(
feature = "beneficiary",
score = 85, // 85% complete
p0Gaps = [], // None - critical items present
p1Gaps = [
Gap(
category = "ViewModel",
item = "DeleteClick action",
specReference = "SPEC.md Section 3.5",
suggestedFix = "Add DeleteClick to BeneficiaryAction sealed interface"
),
Gap(
category = "Screen",
item = "Empty state",
specReference = "SPEC.md Section 2.4",
suggestedFix = "Add BeneficiaryEmpty composable when data.isEmpty()"
)
],
p2Gaps = [
Gap(
category = "Testing",
item = "TestTags object",
specReference = "Testing standards",
suggestedFix = "Add BeneficiaryTestTags object with feature:component pattern"
)
]
)
```
---
## PHASE 5: Report Generation
### Full Verification Report
```
╔═══════════════════════════════════════════════════════════════════════════════╗
║ /verify beneficiary - VERIFICATION REPORT ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ 📊 VERIFICATION SCORE: 85% [████████░░] ║
║ ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ 📚 O(1) CONTEXT LOADED ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ FEATURES_INDEX.md → Feature exists: ✅ Spec status: ✅ Complete ║
║ FEATURE_MAP.md → Services: 1 expected Repos: 1 expected ║
║ MODULES_INDEX.md → VMs: 4 expected Screens: 4 expected ║
║ SCREENS_INDEX.md → 4 screen-VM mappings found ║
║ API_INDEX.md → 4 endpoints expected ║
║ ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ✅ PASSING CHECKS ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ CLIENT LAYER: ║
║ ├─ BeneficiaryService.kt ✅ Exists ║
║ ├─ BeneficiaryRepository.kt ✅ Exists ║
║ ├─ BeneficiaryRepositoryImp.kt ✅ Exists ║
║ ├─ NetworkModule registration ✅ Registered ║
║ └─ RepositoryModule registration ✅ Registered ║
║ ║
║ FEATURE LAYER: ║
║ ├─ BeneficiaryListViewModel.kt ✅ Exists ║
║ ├─ BeneficiaryDetailViewModel.kt ✅ Exists ║
║ ├─ BeneficiaryApplicationViewModel.kt ✅ Exists ║
║ ├─ BeneficiaryApplicationConfirmationVM.kt ✅ Exists ║
║ ├─ 4 Screen files ✅ All exist ║
║ ├─ BeneficiaryModule.kt ✅ DI registered ║
║ └─ Navigation ✅ Configured ║
║ ║
║ STATE MODEL: ║
║ ├─ State class ✅ Defined ║
║ ├─ ScreenState sealed interface ✅ Loading/Success/Error ║
║ ├─ Event sealed interface ✅ Navigation events ║
║ └─ Action sealed interface ✅ User actions ║
║ ║
║ API INTEGRATION: ║
║ ├─ GET /beneficiaries ✅ Called ║
║ ├─ POST /beneficiaries ✅ Called ║
║ ├─ PUT /beneficiaries/{id} ✅ Called ║
║ └─ DELETE /beneficiaries/{id} ✅ Called ║
║ ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ⚠️ GAPS FOUND (3) ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ P1 - MAJOR (2): ║
║ ┌────────────────────────────────────────────────────────────────────────┐ ║
║ │ Gap: Empty state not implemented │ ║
║ │ Spec: SPEC.md Section 2.4 - "Show empty illustration when no data" │ ║
║ │ File: feature/beneficiary/.../ui/BeneficiaryListScreen.kt │ ║
║ │ │ ║
║ │ 📍 Fix: │ ║
║ │ Add to BeneficiaryListScreen: │ ║
║ │ ```kotlin │ ║
║ │ is BeneficiaryUiState.Empty -> { │ ║
║ │ BeneficiaryEmpty( │ ║
║ │ onAddClick = { onAction(BeneficiaryAction.OnAddClick) } │ ║
║ │ ) │ ║
║ │ } │ ║
║ │ ``` │ ║
║ └────────────────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌────────────────────────────────────────────────────────────────────────┐ ║
║ │ Gap: selectedItem state field missing │ ║
║ │ Spec: SPEC.md Section 4.1 - State includes selectedItem for delete │ ║
║ │ File: feature/beneficiary/.../viewmodel/BeneficiaryListViewModel.kt │ ║
║ │ │ ║
║ │ 📍 Fix: │ ║
║ │ Add to BeneficiaryState: │ ║
║ │ ```kotlin │ ║
║ │ val selectedItem: Beneficiary? = null, │ ║
║ │ ``` │ ║
║ └────────────────────────────────────────────────────────────────────────┘ ║
║ ║
║ P2 - MINOR (1): ║
║ ┌────────────────────────────────────────────────────────────────────────┐ ║
║ │ Gap: TestTags object missing │ ║
║ │ Spec: Testing standards - All screens should have TestTags │ ║
║ │ File: feature/beneficiary/.../ui/BeneficiaryTestTags.kt (create) │ ║
║ │ │ ║
║ │ 📍 Fix: Run /feature beneficiary --tags to generate │ ║
║ └────────────────────────────────────────────────────────────────────────┘ ║
║ ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ 📋 SUMMARY ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ | Category | Expected | Found | Score | ║
║ |---------------|:--------:|:-----:|:-----:| ║
║ | Client Layer | 5 | 5 | 100% | ║
║ | Feature Layer | 10 | 9 | 90% | ║
║ | State Model | 8 | 7 | 87% | ║
║ | API Calls | 4 | 4 | 100% | ║
║ | Testing | 2 | 1 | 50% | ║
║ |---------------|----------|-------|-------| ║
║ | TOTAL | 29 | 26 | 85% | ║
║ ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ 🎯 NEXT STEPS ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ Options: ║
║ • f / fix → Run /implement beneficiary to auto-fix gaps ║
║ • m / manual → Fix gaps manually using suggestions above ║
║ • u / update → Update STATUS.md to reflect current state ║
║ • i / ignore → Mark gaps as intentional (document reason) ║
║ ║
╚═══════════════════════════════════════════════════════════════════════════════╝
```
---
## All Features Verification (No Argument)
When `/verify` called without arguments, show summary from index files:
```
╔═══════════════════════════════════════════════════════════════════════════════╗
║ /verify - ALL FEATURES VERIFICATION STATUS ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ Data from: FEATURES_INDEX.md, MODULES_INDEX.md, FEATURE_MAP.md ║
║ ║
║ | # | Feature | Spec | Client | Feature | Score | Gaps | ║
║ |:-:|-------------------|:----:|:------:|:-------:|:-----:|:----:| ║
║ | 1 | auth | ✅ | ✅ | ✅ | 95% | 1 | ║
║ | 2 | home | ✅ | ✅ | ✅ | 100% | 0 | ║
║ | 3 | accounts | ✅ | ✅ | ✅ | 98% | 1 | ║
║ | 4 | beneficiary | ✅ | ✅ | ✅ | 85% | 3 | ║
║ | 5 | loan-account | ✅ | ✅ | ✅ | 92% | 2 | ║
║ | 6 | savings-account | ✅ | ✅ | ✅ | 90% | 2 | ║
║ | 7 | share-account | ✅ | ✅ | ✅ | 88% | 2 | ║
║ | 8 | transfer | ✅ | ✅ | ✅ | 95% | 1 | ║
║ | 9 | recent-transaction| ✅ | ✅ | ✅ | 100% | 0 | ║
║ | 10| notification | ✅ | ✅ | ✅ | 100% | 0 | ║
║ | 11| settings | ✅ | ✅ | ✅ | 85% | 3 | ║
║ | 12| passcode | ✅ | - | ✅ | 100% | 0 | ║
║ | 13| guarantor | ✅ | ✅ | ✅ | 90% | 2 | ║
║ | 14| qr | ✅ | - | ✅ | 95% | 1 | ║
║ | 15| location | ✅ | - | ✅ | 80% | 2 | ║
║ | 16| client-charge | ✅ | ✅ | ✅ | 92% | 1 | ║
║ | 17| dashboard | ⚠️ | ❌ | ❌ | 20% | 8 | ║
║ ║
║ OVERALL: 89% verified | Total Gaps: 29 ║
║ ║
║ Commands: ║
║ • /verify [feature] → Detailed verification ║
║ • /verify all --fix → Show all gaps with fixes ║
║ • /gap-planning feature → Plan to fix gaps ║
║ ║
╚═══════════════════════════════════════════════════════════════════════════════╝
```
---
## Verification Checklist (Quick Reference)
### Client Layer Checks
| Check | Source | Verification |
|-------|--------|--------------|
| Service exists | FEATURE_MAP.md | File exists at path |
| Repository exists | FEATURE_MAP.md | File exists at path |
| RepositoryImpl exists | FEATURE_MAP.md | File exists at path |
| NetworkModule registration | NetworkModule.kt | Contains service binding |
| RepositoryModule registration | RepositoryModule.kt | Contains repo binding |
### Feature Layer Checks
| Check | Source | Verification |
|-------|--------|--------------|
| ViewModel exists | SCREENS_INDEX.md | File exists at path |
| Screen exists | SCREENS_INDEX.md | File exists at path |
| DI Module exists | MODULES_INDEX.md | File exists at path |
| Navigation registered | Navigation graph | Contains route |
### State Model Checks
| Check | Source | Verification |
|-------|--------|--------------|
| State class defined | ViewModel file | `data class ${Feature}State` |
| ScreenState sealed | ViewModel file | `sealed interface ${Feature}UiState` |
| Event sealed | ViewModel file | `sealed interface ${Feature}Event` |
| Action sealed | ViewModel file | `sealed interface ${Feature}Action` |
| handleAction implemented | ViewModel file | `override fun handleAction` |
### UI State Checks
| Check | Source | Verification |
|-------|--------|--------------|
| Loading state | Screen file | `${Feature}UiState.Loading` branch |
| Success state | Screen file | `${Feature}UiState.Success` branch |
| Error state | Screen file | `${Feature}UiState.Error` branch |
| Empty state | Screen file | `${Feature}UiState.Empty` branch (if in spec) |
### Testing Checks
| Check | Source | Verification |
|-------|--------|--------------|
| TestTags object | Screen directory | `${Feature}TestTags.kt` exists |
| testTag modifiers | Screen file | `Modifier.testTag()` used |
| TestTag naming | TestTags object | Follows `feature:component:id` pattern |
| All states tagged | Screen file | Loading, Success, Error have tags |
| Interactive elements | Screen file | Buttons, inputs have tags |
---
## TestTag Validation (Enhanced)
### TestTag Naming Convention
Pattern: `feature:component:element`
| Component | Pattern | Example |
|-----------|---------|---------|
| Screen | `{feature}:screen` | `beneficiary:screen` |
| Loading | `{feature}:loading` | `beneficiary:loading` |
| Error | `{feature}:error` | `beneficiary:error` |
| List | `{feature}:list` | `beneficiary:list` |
| Item | `{feature}:item:{id}` | `beneficiary:item:123` |
| Button | `{feature}:{action}` | `beneficiary:retry`, `beneficiary:add` |
| Input | `{feature}:input:{name}` | `auth:input:username` |
### TestTag Validation Rules
```kotlin
val testTagValidation = TestTagValidation(
// Required TestTags (P2 if missing)
required = [
"${feature}:screen",
"${feature}:loading",
"${feature}:error",
],
// Recommended TestTags (suggestions only)
recommended = [
"${feature}:list", // For list screens
"${feature}:item:{id}", // For list items
"${feature}:retry", // For error retry
"${feature}:empty", // For empty state
],
// Validate naming convention
namingConvention = regex("^[a-z-]+:[a-z-]+(?::[a-z0-9-]+)?$")
)
```
### TestTag Validation Report
```
╠═══════════════════════════════════════════════════════════════════════════════╣
║ 🏷️ TESTTAG VALIDATION ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ TestTags Object: ${Feature}TestTags.kt ║
║ Location: feature/${name}/.../ui/${Feature}TestTags.kt ║
║ Status: [✅ EXISTS | ❌ MISSING] ║
║ ║
║ Required Tags: ║
║ ├─ ${feature}:screen [✅ Found | ❌ Missing] ║
║ ├─ ${feature}:loading [✅ Found | ❌ Missing] ║
║ └─ ${feature}:error [✅ Found | ❌ Missing] ║
║ ║
║ Screen Usage: ║
║ ├─ ${Feature}Screen.kt testTag() calls: [n] ║
║ ├─ ${Feature}Content.kt testTag() calls: [n] ║
║ └─ Total coverage: [n] / [expected] ║
║ ║
║ Naming Convention: ║
║ ├─ Valid tags: [n] ║
║ └─ Invalid tags: [list of non-conforming tags] ║
║ ║
╠═══════════════════════════════════════════════════════════════════════════════╣
```
### TestTag Gap Examples
```
┌────────────────────────────────────────────────────────────────────────┐
│ Gap: TestTags object missing │
│ Severity: P2 (Testing) │
│ File: feature/${name}/.../ui/${Feature}TestTags.kt (create) │
│ │
│ 📍 Fix: Generate TestTags │
│ ```kotlin │
│ internal object ${Feature}TestTags { │
│ const val SCREEN = "${feature}:screen" │
│ const val LOADING = "${feature}:loading" │
│ const val ERROR = "${feature}:error" │
│ const val LIST = "${feature}:list" │
│ const val RETRY_BUTTON = "${feature}:retry" │
│ const val ITEM_PREFIX = "${feature}:item:" // + id │
│ } │
│ ``` │
│ │
│ Command: /feature ${feature} --tags │
└────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────┐
│ Gap: Missing testTag modifiers in Screen │
│ Severity: P2 (Testing) │
│ File: feature/${name}/.../ui/${Feature}Screen.kt │
│ │
│ 📍 Fix: Add testTag modifiers to composables │
│ ```kotlin │
│ // Loading state │
│ MifosLoadingWheel( │
│ modifier = Modifier.testTag(${Feature}TestTags.LOADING) │
│ ) │
│ │
│ // Error state │
│ MifosErrorContent( │
│ modifier = Modifier.testTag(${Feature}TestTags.ERROR) │
│ ) │
│ │
│ // List │
│ LazyColumn( │
│ modifier = Modifier.testTag(${Feature}TestTags.LIST) │
│ ) │
│ ``` │
└────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────┐
│ Gap: TestTag naming doesn't follow convention │
│ Severity: P2 (Polish) │
│ File: feature/${name}/.../ui/${Feature}TestTags.kt │
│ │
│ Found: "BeneficiaryScreen", "LoadingIndicator" │
│ Expected: "beneficiary:screen", "beneficiary:loading" │
│ │
│ 📍 Fix: Update to feature:component:element pattern │
│ ```kotlin │
│ // Before (invalid) │
│ const val SCREEN = "BeneficiaryScreen" │
│ const val LOADING = "LoadingIndicator" │
│ │
│ // After (valid) │
│ const val SCREEN = "beneficiary:screen" │
│ const val LOADING = "beneficiary:loading" │
│ ``` │
└────────────────────────────────────────────────────────────────────────┘
```
### TestTag Scoring
| Criterion | Weight | Passed | Score |
|-----------|:------:|:------:|:-----:|
| TestTags object exists | 40% | ✅/❌ | x/40 |
| Required tags defined | 30% | n/3 | x/30 |
| testTag() modifiers used | 20% | n/m | x/20 |
| Naming convention | 10% | n/n | x/10 |
| **Total** | 100% | | x/100 |
---
## Error Handling
### Feature Not Found
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ❌ FEATURE NOT FOUND │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ Feature: "xyz" │
│ Checked: FEATURES_INDEX.md │
│ │
│ Did you mean one of these? │
│ • beneficiary │
│ • beneficiary-detail │
│ │
│ Or run /verify to see all features. │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Spec Missing
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ⚠️ SPEC MISSING │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ Feature: dashboard │
│ Expected: design-spec-layer/features/dashboard/SPEC.md │
│ Found: File does not exist │
│ │
│ Cannot verify without specification. │
│ │
│ Options: │
│ • d / design → Run /design dashboard to create spec │
│ • c / code → Verify code only (--code flag) │
│ • a / abort → Cancel verification │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## O(1) File Reference
| Index File | Data Used For |
|------------|---------------|
| `design-spec-layer/FEATURES_INDEX.md` | Feature list, spec status |
| `client-layer/FEATURE_MAP.md` | Service/Repository paths |
| `feature-layer/MODULES_INDEX.md` | Module structure, VM/Screen counts |
| `feature-layer/SCREENS_INDEX.md` | Screen-ViewModel mappings, file paths |
| `server-layer/API_INDEX.md` | Expected API endpoints |
---
## Related Commands
| Command | Purpose |
|---------|---------|
| `/implement [Feature]` | Fix gaps automatically |
| `/gap-analysis [Feature]` | Broader gap analysis |
| `/gap-planning [Feature]` | Plan fixes for gaps |
| `/design [Feature]` | Update specification |
| `/verify-tests [Feature]` | Verify test coverage |

View File

@ -1,8 +1,8 @@
# Current Work # Current Work
**Last Updated**: 2026-01-03 **Last Updated**: 2026-01-05
**Branch**: feature/design-specifications **Branch**: feature/design-specifications
**Session Note**: Generated auth mockups, added MCP integration for AI design tools **Session Note**: Phase 4 Complete - core:testing module created with fakes, fixtures, TestTags
--- ---
@ -10,45 +10,89 @@
| # | Task | Feature | Status | Files | Notes | | # | Task | Feature | Status | Files | Notes |
|---|------|---------|:------:|-------|-------| |---|------|---------|:------:|-------|-------|
| 1 | Mockup Generation | home | ⏳ Next | features/home/mockups/ | Run `/design home mockup` | | 1 | Command Rewrite | implement | ✅ Done | .claude/commands/implement.md | O(1) + Pattern Detection |
| 2 | Mockup Generation | auth | ✅ Done | features/auth/mockups/ | PROMPTS.md + design-tokens.json | | 2 | Command Rewrite | client | ✅ Done | .claude/commands/client.md | O(1) + Pattern Detection |
| 3 | v2.0 UI Implementation | dashboard | Planned | feature/dashboard/ | After mockups done | | 3 | Command Rewrite | feature | ✅ Done | .claude/commands/feature.md | O(1) + TestTags |
| 4 | MCP Integration | design | ✅ Done | TOOL_CONFIG.md | Stitch MCP installed | | 4 | Command Rewrite | verify | ✅ Done | .claude/commands/verify.md | O(1) + Gap Detection |
| 5 | Commands README | commands | ✅ Done | .claude/commands/README.md | Full reference | | 5 | Command Rewrite | design | ✅ Done | .claude/commands/design.md | O(1) + Mockup Status |
| 6 | Command Rewrite | verify-tests | ✅ Done | .claude/commands/verify-tests.md | O(1) + Test Status |
| 7 | Design Token Integration | feature | ✅ Done | .claude/commands/feature.md | Phase 3.5 Token Integration |
| 8 | Design Tokens Index | design-spec | ✅ Done | DESIGN_TOKENS_INDEX.md | O(1) token lookup |
| 9 | Test Stub Generation | implement | ✅ Done | .claude/commands/implement.md | Phase 5: Auto-generate tests |
| 10 | TestTag Validation | verify | ✅ Done | .claude/commands/verify.md | Enhanced TestTag checks |
| 11 | Test Stubs Guide | docs | ✅ Done | TEST_STUBS_GUIDE.md | TDD reference |
| 12 | core:testing Module | testing | ✅ Done | core/testing/ | Fakes, fixtures, TestTags |
| 13 | Mockup Generation | home | ⏳ Next | features/home/mockups/ | Run `/design home mockup` |
| 14 | v2.0 UI Implementation | dashboard | Planned | feature/dashboard/ | After mockups done |
--- ---
## In Progress ## In Progress
### Design Layer - Phase 2: Mockup Generation ### Phase 1, 2 & 3 Complete: Commands + Design Integration + Testing Automation
**All 6 core commands now use O(1 lookup pattern**:
| Command | Index Files Used | Key Features |
|---------|------------------|--------------|
| `/implement` | FEATURE_MAP, MODULES_INDEX, SCREENS_INDEX | Pattern detection, TestTags |
| `/client` | FEATURE_MAP, API_INDEX | Service/Repository patterns |
| `/feature` | MODULES_INDEX, SCREENS_INDEX, DESIGN_TOKENS_INDEX | MVI pattern, TestTags, **Token Integration** |
| `/verify` | FEATURES_INDEX, FEATURE_MAP, MODULES_INDEX, SCREENS_INDEX, API_INDEX | Gap detection, verification scoring |
| `/design` | FEATURES_INDEX, MOCKUPS_INDEX, API_INDEX | Mockup status, tool selection |
| `/verify-tests` | TESTING_STATUS.md (all layers), MODULES_INDEX | Test dashboard, coverage tracking |
**Phase 2: Design Token Integration**:
- Created `DESIGN_TOKENS_INDEX.md` for O(1) token lookup
- Added Phase 3.5 to `/feature` command
- Supports both Google Stitch and MD3 token formats
- Auto-generates `${Feature}Theme.kt` with gradients/colors
- Auto-generates `${Feature}Animations.kt` if animations defined
- Maps tokens to existing `DesignToken` system
**Features with Design Tokens**: 8/17
- ✅ auth (google-stitch) - gradients, animations
- ✅ dashboard (md3) - components
- ✅ settings, guarantor, qr, passcode, location, client-charge (md3)
**Phase 3: Testing Automation**:
- Added Phase 5 to `/implement` command for test stub generation
- Enhanced `/verify` command with TestTag validation
- Created `TEST_STUBS_GUIDE.md` documentation
- Auto-generates: ViewModel tests, Screen tests, Fake repositories
- Validates: TestTag naming convention (`feature:component:id`)
- Supports TDD workflow: Red → Green → Refactor
**Testing Layer (6th Layer)**:
- Created `testing-layer/` with O(1) index files
- `LAYER_STATUS.md` - Test coverage dashboard (17 features)
- `TEST_PATTERNS.md` - ViewModel, Screen, Fake, Integration, Screenshot patterns
- `TEST_TAGS_INDEX.md` - TestTag specifications for all features
- `TEST_FIXTURES_INDEX.md` - Test fixture inventory
- `FAKE_REPOS_INDEX.md` - Fake repository inventory
- `patterns/` - Detailed pattern files (viewmodel-test.md, screen-test.md, etc.)
- `templates/` - Code templates (.kt.template files)
**Phase 4: core:testing Module** (NEW):
- Enabled `core:testing` in settings.gradle.kts
- Created KMP module with commonMain/androidMain source sets
- **TestTags**: Complete tags for all 17 features (auth, home, accounts, etc.)
- **Fake Repositories**: FakeUserAuthRepository, FakeHomeRepository, FakeAccountsRepository, FakeBeneficiaryRepository, FakeTransferRepository, FakeNotificationRepository
- **Test Fixtures**: UserFixture, ClientAccountsFixture, BeneficiaryFixture
- **Test Utils**: MainDispatcherRule, FlowTestExtensions, TestCoroutineExtensions
- **DI Module**: TestModule for Koin test setup
- Module compiles successfully ✅
---
## Design Layer - Phase 2: Mockup Generation
**Progress**: 2/17 features (12%) **Progress**: 2/17 features (12%)
- ✅ dashboard - mockups generated - ✅ dashboard - mockups generated
- ✅ auth - mockups generated (this session) - ✅ auth - mockups generated
- ⏳ home - next - ⏳ home - next
- ⏳ 14 more features pending - ⏳ 14 more features pending
**MCP Status**:
```
stitch-ai: ✅ Installed (restart to connect)
figma: ⚠️ Needs authentication
```
**What was done this session**:
- Generated auth mockups (8 screens)
- Added AI tool selection to `/design [feature] mockup`
- Added MCP setup prompts
- Created TOOL_CONFIG.md
- Installed Google Stitch MCP
- Updated commands README with full reference
**What's next** (15 features pending):
1. Restart Claude Code to activate Stitch MCP
2. Run `/design home mockup` to generate home mockups
3. Continue through remaining features
4. Use Google Stitch to generate visual designs
5. Export to Figma
**Commands**: **Commands**:
``` ```
/gap-analysis design mockup # See mockup progress (2/17) /gap-analysis design mockup # See mockup progress (2/17)
@ -56,22 +100,28 @@ figma: ⚠️ Needs authentication
/design [feature] mockup # Generate mockups for feature /design [feature] mockup # Generate mockups for feature
``` ```
### Dashboard Feature (After Mockups)
**Status**: Waiting for all mockups to be generated
--- ---
## Recently Completed ## Recently Completed
| Date | Task | Feature | Outcome | | Date | Task | Feature | Outcome |
|------|------|---------|---------| |------|------|---------|---------|
| 2026-01-05 | core:testing Module | core/testing/ | Fakes, fixtures, TestTags - compiles ✅ |
| 2026-01-05 | /gap-status command | commands | Plan progress tracking |
| 2026-01-05 | Testing Layer (6th Layer) | testing-layer/ | Full O(1) test infrastructure |
| 2026-01-05 | Phase 3: Testing Automation | implement/verify | Test stubs + TestTag validation |
| 2026-01-05 | TEST_STUBS_GUIDE.md | docs | TDD reference documentation |
| 2026-01-05 | Phase 2: Design Token Integration | feature | Phase 3.5 + DESIGN_TOKENS_INDEX.md |
| 2026-01-05 | Command Rewrite | verify | O(1) + Gap Detection + Verification Score |
| 2026-01-05 | Command Rewrite | design | O(1) + Mockup Status + Tool Selection |
| 2026-01-05 | Command Rewrite | verify-tests | O(1) + Test Dashboard + Coverage Tracking |
| 2026-01-05 | Command Rewrite | implement | O(1) + Pattern Detection + TestTags |
| 2026-01-05 | Command Rewrite | client | O(1) + Pattern Detection |
| 2026-01-05 | Command Rewrite | feature | O(1) + Pattern Detection + TestTags |
| 2026-01-03 | Auth mockups | auth | Generated PROMPTS.md + design-tokens.json | | 2026-01-03 | Auth mockups | auth | Generated PROMPTS.md + design-tokens.json |
| 2026-01-03 | MCP integration | design | Added tool selection, installed stitch-ai | | 2026-01-03 | MCP integration | design | Added tool selection, installed stitch-ai |
| 2026-01-03 | Commands README | commands | Full reference with all sub-commands | | 2026-01-03 | Commands README | commands | Full reference with all sub-commands |
| 2026-01-03 | Sub-section support | gap-analysis | Added {layer} {sub-section} syntax | | 2026-01-03 | Sub-section support | gap-analysis | Added {layer} {sub-section} syntax |
| 2026-01-03 | Sub-section support | gap-planning | Added {layer} {sub-section} syntax |
| 2026-01-03 | Sub-section templates | templates | Created 14 templates in subsection/ |
--- ---
@ -79,40 +129,56 @@ figma: ⚠️ Needs authentication
### Key Files to Read ### Key Files to Read
1. This file (`CURRENT_WORK.md`) 1. This file (`CURRENT_WORK.md`)
2. `.claude/commands/README.md` - Full command reference 2. `.claude/commands/implement.md` - E2E implementation with **Phase 5 Test Stubs**
3. `design-spec-layer/TOOL_CONFIG.md` - AI tool settings 3. `.claude/commands/feature.md` - Feature layer with **Phase 3.5 Token Integration**
4. `features/auth/mockups/` - Example of generated mockups 4. `.claude/commands/verify.md` - Verification with O(1) + **TestTag Validation**
5. `.claude/commands/design.md` - Design with O(1) + Mockup Status
6. `.claude/commands/verify-tests.md` - Test verification with O(1)
7. `TEST_STUBS_GUIDE.md` - TDD test stub reference
8. `core/testing/` - **NEW** Testing module with fakes, fixtures, TestTags
9. `design-spec-layer/DESIGN_TOKENS_INDEX.md` - O(1) token lookup
10. `client-layer/FEATURE_MAP.md` - Service/Repository mapping
11. `feature-layer/MODULES_INDEX.md` - Module inventory
12. `feature-layer/SCREENS_INDEX.md` - Screen inventory
### Key Commands ### Key Commands
- `/session-start` - Load this context - `/session-start` - Load this context
- `/gap-analysis design mockup` - See mockup progress - `/gap-analysis` - Quick overview of all layers
- `/design home mockup` - Generate next feature mockups - `/implement [feature]` - Full E2E implementation (updated)
- `claude mcp list` - Check MCP status - `/client [feature]` - Client layer only (updated)
- `/feature [feature]` - Feature layer only (updated)
- `/verify [feature]` - Verify implementation vs spec
### MCP Setup (if needed) ### O(1) Index Files (Core Context)
```bash | File | Purpose | Lines |
# Google Stitch (already installed) |------|---------|:-----:|
claude mcp add stitch-ai -- npx -y stitch-ai-mcp | FEATURE_MAP.md | Service → Feature mapping | ~170 |
| MODULES_INDEX.md | All feature modules | ~115 |
# Figma (optional) | SCREENS_INDEX.md | All 63 screens | ~270 |
claude mcp add figma # Follow auth flow | API_INDEX.md | All API endpoints | ~400 |
``` | FEATURES_INDEX.md | All 17 features | ~100 |
| DESIGN_TOKENS_INDEX.md | **NEW** Design tokens per feature | ~150 |
### Architecture Notes ### Architecture Notes
- KMP: Android, iOS, Desktop, Web - KMP: Android, iOS, Desktop, Web
- DI: Koin modules per feature - DI: Koin modules per feature
- Navigation: Jetbrains Compose Navigation - Navigation: Jetbrains Compose Navigation
- Network: Ktorfit services - Network: Ktorfit services
- State: MVI pattern (State, Event, Action)
- Testing: TestTags pattern (feature:component:id)
--- ---
## Resume Instructions ## Resume Instructions
1. Run `/session-start` to load context 1. Run `/session-start` to load context
2. Check MCP: `claude mcp list` 2. Run `/gap-analysis` to see current status
3. Run `/gap-analysis design mockup` to see progress 3. Test new commands:
4. Run `/design home mockup` to continue mockup generation - `/implement` - Should show feature list with O(1 lookup
5. Repeat for remaining 14 features - `/client [feature]` - Should show service/repo status
- `/feature [feature]` - Should show module/screen status
4. Continue mockup generation with `/design home mockup`
5. Eventually test full E2E with `/implement [new-feature]`
--- ---
@ -120,5 +186,26 @@ claude mcp add figma # Follow auth flow
| Date | Focus | Outcome | | Date | Focus | Outcome |
|------|-------|---------| |------|-------|---------|
| 2026-01-05 | Phase 4: core:testing Module | Created core/testing/ with fakes, fixtures, TestTags ✅ |
| 2026-01-05 | Testing Layer (6th Layer) | Created testing-layer/ with O(1 indexes, patterns, templates |
| 2026-01-05 | Phase 3: Testing Automation | Test stubs in /implement, TestTag validation in /verify |
| 2026-01-05 | Phase 2: Design-Code Integration | Token integration in /feature, DESIGN_TOKENS_INDEX.md |
| 2026-01-05 | Phase 1: Command Rewrite | All 6 core commands now O(1): verify, design, verify-tests |
| 2026-01-05 | Command rewrite | /implement, /client, /feature with O(1) + patterns |
| 2026-01-03 | Mockup generation | Auth mockups done, MCP integrated, 2/17 complete | | 2026-01-03 | Mockup generation | Auth mockups done, MCP integrated, 2/17 complete |
| 2026-01-03 | Command refactoring | Created template system, 5-layer structure | | 2026-01-03 | Command refactoring | Created template system, 5-layer structure |
---
## What's Next
1. **Write First Tests**: Use `core:testing` module to write ViewModel tests for auth feature
2. **Continue Mockups**: Generate mockups for remaining 15 features (`/design home mockup`)
3. **Dashboard Feature**: After mockups, implement new dashboard
4. **Phase 5: Session Persistence** (gap-planning roadmap)
- Intelligent session checkpoints
- Context continuity across restarts
5. **Phase 6: Documentation Integration** (gap-planning roadmap)
- Auto-generate README updates
- Architecture diagram generation
6. **Migration**: Move claude-product-cycle to separate repo (after validation)

View File

@ -0,0 +1,560 @@
# Claude Product Cycle - Template Migration Plan
## Overview
Move claude-product-cycle architecture to `kmp-project-template` so all derived projects inherit the framework while maintaining project-specific implementations.
---
## Current State
```
kmp-project-template/ # Base template (no claude-product-cycle)
├── mifos-mobile/ # Has full claude-product-cycle
├── mobile-wallet/ # No claude-product-cycle
├── android-client/ # No claude-product-cycle
└── mifos-x-group-banking/ # No claude-product-cycle
```
## Target State
```
kmp-project-template/
├── .claude/
│ └── commands/ # Framework commands (parameterized)
├── claude-product-cycle/
│ ├── _templates/ # Reusable templates
│ ├── _framework/ # Architecture documentation
│ └── PROJECT_SETUP.md # How to configure for a project
├── mifos-mobile/ # Extends template
│ └── claude-product-cycle/
│ ├── design-spec-layer/ # Project-specific specs
│ ├── server-layer/ # Project-specific APIs
│ ├── client-layer/ # Project-specific mappings
│ ├── feature-layer/ # Project-specific features
│ └── platform-layer/ # Project-specific platforms
```
---
## Architecture Decision
### What Goes in Template (Reusable)
| Component | Purpose | Sync Strategy |
|-----------|---------|---------------|
| Commands Framework | `/gap-analysis`, `/gap-planning`, etc. | Override in projects |
| Layer Templates | SPEC.md, API.md structure | Copy on init |
| Index Templates | FEATURES_INDEX.md structure | Generate per project |
| Testing Framework | Test patterns, TestTag system | Inherit |
| Architecture Docs | 5-layer lifecycle, patterns | Reference |
### What Stays in Projects (Specific)
| Component | Purpose | Example |
|-----------|---------|---------|
| Feature Specs | SPEC.md content | "Login with biometrics" |
| API Documentation | Actual endpoints | `/self/authentication` |
| Mockups | UI designs | Figma links, tokens |
| Implementation Status | Progress tracking | ✅/⚠️/❌ per feature |
| Feature List | Project features | auth, home, transfer... |
---
## Proposed Directory Structure
### Template Repository (`kmp-project-template`)
```
kmp-project-template/
├── .claude/
│ ├── commands/
│ │ ├── gap-analysis.md # Parameterized framework
│ │ ├── gap-planning.md # Parameterized framework
│ │ ├── design.md # Parameterized framework
│ │ ├── implement.md # Parameterized framework
│ │ ├── verify.md # Parameterized framework
│ │ ├── verify-tests.md # Parameterized framework
│ │ ├── session-start.md # Session management
│ │ ├── session-end.md # Session management
│ │ └── projectstatus.md # Project overview
│ └── settings.json # Default Claude settings
├── claude-product-cycle/
│ ├── _framework/
│ │ ├── 5-LAYER-LIFECYCLE.md # Architecture overview
│ │ ├── O1-LOOKUP-PATTERN.md # Index file strategy
│ │ ├── COMMAND-REFERENCE.md # All commands explained
│ │ └── TESTING-STRATEGY.md # TDD approach
│ │
│ ├── _templates/
│ │ ├── design-spec-layer/
│ │ │ ├── FEATURES_INDEX.template.md
│ │ │ ├── MOCKUPS_INDEX.template.md
│ │ │ ├── TESTING_STATUS.template.md
│ │ │ └── feature/
│ │ │ ├── SPEC.template.md
│ │ │ ├── API.template.md
│ │ │ └── STATUS.template.md
│ │ │
│ │ ├── server-layer/
│ │ │ ├── API_INDEX.template.md
│ │ │ ├── API_REFERENCE.template.md
│ │ │ ├── TESTING_STATUS.template.md
│ │ │ └── endpoints/
│ │ │ └── CATEGORY.template.md
│ │ │
│ │ ├── client-layer/
│ │ │ ├── FEATURE_MAP.template.md
│ │ │ ├── LAYER_STATUS.template.md
│ │ │ └── TESTING_STATUS.template.md
│ │ │
│ │ ├── feature-layer/
│ │ │ ├── MODULES_INDEX.template.md
│ │ │ ├── SCREENS_INDEX.template.md
│ │ │ ├── LAYER_STATUS.template.md
│ │ │ └── TESTING_STATUS.template.md
│ │ │
│ │ ├── platform-layer/
│ │ │ ├── LAYER_STATUS.template.md
│ │ │ ├── TESTING_STATUS.template.md
│ │ │ └── platforms/
│ │ │ ├── ANDROID.template.md
│ │ │ ├── IOS.template.md
│ │ │ ├── DESKTOP.template.md
│ │ │ └── WEB.template.md
│ │ │
│ │ └── gap-analysis/
│ │ ├── dashboard.template.md
│ │ └── layer-*.template.md
│ │
│ ├── PROJECT_CONFIG.md # How to configure
│ ├── SYNC_GUIDE.md # How to sync updates
│ └── CHANGELOG.md # Version history
└── CLAUDE.md # References claude-product-cycle
```
### Derived Project (`mifos-mobile`, `mobile-wallet`, etc.)
```
mifos-mobile/
├── .claude/
│ ├── commands/ # Can override template commands
│ │ └── custom-command.md # Project-specific commands
│ └── settings.json # Project-specific settings
├── claude-product-cycle/
│ ├── PROJECT.md # Project identity & config
│ │ - name: "Mifos Mobile"
│ │ - type: "Self-Service Banking"
│ │ - features: [auth, home, accounts, ...]
│ │ - api_base: "https://server/fineract-provider/api/v1/self/"
│ │
│ ├── design-spec-layer/
│ │ ├── FEATURES_INDEX.md # Generated from template
│ │ ├── MOCKUPS_INDEX.md # Project-specific content
│ │ ├── STATUS.md
│ │ ├── TESTING_STATUS.md
│ │ └── features/
│ │ ├── auth/
│ │ │ ├── SPEC.md # Project-specific
│ │ │ ├── API.md # Project-specific
│ │ │ ├── STATUS.md
│ │ │ └── mockups/
│ │ ├── home/
│ │ └── ... (all project features)
│ │
│ ├── server-layer/
│ │ ├── API_INDEX.md # Project-specific endpoints
│ │ ├── API_REFERENCE.md
│ │ ├── TESTING_STATUS.md
│ │ └── endpoints/ # Project-specific
│ │
│ ├── client-layer/
│ │ ├── FEATURE_MAP.md # Project-specific mappings
│ │ ├── LAYER_STATUS.md
│ │ └── TESTING_STATUS.md
│ │
│ ├── feature-layer/
│ │ ├── MODULES_INDEX.md # Project-specific modules
│ │ ├── SCREENS_INDEX.md # Project-specific screens
│ │ ├── LAYER_STATUS.md
│ │ └── TESTING_STATUS.md
│ │
│ └── platform-layer/
│ ├── LAYER_STATUS.md # Project-specific
│ ├── TESTING_STATUS.md
│ └── platforms/
└── CLAUDE.md # Project-specific instructions
```
---
## Project Configuration (`PROJECT.md`)
Each derived project has a `PROJECT.md` that configures the framework:
```markdown
# Project Configuration
## Identity
| Key | Value |
|-----|-------|
| name | Mifos Mobile |
| type | Self-Service Banking |
| repo | openMF/mifos-mobile |
| template_version | 1.0.0 |
## API Configuration
| Key | Value |
|-----|-------|
| base_url | https://{server}/fineract-provider/api/v1/self/ |
| auth_type | Basic + Tenant Header |
| demo_server | tt.mifos.community |
| demo_user | maria / password |
## Features
| # | Feature | Design Dir | Feature Dir |
|:-:|---------|------------|-------------|
| 1 | auth | features/auth/ | feature/auth/ |
| 2 | home | features/home/ | feature/home/ |
| ... | ... | ... | ... |
## Platforms
| Platform | Module | Status |
|----------|--------|--------|
| Android | cmp-android | Primary |
| iOS | cmp-ios | CocoaPods |
| Desktop | cmp-desktop | JVM |
| Web | cmp-web | Experimental |
```
---
## Command Parameterization
Commands in template read from `PROJECT.md` to adapt behavior:
### Template Command (gap-analysis.md)
```markdown
# Gap Analysis Command
## Instructions
### Step 0: Read Project Config
Read `claude-product-cycle/PROJECT.md` to get:
- Feature list
- API base URL
- Platform configuration
### Step 1: Read O(1) Index Files
[... rest of command using project config ...]
```
### Override Mechanism
Projects can override commands by creating same file in `.claude/commands/`:
```
Template: kmp-project-template/.claude/commands/gap-analysis.md
Override: mifos-mobile/.claude/commands/gap-analysis.md (takes precedence)
```
---
## Sync Strategy
### Option A: Git Subtree (Recommended)
```bash
# In derived project, add template as subtree
git subtree add --prefix=claude-product-cycle/_framework \
https://github.com/openMF/kmp-project-template.git \
main --squash
# Pull updates from template
git subtree pull --prefix=claude-product-cycle/_framework \
https://github.com/openMF/kmp-project-template.git \
main --squash
```
**Pros**:
- Single repo, no submodule complexity
- Can modify locally if needed
- Easy to pull updates
**Cons**:
- Subtree history can get messy
- Manual pull required
### Option B: Git Submodule
```bash
# Add template as submodule
git submodule add https://github.com/openMF/kmp-project-template.git \
claude-product-cycle/_framework
# Update submodule
git submodule update --remote
```
**Pros**:
- Clear separation
- Explicit versioning
**Cons**:
- Submodule complexity
- Extra clone steps
### Option C: Copy + Version Tag (Simplest)
```bash
# Copy template files on setup
cp -r kmp-project-template/claude-product-cycle/_framework/ \
mifos-mobile/claude-product-cycle/_framework/
# Track version in PROJECT.md
template_version: 1.0.0
```
**Pros**:
- Simplest to understand
- No git complexity
**Cons**:
- Manual sync
- Can diverge
### Recommendation: Option A (Git Subtree)
Best balance of simplicity and maintainability.
---
## Migration Steps
### Phase 1: Prepare Template (Week 1)
1. **Create framework docs in template**
```bash
mkdir -p kmp-project-template/claude-product-cycle/_framework
mkdir -p kmp-project-template/claude-product-cycle/_templates
```
2. **Extract reusable content from mifos-mobile**
- Copy command files to template
- Parameterize hardcoded values
- Create template files with `{{PLACEHOLDER}}`
3. **Create PROJECT_CONFIG.md template**
4. **Test in template repo**
### Phase 2: Migrate mifos-mobile (Week 2)
1. **Restructure claude-product-cycle**
```bash
# Move framework to _framework/
# Keep project-specific in layer folders
```
2. **Create PROJECT.md with mifos-mobile config**
3. **Update commands to read from PROJECT.md**
4. **Verify all commands work**
### Phase 3: Rollout to Other Projects (Week 3-4)
1. **mobile-wallet (mifos-pay)**
- Clone template structure
- Create PROJECT.md
- Generate initial index files
- Add feature specs
2. **android-client (field officer)**
- Same process
3. **mifos-x-group-banking**
- Same process
### Phase 4: Establish Sync Process (Week 5)
1. **Document sync procedure**
2. **Create GitHub Action for version check**
3. **Add CHANGELOG.md to template**
---
## Project-Specific Customizations
### mifos-mobile (Self-Service)
| Aspect | Value |
|--------|-------|
| User Type | End User (Client) |
| API Prefix | `/self/` |
| Features | 17 (auth, accounts, transfer, etc.) |
| Auth | Username/Password + Passcode |
### mobile-wallet (Mifos Pay)
| Aspect | Value |
|--------|-------|
| User Type | Wallet User |
| API Prefix | `/wallet/` (different API) |
| Features | wallet, send, receive, history, etc. |
| Auth | Phone + OTP |
### android-client (Field Officer)
| Aspect | Value |
|--------|-------|
| User Type | Staff (Field Officer) |
| API Prefix | `/` (full API access) |
| Features | clients, loans, groups, collections, etc. |
| Auth | Username/Password + Staff permissions |
### mifos-x-group-banking
| Aspect | Value |
|--------|-------|
| User Type | Group Leader |
| API Prefix | `/groups/` |
| Features | groups, meetings, attendance, collections |
| Auth | Username/Password + Group permissions |
---
## Template Versioning
```markdown
# CHANGELOG.md (in template)
## [1.1.0] - 2025-02-01
### Added
- Testing documentation templates
- /verify-tests command
### Changed
- Improved gap-analysis comprehensive view
## [1.0.0] - 2025-01-05
### Added
- Initial 5-layer lifecycle framework
- O(1) lookup pattern
- All command templates
```
### Version Compatibility
| Template Version | mifos-mobile | mobile-wallet | android-client |
|------------------|--------------|---------------|----------------|
| 1.0.0 | ✅ | - | - |
| 1.1.0 | ✅ | ✅ | - |
---
## Files to Create in Template
### Priority 1: Framework Documentation
| File | Purpose | Lines |
|------|---------|:-----:|
| `_framework/5-LAYER-LIFECYCLE.md` | Core architecture | ~150 |
| `_framework/O1-LOOKUP-PATTERN.md` | Index strategy | ~100 |
| `_framework/COMMAND-REFERENCE.md` | All commands | ~200 |
| `_framework/TESTING-STRATEGY.md` | TDD approach | ~150 |
### Priority 2: Templates
| File | Purpose |
|------|---------|
| `_templates/design-spec-layer/*.template.md` | Design layer templates |
| `_templates/server-layer/*.template.md` | Server layer templates |
| `_templates/client-layer/*.template.md` | Client layer templates |
| `_templates/feature-layer/*.template.md` | Feature layer templates |
| `_templates/platform-layer/*.template.md` | Platform layer templates |
### Priority 3: Commands
| File | Purpose |
|------|---------|
| `.claude/commands/gap-analysis.md` | Parameterized |
| `.claude/commands/gap-planning.md` | Parameterized |
| `.claude/commands/design.md` | Parameterized |
| `.claude/commands/implement.md` | Parameterized |
| `.claude/commands/verify.md` | Parameterized |
---
## Success Criteria
### Template
- [ ] All framework docs in `_framework/`
- [ ] All templates in `_templates/`
- [ ] Parameterized commands in `.claude/commands/`
- [ ] PROJECT_CONFIG.md template
- [ ] SYNC_GUIDE.md documentation
### Each Derived Project
- [ ] PROJECT.md configured
- [ ] Feature specs in design-spec-layer
- [ ] API docs in server-layer
- [ ] Index files generated
- [ ] Commands working with project config
---
## Open Questions
1. **Command Override Strategy**
- Should projects fully override commands or extend them?
- How to handle project-specific commands?
2. **Sync Frequency**
- How often should projects sync from template?
- Breaking change policy?
3. **Feature Naming**
- Standardize feature names across projects?
- Or allow project-specific naming?
4. **Testing**
- Should test framework be in template?
- Or project-specific?
---
## Next Steps
1. Review this plan
2. Decide on sync strategy (subtree vs submodule vs copy)
3. Start Phase 1: Prepare template
4. Create PR to kmp-project-template
---
## Commands After Migration
```bash
# In any derived project
/gap-analysis # Reads PROJECT.md, shows project-specific status
/gap-planning design # Plans based on project features
/implement auth # Implements based on project specs
# Sync from template
git subtree pull --prefix=claude-product-cycle/_framework \
https://github.com/openMF/kmp-project-template.git main --squash
```

View File

@ -0,0 +1,535 @@
# Test Stubs Guide
> Auto-generated test scaffolding for TDD support in `/implement` command
---
## Overview
The `/implement` command automatically generates test stubs (Phase 5) to support Test-Driven Development. This guide explains the generated files, how to use them, and best practices.
---
## Generated Test Files
When running `/implement [feature]`, the following test files are created:
```
feature/[feature]/
├── src/
│ ├── commonTest/kotlin/org/mifos/mobile/feature/[feature]/
│ │ ├── [Feature]ViewModelTest.kt # ViewModel unit tests
│ │ └── fake/
│ │ └── Fake[Feature]Repository.kt # Test double
│ │
│ └── androidInstrumentedTest/kotlin/org/mifos/mobile/feature/[feature]/
│ └── [Feature]ScreenTest.kt # Compose UI tests
```
---
## Test Patterns
### 1. ViewModel Test Pattern
```kotlin
class ${Feature}ViewModelTest {
// Rule for coroutine testing
private val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: ${Feature}ViewModel
private lateinit var fakeRepository: Fake${Feature}Repository
@BeforeTest
fun setup() {
fakeRepository = Fake${Feature}Repository()
viewModel = ${Feature}ViewModel(repository = fakeRepository)
}
// Test Categories:
// 1. Initial State Tests
@Test
fun `initial state is loading`() = runTest {
viewModel.stateFlow.test {
val state = awaitItem()
assertTrue(state.uiState is ${Feature}ScreenState.Loading)
}
}
// 2. Success State Tests
@Test
fun `data loaded successfully shows success state`() = runTest {
fakeRepository.setSuccessResponse(testData)
viewModel.loadData()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is ${Feature}ScreenState.Success)
}
}
// 3. Error State Tests
@Test
fun `data load failure shows error state`() = runTest {
fakeRepository.setErrorResponse("Network error")
viewModel.loadData()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is ${Feature}ScreenState.Error)
}
}
// 4. Action Tests
@Test
fun `action updates state correctly`() = runTest {
viewModel.trySendAction(${Feature}Action.SomeAction)
viewModel.stateFlow.test {
// Verify state change
}
}
// 5. Event Tests
@Test
fun `action triggers navigation event`() = runTest {
viewModel.trySendAction(${Feature}Action.ItemClicked(id))
viewModel.eventFlow.test {
assertEquals(${Feature}Event.NavigateToDetail(id), awaitItem())
}
}
}
```
### 2. Screen Test Pattern
```kotlin
class ${Feature}ScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
// Test Categories:
// 1. Loading State
@Test
fun loadingState_displaysLoadingIndicator() {
val state = ${Feature}State(uiState = ${Feature}ScreenState.Loading)
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.LOADING)
.assertIsDisplayed()
}
// 2. Success State
@Test
fun successState_displaysContent() {
val state = ${Feature}State(
uiState = ${Feature}ScreenState.Success(testData)
)
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.SCREEN)
.assertIsDisplayed()
composeTestRule
.onNodeWithTag(${Feature}TestTags.LIST)
.assertIsDisplayed()
}
// 3. Error State
@Test
fun errorState_displaysErrorMessage() {
val state = ${Feature}State(
uiState = ${Feature}ScreenState.Error("Network error")
)
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.ERROR)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Network error")
.assertIsDisplayed()
}
// 4. Empty State
@Test
fun emptyState_displaysEmptyMessage() {
val state = ${Feature}State(
uiState = ${Feature}ScreenState.Success(emptyList())
)
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.EMPTY)
.assertIsDisplayed()
}
// 5. User Interaction
@Test
fun itemClick_triggersAction() {
var actionReceived: ${Feature}Action? = null
val state = ${Feature}State(
uiState = ${Feature}ScreenState.Success(testData)
)
composeTestRule.setContent {
${Feature}Content(
state = state,
onAction = { actionReceived = it }
)
}
composeTestRule
.onNodeWithTag("${feature}:item:1")
.performClick()
assertEquals(${Feature}Action.ItemClicked(1), actionReceived)
}
}
```
### 3. Fake Repository Pattern
```kotlin
class Fake${Feature}Repository : ${Feature}Repository {
// Call tracking
var loadCallCount = 0
private set
// Configurable response
private var response: DataState<List<${Model}>> = DataState.Loading
// Setup methods
fun setSuccessResponse(data: List<${Model}>) {
response = DataState.Success(data)
}
fun setErrorResponse(message: String) {
response = DataState.Error(message)
}
fun setEmptyResponse() {
response = DataState.Success(emptyList())
}
fun setLoadingState() {
response = DataState.Loading
}
// Repository implementation
override fun get${Feature}(): Flow<DataState<List<${Model}>>> = flow {
loadCallCount++
emit(response)
}
// Reset for test isolation
fun reset() {
loadCallCount = 0
response = DataState.Loading
}
}
```
---
## TestTag Convention
### Naming Pattern
```
{feature}:{component}:{identifier}
```
### Standard Tags
| Component | Pattern | Example |
|-----------|---------|---------|
| Screen container | `{feature}:screen` | `beneficiary:screen` |
| Loading indicator | `{feature}:loading` | `beneficiary:loading` |
| Error container | `{feature}:error` | `beneficiary:error` |
| Empty state | `{feature}:empty` | `beneficiary:empty` |
| List container | `{feature}:list` | `beneficiary:list` |
| List item | `{feature}:item:{id}` | `beneficiary:item:123` |
| Action button | `{feature}:{action}` | `beneficiary:add` |
| Retry button | `{feature}:retry` | `beneficiary:retry` |
| Input field | `{feature}:input:{name}` | `auth:input:username` |
| Form submit | `{feature}:submit` | `auth:submit` |
### TestTags Object
Each feature should have a TestTags object:
```kotlin
object ${Feature}TestTags {
const val SCREEN = "${feature}:screen"
const val LOADING = "${feature}:loading"
const val ERROR = "${feature}:error"
const val EMPTY = "${feature}:empty"
const val LIST = "${feature}:list"
const val RETRY = "${feature}:retry"
fun item(id: Long) = "${feature}:item:$id"
}
```
### Applying TestTags
```kotlin
@Composable
fun ${Feature}Screen(...) {
Scaffold(
modifier = Modifier.testTag(${Feature}TestTags.SCREEN)
) {
when (state.uiState) {
is Loading -> LoadingIndicator(
modifier = Modifier.testTag(${Feature}TestTags.LOADING)
)
is Error -> ErrorView(
modifier = Modifier.testTag(${Feature}TestTags.ERROR)
)
is Success -> ContentList(
modifier = Modifier.testTag(${Feature}TestTags.LIST)
)
}
}
}
```
---
## Test Dependencies
### build.gradle.kts
```kotlin
kotlin {
sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
}
androidInstrumentedTest.dependencies {
implementation(libs.compose.ui.test.junit4)
implementation(libs.compose.ui.test.manifest)
}
}
}
```
### Key Libraries
| Library | Purpose | Usage |
|---------|---------|-------|
| `kotlin-test` | Assertions | `assertEquals`, `assertTrue` |
| `kotlinx-coroutines-test` | Coroutine testing | `runTest`, `TestDispatcher` |
| `turbine` | Flow testing | `stateFlow.test { }` |
| `compose-ui-test` | Compose UI testing | `onNodeWithTag`, `performClick` |
---
## Test Execution
### Run Commands
```bash
# Run all tests for a feature
./gradlew :feature:${feature}:test
# Run ViewModel tests only (commonTest)
./gradlew :feature:${feature}:jvmTest
# Run Screen tests (Android instrumented)
./gradlew :feature:${feature}:connectedAndroidTest
# Run with coverage
./gradlew :feature:${feature}:test jacocoTestReport
```
### CI Integration
```yaml
# .github/workflows/test.yml
- name: Run Unit Tests
run: ./gradlew testDebug
- name: Run UI Tests
run: ./gradlew connectedAndroidTest
```
---
## TDD Workflow
### Red-Green-Refactor
```
1. WRITE FAILING TEST (Red)
└─ Generated stub has TODO assertions
└─ Test fails because implementation is incomplete
2. IMPLEMENT (Green)
└─ Write minimum code to pass
└─ Fill in ViewModel/Screen logic
3. REFACTOR (Clean)
└─ Improve code quality
└─ Keep tests passing
4. VERIFY
└─ Run /verify [feature]
└─ Check TestTag validation
```
### Stub Completion Checklist
After `/implement` generates stubs:
- [ ] Fill in test data fixtures
- [ ] Complete assertion logic (replace TODOs)
- [ ] Add edge case tests
- [ ] Verify all TestTags are applied
- [ ] Run tests to confirm passing
- [ ] Update TESTING_STATUS.md
---
## Common Test Scenarios
### 1. Pagination Test
```kotlin
@Test
fun `load more appends to list`() = runTest {
fakeRepository.setSuccessResponse(page1Data)
viewModel.loadData()
fakeRepository.setSuccessResponse(page2Data)
viewModel.trySendAction(${Feature}Action.LoadMore)
viewModel.stateFlow.test {
val state = expectMostRecentItem()
val data = (state.uiState as Success).data
assertEquals(page1Data + page2Data, data)
}
}
```
### 2. Pull-to-Refresh Test
```kotlin
@Test
fun `refresh replaces data`() = runTest {
fakeRepository.setSuccessResponse(oldData)
viewModel.loadData()
fakeRepository.setSuccessResponse(newData)
viewModel.trySendAction(${Feature}Action.Refresh)
viewModel.stateFlow.test {
val state = expectMostRecentItem()
val data = (state.uiState as Success).data
assertEquals(newData, data)
}
}
```
### 3. Form Validation Test
```kotlin
@Test
fun `invalid input shows validation error`() = runTest {
viewModel.trySendAction(${Feature}Action.Submit(invalidInput))
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertNotNull(state.validationError)
}
}
```
### 4. Dialog Confirmation Test
```kotlin
@Test
fun `delete shows confirmation dialog`() = runTest {
viewModel.trySendAction(${Feature}Action.DeleteClicked(id))
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.dialogState is DialogState.Confirmation)
}
}
@Test
fun `confirm delete triggers delete action`() = runTest {
viewModel.trySendAction(${Feature}Action.ConfirmDelete(id))
assertEquals(1, fakeRepository.deleteCallCount)
}
```
---
## Troubleshooting
### Common Issues
| Issue | Cause | Solution |
|-------|-------|----------|
| Test timeout | Missing `runTest` | Wrap in `runTest { }` |
| Flow not emitting | Wrong dispatcher | Use `MainDispatcherRule` |
| Node not found | Missing testTag | Add `Modifier.testTag()` |
| Assertion failure | Stale state | Use `expectMostRecentItem()` |
### Debug Tips
```kotlin
// Print state for debugging
viewModel.stateFlow.test {
val state = expectMostRecentItem()
println("Current state: $state")
// assertions...
}
// Print compose tree
composeTestRule.onRoot().printToLog("COMPOSE_TREE")
```
---
## Related Files
- [TESTING_STATUS.md](./feature-layer/TESTING_STATUS.md) - Feature test coverage
- [/verify command](../.claude/commands/verify.md) - TestTag validation
- [/implement command](../.claude/commands/implement.md) - Test stub generation
---
## Commands
```bash
# Generate test stubs for feature
/implement [feature] # Phase 5 generates tests
# Verify TestTag compliance
/verify [feature] # Includes TestTag validation
# Check testing gaps
/gap-analysis testing # Shows test coverage gaps
```

View File

@ -0,0 +1,283 @@
# Design Tokens Index - O(1) Lookup
> **8 features** with tokens | **2 formats** (Google Stitch, MD3) | **Last Updated**: 2026-01-05
---
## Quick Lookup
| # | Feature | Has Tokens | Format | Colors | Typography | Components | Animations |
|:-:|---------|:----------:|--------|:------:|:----------:|:----------:|:----------:|
| 1 | auth | ✅ | google-stitch | ✅ | ✅ | ✅ | ✅ |
| 2 | dashboard | ✅ | md3 | ✅ | ✅ | ✅ | ❌ |
| 3 | settings | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
| 4 | guarantor | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
| 5 | qr | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
| 6 | passcode | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
| 7 | location | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
| 8 | client-charge | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
| 9 | accounts | ❌ | - | - | - | - | - |
| 10 | beneficiary | ❌ | - | - | - | - | - |
| 11 | home | ❌ | - | - | - | - | - |
| 12 | loan-account | ❌ | - | - | - | - | - |
| 13 | notification | ❌ | - | - | - | - | - |
| 14 | recent-transaction | ❌ | - | - | - | - | - |
| 15 | savings-account | ❌ | - | - | - | - | - |
| 16 | share-account | ❌ | - | - | - | - | - |
| 17 | transfer | ❌ | - | - | - | - | - |
---
## Token Formats
### Google Stitch Format (v2.0)
```json
{
"feature": "auth",
"generated": "2026-01-03",
"tool": "google-stitch",
"version": "2.0",
"tokens": {
"colors": {
"primary": { "gradient": {...}, "solid": "#667EEA" },
"surface": { "light": "#FFFFFF", "dark": "#0D1117" },
"text": { "primary": {...}, "secondary": {...} },
"semantic": { "success": "#00D09C", "error": "#FF4757" }
},
"typography": { "fontFamily": "Inter", "display": {...}, "headline": {...} },
"spacing": { "xs": "4dp", "sm": "8dp", "md": "12dp", "lg": "16dp" },
"radius": { "sm": "8dp", "md": "12dp", "lg": "16dp" },
"shadow": { "button": {...}, "card": {...} }
},
"screens": [...],
"components": [...],
"animations": {...}
}
```
### MD3 Format (Standard)
```json
{
"feature": "Dashboard",
"generated": "2025-12-28",
"tokens": {
"colors": { "primary": "#6750A4", "surface": "#FFFBFE", ... },
"typography": { "displayLarge": {...}, "bodyMedium": {...} },
"spacing": { "xs": 4, "sm": 8, "md": 16 },
"radius": { "sm": 8, "md": 12, "lg": 16 }
},
"components": [...],
"screens": [...]
}
```
---
## O(1) Path Pattern
```
design-spec-layer/features/[feature]/mockups/design-tokens.json
```
---
## Token → DesignToken Mapping
| Token JSON | Compose DesignToken | Type |
|------------|---------------------|------|
| `tokens.spacing.xs` | `DesignToken.spacing.extraSmall` | `Dp` |
| `tokens.spacing.sm` | `DesignToken.spacing.small` | `Dp` |
| `tokens.spacing.md` | `DesignToken.spacing.medium` | `Dp` |
| `tokens.spacing.lg` | `DesignToken.spacing.large` | `Dp` |
| `tokens.radius.sm` | `DesignToken.shapes.small` | `Shape` |
| `tokens.radius.md` | `DesignToken.shapes.medium` | `Shape` |
| `tokens.radius.lg` | `DesignToken.shapes.large` | `Shape` |
| `tokens.colors.primary` | `MaterialTheme.colorScheme.primary` | `Color` |
| `tokens.colors.surface` | `MaterialTheme.colorScheme.surface` | `Color` |
---
## Gradient Support
Features with gradients (Google Stitch format):
| Feature | Gradient Type | Colors | Usage |
|---------|---------------|--------|-------|
| auth | primary | `#667EEA → #764BA2` | Buttons, headers |
| auth | secondary | `#11998E → #38EF7D` | Success states |
### Compose Gradient Code
```kotlin
// From design-tokens.json:
// "gradient": { "start": "#667EEA", "end": "#764BA2", "angle": 45 }
val AuthGradient = Brush.linearGradient(
colors = listOf(
Color(0xFF667EEA),
Color(0xFF764BA2)
),
start = Offset(0f, 0f),
end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
)
// Usage in Button:
Button(
onClick = { },
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
modifier = Modifier.background(AuthGradient, shape = DesignToken.shapes.large)
) {
Text("Login")
}
```
---
## Component Specs
Features with component specifications:
| Feature | Components | Details |
|---------|:----------:|---------|
| auth | 5 | primary-button, text-input, otp-input, auth-card, trust-badge |
| dashboard | 7 | NetWorthCard, QuickActions, AccountCard, TransactionItem, BottomNav, TopBar, SectionHeader |
### Component Spec Example
```json
{
"id": "primary-button",
"name": "Primary Button",
"specs": {
"height": "56dp",
"radius": "16dp",
"background": "gradient",
"textSize": "16sp",
"textWeight": "600",
"textColor": "#FFFFFF",
"shadow": "button"
}
}
```
### Generated Compose Code
```kotlin
@Composable
fun AuthPrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Button(
onClick = onClick,
modifier = modifier
.height(56.dp)
.background(
brush = AuthGradient,
shape = RoundedCornerShape(16.dp)
),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent
),
shape = RoundedCornerShape(16.dp),
) {
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
}
}
```
---
## Animation Specs
Features with animation specifications:
| Feature | Animations | Details |
|---------|:----------:|---------|
| auth | 5 | pageTransition, buttonPress, inputFocus, successCelebration, errorShake |
### Animation Spec Example
```json
"animations": {
"buttonPress": {
"scale": "0.98",
"duration": "100ms"
},
"errorShake": {
"translateX": "[-10, 10, -5, 5, 0]",
"duration": "300ms"
}
}
```
---
## Usage in /feature Command
### Phase 0: O(1) Context Loading
```kotlin
// Check if design tokens exist
val tokensPath = "design-spec-layer/features/$feature/mockups/design-tokens.json"
val hasTokens = checkInIndex("DESIGN_TOKENS_INDEX.md", feature)
val tokenFormat = getTokenFormat(feature) // "google-stitch" | "md3" | null
```
### Phase 3: Apply Design Tokens
```
IF hasTokens THEN
1. Read design-tokens.json
2. Extract colors → Generate feature-specific colors if custom
3. Extract gradients → Generate Brush definitions
4. Extract component specs → Apply to generated components
5. Extract animations → Add animation modifiers
6. Map spacing/radius → Use DesignToken equivalents
ELSE
Use default DesignToken values
END
```
---
## Auto-Update Rules
| Scenario | Action |
|----------|--------|
| New tokens generated | Add row to Quick Lookup table |
| `/design mockup` completes | Update Has Tokens column |
| Token format changes | Update Format column |
| Components added | Update Components column |
---
## Related Files
- [FEATURES_INDEX.md](./FEATURES_INDEX.md) - All features
- [MOCKUPS_INDEX.md](./MOCKUPS_INDEX.md) - Mockup status
- `core/designsystem/theme/DesignToken.kt` - Compose design tokens
- `core/designsystem/theme/Color.kt` - Color definitions
---
## Commands
```bash
# Check token status
/gap-analysis design tokens
# Generate tokens for feature
/design [feature] mockup
# Feature with token integration
/feature [feature] # Auto-applies tokens if available
```

View File

@ -0,0 +1,116 @@
# Plans Index - O(1) Lookup
> Track implementation plans created by `/gap-planning`
**Last Updated**: 2026-01-05
---
## Quick Overview
| Status | Count | Description |
|:------:|:-----:|-------------|
| 🔄 Active | 0 | Plans in progress |
| ✅ Completed | 0 | Finished plans |
| ⏸️ Paused | 0 | Plans on hold |
---
## Active Plans
| # | Plan | Target | Progress | Current Step | Created |
|:-:|------|--------|:--------:|--------------|---------|
| - | (none) | - | - | - | - |
---
## Completed Plans
| # | Plan | Target | Steps | Completed |
|:-:|------|--------|:-----:|-----------|
| - | (none) | - | - | - |
---
## Plan File Format
Each plan file in `active/` or `completed/` follows this format:
```markdown
# Plan: [Target Name]
**Created**: YYYY-MM-DD
**Status**: 🔄 Active | ✅ Completed | ⏸️ Paused
**Command**: /gap-planning [args]
**Progress**: X/Y steps (Z%)
---
## Steps
- [ ] **Step 1**: Description
- Sub-task 1
- Sub-task 2
- Files: `path/to/file.kt`
- [ ] **Step 2**: Description
- Sub-task 1
- Files: `path/to/file.kt`
- [x] **Step 3**: Description (COMPLETED)
- ✅ Sub-task 1
- ✅ Sub-task 2
---
## Progress Log
| Date | Step | Action | Outcome |
|------|------|--------|---------|
| YYYY-MM-DD | 1 | Started | In progress |
| YYYY-MM-DD | 1 | Completed | Files created |
```
---
## O(1) Path Pattern
```
plans/active/[target]-[type].md # Active plan
plans/completed/[target]-[type].md # Completed plan
```
Examples:
- `plans/active/design-mockup.md`
- `plans/active/testing-auth.md`
- `plans/active/feature-beneficiary.md`
- `plans/completed/client-layer.md`
---
## Commands
```bash
# Check status of all plans
/gap-status # Shows this index + active plans
# Check status of specific plan
/gap-status design mockup # Shows design-mockup.md progress
# Create new plan
/gap-planning [target] # Creates plan in plans/active/
# Mark plan complete
/gap-status complete [plan] # Moves to plans/completed/
```
---
## Auto-Update Rules
| Trigger | Action |
|---------|--------|
| `/gap-planning [target]` | Create plan file, add to Active Plans |
| `/gap-status complete [plan]` | Move to Completed Plans |
| Step completed | Update progress in plan file |
| `/implement` checkpoint | Update related plan progress |

View File

@ -0,0 +1,511 @@
# Fake Repositories Index - O(1) Lookup
> **17 repositories** | Test doubles for isolation | **Last Updated**: 2026-01-05
---
## Quick Lookup
| # | Feature | Repository | Fake Repository | Status |
|:-:|---------|------------|-----------------|:------:|
| 1 | auth | `UserAuthRepository` | `FakeUserAuthRepository` | ❌ |
| 2 | home | `HomeRepository` | `FakeHomeRepository` | ❌ |
| 3 | accounts | `AccountsRepository` | `FakeAccountsRepository` | ❌ |
| 4 | savings-account | `SavingsAccountRepository` | `FakeSavingsAccountRepository` | ❌ |
| 5 | loan-account | `LoanRepository` | `FakeLoanRepository` | ❌ |
| 6 | share-account | `ShareAccountRepository` | `FakeShareAccountRepository` | ❌ |
| 7 | beneficiary | `BeneficiaryRepository` | `FakeBeneficiaryRepository` | ❌ |
| 8 | transfer | `TransferRepository` | `FakeTransferRepository` | ❌ |
| 9 | recent-transaction | `RecentTransactionRepository` | `FakeRecentTransactionRepository` | ❌ |
| 10 | notification | `NotificationRepository` | `FakeNotificationRepository` | ❌ |
| 11 | settings | `UserPreferencesRepository` | `FakeUserPreferencesRepository` | ❌ |
| 12 | guarantor | `GuarantorRepository` | `FakeGuarantorRepository` | ❌ |
| 13 | qr | - | - | N/A |
| 14 | location | - | - | N/A |
| 15 | client-charge | `ClientChargeRepository` | `FakeClientChargeRepository` | ❌ |
| 16 | passcode | - | - | N/A |
| 17 | dashboard | - | - | N/A |
---
## O(1) Path Pattern
```
core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/Fake${Feature}Repository.kt
```
---
## Fake Repository Pattern
### Standard Structure
```kotlin
class Fake${Feature}Repository : ${Feature}Repository {
// ═══════════════════════════════════════════════════════════════
// CALL TRACKING
// ═══════════════════════════════════════════════════════════════
var loadCallCount = 0
private set
var createCallCount = 0
private set
var updateCallCount = 0
private set
var deleteCallCount = 0
private set
// ═══════════════════════════════════════════════════════════════
// CONFIGURABLE RESPONSES
// ═══════════════════════════════════════════════════════════════
private var loadResponse: DataState<List<${Model}>> = DataState.Loading
private var createResponse: DataState<${Model}> = DataState.Loading
private var deleteResponse: DataState<Unit> = DataState.Loading
// ═══════════════════════════════════════════════════════════════
// SETUP METHODS (for test configuration)
// ═══════════════════════════════════════════════════════════════
fun setLoadSuccessResponse(data: List<${Model}>) {
loadResponse = DataState.Success(data)
}
fun setLoadErrorResponse(message: String) {
loadResponse = DataState.Error(message)
}
fun setLoadEmptyResponse() {
loadResponse = DataState.Success(emptyList())
}
fun setCreateSuccessResponse(data: ${Model}) {
createResponse = DataState.Success(data)
}
fun setCreateErrorResponse(message: String) {
createResponse = DataState.Error(message)
}
fun setDeleteSuccessResponse() {
deleteResponse = DataState.Success(Unit)
}
fun setDeleteErrorResponse(message: String) {
deleteResponse = DataState.Error(message)
}
// ═══════════════════════════════════════════════════════════════
// REPOSITORY IMPLEMENTATION
// ═══════════════════════════════════════════════════════════════
override fun get${Feature}s(): Flow<DataState<List<${Model}>>> = flow {
loadCallCount++
emit(loadResponse)
}
override fun create${Model}(payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
createCallCount++
emit(createResponse)
}
override fun delete${Model}(id: Long): Flow<DataState<Unit>> = flow {
deleteCallCount++
emit(deleteResponse)
}
// ═══════════════════════════════════════════════════════════════
// RESET (for test isolation)
// ═══════════════════════════════════════════════════════════════
fun reset() {
loadCallCount = 0
createCallCount = 0
updateCallCount = 0
deleteCallCount = 0
loadResponse = DataState.Loading
createResponse = DataState.Loading
deleteResponse = DataState.Loading
}
}
```
---
## Feature Fake Repositories
### 1. FakeUserAuthRepository
```kotlin
class FakeUserAuthRepository : UserAuthRepository {
var loginCallCount = 0
var registerCallCount = 0
var verifyOtpCallCount = 0
private var loginResponse: DataState<User> = DataState.Loading
private var registerResponse: DataState<RegisterPayload> = DataState.Loading
private var otpResponse: DataState<Unit> = DataState.Loading
fun setLoginSuccess(user: User = AuthFixtures.validUser) {
loginResponse = DataState.Success(user)
}
fun setLoginError(message: String = "Invalid credentials") {
loginResponse = DataState.Error(message)
}
fun setRegisterSuccess() {
registerResponse = DataState.Success(RegisterPayload())
}
fun setOtpSuccess() {
otpResponse = DataState.Success(Unit)
}
override fun login(payload: LoginPayload): Flow<DataState<User>> = flow {
loginCallCount++
emit(loginResponse)
}
override fun register(payload: RegisterPayload): Flow<DataState<RegisterPayload>> = flow {
registerCallCount++
emit(registerResponse)
}
override fun verifyOtp(otp: String): Flow<DataState<Unit>> = flow {
verifyOtpCallCount++
emit(otpResponse)
}
fun reset() {
loginCallCount = 0
registerCallCount = 0
verifyOtpCallCount = 0
loginResponse = DataState.Loading
registerResponse = DataState.Loading
otpResponse = DataState.Loading
}
}
```
### 2. FakeHomeRepository
```kotlin
class FakeHomeRepository : HomeRepository {
var loadAccountsCallCount = 0
var loadUserCallCount = 0
private var accountsResponse: DataState<ClientAccounts> = DataState.Loading
private var userResponse: DataState<Client> = DataState.Loading
fun setAccountsSuccess(accounts: ClientAccounts = AccountFixtures.fullPortfolio) {
accountsResponse = DataState.Success(accounts)
}
fun setAccountsError(message: String = "Failed to load accounts") {
accountsResponse = DataState.Error(message)
}
fun setUserSuccess(client: Client) {
userResponse = DataState.Success(client)
}
override fun getClientAccounts(): Flow<DataState<ClientAccounts>> = flow {
loadAccountsCallCount++
emit(accountsResponse)
}
override fun getCurrentUser(): Flow<DataState<Client>> = flow {
loadUserCallCount++
emit(userResponse)
}
fun reset() {
loadAccountsCallCount = 0
loadUserCallCount = 0
accountsResponse = DataState.Loading
userResponse = DataState.Loading
}
}
```
### 3. FakeBeneficiaryRepository
```kotlin
class FakeBeneficiaryRepository : BeneficiaryRepository {
var loadCallCount = 0
var createCallCount = 0
var updateCallCount = 0
var deleteCallCount = 0
private var loadResponse: DataState<List<Beneficiary>> = DataState.Loading
private var createResponse: DataState<Beneficiary> = DataState.Loading
private var updateResponse: DataState<Beneficiary> = DataState.Loading
private var deleteResponse: DataState<Unit> = DataState.Loading
fun setLoadSuccess(data: List<Beneficiary> = BeneficiaryFixtures.multipleBeneficiaries) {
loadResponse = DataState.Success(data)
}
fun setLoadError(message: String = "Failed to load beneficiaries") {
loadResponse = DataState.Error(message)
}
fun setLoadEmpty() {
loadResponse = DataState.Success(emptyList())
}
fun setCreateSuccess(beneficiary: Beneficiary = BeneficiaryFixtures.createBeneficiary()) {
createResponse = DataState.Success(beneficiary)
}
fun setCreateError(message: String = "Failed to create beneficiary") {
createResponse = DataState.Error(message)
}
fun setDeleteSuccess() {
deleteResponse = DataState.Success(Unit)
}
fun setDeleteError(message: String = "Failed to delete beneficiary") {
deleteResponse = DataState.Error(message)
}
override fun getBeneficiaries(): Flow<DataState<List<Beneficiary>>> = flow {
loadCallCount++
emit(loadResponse)
}
override fun createBeneficiary(payload: BeneficiaryPayload): Flow<DataState<Beneficiary>> = flow {
createCallCount++
emit(createResponse)
}
override fun updateBeneficiary(id: Long, payload: BeneficiaryPayload): Flow<DataState<Beneficiary>> = flow {
updateCallCount++
emit(updateResponse)
}
override fun deleteBeneficiary(id: Long): Flow<DataState<Unit>> = flow {
deleteCallCount++
emit(deleteResponse)
}
fun reset() {
loadCallCount = 0
createCallCount = 0
updateCallCount = 0
deleteCallCount = 0
loadResponse = DataState.Loading
createResponse = DataState.Loading
updateResponse = DataState.Loading
deleteResponse = DataState.Loading
}
}
```
### 4. FakeTransferRepository
```kotlin
class FakeTransferRepository : TransferRepository {
var loadTemplateCallCount = 0
var makeTransferCallCount = 0
private var templateResponse: DataState<TransferTemplate> = DataState.Loading
private var transferResponse: DataState<Unit> = DataState.Loading
fun setTemplateSuccess(template: TransferTemplate = TransferFixtures.createTransferTemplate()) {
templateResponse = DataState.Success(template)
}
fun setTemplateError(message: String = "Failed to load transfer template") {
templateResponse = DataState.Error(message)
}
fun setTransferSuccess() {
transferResponse = DataState.Success(Unit)
}
fun setTransferError(message: String = "Transfer failed") {
transferResponse = DataState.Error(message)
}
override fun getTransferTemplate(): Flow<DataState<TransferTemplate>> = flow {
loadTemplateCallCount++
emit(templateResponse)
}
override fun makeTransfer(payload: TransferPayload): Flow<DataState<Unit>> = flow {
makeTransferCallCount++
emit(transferResponse)
}
fun reset() {
loadTemplateCallCount = 0
makeTransferCallCount = 0
templateResponse = DataState.Loading
transferResponse = DataState.Loading
}
}
```
### 5. FakeNotificationRepository
```kotlin
class FakeNotificationRepository : NotificationRepository {
var loadCallCount = 0
var markReadCallCount = 0
private var loadResponse: DataState<List<Notification>> = DataState.Loading
private var markReadResponse: DataState<Unit> = DataState.Loading
fun setLoadSuccess(data: List<Notification> = NotificationFixtures.createNotificationList()) {
loadResponse = DataState.Success(data)
}
fun setLoadError(message: String = "Failed to load notifications") {
loadResponse = DataState.Error(message)
}
fun setLoadEmpty() {
loadResponse = DataState.Success(emptyList())
}
fun setMarkReadSuccess() {
markReadResponse = DataState.Success(Unit)
}
override fun getNotifications(): Flow<DataState<List<Notification>>> = flow {
loadCallCount++
emit(loadResponse)
}
override fun markAsRead(id: Long): Flow<DataState<Unit>> = flow {
markReadCallCount++
emit(markReadResponse)
}
fun reset() {
loadCallCount = 0
markReadCallCount = 0
loadResponse = DataState.Loading
markReadResponse = DataState.Loading
}
}
```
### 6. FakeUserPreferencesRepository
```kotlin
class FakeUserPreferencesRepository : UserPreferencesRepository {
private var _theme: String = "system"
private var _language: String = "en"
private var _passcodeEnabled: Boolean = false
val themeFlow = MutableStateFlow(_theme)
val languageFlow = MutableStateFlow(_language)
val passcodeEnabledFlow = MutableStateFlow(_passcodeEnabled)
fun setTheme(theme: String) {
_theme = theme
themeFlow.value = theme
}
fun setLanguage(language: String) {
_language = language
languageFlow.value = language
}
fun setPasscodeEnabled(enabled: Boolean) {
_passcodeEnabled = enabled
passcodeEnabledFlow.value = enabled
}
override fun getTheme(): Flow<String> = themeFlow
override fun getLanguage(): Flow<String> = languageFlow
override fun isPasscodeEnabled(): Flow<Boolean> = passcodeEnabledFlow
override suspend fun updateTheme(theme: String) {
setTheme(theme)
}
override suspend fun updateLanguage(language: String) {
setLanguage(language)
}
fun reset() {
_theme = "system"
_language = "en"
_passcodeEnabled = false
themeFlow.value = _theme
languageFlow.value = _language
passcodeEnabledFlow.value = _passcodeEnabled
}
}
```
---
## Usage in Tests
### Basic Usage
```kotlin
class BeneficiaryViewModelTest {
private lateinit var fakeRepository: FakeBeneficiaryRepository
private lateinit var viewModel: BeneficiaryViewModel
@BeforeTest
fun setup() {
fakeRepository = FakeBeneficiaryRepository()
viewModel = BeneficiaryViewModel(repository = fakeRepository)
}
@AfterTest
fun teardown() {
fakeRepository.reset() // Ensure test isolation
}
@Test
fun `load success shows data`() = runTest {
fakeRepository.setLoadSuccess()
viewModel.loadBeneficiaries()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is Success)
}
}
@Test
fun `delete calls repository`() = runTest {
fakeRepository.setDeleteSuccess()
viewModel.deleteBeneficiary(1L)
assertEquals(1, fakeRepository.deleteCallCount)
}
}
```
### Verifying Calls
```kotlin
@Test
fun `refresh reloads data`() = runTest {
fakeRepository.setLoadSuccess()
viewModel.loadBeneficiaries()
viewModel.trySendAction(Action.Refresh)
assertEquals(2, fakeRepository.loadCallCount)
}
```
---
## Commands
```bash
# Generate fake repository for feature
/implement [feature] # Creates fake in Phase 5
# Check fake repository status
/gap-analysis testing fakes
```

View File

@ -0,0 +1,194 @@
# Testing Layer - Status Dashboard
> **17 features** | **49 ViewModels** | **63 Screens** | **Last Updated**: 2026-01-05
---
## Quick Overview
| Metric | Current | Target | Progress |
|--------|:-------:|:------:|:--------:|
| ViewModel Tests | 0/49 | 49 | [░░░░░░░░░░] 0% |
| Screen Tests | 0/63 | 63 | [░░░░░░░░░░] 0% |
| Fake Repositories | 6/17 | 17 | [███░░░░░░░] 35% |
| TestTags Objects | 17/17 | 17 | [██████████] 100% |
| Test Fixtures | 3/10 | 10 | [███░░░░░░░] 30% |
| Integration Tests | 0/5 | 5 | [░░░░░░░░░░] 0% |
| Screenshot Tests | 0/20 | 20 | [░░░░░░░░░░] 0% |
---
## core:testing Module (KMP)
**Location**: `core/testing/`
**Status**: ✅ Active
**Platforms**: Android, iOS, Desktop, Native
### Module Structure
```
core/testing/src/
├── commonMain/kotlin/org/mifos/mobile/core/testing/
│ ├── di/TestModule.kt # Koin test module
│ ├── fake/ # Fake repositories (6)
│ │ ├── FakeUserAuthRepository.kt
│ │ ├── FakeHomeRepository.kt
│ │ ├── FakeAccountsRepository.kt
│ │ ├── FakeBeneficiaryRepository.kt
│ │ ├── FakeTransferRepository.kt
│ │ └── FakeNotificationRepository.kt
│ ├── fixture/ # Test fixtures (3)
│ │ ├── UserFixture.kt
│ │ ├── ClientAccountsFixture.kt
│ │ └── BeneficiaryFixture.kt
│ ├── rule/MainDispatcherRule.kt # Coroutine testing (KMP)
│ └── util/
│ ├── TestTags.kt # TestTags for all 17 features
│ ├── FlowTestExtensions.kt # Flow testing helpers
│ └── TestCoroutineExtensions.kt # Coroutine helpers
├── androidMain/kotlin/org/mifos/mobile/core/testing/
│ ├── rule/MainDispatcherTestRule.kt # JUnit4 TestRule
│ └── ComposeTestHelpers.kt # Compose UI test extensions
├── iosMain/kotlin/org/mifos/mobile/core/testing/
│ └── IosTestUtils.kt # iOS accessibility helpers
├── desktopMain/kotlin/org/mifos/mobile/core/testing/
│ └── DesktopTestUtils.kt # Desktop/Swing helpers
└── nativeMain/kotlin/org/mifos/mobile/core/testing/
└── NativeTestUtils.kt # Native platform helpers
```
### Platform-Specific Features
| Platform | Source Set | Features |
|----------|------------|----------|
| Common | `commonMain` | TestTags, Fakes, Fixtures, MainDispatcherRule |
| Android | `androidMain` | JUnit4 TestRule, Compose UI test helpers |
| iOS | `iosMain` | Accessibility ID conversion, XCTest helpers |
| Desktop | `desktopMain` | Swing dispatcher, Desktop test setup |
| Native | `nativeMain` | Native dispatcher setup, timing utils |
---
## Feature Test Coverage
| # | Feature | VMs | VM Tests | Screens | Screen Tests | Fakes | TestTags | Status |
|:-:|---------|:---:|:--------:|:-------:|:------------:|:-----:|:--------:|:------:|
| 1 | auth | 5 | 0 | 6 | 0 | ✅ | ✅ | Infrastructure Ready |
| 2 | home | 1 | 0 | 1 | 0 | ✅ | ✅ | Infrastructure Ready |
| 3 | accounts | 3 | 0 | 3 | 0 | ✅ | ✅ | Infrastructure Ready |
| 4 | savings-account | 3 | 0 | 4 | 0 | ❌ | ✅ | TestTags Ready |
| 5 | loan-account | 4 | 0 | 4 | 0 | ❌ | ✅ | TestTags Ready |
| 6 | share-account | 2 | 0 | 2 | 0 | ❌ | ✅ | TestTags Ready |
| 7 | beneficiary | 4 | 0 | 4 | 0 | ✅ | ✅ | Infrastructure Ready |
| 8 | transfer | 2 | 0 | 2 | 0 | ✅ | ✅ | Infrastructure Ready |
| 9 | recent-transaction | 1 | 0 | 1 | 0 | ❌ | ✅ | TestTags Ready |
| 10 | notification | 1 | 0 | 1 | 0 | ✅ | ✅ | Infrastructure Ready |
| 11 | settings | 5 | 0 | 9 | 0 | ❌ | ✅ | TestTags Ready |
| 12 | passcode | 2 | 0 | 2 | 0 | ❌ | ✅ | TestTags Ready |
| 13 | guarantor | 3 | 0 | 3 | 0 | ❌ | ✅ | TestTags Ready |
| 14 | qr | 3 | 0 | 3 | 0 | ❌ | ✅ | TestTags Ready |
| 15 | location | 0 | 0 | 1 | 0 | ❌ | ✅ | TestTags Ready |
| 16 | client-charge | 2 | 0 | 2 | 0 | ❌ | ✅ | TestTags Ready |
| 17 | dashboard | 0 | 0 | 0 | 0 | ❌ | ✅ | TestTags Ready |
| | **TOTAL** | **41** | **0** | **48** | **0** | **6/17** | **17/17** | |
**Legend**: ✅ Complete | ⚠️ Partial | ❌ Missing
### Fake Repositories Available (6)
| Fake | Interface | Key Methods |
|------|-----------|-------------|
| `FakeUserAuthRepository` | `UserAuthRepository` | login, register, logout |
| `FakeHomeRepository` | `HomeRepository` | getClientAccounts, getUserData |
| `FakeAccountsRepository` | `AccountsRepository` | getLoanAccounts, getSavingsAccounts |
| `FakeBeneficiaryRepository` | `BeneficiaryRepository` | getBeneficiaries, createBeneficiary |
| `FakeTransferRepository` | `TransferRepository` | makeTransfer, getTransferTemplate |
| `FakeNotificationRepository` | `NotificationRepository` | getNotifications, markAsRead |
### Test Fixtures Available (3)
| Fixture | Entity | Factory Methods |
|---------|--------|-----------------|
| `UserFixture` | `User` | createDefault, createAdmin, createUnauthenticated |
| `ClientAccountsFixture` | `ClientAccounts` | createEmpty, createWithSampleData, createWithLoansOnly |
| `BeneficiaryFixture` | `Beneficiary` | createDefault, createList, createPayload |
---
## Test Types
### Unit Tests (commonTest)
| Category | Location | Framework | Status |
|----------|----------|-----------|:------:|
| ViewModel Tests | `feature/*/src/commonTest/` | kotlin-test, Turbine | ❌ |
| Repository Tests | `core/data/src/commonTest/` | kotlin-test | ✅ 14 |
| DataStore Tests | `core/datastore/src/commonTest/` | kotlin-test | ✅ |
### UI Tests (androidInstrumentedTest)
| Category | Location | Framework | Status |
|----------|----------|-----------|:------:|
| Screen Tests | `feature/*/src/androidInstrumentedTest/` | Compose UI Test | ❌ |
| Integration Tests | `cmp-android/src/androidTest/` | Compose UI Test | ❌ |
### Screenshot Tests
| Category | Location | Framework | Status |
|----------|----------|-----------|:------:|
| Component Screenshots | `core/designsystem/src/test/` | Roborazzi | ❌ |
| Screen Screenshots | `feature/*/src/test/` | Roborazzi | ❌ |
---
## Priority Queue
| Priority | Feature | Reason | Effort |
|:--------:|---------|--------|:------:|
| P0 | auth | Core flow, most complex | L |
| P0 | home | Entry point, high visibility | M |
| P0 | accounts | Core business logic | M |
| P1 | transfer | Financial operations | L |
| P1 | beneficiary | CRUD operations | M |
| P1 | loan-account | Complex states | M |
| P1 | savings-account | Multiple views | M |
| P2 | settings | Many screens, lower risk | L |
| P2 | notification | Simple list | S |
| P2 | recent-transaction | Simple list | S |
---
## O(1) Index Files
| File | Purpose | Entries |
|------|---------|:-------:|
| [TEST_PATTERNS.md](./TEST_PATTERNS.md) | Test pattern reference | 5 |
| [TEST_TAGS_INDEX.md](./TEST_TAGS_INDEX.md) | TestTag lookup | 17 |
| [TEST_FIXTURES_INDEX.md](./TEST_FIXTURES_INDEX.md) | Fixture lookup | 0 |
| [FAKE_REPOS_INDEX.md](./FAKE_REPOS_INDEX.md) | Fake repo lookup | 0 |
---
## Commands
```bash
# Check testing status
/gap-analysis testing # Overall test coverage
# Generate tests for feature
/implement [feature] # Phase 5 generates test stubs
# Verify TestTag compliance
/verify [feature] # Includes TestTag validation
# Run tests
/verify-tests [feature] # Run and report test results
```
---
## Related Files
- [TEST_STUBS_GUIDE.md](../TEST_STUBS_GUIDE.md) - TDD reference guide
- [patterns/](./patterns/) - Detailed test patterns
- [templates/](./templates/) - Code templates

View File

@ -0,0 +1,401 @@
# Test Fixtures Index - O(1) Lookup
> **17 features** | Reusable test data | **Last Updated**: 2026-01-05
---
## Quick Lookup
| # | Feature | Fixture File | Models | Status |
|:-:|---------|--------------|:------:|:------:|
| 1 | auth | `AuthFixtures.kt` | User, AuthPayload | ❌ |
| 2 | home | `HomeFixtures.kt` | ClientAccounts | ❌ |
| 3 | accounts | `AccountFixtures.kt` | SavingAccount, LoanAccount, ShareAccount | ❌ |
| 4 | savings-account | `SavingsFixtures.kt` | SavingAccount, Transaction | ❌ |
| 5 | loan-account | `LoanFixtures.kt` | LoanAccount, RepaymentSchedule | ❌ |
| 6 | share-account | `ShareFixtures.kt` | ShareAccount | ❌ |
| 7 | beneficiary | `BeneficiaryFixtures.kt` | Beneficiary, BeneficiaryPayload | ❌ |
| 8 | transfer | `TransferFixtures.kt` | TransferPayload, TransferTemplate | ❌ |
| 9 | recent-transaction | `TransactionFixtures.kt` | Transaction | ❌ |
| 10 | notification | `NotificationFixtures.kt` | Notification | ❌ |
| 11 | settings | `SettingsFixtures.kt` | - | ❌ |
| 12 | passcode | `PasscodeFixtures.kt` | - | ❌ |
| 13 | guarantor | `GuarantorFixtures.kt` | Guarantor | ❌ |
| 14 | qr | `QrFixtures.kt` | QrPayload | ❌ |
| 15 | location | `LocationFixtures.kt` | Office | ❌ |
| 16 | client-charge | `ChargeFixtures.kt` | Charge | ❌ |
| 17 | dashboard | `DashboardFixtures.kt` | - | ❌ |
---
## O(1) Path Pattern
```
core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixtures/[Feature]Fixtures.kt
```
---
## Fixture Pattern
### Standard Structure
```kotlin
object ${Feature}Fixtures {
// Single item
fun create${Model}(
id: Long = 1L,
name: String = "Test ${Model}",
// ... other params with defaults
): ${Model} = ${Model}(
id = id,
name = name,
// ...
)
// List
fun create${Model}List(count: Int = 3): List<${Model}> =
(1..count).map { create${Model}(id = it.toLong()) }
// Specific scenarios
val empty${Model} = create${Model}(name = "")
val invalid${Model} = create${Model}(id = -1)
}
```
---
## Feature Fixtures
### 1. AuthFixtures
```kotlin
object AuthFixtures {
fun createUser(
userId: Long = 1L,
userName: String = "testuser",
clientId: Long = 100L,
clientName: String = "Test Client",
authenticated: Boolean = true
): User = User(
userId = userId,
userName = userName,
clientId = clientId,
clientName = clientName,
authenticated = authenticated
)
fun createAuthPayload(
username: String = "testuser",
password: String = "password123"
): LoginPayload = LoginPayload(
username = username,
password = password
)
// Pre-built scenarios
val validUser = createUser()
val unauthenticatedUser = createUser(authenticated = false)
val validCredentials = createAuthPayload()
val invalidCredentials = createAuthPayload(password = "wrong")
}
```
### 2. BeneficiaryFixtures
```kotlin
object BeneficiaryFixtures {
fun createBeneficiary(
id: Long = 1L,
name: String = "John Doe",
officeName: String = "Main Office",
accountNumber: String = "000000001",
transferLimit: Double = 10000.0
): Beneficiary = Beneficiary(
id = id,
name = name,
officeName = officeName,
accountNumber = accountNumber,
transferLimit = transferLimit
)
fun createBeneficiaryList(count: Int = 3): List<Beneficiary> =
(1..count).map { i ->
createBeneficiary(
id = i.toLong(),
name = "Beneficiary $i",
accountNumber = "00000000$i"
)
}
fun createBeneficiaryPayload(
name: String = "New Beneficiary",
accountNumber: String = "123456789",
officeId: Int = 1,
accountType: Int = 1
): BeneficiaryPayload = BeneficiaryPayload(
name = name,
accountNumber = accountNumber,
officeId = officeId,
accountType = accountType
)
// Pre-built scenarios
val emptyList = emptyList<Beneficiary>()
val singleBeneficiary = listOf(createBeneficiary())
val multipleBeneficiaries = createBeneficiaryList(5)
}
```
### 3. AccountFixtures
```kotlin
object AccountFixtures {
fun createSavingAccount(
id: Long = 1L,
accountNo: String = "SAV-001",
productName: String = "Regular Savings",
balance: Double = 5000.0,
status: Status = Status(active = true)
): SavingAccount = SavingAccount(
id = id,
accountNo = accountNo,
productName = productName,
accountBalance = balance,
status = status
)
fun createLoanAccount(
id: Long = 1L,
accountNo: String = "LOAN-001",
productName: String = "Personal Loan",
principal: Double = 50000.0,
outstanding: Double = 25000.0,
status: Status = Status(active = true)
): LoanAccount = LoanAccount(
id = id,
accountNo = accountNo,
productName = productName,
principal = principal,
loanBalance = outstanding,
status = status
)
fun createShareAccount(
id: Long = 1L,
accountNo: String = "SHR-001",
productName: String = "Community Shares",
totalShares: Int = 100,
status: Status = Status(active = true)
): ShareAccount = ShareAccount(
id = id,
accountNo = accountNo,
productName = productName,
totalApprovedShares = totalShares,
status = status
)
fun createClientAccounts(
savingsCount: Int = 2,
loansCount: Int = 1,
sharesCount: Int = 1
): ClientAccounts = ClientAccounts(
savingsAccounts = (1..savingsCount).map { createSavingAccount(id = it.toLong()) },
loanAccounts = (1..loansCount).map { createLoanAccount(id = it.toLong()) },
shareAccounts = (1..sharesCount).map { createShareAccount(id = it.toLong()) }
)
// Pre-built scenarios
val emptyAccounts = ClientAccounts(emptyList(), emptyList(), emptyList())
val savingsOnly = ClientAccounts(listOf(createSavingAccount()), emptyList(), emptyList())
val fullPortfolio = createClientAccounts(3, 2, 1)
}
```
### 4. TransferFixtures
```kotlin
object TransferFixtures {
fun createTransferPayload(
fromAccountId: Long = 1L,
fromClientId: Long = 100L,
toAccountId: Long = 2L,
toClientId: Long = 200L,
amount: Double = 1000.0,
transferDate: String = "2026-01-05",
remark: String = "Test transfer"
): TransferPayload = TransferPayload(
fromAccountId = fromAccountId,
fromClientId = fromClientId,
toAccountId = toAccountId,
toClientId = toClientId,
transferAmount = amount,
transferDate = transferDate,
transferDescription = remark
)
fun createTransferTemplate(
fromAccounts: List<SavingAccount> = listOf(AccountFixtures.createSavingAccount()),
beneficiaries: List<Beneficiary> = BeneficiaryFixtures.createBeneficiaryList()
): TransferTemplate = TransferTemplate(
fromAccountOptions = fromAccounts,
toBeneficiaryList = beneficiaries
)
// Pre-built scenarios
val minTransfer = createTransferPayload(amount = 1.0)
val maxTransfer = createTransferPayload(amount = 100000.0)
val invalidTransfer = createTransferPayload(amount = -100.0)
}
```
### 5. LoanFixtures
```kotlin
object LoanFixtures {
fun createLoanWithSchedule(
account: LoanAccount = AccountFixtures.createLoanAccount(),
scheduleCount: Int = 12
): LoanWithSchedule = LoanWithSchedule(
account = account,
schedule = (1..scheduleCount).map { i ->
RepaymentSchedule(
installment = i,
dueDate = "2026-${String.format("%02d", i)}-01",
principalDue = account.principal / scheduleCount,
interestDue = 100.0,
totalDue = (account.principal / scheduleCount) + 100.0,
paid = i <= 6
)
}
)
fun createLoanTransaction(
id: Long = 1L,
type: String = "REPAYMENT",
amount: Double = 1000.0,
date: String = "2026-01-05"
): LoanTransaction = LoanTransaction(
id = id,
type = TransactionType(value = type),
amount = amount,
date = listOf(2026, 1, 5)
)
// Pre-built scenarios
val newLoan = createLoanWithSchedule(scheduleCount = 12)
val fullyPaidLoan = LoanWithSchedule(
account = AccountFixtures.createLoanAccount(outstanding = 0.0),
schedule = emptyList()
)
}
```
### 6. NotificationFixtures
```kotlin
object NotificationFixtures {
fun createNotification(
id: Long = 1L,
title: String = "Test Notification",
content: String = "This is a test notification",
isRead: Boolean = false,
createdAt: String = "2026-01-05T10:00:00Z"
): Notification = Notification(
id = id,
objectType = "notification",
objectId = id,
action = title,
content = content,
isRead = isRead,
createdAt = createdAt
)
fun createNotificationList(count: Int = 5): List<Notification> =
(1..count).map { i ->
createNotification(
id = i.toLong(),
title = "Notification $i",
isRead = i % 2 == 0
)
}
// Pre-built scenarios
val unreadNotification = createNotification(isRead = false)
val readNotification = createNotification(isRead = true)
val emptyNotifications = emptyList<Notification>()
}
```
---
## Usage in Tests
### ViewModel Test
```kotlin
class BeneficiaryViewModelTest {
@Test
fun `load success shows list`() = runTest {
val testData = BeneficiaryFixtures.createBeneficiaryList(5)
fakeRepository.setSuccessResponse(testData)
viewModel.loadBeneficiaries()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertEquals(testData, (state.uiState as Success).data)
}
}
@Test
fun `empty list shows empty state`() = runTest {
fakeRepository.setSuccessResponse(BeneficiaryFixtures.emptyList)
viewModel.loadBeneficiaries()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue((state.uiState as Success).data.isEmpty())
}
}
}
```
### Screen Test
```kotlin
class BeneficiaryScreenTest {
@Test
fun successState_displaysItems() {
val testData = BeneficiaryFixtures.createBeneficiaryList(3)
composeTestRule.setContent {
BeneficiaryContent(
state = BeneficiaryState(
uiState = BeneficiaryUiState.Success(testData)
),
onAction = {}
)
}
testData.forEach { beneficiary ->
composeTestRule
.onNodeWithTag("beneficiary:item:${beneficiary.id}")
.assertIsDisplayed()
}
}
}
```
---
## Commands
```bash
# Generate fixtures for feature
/implement [feature] # Creates fixture file in Phase 5
# Check fixture status
/gap-analysis testing fixtures
```

View File

@ -0,0 +1,390 @@
# Test Patterns - O(1) Reference
> Quick lookup for test patterns used in Mifos Mobile
---
## Pattern Quick Reference
| # | Pattern | Use Case | Location | Details |
|:-:|---------|----------|----------|---------|
| 1 | ViewModel Test | Test state, actions, events | `commonTest/` | [viewmodel-test.md](./patterns/viewmodel-test.md) |
| 2 | Screen Test | Test UI composition | `androidInstrumentedTest/` | [screen-test.md](./patterns/screen-test.md) |
| 3 | Fake Repository | Test isolation | `commonTest/fake/` | [fake-repository.md](./patterns/fake-repository.md) |
| 4 | Integration Test | Test user flows | `cmp-android/androidTest/` | [integration-test.md](./patterns/integration-test.md) |
| 5 | Screenshot Test | Visual regression | `test/` (Roborazzi) | [screenshot-test.md](./patterns/screenshot-test.md) |
---
## 1. ViewModel Test Pattern
### When to Use
- Testing state transitions (Loading → Success → Error)
- Testing action handling
- Testing event emission (navigation, dialogs)
### Quick Template
```kotlin
class ${Feature}ViewModelTest {
private val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: ${Feature}ViewModel
private lateinit var fakeRepository: Fake${Feature}Repository
@BeforeTest
fun setup() {
fakeRepository = Fake${Feature}Repository()
viewModel = ${Feature}ViewModel(repository = fakeRepository)
}
@Test
fun `initial state is loading`() = runTest {
viewModel.stateFlow.test {
assertTrue(awaitItem().uiState is Loading)
}
}
@Test
fun `load success updates state`() = runTest {
fakeRepository.setSuccessResponse(testData)
viewModel.loadData()
viewModel.stateFlow.test {
assertTrue(expectMostRecentItem().uiState is Success)
}
}
}
```
### Key Libraries
| Library | Import | Purpose |
|---------|--------|---------|
| Turbine | `app.cash.turbine.test` | Flow testing |
| Coroutines Test | `kotlinx.coroutines.test.runTest` | Coroutine testing |
| kotlin-test | `kotlin.test.*` | Assertions |
---
## 2. Screen Test Pattern
### When to Use
- Testing UI renders correctly for each state
- Testing user interactions trigger correct actions
- Testing accessibility (content descriptions)
### Quick Template
```kotlin
class ${Feature}ScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loadingState_displaysLoader() {
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(uiState = Loading),
onAction = {}
)
}
composeTestRule
.onNodeWithTag("${feature}:loading")
.assertIsDisplayed()
}
@Test
fun itemClick_triggersAction() {
var receivedAction: ${Feature}Action? = null
composeTestRule.setContent {
${Feature}Content(
state = successState,
onAction = { receivedAction = it }
)
}
composeTestRule
.onNodeWithTag("${feature}:item:1")
.performClick()
assertEquals(${Feature}Action.ItemClicked(1), receivedAction)
}
}
```
### Key Methods
| Method | Purpose |
|--------|---------|
| `onNodeWithTag(tag)` | Find by testTag |
| `onNodeWithText(text)` | Find by text |
| `assertIsDisplayed()` | Verify visible |
| `assertIsEnabled()` | Verify clickable |
| `performClick()` | Simulate tap |
| `performTextInput(text)` | Type text |
---
## 3. Fake Repository Pattern
### When to Use
- Isolating ViewModel from real data source
- Testing different response scenarios
- Verifying repository method calls
### Quick Template
```kotlin
class Fake${Feature}Repository : ${Feature}Repository {
var loadCallCount = 0
private set
private var response: DataState<List<${Model}>> = DataState.Loading
fun setSuccessResponse(data: List<${Model}>) {
response = DataState.Success(data)
}
fun setErrorResponse(message: String) {
response = DataState.Error(message)
}
override fun getData(): Flow<DataState<List<${Model}>>> = flow {
loadCallCount++
emit(response)
}
fun reset() {
loadCallCount = 0
response = DataState.Loading
}
}
```
### Naming Convention
| Real | Fake |
|------|------|
| `BeneficiaryRepository` | `FakeBeneficiaryRepository` |
| `HomeRepository` | `FakeHomeRepository` |
| `LoanRepository` | `FakeLoanRepository` |
---
## 4. Integration Test Pattern
### When to Use
- Testing complete user flows
- Testing navigation between screens
- Testing data persistence across screens
### Quick Template
```kotlin
@HiltAndroidTest
class ${Feature}FlowTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun loginFlow_navigatesToHome() {
// Enter credentials
composeTestRule
.onNodeWithTag("auth:input:username")
.performTextInput("testuser")
composeTestRule
.onNodeWithTag("auth:input:password")
.performTextInput("password123")
// Click login
composeTestRule
.onNodeWithTag("auth:submit")
.performClick()
// Verify navigation to home
composeTestRule
.onNodeWithTag("home:screen")
.assertIsDisplayed()
}
}
```
### Critical Flows
| Flow | Screens | Priority |
|------|---------|:--------:|
| Login → Passcode → Home | 3 | P0 |
| Home → Transfer → Confirm | 3 | P0 |
| Home → Loan Details → Schedule | 3 | P1 |
| Settings → Change Password | 2 | P2 |
---
## 5. Screenshot Test Pattern
### When to Use
- Visual regression testing
- Documenting UI states
- Catching unintended UI changes
### Quick Template
```kotlin
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ${Feature}ScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val roborazziRule = RoborazziRule(
options = RoborazziRule.Options(
captureType = RoborazziRule.CaptureType.LastImage
)
)
@Test
fun ${feature}Screen_loading() {
composeTestRule.setContent {
MifosTheme {
${Feature}Content(
state = ${Feature}State(uiState = Loading),
onAction = {}
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
}
```
### Golden Image Location
```
feature/${feature}/src/test/resources/screenshots/
├── ${feature}Screen_loading.png
├── ${feature}Screen_success.png
├── ${feature}Screen_error.png
└── ${feature}Screen_empty.png
```
---
## Test State Categories
Every feature should test these states:
| State | Description | TestTag |
|-------|-------------|---------|
| Loading | Initial data fetch | `{feature}:loading` |
| Success | Data loaded | `{feature}:screen` |
| Error | Load failed | `{feature}:error` |
| Empty | No data | `{feature}:empty` |
| Refreshing | Pull-to-refresh | `{feature}:refreshing` |
---
## Common Test Scenarios
### Pagination
```kotlin
@Test
fun `load more appends data`() = runTest {
fakeRepository.setSuccessResponse(page1)
viewModel.loadData()
fakeRepository.setSuccessResponse(page2)
viewModel.trySendAction(Action.LoadMore)
viewModel.stateFlow.test {
val data = (expectMostRecentItem().uiState as Success).data
assertEquals(page1 + page2, data)
}
}
```
### Pull-to-Refresh
```kotlin
@Test
fun `refresh replaces data`() = runTest {
fakeRepository.setSuccessResponse(oldData)
viewModel.loadData()
fakeRepository.setSuccessResponse(newData)
viewModel.trySendAction(Action.Refresh)
viewModel.stateFlow.test {
val data = (expectMostRecentItem().uiState as Success).data
assertEquals(newData, data)
}
}
```
### Form Validation
```kotlin
@Test
fun `invalid input shows error`() = runTest {
viewModel.trySendAction(Action.Submit(invalidInput))
viewModel.stateFlow.test {
assertNotNull(expectMostRecentItem().validationError)
}
}
```
### Dialog Confirmation
```kotlin
@Test
fun `delete shows confirmation`() = runTest {
viewModel.trySendAction(Action.DeleteClicked(id))
viewModel.stateFlow.test {
assertTrue(expectMostRecentItem().dialogState is DialogState.Confirmation)
}
}
```
---
## Dependencies
```kotlin
// build.gradle.kts
kotlin {
sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
}
androidInstrumentedTest.dependencies {
implementation(libs.compose.ui.test.junit4)
implementation(libs.compose.ui.test.manifest)
}
val androidUnitTest by getting {
dependencies {
implementation(libs.roborazzi)
implementation(libs.robolectric)
}
}
}
}
```
---
## Commands
```bash
# Generate tests using pattern
/implement [feature] # Uses patterns from this file
# Verify pattern compliance
/verify [feature] # Checks TestTag patterns
# See detailed pattern
Read testing-layer/patterns/[pattern].md
```

View File

@ -0,0 +1,435 @@
# TestTags Index - O(1) Lookup
> **17 features** | **Pattern**: `{feature}:{component}:{id}` | **Last Updated**: 2026-01-05
---
## Quick Lookup
| # | Feature | TestTags Object | Required Tags | Status |
|:-:|---------|-----------------|:-------------:|:------:|
| 1 | auth | `AuthTestTags` | 8 | ❌ Not Created |
| 2 | home | `HomeTestTags` | 5 | ❌ Not Created |
| 3 | accounts | `AccountsTestTags` | 6 | ❌ Not Created |
| 4 | savings-account | `SavingsTestTags` | 7 | ❌ Not Created |
| 5 | loan-account | `LoanTestTags` | 7 | ❌ Not Created |
| 6 | share-account | `ShareTestTags` | 5 | ❌ Not Created |
| 7 | beneficiary | `BeneficiaryTestTags` | 8 | ❌ Not Created |
| 8 | transfer | `TransferTestTags` | 6 | ❌ Not Created |
| 9 | recent-transaction | `TransactionTestTags` | 4 | ❌ Not Created |
| 10 | notification | `NotificationTestTags` | 4 | ❌ Not Created |
| 11 | settings | `SettingsTestTags` | 10 | ❌ Not Created |
| 12 | passcode | `PasscodeTestTags` | 5 | ❌ Not Created |
| 13 | guarantor | `GuarantorTestTags` | 6 | ❌ Not Created |
| 14 | qr | `QrTestTags` | 5 | ❌ Not Created |
| 15 | location | `LocationTestTags` | 3 | ❌ Not Created |
| 16 | client-charge | `ChargeTestTags` | 4 | ❌ Not Created |
| 17 | dashboard | `DashboardTestTags` | 6 | ❌ Not Created |
---
## Naming Convention
### Pattern
```
{feature}:{component}:{identifier}
```
### Standard Components
| Component | Pattern | Example | Required |
|-----------|---------|---------|:--------:|
| Screen container | `{feature}:screen` | `auth:screen` | ✅ |
| Loading indicator | `{feature}:loading` | `auth:loading` | ✅ |
| Error container | `{feature}:error` | `auth:error` | ✅ |
| Empty state | `{feature}:empty` | `beneficiary:empty` | ⚠️ |
| List container | `{feature}:list` | `beneficiary:list` | ⚠️ |
| List item | `{feature}:item:{id}` | `beneficiary:item:123` | ⚠️ |
| Retry button | `{feature}:retry` | `auth:retry` | ⚠️ |
| Submit button | `{feature}:submit` | `auth:submit` | ⚠️ |
| Input field | `{feature}:input:{name}` | `auth:input:username` | ⚠️ |
| FAB | `{feature}:fab` | `beneficiary:fab` | ⚠️ |
**Legend**: ✅ Required | ⚠️ Recommended (if applicable)
---
## Feature TestTags Specifications
### 1. auth
**Object**: `feature/auth/src/commonMain/.../util/AuthTestTags.kt`
```kotlin
object AuthTestTags {
// Screens
const val LOGIN_SCREEN = "auth:login:screen"
const val REGISTER_SCREEN = "auth:register:screen"
const val OTP_SCREEN = "auth:otp:screen"
// Common
const val LOADING = "auth:loading"
const val ERROR = "auth:error"
// Login inputs
const val USERNAME_INPUT = "auth:input:username"
const val PASSWORD_INPUT = "auth:input:password"
const val LOGIN_BUTTON = "auth:submit:login"
// Register inputs
const val EMAIL_INPUT = "auth:input:email"
const val FIRST_NAME_INPUT = "auth:input:firstName"
const val LAST_NAME_INPUT = "auth:input:lastName"
const val MOBILE_INPUT = "auth:input:mobile"
const val REGISTER_BUTTON = "auth:submit:register"
// OTP
const val OTP_INPUT = "auth:input:otp"
const val VERIFY_BUTTON = "auth:submit:verify"
const val RESEND_BUTTON = "auth:resend"
}
```
### 2. home
**Object**: `feature/home/src/commonMain/.../util/HomeTestTags.kt`
```kotlin
object HomeTestTags {
const val SCREEN = "home:screen"
const val LOADING = "home:loading"
const val ERROR = "home:error"
// Content
const val USER_NAME = "home:userName"
const val TOTAL_SAVINGS = "home:totalSavings"
const val TOTAL_LOAN = "home:totalLoan"
const val ACCOUNTS_SECTION = "home:accounts"
const val QUICK_ACTIONS = "home:quickActions"
// Actions
const val TRANSFER_ACTION = "home:action:transfer"
const val QR_ACTION = "home:action:qr"
const val CHARGES_ACTION = "home:action:charges"
}
```
### 3. accounts
**Object**: `feature/account/src/commonMain/.../util/AccountsTestTags.kt`
```kotlin
object AccountsTestTags {
const val SCREEN = "accounts:screen"
const val LOADING = "accounts:loading"
const val ERROR = "accounts:error"
const val EMPTY = "accounts:empty"
// Tabs
const val SAVINGS_TAB = "accounts:tab:savings"
const val LOAN_TAB = "accounts:tab:loan"
const val SHARE_TAB = "accounts:tab:share"
// Lists
const val LIST = "accounts:list"
fun item(accountId: Long) = "accounts:item:$accountId"
}
```
### 4. beneficiary
**Object**: `feature/beneficiary/src/commonMain/.../util/BeneficiaryTestTags.kt`
```kotlin
object BeneficiaryTestTags {
const val SCREEN = "beneficiary:screen"
const val LOADING = "beneficiary:loading"
const val ERROR = "beneficiary:error"
const val EMPTY = "beneficiary:empty"
const val LIST = "beneficiary:list"
const val FAB = "beneficiary:fab"
const val RETRY = "beneficiary:retry"
fun item(id: Long) = "beneficiary:item:$id"
// Detail screen
const val DETAIL_SCREEN = "beneficiary:detail:screen"
const val NAME_TEXT = "beneficiary:detail:name"
const val ACCOUNT_TEXT = "beneficiary:detail:account"
const val EDIT_BUTTON = "beneficiary:detail:edit"
const val DELETE_BUTTON = "beneficiary:detail:delete"
// Add/Edit screen
const val FORM_SCREEN = "beneficiary:form:screen"
const val NAME_INPUT = "beneficiary:input:name"
const val ACCOUNT_INPUT = "beneficiary:input:account"
const val OFFICE_INPUT = "beneficiary:input:office"
const val SUBMIT_BUTTON = "beneficiary:submit"
}
```
### 5. transfer
**Object**: `feature/transfer-process/src/commonMain/.../util/TransferTestTags.kt`
```kotlin
object TransferTestTags {
const val SCREEN = "transfer:screen"
const val LOADING = "transfer:loading"
const val ERROR = "transfer:error"
// Inputs
const val FROM_ACCOUNT = "transfer:input:fromAccount"
const val TO_BENEFICIARY = "transfer:input:toBeneficiary"
const val AMOUNT_INPUT = "transfer:input:amount"
const val REMARK_INPUT = "transfer:input:remark"
const val DATE_INPUT = "transfer:input:date"
// Actions
const val REVIEW_BUTTON = "transfer:review"
const val CONFIRM_BUTTON = "transfer:confirm"
const val CANCEL_BUTTON = "transfer:cancel"
// Success
const val SUCCESS_SCREEN = "transfer:success:screen"
const val DONE_BUTTON = "transfer:done"
}
```
### 6. loan-account
**Object**: `feature/loan-account/src/commonMain/.../util/LoanTestTags.kt`
```kotlin
object LoanTestTags {
const val SCREEN = "loan:screen"
const val LOADING = "loan:loading"
const val ERROR = "loan:error"
// Summary
const val LOAN_AMOUNT = "loan:amount"
const val OUTSTANDING = "loan:outstanding"
const val STATUS = "loan:status"
// Tabs
const val SUMMARY_TAB = "loan:tab:summary"
const val REPAYMENT_TAB = "loan:tab:repayment"
const val TRANSACTIONS_TAB = "loan:tab:transactions"
// Lists
const val SCHEDULE_LIST = "loan:schedule:list"
const val TRANSACTION_LIST = "loan:transaction:list"
fun scheduleItem(index: Int) = "loan:schedule:item:$index"
fun transactionItem(id: Long) = "loan:transaction:item:$id"
}
```
### 7. savings-account
**Object**: `feature/savings-account/src/commonMain/.../util/SavingsTestTags.kt`
```kotlin
object SavingsTestTags {
const val SCREEN = "savings:screen"
const val LOADING = "savings:loading"
const val ERROR = "savings:error"
// Summary
const val BALANCE = "savings:balance"
const val STATUS = "savings:status"
const val ACCOUNT_NUMBER = "savings:accountNumber"
// Tabs
const val SUMMARY_TAB = "savings:tab:summary"
const val TRANSACTIONS_TAB = "savings:tab:transactions"
// Actions
const val WITHDRAW_BUTTON = "savings:withdraw"
const val DEPOSIT_BUTTON = "savings:deposit"
const val TRANSFER_BUTTON = "savings:transfer"
// Transaction list
const val TRANSACTION_LIST = "savings:transaction:list"
fun transactionItem(id: Long) = "savings:transaction:item:$id"
}
```
### 8. settings
**Object**: `feature/settings/src/commonMain/.../util/SettingsTestTags.kt`
```kotlin
object SettingsTestTags {
const val SCREEN = "settings:screen"
const val LOADING = "settings:loading"
// Menu items
const val CHANGE_PASSCODE = "settings:item:changePasscode"
const val CHANGE_PASSWORD = "settings:item:changePassword"
const val LANGUAGE = "settings:item:language"
const val THEME = "settings:item:theme"
const val NOTIFICATION = "settings:item:notification"
const val ABOUT = "settings:item:about"
const val LOGOUT = "settings:item:logout"
// Change password screen
const val CURRENT_PASSWORD = "settings:input:currentPassword"
const val NEW_PASSWORD = "settings:input:newPassword"
const val CONFIRM_PASSWORD = "settings:input:confirmPassword"
const val SUBMIT_PASSWORD = "settings:submit:password"
}
```
### 9. notification
**Object**: `feature/notification/src/commonMain/.../util/NotificationTestTags.kt`
```kotlin
object NotificationTestTags {
const val SCREEN = "notification:screen"
const val LOADING = "notification:loading"
const val ERROR = "notification:error"
const val EMPTY = "notification:empty"
const val LIST = "notification:list"
fun item(id: Long) = "notification:item:$id"
}
```
### 10. qr
**Object**: `feature/qr-code/src/commonMain/.../util/QrTestTags.kt`
```kotlin
object QrTestTags {
const val SCREEN = "qr:screen"
const val LOADING = "qr:loading"
const val ERROR = "qr:error"
// Display
const val QR_IMAGE = "qr:image"
const val SHARE_BUTTON = "qr:share"
// Scan
const val SCAN_SCREEN = "qr:scan:screen"
const val CAMERA_VIEW = "qr:scan:camera"
const val RESULT_TEXT = "qr:scan:result"
}
```
---
## Validation Rules
### Required Tags (Must Exist)
```kotlin
val requiredTags = listOf(
"${feature}:screen", // Main screen container
"${feature}:loading", // Loading state
"${feature}:error" // Error state
)
```
### Recommended Tags (If Applicable)
```kotlin
val recommendedTags = listOf(
"${feature}:empty", // Empty state (for lists)
"${feature}:list", // List container
"${feature}:retry", // Retry button (for errors)
"${feature}:fab" // Floating action button
)
```
### Naming Validation Regex
```kotlin
val testTagPattern = Regex("^[a-z-]+:[a-z-]+(?::[a-z0-9-]+)?$")
// Valid examples:
// "auth:screen" ✅
// "beneficiary:item:123" ✅
// "transfer:input:amount" ✅
// Invalid examples:
// "AuthScreen" ❌ (no colons, PascalCase)
// "auth_screen" ❌ (underscore)
// "auth:LOADING" ❌ (uppercase)
```
---
## Usage in Compose
### Applying TestTags
```kotlin
@Composable
fun BeneficiaryScreen(state: BeneficiaryState) {
Scaffold(
modifier = Modifier.testTag(BeneficiaryTestTags.SCREEN)
) {
when (state.uiState) {
is Loading -> CircularProgressIndicator(
modifier = Modifier.testTag(BeneficiaryTestTags.LOADING)
)
is Error -> ErrorView(
modifier = Modifier.testTag(BeneficiaryTestTags.ERROR)
)
is Success -> BeneficiaryList(
items = state.uiState.data,
modifier = Modifier.testTag(BeneficiaryTestTags.LIST)
)
}
}
}
@Composable
fun BeneficiaryItem(
beneficiary: Beneficiary,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.testTag(BeneficiaryTestTags.item(beneficiary.id))
.clickable(onClick = onClick)
) {
// Content
}
}
```
---
## O(1) Path Pattern
```
feature/[feature]/src/commonMain/kotlin/org/mifos/mobile/feature/[feature]/util/[Feature]TestTags.kt
```
---
## Auto-Update Rules
| Trigger | Action |
|---------|--------|
| `/implement [feature]` | Create TestTags object if missing |
| `/verify [feature]` | Validate TestTags exist and follow convention |
| New screen added | Add corresponding tags to object |
---
## Commands
```bash
# Check TestTag status
/gap-analysis testing tags
# Generate TestTags for feature
/implement [feature] # Creates TestTags in Phase 4
# Validate TestTags
/verify [feature] # Includes TestTag validation report
```

View File

@ -0,0 +1,467 @@
# Fake Repository Pattern
> Detailed instructions for creating test doubles in Mifos Mobile
---
## Overview
Fake repositories:
- Implement the real repository interface
- Provide configurable responses for testing
- Track method calls for verification
- Enable test isolation
---
## File Location
```
core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/Fake${Feature}Repository.kt
```
---
## Standard Template
```kotlin
package org.mifos.mobile.core.testing.fake
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.mifos.mobile.core.data.repository.${Feature}Repository
import org.mifos.mobile.core.model.${Model}
import org.mifos.mobile.core.common.DataState
class Fake${Feature}Repository : ${Feature}Repository {
// ═══════════════════════════════════════════════════════════════
// CALL TRACKING
// ═══════════════════════════════════════════════════════════════
/**
* Number of times load method was called.
* Use to verify refresh/retry behavior.
*/
var loadCallCount = 0
private set
/**
* Number of times create method was called.
*/
var createCallCount = 0
private set
/**
* Number of times update method was called.
*/
var updateCallCount = 0
private set
/**
* Number of times delete method was called.
*/
var deleteCallCount = 0
private set
/**
* Last payload passed to create method.
* Use to verify correct data was sent.
*/
var lastCreatePayload: ${Model}Payload? = null
private set
/**
* Last ID passed to delete method.
*/
var lastDeleteId: Long? = null
private set
// ═══════════════════════════════════════════════════════════════
// CONFIGURABLE RESPONSES
// ═══════════════════════════════════════════════════════════════
private var loadResponse: DataState<List<${Model}>> = DataState.Loading
private var singleResponse: DataState<${Model}> = DataState.Loading
private var createResponse: DataState<${Model}> = DataState.Loading
private var updateResponse: DataState<${Model}> = DataState.Loading
private var deleteResponse: DataState<Unit> = DataState.Loading
// ═══════════════════════════════════════════════════════════════
// SETUP METHODS
// ═══════════════════════════════════════════════════════════════
/**
* Configure load to return success with data.
*
* @param data The list of items to return
*/
fun setLoadSuccess(data: List<${Model}>) {
loadResponse = DataState.Success(data)
}
/**
* Configure load to return error.
*
* @param message The error message
*/
fun setLoadError(message: String = "Failed to load") {
loadResponse = DataState.Error(message)
}
/**
* Configure load to return empty list.
*/
fun setLoadEmpty() {
loadResponse = DataState.Success(emptyList())
}
/**
* Configure load to return loading state (useful for testing loading UI).
*/
fun setLoadLoading() {
loadResponse = DataState.Loading
}
/**
* Configure get single item to return success.
*/
fun setSingleSuccess(item: ${Model}) {
singleResponse = DataState.Success(item)
}
/**
* Configure get single item to return error.
*/
fun setSingleError(message: String = "Item not found") {
singleResponse = DataState.Error(message)
}
/**
* Configure create to return success.
*/
fun setCreateSuccess(item: ${Model}) {
createResponse = DataState.Success(item)
}
/**
* Configure create to return error.
*/
fun setCreateError(message: String = "Failed to create") {
createResponse = DataState.Error(message)
}
/**
* Configure update to return success.
*/
fun setUpdateSuccess(item: ${Model}) {
updateResponse = DataState.Success(item)
}
/**
* Configure update to return error.
*/
fun setUpdateError(message: String = "Failed to update") {
updateResponse = DataState.Error(message)
}
/**
* Configure delete to return success.
*/
fun setDeleteSuccess() {
deleteResponse = DataState.Success(Unit)
}
/**
* Configure delete to return error.
*/
fun setDeleteError(message: String = "Failed to delete") {
deleteResponse = DataState.Error(message)
}
// ═══════════════════════════════════════════════════════════════
// REPOSITORY IMPLEMENTATION
// ═══════════════════════════════════════════════════════════════
override fun get${Feature}s(): Flow<DataState<List<${Model}>>> = flow {
loadCallCount++
emit(loadResponse)
}
override fun get${Feature}(id: Long): Flow<DataState<${Model}>> = flow {
emit(singleResponse)
}
override fun create${Feature}(payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
createCallCount++
lastCreatePayload = payload
emit(createResponse)
}
override fun update${Feature}(id: Long, payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
updateCallCount++
emit(updateResponse)
}
override fun delete${Feature}(id: Long): Flow<DataState<Unit>> = flow {
deleteCallCount++
lastDeleteId = id
emit(deleteResponse)
}
// ═══════════════════════════════════════════════════════════════
// RESET
// ═══════════════════════════════════════════════════════════════
/**
* Reset all counters and responses.
* Call in @AfterTest to ensure test isolation.
*/
fun reset() {
// Reset counters
loadCallCount = 0
createCallCount = 0
updateCallCount = 0
deleteCallCount = 0
// Reset captured data
lastCreatePayload = null
lastDeleteId = null
// Reset responses to loading
loadResponse = DataState.Loading
singleResponse = DataState.Loading
createResponse = DataState.Loading
updateResponse = DataState.Loading
deleteResponse = DataState.Loading
}
}
```
---
## Usage Examples
### Basic Test Setup
```kotlin
class ${Feature}ViewModelTest {
private lateinit var fakeRepository: Fake${Feature}Repository
private lateinit var viewModel: ${Feature}ViewModel
@BeforeTest
fun setup() {
fakeRepository = Fake${Feature}Repository()
viewModel = ${Feature}ViewModel(repository = fakeRepository)
}
@AfterTest
fun teardown() {
fakeRepository.reset()
}
}
```
### Testing Success State
```kotlin
@Test
fun `load success updates state`() = runTest {
val testData = ${Feature}Fixtures.createList(5)
fakeRepository.setLoadSuccess(testData)
viewModel.loadData()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertEquals(testData, (state.uiState as Success).data)
}
}
```
### Testing Error State
```kotlin
@Test
fun `load error shows error`() = runTest {
fakeRepository.setLoadError("Network unavailable")
viewModel.loadData()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is Error)
assertEquals("Network unavailable", (state.uiState as Error).message)
}
}
```
### Verifying Method Calls
```kotlin
@Test
fun `refresh calls repository twice`() = runTest {
fakeRepository.setLoadSuccess(emptyList())
viewModel.loadData()
viewModel.trySendAction(Action.Refresh)
assertEquals(2, fakeRepository.loadCallCount)
}
```
### Verifying Payload
```kotlin
@Test
fun `create sends correct payload`() = runTest {
val payload = ${Feature}Payload(name = "Test")
fakeRepository.setCreateSuccess(${Feature}Fixtures.create())
viewModel.create(payload)
assertEquals(payload, fakeRepository.lastCreatePayload)
}
```
### Testing Delete Flow
```kotlin
@Test
fun `delete calls repository with correct id`() = runTest {
fakeRepository.setDeleteSuccess()
viewModel.delete(itemId = 42L)
assertEquals(1, fakeRepository.deleteCallCount)
assertEquals(42L, fakeRepository.lastDeleteId)
}
```
---
## Advanced Patterns
### Sequential Responses
For testing pagination or retry:
```kotlin
class FakePaginatedRepository : Repository {
private val responses = mutableListOf<DataState<List<Item>>>()
private var responseIndex = 0
fun addResponse(response: DataState<List<Item>>) {
responses.add(response)
}
override fun getItems(): Flow<DataState<List<Item>>> = flow {
if (responseIndex < responses.size) {
emit(responses[responseIndex++])
}
}
}
// Usage in test:
@Test
fun `pagination loads next page`() = runTest {
fakeRepository.addResponse(DataState.Success(page1))
fakeRepository.addResponse(DataState.Success(page2))
viewModel.loadData() // Gets page1
viewModel.loadMore() // Gets page2
// Verify combined data
}
```
### Delayed Responses
For testing loading states:
```kotlin
class FakeDelayedRepository : Repository {
var delay: Long = 0L
override fun getItems(): Flow<DataState<List<Item>>> = flow {
emit(DataState.Loading)
delay(delay)
emit(DataState.Success(data))
}
}
// Usage:
@Test
fun `shows loading while fetching`() = runTest {
fakeRepository.delay = 1000L
viewModel.loadData()
viewModel.stateFlow.test {
assertTrue(awaitItem().uiState is Loading)
// ...
}
}
```
### Error Then Success
For testing retry:
```kotlin
@Test
fun `retry after error succeeds`() = runTest {
fakeRepository.setLoadError("Network error")
viewModel.loadData()
// Verify error state
viewModel.stateFlow.test {
assertTrue(expectMostRecentItem().uiState is Error)
}
// Configure success and retry
fakeRepository.setLoadSuccess(testData)
viewModel.trySendAction(Action.Retry)
// Verify success state
viewModel.stateFlow.test {
assertTrue(expectMostRecentItem().uiState is Success)
}
}
```
---
## Naming Convention
| Real Repository | Fake Repository |
|-----------------|-----------------|
| `UserAuthRepository` | `FakeUserAuthRepository` |
| `BeneficiaryRepository` | `FakeBeneficiaryRepository` |
| `HomeRepository` | `FakeHomeRepository` |
| `LoanRepository` | `FakeLoanRepository` |
---
## Checklist
When creating a fake repository:
- [ ] Implements real repository interface
- [ ] Has call counters for all methods
- [ ] Has configurable responses (success, error, loading)
- [ ] Captures payloads for verification
- [ ] Has `reset()` method
- [ ] Uses fixtures for default data
- [ ] Documents public methods
---
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Not resetting between tests | Call `reset()` in `@AfterTest` |
| Returning same response | Use response queues for sequences |
| Missing interface methods | Implement all methods |
| Not tracking calls | Add counter for each method |

View File

@ -0,0 +1,526 @@
# Integration Test Pattern
> Detailed instructions for testing user flows in Mifos Mobile
---
## Overview
Integration tests verify:
- Complete user flows across screens
- Navigation between features
- Data persistence across screens
- Real user scenarios
---
## File Location
```
cmp-android/src/androidTest/kotlin/org/mifos/mobile/flow/${Feature}FlowTest.kt
```
---
## Dependencies
```kotlin
// cmp-android/build.gradle.kts
dependencies {
androidTestImplementation(libs.compose.ui.test.junit4)
androidTestImplementation(libs.compose.ui.test.manifest)
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.compiler)
}
```
---
## Test Structure
```kotlin
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ${Feature}FlowTest {
// ═══════════════════════════════════════════════════════════════
// SETUP
// ═══════════════════════════════════════════════════════════════
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var testRepository: Fake${Feature}Repository
@Before
fun setup() {
hiltRule.inject()
// Configure initial state
}
@After
fun teardown() {
testRepository.reset()
}
// ═══════════════════════════════════════════════════════════════
// FLOW TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun loginFlow_navigatesToHome() {
// Given: User is on login screen
composeTestRule
.onNodeWithTag("auth:login:screen")
.assertIsDisplayed()
// When: User enters valid credentials
composeTestRule
.onNodeWithTag("auth:input:username")
.performTextInput("testuser")
composeTestRule
.onNodeWithTag("auth:input:password")
.performTextInput("password123")
composeTestRule
.onNodeWithTag("auth:submit:login")
.performClick()
// Then: User is navigated to passcode screen
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithTag("passcode:screen")
.fetchSemanticsNodes()
.isNotEmpty()
}
// When: User enters passcode
enterPasscode("1234")
// Then: User is navigated to home screen
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithTag("home:screen")
.fetchSemanticsNodes()
.isNotEmpty()
}
}
@Test
fun transferFlow_completesSuccessfully() {
// Given: User is logged in and on home
navigateToHome()
// When: User initiates transfer
composeTestRule
.onNodeWithTag("home:action:transfer")
.performClick()
// Then: Transfer screen is displayed
composeTestRule
.onNodeWithTag("transfer:screen")
.assertIsDisplayed()
// When: User fills transfer form
selectFromAccount(accountIndex = 0)
selectBeneficiary(beneficiaryIndex = 0)
enterAmount("1000")
// When: User reviews transfer
composeTestRule
.onNodeWithTag("transfer:review")
.performClick()
// Then: Review screen shows correct info
composeTestRule
.onNodeWithText("1000")
.assertIsDisplayed()
// When: User confirms transfer
composeTestRule
.onNodeWithTag("transfer:confirm")
.performClick()
// Then: Success screen is displayed
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithTag("transfer:success:screen")
.fetchSemanticsNodes()
.isNotEmpty()
}
}
// ═══════════════════════════════════════════════════════════════
// HELPER METHODS
// ═══════════════════════════════════════════════════════════════
private fun navigateToHome() {
// Skip login/passcode if already logged in
// Or perform login flow
}
private fun enterPasscode(passcode: String) {
passcode.forEach { digit ->
composeTestRule
.onNodeWithText(digit.toString())
.performClick()
}
}
private fun selectFromAccount(accountIndex: Int) {
composeTestRule
.onNodeWithTag("transfer:input:fromAccount")
.performClick()
composeTestRule
.onAllNodesWithTag("dropdown:item")
.get(accountIndex)
.performClick()
}
private fun selectBeneficiary(beneficiaryIndex: Int) {
composeTestRule
.onNodeWithTag("transfer:input:toBeneficiary")
.performClick()
composeTestRule
.onAllNodesWithTag("dropdown:item")
.get(beneficiaryIndex)
.performClick()
}
private fun enterAmount(amount: String) {
composeTestRule
.onNodeWithTag("transfer:input:amount")
.performTextInput(amount)
}
}
```
---
## Critical User Flows
### 1. Authentication Flow
```
Login Screen → Passcode Setup → Home
```
```kotlin
@Test
fun newUserRegistration_flow() {
// 1. Navigate to registration
composeTestRule.onNodeWithText("Register").performClick()
// 2. Fill registration form
composeTestRule.onNodeWithTag("auth:input:firstName").performTextInput("John")
composeTestRule.onNodeWithTag("auth:input:lastName").performTextInput("Doe")
composeTestRule.onNodeWithTag("auth:input:email").performTextInput("john@test.com")
composeTestRule.onNodeWithTag("auth:input:mobile").performTextInput("1234567890")
composeTestRule.onNodeWithTag("auth:input:username").performTextInput("johndoe")
composeTestRule.onNodeWithTag("auth:input:password").performTextInput("password123")
composeTestRule.onNodeWithTag("auth:submit:register").performClick()
// 3. Verify OTP screen
waitForScreen("auth:otp:screen")
// 4. Enter OTP
composeTestRule.onNodeWithTag("auth:input:otp").performTextInput("123456")
composeTestRule.onNodeWithTag("auth:submit:verify").performClick()
// 5. Verify navigation to login
waitForScreen("auth:login:screen")
}
```
### 2. Transfer Flow
```
Home → Transfer → Review → Confirm → Success
```
```kotlin
@Test
fun thirdPartyTransfer_flow() {
navigateToHome()
// 1. Open transfer
composeTestRule.onNodeWithTag("home:action:transfer").performClick()
waitForScreen("transfer:screen")
// 2. Select accounts
selectFromAccount(0)
selectBeneficiary(0)
// 3. Enter details
enterAmount("500")
composeTestRule.onNodeWithTag("transfer:input:remark").performTextInput("Test transfer")
// 4. Review
composeTestRule.onNodeWithTag("transfer:review").performClick()
composeTestRule.onNodeWithText("500").assertIsDisplayed()
// 5. Confirm
composeTestRule.onNodeWithTag("transfer:confirm").performClick()
// 6. Verify success
waitForScreen("transfer:success:screen")
}
```
### 3. Beneficiary CRUD Flow
```
List → Add → Success → List (with new item)
```
```kotlin
@Test
fun addBeneficiary_flow() {
navigateToHome()
navigateToBeneficiaries()
// 1. Open add form
composeTestRule.onNodeWithTag("beneficiary:fab").performClick()
waitForScreen("beneficiary:form:screen")
// 2. Fill form
composeTestRule.onNodeWithTag("beneficiary:input:name").performTextInput("Test Beneficiary")
composeTestRule.onNodeWithTag("beneficiary:input:account").performTextInput("123456789")
// 3. Submit
composeTestRule.onNodeWithTag("beneficiary:submit").performClick()
// 4. Verify navigation back to list
waitForScreen("beneficiary:screen")
// 5. Verify new beneficiary in list
composeTestRule.onNodeWithText("Test Beneficiary").assertIsDisplayed()
}
```
### 4. Account Detail Flow
```
Home → Accounts → Savings Detail → Transactions
```
```kotlin
@Test
fun viewSavingsAccount_flow() {
navigateToHome()
// 1. Open accounts
composeTestRule.onNodeWithTag("home:accounts").performClick()
waitForScreen("accounts:screen")
// 2. Select savings tab
composeTestRule.onNodeWithTag("accounts:tab:savings").performClick()
// 3. Open first account
composeTestRule.onAllNodesWithTag("accounts:item").get(0).performClick()
waitForScreen("savings:screen")
// 4. Verify account details
composeTestRule.onNodeWithTag("savings:balance").assertIsDisplayed()
// 5. View transactions
composeTestRule.onNodeWithTag("savings:tab:transactions").performClick()
composeTestRule.onNodeWithTag("savings:transaction:list").assertIsDisplayed()
}
```
### 5. Settings Flow
```
Home → Settings → Change Password → Success
```
```kotlin
@Test
fun changePassword_flow() {
navigateToHome()
navigateToSettings()
// 1. Open change password
composeTestRule.onNodeWithTag("settings:item:changePassword").performClick()
// 2. Enter passwords
composeTestRule.onNodeWithTag("settings:input:currentPassword").performTextInput("oldpass")
composeTestRule.onNodeWithTag("settings:input:newPassword").performTextInput("newpass123")
composeTestRule.onNodeWithTag("settings:input:confirmPassword").performTextInput("newpass123")
// 3. Submit
composeTestRule.onNodeWithTag("settings:submit:password").performClick()
// 4. Verify success message
composeTestRule.onNodeWithText("Password changed successfully").assertIsDisplayed()
}
```
---
## Helper Functions
### Wait for Screen
```kotlin
private fun waitForScreen(screenTag: String, timeoutMillis: Long = 5000) {
composeTestRule.waitUntil(timeoutMillis) {
composeTestRule
.onAllNodesWithTag(screenTag)
.fetchSemanticsNodes()
.isNotEmpty()
}
}
```
### Navigation Helpers
```kotlin
private fun navigateToHome() {
// If not logged in, perform login
// Otherwise just verify home screen
}
private fun navigateToBeneficiaries() {
composeTestRule.onNodeWithTag("home:menu").performClick()
composeTestRule.onNodeWithText("Beneficiaries").performClick()
waitForScreen("beneficiary:screen")
}
private fun navigateToSettings() {
composeTestRule.onNodeWithTag("home:menu").performClick()
composeTestRule.onNodeWithText("Settings").performClick()
waitForScreen("settings:screen")
}
```
### Form Helpers
```kotlin
private fun fillTextField(tag: String, text: String) {
composeTestRule
.onNodeWithTag(tag)
.performTextClearance()
.performTextInput(text)
}
private fun selectDropdownItem(dropdownTag: String, itemIndex: Int) {
composeTestRule.onNodeWithTag(dropdownTag).performClick()
composeTestRule.onAllNodesWithTag("dropdown:item").get(itemIndex).performClick()
}
```
---
## Test Data Setup
### Using Fake Repositories
```kotlin
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DataModule::class]
)
object TestDataModule {
@Provides
@Singleton
fun provideFakeRepository(): BeneficiaryRepository = FakeBeneficiaryRepository()
}
```
### Injecting Test Data
```kotlin
@Before
fun setup() {
hiltRule.inject()
// Configure test data
testRepository.setLoadSuccess(
listOf(
Beneficiary(id = 1, name = "Test Beneficiary 1"),
Beneficiary(id = 2, name = "Test Beneficiary 2")
)
)
}
```
---
## Best Practices
### 1. Test User Behavior
```kotlin
// Good: Test what user sees and does
@Test
fun user_canViewAccountBalance() {
navigateToAccount()
composeTestRule.onNodeWithText("$5,000").assertIsDisplayed()
}
// Bad: Test implementation details
@Test
fun viewModel_stateIsCorrect() { ... }
```
### 2. Use Meaningful Names
```kotlin
// Good
@Test
fun transferWithInsufficientFunds_showsError() { ... }
// Bad
@Test
fun test1() { ... }
```
### 3. Single Assertion Focus
```kotlin
// Good: One logical assertion per test
@Test
fun loginSuccess_navigatesToHome() { ... }
@Test
fun loginFailure_showsErrorMessage() { ... }
// Bad: Multiple unrelated assertions
@Test
fun loginTest() {
// Tests success AND failure AND navigation...
}
```
---
## Checklist
For each critical flow:
- [ ] Happy path test
- [ ] Error handling test
- [ ] Edge case tests
- [ ] Back navigation test
- [ ] State persistence test
---
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Hardcoded waits | Use `waitUntil` |
| Missing test data | Inject fake repositories |
| Flaky tests | Add proper synchronization |
| Testing too much | Split into smaller tests |

View File

@ -0,0 +1,529 @@
# Screen Test Pattern
> Detailed instructions for testing Compose screens in Mifos Mobile
---
## Overview
Screen tests verify:
- UI renders correctly for each state
- User interactions trigger correct actions
- Accessibility (content descriptions, testTags)
- Visual appearance (with screenshots)
---
## File Location
```
feature/${feature}/src/androidInstrumentedTest/kotlin/org/mifos/mobile/feature/${feature}/${Feature}ScreenTest.kt
```
---
## Dependencies
```kotlin
// build.gradle.kts
kotlin {
sourceSets {
androidInstrumentedTest.dependencies {
implementation(libs.compose.ui.test.junit4)
implementation(libs.compose.ui.test.manifest)
}
}
}
```
---
## Test Structure
```kotlin
class ${Feature}ScreenTest {
// ═══════════════════════════════════════════════════════════════
// SETUP
// ═══════════════════════════════════════════════════════════════
@get:Rule
val composeTestRule = createComposeRule()
// ═══════════════════════════════════════════════════════════════
// LOADING STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun loadingState_displaysLoadingIndicator() {
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(uiState = ${Feature}UiState.Loading),
onAction = {}
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.LOADING)
.assertIsDisplayed()
composeTestRule
.onNodeWithTag(${Feature}TestTags.LIST)
.assertDoesNotExist()
}
// ═══════════════════════════════════════════════════════════════
// SUCCESS STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun successState_displaysContent() {
val testData = ${Feature}Fixtures.createList(3)
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(testData)
),
onAction = {}
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.SCREEN)
.assertIsDisplayed()
composeTestRule
.onNodeWithTag(${Feature}TestTags.LIST)
.assertIsDisplayed()
}
@Test
fun successState_displaysAllItems() {
val testData = ${Feature}Fixtures.createList(5)
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(testData)
),
onAction = {}
)
}
testData.forEach { item ->
composeTestRule
.onNodeWithTag(${Feature}TestTags.item(item.id))
.assertIsDisplayed()
}
}
// ═══════════════════════════════════════════════════════════════
// ERROR STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun errorState_displaysErrorMessage() {
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Error("Network error")
),
onAction = {}
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.ERROR)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Network error")
.assertIsDisplayed()
}
@Test
fun errorState_displaysRetryButton() {
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Error("Error")
),
onAction = {}
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.RETRY)
.assertIsDisplayed()
.assertIsEnabled()
}
// ═══════════════════════════════════════════════════════════════
// EMPTY STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun emptyState_displaysEmptyMessage() {
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(emptyList())
),
onAction = {}
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.EMPTY)
.assertIsDisplayed()
}
// ═══════════════════════════════════════════════════════════════
// USER INTERACTION TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun itemClick_triggersAction() {
var receivedAction: ${Feature}Action? = null
val testData = ${Feature}Fixtures.createList(3)
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(testData)
),
onAction = { receivedAction = it }
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.item(testData[0].id))
.performClick()
assertEquals(
${Feature}Action.ItemClicked(testData[0].id),
receivedAction
)
}
@Test
fun retryClick_triggersRetryAction() {
var receivedAction: ${Feature}Action? = null
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Error("Error")
),
onAction = { receivedAction = it }
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.RETRY)
.performClick()
assertEquals(${Feature}Action.Retry, receivedAction)
}
@Test
fun fabClick_triggersAddAction() {
var receivedAction: ${Feature}Action? = null
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(emptyList())
),
onAction = { receivedAction = it }
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.FAB)
.performClick()
assertEquals(${Feature}Action.AddClicked, receivedAction)
}
// ═══════════════════════════════════════════════════════════════
// FORM INPUT TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun textInput_updatesState() {
var receivedAction: ${Feature}Action? = null
composeTestRule.setContent {
${Feature}FormContent(
state = ${Feature}FormState(),
onAction = { receivedAction = it }
)
}
composeTestRule
.onNodeWithTag("${feature}:input:name")
.performTextInput("Test Name")
assertEquals(
${Feature}Action.NameChanged("Test Name"),
receivedAction
)
}
@Test
fun submitButton_disabledWhenInvalid() {
composeTestRule.setContent {
${Feature}FormContent(
state = ${Feature}FormState(name = ""), // Invalid
onAction = {}
)
}
composeTestRule
.onNodeWithTag("${feature}:submit")
.assertIsNotEnabled()
}
@Test
fun submitButton_enabledWhenValid() {
composeTestRule.setContent {
${Feature}FormContent(
state = ${Feature}FormState(name = "Valid Name"),
onAction = {}
)
}
composeTestRule
.onNodeWithTag("${feature}:submit")
.assertIsEnabled()
}
// ═══════════════════════════════════════════════════════════════
// DIALOG TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun confirmationDialog_displayedWhenDialogStateSet() {
composeTestRule.setContent {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(emptyList()),
dialogState = DialogState.Confirmation(
title = "Delete?",
message = "Are you sure?"
)
),
onAction = {}
)
}
composeTestRule
.onNodeWithText("Delete?")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Are you sure?")
.assertIsDisplayed()
}
}
```
---
## Compose Test API Reference
### Finding Nodes
| Method | Purpose | Example |
|--------|---------|---------|
| `onNodeWithTag(tag)` | Find by testTag | `onNodeWithTag("auth:screen")` |
| `onNodeWithText(text)` | Find by text | `onNodeWithText("Login")` |
| `onNodeWithContentDescription(desc)` | Find by a11y label | `onNodeWithContentDescription("Close")` |
| `onAllNodesWithTag(tag)` | Find all matching | `onAllNodesWithTag("item")` |
| `onRoot()` | Get root node | `onRoot()` |
### Assertions
| Method | Purpose |
|--------|---------|
| `assertIsDisplayed()` | Verify visible |
| `assertDoesNotExist()` | Verify not in tree |
| `assertIsEnabled()` | Verify clickable |
| `assertIsNotEnabled()` | Verify disabled |
| `assertTextEquals(text)` | Verify text content |
| `assertHasClickAction()` | Verify clickable |
### Actions
| Method | Purpose |
|--------|---------|
| `performClick()` | Tap element |
| `performTextInput(text)` | Type text |
| `performTextClearance()` | Clear text field |
| `performScrollTo()` | Scroll to element |
| `performSwipeLeft()` | Swipe gesture |
| `performTouchInput { swipeUp() }` | Custom touch |
### Waiting
```kotlin
// Wait for condition
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithTag("item")
.fetchSemanticsNodes()
.isNotEmpty()
}
```
---
## Test Categories
### 1. State Rendering Tests
Test each UI state renders correctly.
```kotlin
@Test
fun loading_showsProgressIndicator() { ... }
@Test
fun success_showsContent() { ... }
@Test
fun error_showsErrorView() { ... }
@Test
fun empty_showsEmptyView() { ... }
```
### 2. User Interaction Tests
Test all clickable elements trigger correct actions.
```kotlin
@Test
fun button_click_triggersAction() {
var action: Action? = null
composeTestRule.setContent {
Screen(onAction = { action = it })
}
composeTestRule.onNodeWithTag("button").performClick()
assertEquals(Action.ButtonClicked, action)
}
```
### 3. Form Tests
Test input fields and validation.
```kotlin
@Test
fun input_updatesOnTyping() { ... }
@Test
fun validation_showsErrorOnInvalid() { ... }
@Test
fun submit_disabledWhenInvalid() { ... }
```
### 4. Navigation Tests
Test navigation callbacks.
```kotlin
@Test
fun backButton_triggersNavigateBack() { ... }
@Test
fun item_click_triggersNavigateToDetail() { ... }
```
---
## TestTag Best Practices
### Applying Tags
```kotlin
@Composable
fun ${Feature}Screen() {
Scaffold(
modifier = Modifier.testTag(${Feature}TestTags.SCREEN)
) {
// Content
}
}
```
### Tag Naming
```kotlin
object ${Feature}TestTags {
const val SCREEN = "${feature}:screen"
const val LOADING = "${feature}:loading"
const val ERROR = "${feature}:error"
const val EMPTY = "${feature}:empty"
const val LIST = "${feature}:list"
const val FAB = "${feature}:fab"
fun item(id: Long) = "${feature}:item:$id"
}
```
---
## Test Coverage Checklist
For each screen, test:
- [ ] Loading state displays correctly
- [ ] Success state displays content
- [ ] Error state displays message and retry
- [ ] Empty state displays empty message
- [ ] All clickable elements trigger actions
- [ ] Form inputs update state
- [ ] Form validation works
- [ ] Dialogs display correctly
- [ ] Accessibility labels present
---
## Debug Helpers
### Print Compose Tree
```kotlin
composeTestRule.onRoot().printToLog("COMPOSE_TREE")
```
### Print All Nodes
```kotlin
composeTestRule
.onAllNodes(hasTestTag("item"))
.printToLog("ITEMS")
```
### Screenshot Debugging
```kotlin
composeTestRule.onRoot().captureToImage()
```
---
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Not finding node | Check testTag is applied |
| Flaky tests | Use `waitUntil` for async |
| Testing implementation | Test behavior, not structure |
| Missing states | Test all UI states |

View File

@ -0,0 +1,503 @@
# Screenshot Test Pattern
> Visual regression testing with Roborazzi in Mifos Mobile
---
## Overview
Screenshot tests:
- Capture golden images of UI states
- Detect visual regressions
- Document UI appearance
- Ensure design consistency
---
## File Location
```
feature/${feature}/src/test/kotlin/org/mifos/mobile/feature/${feature}/${Feature}ScreenshotTest.kt
```
---
## Dependencies
```kotlin
// build.gradle.kts
plugins {
id("io.github.takahirom.roborazzi")
}
dependencies {
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.robolectric)
testImplementation(libs.compose.ui.test.junit4)
}
```
---
## Test Structure
```kotlin
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [33], qualifiers = "w360dp-h640dp-xhdpi")
class ${Feature}ScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val roborazziRule = RoborazziRule(
composeRule = composeTestRule,
captureRoot = composeTestRule.onRoot(),
options = RoborazziRule.Options(
captureType = RoborazziRule.CaptureType.LastImage(),
outputDirectoryPath = "src/test/resources/screenshots/${feature}"
)
)
// ═══════════════════════════════════════════════════════════════
// LOADING STATE
// ═══════════════════════════════════════════════════════════════
@Test
fun ${feature}Screen_loading() {
composeTestRule.setContent {
MifosTheme {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Loading
),
onAction = {}
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
// ═══════════════════════════════════════════════════════════════
// SUCCESS STATE
// ═══════════════════════════════════════════════════════════════
@Test
fun ${feature}Screen_success() {
val testData = ${Feature}Fixtures.createList(3)
composeTestRule.setContent {
MifosTheme {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(testData)
),
onAction = {}
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
@Test
fun ${feature}Screen_successWithManyItems() {
val testData = ${Feature}Fixtures.createList(10)
composeTestRule.setContent {
MifosTheme {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(testData)
),
onAction = {}
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
// ═══════════════════════════════════════════════════════════════
// EMPTY STATE
// ═══════════════════════════════════════════════════════════════
@Test
fun ${feature}Screen_empty() {
composeTestRule.setContent {
MifosTheme {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(emptyList())
),
onAction = {}
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
// ═══════════════════════════════════════════════════════════════
// ERROR STATE
// ═══════════════════════════════════════════════════════════════
@Test
fun ${feature}Screen_error() {
composeTestRule.setContent {
MifosTheme {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Error("Network error")
),
onAction = {}
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
// ═══════════════════════════════════════════════════════════════
// DIALOG STATES
// ═══════════════════════════════════════════════════════════════
@Test
fun ${feature}Screen_confirmationDialog() {
composeTestRule.setContent {
MifosTheme {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(emptyList()),
dialogState = DialogState.Confirmation(
title = "Delete Item?",
message = "This action cannot be undone."
)
),
onAction = {}
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
// ═══════════════════════════════════════════════════════════════
// DARK THEME
// ═══════════════════════════════════════════════════════════════
@Test
fun ${feature}Screen_darkTheme() {
val testData = ${Feature}Fixtures.createList(3)
composeTestRule.setContent {
MifosTheme(darkTheme = true) {
${Feature}Content(
state = ${Feature}State(
uiState = ${Feature}UiState.Success(testData)
),
onAction = {}
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
}
```
---
## Golden Image Directory
```
feature/${feature}/src/test/resources/screenshots/${feature}/
├── ${feature}Screen_loading.png
├── ${feature}Screen_success.png
├── ${feature}Screen_successWithManyItems.png
├── ${feature}Screen_empty.png
├── ${feature}Screen_error.png
├── ${feature}Screen_confirmationDialog.png
└── ${feature}Screen_darkTheme.png
```
---
## Roborazzi Commands
### Record New Golden Images
```bash
# Record all screenshots
./gradlew recordRoborazziDebug
# Record for specific module
./gradlew :feature:${feature}:recordRoborazziDebug
```
### Compare Against Golden Images
```bash
# Verify screenshots match
./gradlew verifyRoborazziDebug
# Verify specific module
./gradlew :feature:${feature}:verifyRoborazziDebug
```
### Compare and Generate Report
```bash
# Generate comparison report
./gradlew compareRoborazziDebug
```
---
## Device Configurations
### Standard Phone
```kotlin
@Config(
sdk = [33],
qualifiers = "w360dp-h640dp-xhdpi"
)
```
### Large Phone
```kotlin
@Config(
sdk = [33],
qualifiers = "w412dp-h915dp-xxhdpi"
)
```
### Tablet
```kotlin
@Config(
sdk = [33],
qualifiers = "w800dp-h1280dp-mdpi"
)
```
### Landscape
```kotlin
@Config(
sdk = [33],
qualifiers = "land-w640dp-h360dp-xhdpi"
)
```
---
## Component Screenshots
For design system components:
```kotlin
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class MifosButtonScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun mifosButton_primary() {
composeTestRule.setContent {
MifosTheme {
MifosButton(
text = "Login",
onClick = {}
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
@Test
fun mifosButton_disabled() {
composeTestRule.setContent {
MifosTheme {
MifosButton(
text = "Login",
onClick = {},
enabled = false
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
@Test
fun mifosButton_loading() {
composeTestRule.setContent {
MifosTheme {
MifosButton(
text = "Login",
onClick = {},
isLoading = true
)
}
}
composeTestRule.onRoot().captureRoboImage()
}
}
```
---
## Multi-State Preview
Capture multiple states in one image:
```kotlin
@Test
fun ${feature}Screen_allStates() {
composeTestRule.setContent {
MifosTheme {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Loading
Box(modifier = Modifier.weight(1f)) {
${Feature}Content(
state = ${Feature}State(uiState = Loading),
onAction = {}
)
}
// Success
Box(modifier = Modifier.weight(1f)) {
${Feature}Content(
state = ${Feature}State(uiState = Success(data)),
onAction = {}
)
}
// Error
Box(modifier = Modifier.weight(1f)) {
${Feature}Content(
state = ${Feature}State(uiState = Error("Error")),
onAction = {}
)
}
}
}
}
composeTestRule.onRoot().captureRoboImage()
}
```
---
## CI Integration
### GitHub Actions
```yaml
# .github/workflows/screenshots.yml
name: Screenshot Tests
on: [pull_request]
jobs:
screenshot-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Verify Screenshots
run: ./gradlew verifyRoborazziDebug
- name: Upload Comparison Report
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshot-comparison
path: '**/build/outputs/roborazzi/**'
```
---
## Best Practices
### 1. Consistent Test Data
```kotlin
// Use fixtures for consistent data
val testData = ${Feature}Fixtures.createList(3)
// Not random data that changes
val testData = listOf(
Item(name = UUID.randomUUID().toString()) // Bad!
)
```
### 2. Fixed Dimensions
```kotlin
// Wrap content in fixed size for consistency
Box(modifier = Modifier.size(360.dp, 640.dp)) {
${Feature}Content(...)
}
```
### 3. Disable Animations
```kotlin
@Before
fun setup() {
// Disable animations for consistent screenshots
composeTestRule.mainClock.autoAdvance = false
}
```
### 4. Test All States
- Loading
- Success (with data)
- Success (empty)
- Error
- Dialogs
- Dark theme
---
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Images differ slightly | Ensure fixed device config |
| Animations cause diff | Disable animations |
| Font rendering differs | Use Robolectric native graphics |
| Random data in image | Use fixtures with fixed data |
---
## Checklist
For each screen:
- [ ] Loading state screenshot
- [ ] Success state (with data)
- [ ] Success state (empty)
- [ ] Error state
- [ ] Dialog states
- [ ] Dark theme variant
- [ ] Different device sizes (optional)

View File

@ -0,0 +1,366 @@
# ViewModel Test Pattern
> Detailed instructions for testing ViewModels in Mifos Mobile
---
## Overview
ViewModel tests verify:
- State transitions (Loading → Success/Error)
- Action handling (user interactions)
- Event emission (navigation, dialogs)
- Business logic correctness
---
## File Location
```
feature/${feature}/src/commonTest/kotlin/org/mifos/mobile/feature/${feature}/${Feature}ViewModelTest.kt
```
---
## Dependencies
```kotlin
// build.gradle.kts
kotlin {
sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine) // Flow testing
}
}
}
```
---
## Test Structure
```kotlin
class ${Feature}ViewModelTest {
// ═══════════════════════════════════════════════════════════════
// SETUP
// ═══════════════════════════════════════════════════════════════
private val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: ${Feature}ViewModel
private lateinit var fakeRepository: Fake${Feature}Repository
@BeforeTest
fun setup() {
fakeRepository = Fake${Feature}Repository()
viewModel = ${Feature}ViewModel(
repository = fakeRepository
)
}
@AfterTest
fun teardown() {
fakeRepository.reset()
}
// ═══════════════════════════════════════════════════════════════
// INITIAL STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `initial state is loading`() = runTest {
viewModel.stateFlow.test {
val state = awaitItem()
assertTrue(state.uiState is ${Feature}UiState.Loading)
}
}
// ═══════════════════════════════════════════════════════════════
// SUCCESS STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `load success updates state with data`() = runTest {
val testData = ${Feature}Fixtures.createList(5)
fakeRepository.setLoadSuccess(testData)
viewModel.loadData()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is ${Feature}UiState.Success)
assertEquals(testData, (state.uiState as ${Feature}UiState.Success).data)
}
}
@Test
fun `empty data shows empty state`() = runTest {
fakeRepository.setLoadEmpty()
viewModel.loadData()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is ${Feature}UiState.Success)
assertTrue((state.uiState as ${Feature}UiState.Success).data.isEmpty())
}
}
// ═══════════════════════════════════════════════════════════════
// ERROR STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `load error shows error state`() = runTest {
fakeRepository.setLoadError("Network error")
viewModel.loadData()
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is ${Feature}UiState.Error)
assertEquals("Network error", (state.uiState as ${Feature}UiState.Error).message)
}
}
// ═══════════════════════════════════════════════════════════════
// ACTION TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `refresh action reloads data`() = runTest {
fakeRepository.setLoadSuccess()
viewModel.loadData()
viewModel.trySendAction(${Feature}Action.Refresh)
assertEquals(2, fakeRepository.loadCallCount)
}
@Test
fun `item click action triggers navigation event`() = runTest {
viewModel.trySendAction(${Feature}Action.ItemClicked(itemId = 1L))
viewModel.eventFlow.test {
val event = awaitItem()
assertEquals(${Feature}Event.NavigateToDetail(1L), event)
}
}
// ═══════════════════════════════════════════════════════════════
// DIALOG TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `delete action shows confirmation dialog`() = runTest {
viewModel.trySendAction(${Feature}Action.DeleteClicked(itemId = 1L))
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.dialogState is DialogState.Confirmation)
}
}
@Test
fun `confirm delete calls repository`() = runTest {
fakeRepository.setDeleteSuccess()
viewModel.trySendAction(${Feature}Action.ConfirmDelete(itemId = 1L))
assertEquals(1, fakeRepository.deleteCallCount)
}
@Test
fun `dialog dismiss clears dialog state`() = runTest {
viewModel.trySendAction(${Feature}Action.DeleteClicked(itemId = 1L))
viewModel.trySendAction(${Feature}Action.DismissDialog)
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertNull(state.dialogState)
}
}
}
```
---
## Test Categories
### 1. Initial State Tests
Verify the ViewModel starts with correct default state.
```kotlin
@Test
fun `initial state has loading ui state`() = runTest {
viewModel.stateFlow.test {
assertTrue(awaitItem().uiState is Loading)
}
}
@Test
fun `initial state has null dialog state`() = runTest {
viewModel.stateFlow.test {
assertNull(awaitItem().dialogState)
}
}
```
### 2. Data Loading Tests
Test success, error, and empty scenarios.
```kotlin
@Test
fun `load with pagination appends data`() = runTest {
fakeRepository.setLoadSuccess(page1Data)
viewModel.loadData()
fakeRepository.setLoadSuccess(page2Data)
viewModel.trySendAction(Action.LoadMore)
viewModel.stateFlow.test {
val data = (expectMostRecentItem().uiState as Success).data
assertEquals(page1Data + page2Data, data)
}
}
```
### 3. User Action Tests
Test all actions defined in the Action sealed class.
```kotlin
// For each action in ${Feature}Action:
@Test
fun `action X updates state correctly`() = runTest {
viewModel.trySendAction(${Feature}Action.X)
viewModel.stateFlow.test {
// Verify state change
}
}
```
### 4. Event Tests
Test navigation and one-time events.
```kotlin
@Test
fun `submit success emits navigation event`() = runTest {
fakeRepository.setCreateSuccess()
viewModel.trySendAction(Action.Submit)
viewModel.eventFlow.test {
assertEquals(Event.NavigateBack, awaitItem())
}
}
```
### 5. Validation Tests
Test form validation logic.
```kotlin
@Test
fun `empty input shows validation error`() = runTest {
viewModel.trySendAction(Action.NameChanged(""))
viewModel.trySendAction(Action.Submit)
viewModel.stateFlow.test {
assertNotNull(expectMostRecentItem().validationError)
}
}
@Test
fun `valid input clears validation error`() = runTest {
viewModel.trySendAction(Action.NameChanged(""))
viewModel.trySendAction(Action.NameChanged("Valid Name"))
viewModel.stateFlow.test {
assertNull(expectMostRecentItem().validationError)
}
}
```
---
## MainDispatcherRule
Required for testing coroutines in ViewModels:
```kotlin
// core/testing/src/commonMain/.../rule/MainDispatcherRule.kt
class MainDispatcherRule(
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) {
@BeforeTest
fun setup() {
Dispatchers.setMain(dispatcher)
}
@AfterTest
fun teardown() {
Dispatchers.resetMain()
}
}
```
---
## Turbine Usage
### Basic Flow Testing
```kotlin
viewModel.stateFlow.test {
val first = awaitItem() // Get first emission
val latest = expectMostRecentItem() // Skip to latest
cancelAndIgnoreRemainingEvents()
}
```
### Common Turbine Methods
| Method | Purpose |
|--------|---------|
| `awaitItem()` | Wait for next emission |
| `expectMostRecentItem()` | Get latest, skip intermediates |
| `awaitComplete()` | Wait for flow completion |
| `cancelAndIgnoreRemainingEvents()` | Clean up |
| `expectNoEvents()` | Verify no emissions |
---
## Test Coverage Checklist
For each ViewModel, test:
- [ ] Initial state
- [ ] Load success with data
- [ ] Load success with empty data
- [ ] Load error
- [ ] Each action in Action sealed class
- [ ] Each event in Event sealed class
- [ ] Validation (if applicable)
- [ ] Dialog states (if applicable)
- [ ] Refresh/Retry
- [ ] Pagination (if applicable)
---
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Not using `runTest` | Wrap all tests in `runTest { }` |
| Missing dispatcher rule | Add `MainDispatcherRule` |
| Not resetting fakes | Call `reset()` in `@AfterTest` |
| Using `awaitItem()` for latest | Use `expectMostRecentItem()` |
| Not testing all states | Check coverage checklist |

View File

@ -0,0 +1,270 @@
package org.mifos.mobile.core.testing.fake
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.mifos.mobile.core.data.repository.${Feature}Repository
import org.mifos.mobile.core.model.${Model}
import org.mifos.mobile.core.model.${Model}Payload
import org.mifos.mobile.core.common.DataState
/**
* Fake implementation of [${Feature}Repository] for testing.
*
* Provides configurable responses and call tracking for test verification.
*
* Usage:
* ```kotlin
* val fakeRepository = Fake${Feature}Repository()
* fakeRepository.setLoadSuccess(testData)
* val viewModel = ${Feature}ViewModel(repository = fakeRepository)
* ```
*/
class Fake${Feature}Repository : ${Feature}Repository {
// ═══════════════════════════════════════════════════════════════
// CALL TRACKING
// ═══════════════════════════════════════════════════════════════
/**
* Number of times [get${Feature}s] was called.
* Use to verify refresh/retry behavior.
*/
var loadCallCount = 0
private set
/**
* Number of times [get${Feature}] (single item) was called.
*/
var getByIdCallCount = 0
private set
/**
* Number of times [create${Feature}] was called.
*/
var createCallCount = 0
private set
/**
* Number of times [update${Feature}] was called.
*/
var updateCallCount = 0
private set
/**
* Number of times [delete${Feature}] was called.
*/
var deleteCallCount = 0
private set
/**
* Last payload passed to [create${Feature}].
* Use to verify correct data was sent.
*/
var lastCreatePayload: ${Model}Payload? = null
private set
/**
* Last ID passed to [get${Feature}].
*/
var lastGetId: Long? = null
private set
/**
* Last ID passed to [delete${Feature}].
*/
var lastDeleteId: Long? = null
private set
// ═══════════════════════════════════════════════════════════════
// CONFIGURABLE RESPONSES
// ═══════════════════════════════════════════════════════════════
private var loadResponse: DataState<List<${Model}>> = DataState.Loading
private var singleResponse: DataState<${Model}> = DataState.Loading
private var createResponse: DataState<${Model}> = DataState.Loading
private var updateResponse: DataState<${Model}> = DataState.Loading
private var deleteResponse: DataState<Unit> = DataState.Loading
// ═══════════════════════════════════════════════════════════════
// SETUP METHODS - LOAD
// ═══════════════════════════════════════════════════════════════
/**
* Configure [get${Feature}s] to return success with data.
*
* @param data The list of items to return
*/
fun setLoadSuccess(data: List<${Model}>) {
loadResponse = DataState.Success(data)
}
/**
* Configure [get${Feature}s] to return error.
*
* @param message The error message
*/
fun setLoadError(message: String = "Failed to load ${feature_lowercase}") {
loadResponse = DataState.Error(message)
}
/**
* Configure [get${Feature}s] to return empty list.
*/
fun setLoadEmpty() {
loadResponse = DataState.Success(emptyList())
}
/**
* Configure [get${Feature}s] to return loading state.
*/
fun setLoadLoading() {
loadResponse = DataState.Loading
}
// ═══════════════════════════════════════════════════════════════
// SETUP METHODS - SINGLE ITEM
// ═══════════════════════════════════════════════════════════════
/**
* Configure [get${Feature}] to return success.
*
* @param item The item to return
*/
fun setSingleSuccess(item: ${Model}) {
singleResponse = DataState.Success(item)
}
/**
* Configure [get${Feature}] to return error.
*
* @param message The error message
*/
fun setSingleError(message: String = "${Feature} not found") {
singleResponse = DataState.Error(message)
}
// ═══════════════════════════════════════════════════════════════
// SETUP METHODS - CREATE
// ═══════════════════════════════════════════════════════════════
/**
* Configure [create${Feature}] to return success.
*
* @param item The created item to return
*/
fun setCreateSuccess(item: ${Model}) {
createResponse = DataState.Success(item)
}
/**
* Configure [create${Feature}] to return error.
*
* @param message The error message
*/
fun setCreateError(message: String = "Failed to create ${feature_lowercase}") {
createResponse = DataState.Error(message)
}
// ═══════════════════════════════════════════════════════════════
// SETUP METHODS - UPDATE
// ═══════════════════════════════════════════════════════════════
/**
* Configure [update${Feature}] to return success.
*
* @param item The updated item to return
*/
fun setUpdateSuccess(item: ${Model}) {
updateResponse = DataState.Success(item)
}
/**
* Configure [update${Feature}] to return error.
*
* @param message The error message
*/
fun setUpdateError(message: String = "Failed to update ${feature_lowercase}") {
updateResponse = DataState.Error(message)
}
// ═══════════════════════════════════════════════════════════════
// SETUP METHODS - DELETE
// ═══════════════════════════════════════════════════════════════
/**
* Configure [delete${Feature}] to return success.
*/
fun setDeleteSuccess() {
deleteResponse = DataState.Success(Unit)
}
/**
* Configure [delete${Feature}] to return error.
*
* @param message The error message
*/
fun setDeleteError(message: String = "Failed to delete ${feature_lowercase}") {
deleteResponse = DataState.Error(message)
}
// ═══════════════════════════════════════════════════════════════
// REPOSITORY IMPLEMENTATION
// ═══════════════════════════════════════════════════════════════
override fun get${Feature}s(): Flow<DataState<List<${Model}>>> = flow {
loadCallCount++
emit(loadResponse)
}
override fun get${Feature}(id: Long): Flow<DataState<${Model}>> = flow {
getByIdCallCount++
lastGetId = id
emit(singleResponse)
}
override fun create${Feature}(payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
createCallCount++
lastCreatePayload = payload
emit(createResponse)
}
override fun update${Feature}(id: Long, payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
updateCallCount++
emit(updateResponse)
}
override fun delete${Feature}(id: Long): Flow<DataState<Unit>> = flow {
deleteCallCount++
lastDeleteId = id
emit(deleteResponse)
}
// ═══════════════════════════════════════════════════════════════
// RESET
// ═══════════════════════════════════════════════════════════════
/**
* Reset all counters and responses to initial state.
* Call in @AfterTest to ensure test isolation.
*/
fun reset() {
// Reset counters
loadCallCount = 0
getByIdCallCount = 0
createCallCount = 0
updateCallCount = 0
deleteCallCount = 0
// Reset captured data
lastCreatePayload = null
lastGetId = null
lastDeleteId = null
// Reset responses to loading
loadResponse = DataState.Loading
singleResponse = DataState.Loading
createResponse = DataState.Loading
updateResponse = DataState.Loading
deleteResponse = DataState.Loading
}
}

View File

@ -0,0 +1,286 @@
package org.mifos.mobile.feature.${feature_lowercase}
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import kotlin.test.Test
import kotlin.test.assertEquals
import org.junit.Rule
import org.mifos.mobile.core.testing.fixtures.${Feature}Fixtures
class ${Feature}ScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
// ═══════════════════════════════════════════════════════════════
// LOADING STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun loadingState_displaysLoadingIndicator() {
// Given
val state = ${Feature}State(uiState = ${Feature}UiState.Loading)
// When
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
// Then
composeTestRule
.onNodeWithTag(${Feature}TestTags.LOADING)
.assertIsDisplayed()
}
@Test
fun loadingState_doesNotDisplayContent() {
// Given
val state = ${Feature}State(uiState = ${Feature}UiState.Loading)
// When
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
// Then
composeTestRule
.onNodeWithTag(${Feature}TestTags.LIST)
.assertDoesNotExist()
}
// ═══════════════════════════════════════════════════════════════
// SUCCESS STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun successState_displaysContent() {
// Given
val testData = ${Feature}Fixtures.createList(3)
val state = ${Feature}State(
uiState = ${Feature}UiState.Success(testData)
)
// When
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
// Then
composeTestRule
.onNodeWithTag(${Feature}TestTags.SCREEN)
.assertIsDisplayed()
composeTestRule
.onNodeWithTag(${Feature}TestTags.LIST)
.assertIsDisplayed()
}
@Test
fun successState_displaysAllItems() {
// Given
val testData = ${Feature}Fixtures.createList(3)
val state = ${Feature}State(
uiState = ${Feature}UiState.Success(testData)
)
// When
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
// Then
testData.forEach { item ->
composeTestRule
.onNodeWithTag(${Feature}TestTags.item(item.id))
.assertIsDisplayed()
}
}
// ═══════════════════════════════════════════════════════════════
// EMPTY STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun emptyState_displaysEmptyMessage() {
// Given
val state = ${Feature}State(
uiState = ${Feature}UiState.Success(emptyList())
)
// When
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
// Then
composeTestRule
.onNodeWithTag(${Feature}TestTags.EMPTY)
.assertIsDisplayed()
}
// ═══════════════════════════════════════════════════════════════
// ERROR STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun errorState_displaysErrorMessage() {
// Given
val errorMessage = "Network error"
val state = ${Feature}State(
uiState = ${Feature}UiState.Error(errorMessage)
)
// When
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
// Then
composeTestRule
.onNodeWithTag(${Feature}TestTags.ERROR)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(errorMessage)
.assertIsDisplayed()
}
@Test
fun errorState_displaysRetryButton() {
// Given
val state = ${Feature}State(
uiState = ${Feature}UiState.Error("Error")
)
// When
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
// Then
composeTestRule
.onNodeWithTag(${Feature}TestTags.RETRY)
.assertIsDisplayed()
.assertIsEnabled()
}
// ═══════════════════════════════════════════════════════════════
// USER INTERACTION TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun itemClick_triggersAction() {
// Given
var receivedAction: ${Feature}Action? = null
val testData = ${Feature}Fixtures.createList(3)
val state = ${Feature}State(
uiState = ${Feature}UiState.Success(testData)
)
// When
composeTestRule.setContent {
${Feature}Content(
state = state,
onAction = { receivedAction = it }
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.item(testData[0].id))
.performClick()
// Then
assertEquals(
${Feature}Action.ItemClicked(testData[0].id),
receivedAction
)
}
@Test
fun retryClick_triggersRetryAction() {
// Given
var receivedAction: ${Feature}Action? = null
val state = ${Feature}State(
uiState = ${Feature}UiState.Error("Error")
)
// When
composeTestRule.setContent {
${Feature}Content(
state = state,
onAction = { receivedAction = it }
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.RETRY)
.performClick()
// Then
assertEquals(${Feature}Action.Retry, receivedAction)
}
@Test
fun fabClick_triggersAddAction() {
// Given
var receivedAction: ${Feature}Action? = null
val state = ${Feature}State(
uiState = ${Feature}UiState.Success(emptyList())
)
// When
composeTestRule.setContent {
${Feature}Content(
state = state,
onAction = { receivedAction = it }
)
}
composeTestRule
.onNodeWithTag(${Feature}TestTags.FAB)
.performClick()
// Then
assertEquals(${Feature}Action.AddClicked, receivedAction)
}
// ═══════════════════════════════════════════════════════════════
// DIALOG TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun confirmationDialog_isDisplayedWhenDialogStateSet() {
// Given
val state = ${Feature}State(
uiState = ${Feature}UiState.Success(emptyList()),
dialogState = DialogState.Confirmation(
title = "Delete?",
message = "Are you sure?"
)
)
// When
composeTestRule.setContent {
${Feature}Content(state = state, onAction = {})
}
// Then
composeTestRule
.onNodeWithText("Delete?")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Are you sure?")
.assertIsDisplayed()
}
// ═══════════════════════════════════════════════════════════════
// TODO: Add more tests for specific screen behavior
// ═══════════════════════════════════════════════════════════════
}

View File

@ -0,0 +1,166 @@
package org.mifos.mobile.feature.${feature_lowercase}.util
/**
* TestTags for ${Feature} feature screens.
*
* Naming convention: `{feature}:{component}:{identifier}`
*
* Usage in Compose:
* ```kotlin
* Modifier.testTag(${Feature}TestTags.SCREEN)
* Modifier.testTag(${Feature}TestTags.item(item.id))
* ```
*
* Usage in tests:
* ```kotlin
* composeTestRule.onNodeWithTag(${Feature}TestTags.SCREEN).assertIsDisplayed()
* ```
*/
object ${Feature}TestTags {
// ═══════════════════════════════════════════════════════════════
// SCREEN CONTAINERS
// ═══════════════════════════════════════════════════════════════
/** Main screen container */
const val SCREEN = "${feature_lowercase}:screen"
/** Detail screen container */
const val DETAIL_SCREEN = "${feature_lowercase}:detail:screen"
/** Form/Add/Edit screen container */
const val FORM_SCREEN = "${feature_lowercase}:form:screen"
// ═══════════════════════════════════════════════════════════════
// STATE INDICATORS
// ═══════════════════════════════════════════════════════════════
/** Loading indicator */
const val LOADING = "${feature_lowercase}:loading"
/** Error container */
const val ERROR = "${feature_lowercase}:error"
/** Empty state container */
const val EMPTY = "${feature_lowercase}:empty"
/** Refreshing indicator (pull-to-refresh) */
const val REFRESHING = "${feature_lowercase}:refreshing"
// ═══════════════════════════════════════════════════════════════
// LIST COMPONENTS
// ═══════════════════════════════════════════════════════════════
/** List container */
const val LIST = "${feature_lowercase}:list"
/**
* Individual list item.
*
* @param id The item's unique identifier
* @return TestTag string like "${feature_lowercase}:item:123"
*/
fun item(id: Long): String = "${feature_lowercase}:item:$id"
/**
* Individual list item by index (for items without IDs).
*
* @param index The item's position in the list
* @return TestTag string like "${feature_lowercase}:item:index:0"
*/
fun itemByIndex(index: Int): String = "${feature_lowercase}:item:index:$index"
// ═══════════════════════════════════════════════════════════════
// ACTION BUTTONS
// ═══════════════════════════════════════════════════════════════
/** Floating action button (add) */
const val FAB = "${feature_lowercase}:fab"
/** Retry button (in error state) */
const val RETRY = "${feature_lowercase}:retry"
/** Submit/Save button */
const val SUBMIT = "${feature_lowercase}:submit"
/** Cancel button */
const val CANCEL = "${feature_lowercase}:cancel"
/** Edit button */
const val EDIT = "${feature_lowercase}:edit"
/** Delete button */
const val DELETE = "${feature_lowercase}:delete"
/** Back button */
const val BACK = "${feature_lowercase}:back"
// ═══════════════════════════════════════════════════════════════
// INPUT FIELDS
// ═══════════════════════════════════════════════════════════════
/**
* Input field by name.
*
* @param name The field name (e.g., "name", "email", "amount")
* @return TestTag string like "${feature_lowercase}:input:name"
*/
fun input(name: String): String = "${feature_lowercase}:input:$name"
// Common input fields
object Input {
const val NAME = "${feature_lowercase}:input:name"
const val DESCRIPTION = "${feature_lowercase}:input:description"
const val AMOUNT = "${feature_lowercase}:input:amount"
const val DATE = "${feature_lowercase}:input:date"
const val SEARCH = "${feature_lowercase}:input:search"
}
// ═══════════════════════════════════════════════════════════════
// DROPDOWNS / SELECTORS
// ═══════════════════════════════════════════════════════════════
/**
* Dropdown/selector by name.
*
* @param name The selector name
* @return TestTag string like "${feature_lowercase}:select:type"
*/
fun select(name: String): String = "${feature_lowercase}:select:$name"
// ═══════════════════════════════════════════════════════════════
// TABS
// ═══════════════════════════════════════════════════════════════
/**
* Tab by name.
*
* @param name The tab name
* @return TestTag string like "${feature_lowercase}:tab:details"
*/
fun tab(name: String): String = "${feature_lowercase}:tab:$name"
// ═══════════════════════════════════════════════════════════════
// DETAIL SCREEN COMPONENTS
// ═══════════════════════════════════════════════════════════════
object Detail {
const val TITLE = "${feature_lowercase}:detail:title"
const val SUBTITLE = "${feature_lowercase}:detail:subtitle"
const val STATUS = "${feature_lowercase}:detail:status"
const val AMOUNT = "${feature_lowercase}:detail:amount"
const val DATE = "${feature_lowercase}:detail:date"
}
// ═══════════════════════════════════════════════════════════════
// DIALOGS
// ═══════════════════════════════════════════════════════════════
object Dialog {
const val CONTAINER = "${feature_lowercase}:dialog"
const val TITLE = "${feature_lowercase}:dialog:title"
const val MESSAGE = "${feature_lowercase}:dialog:message"
const val CONFIRM = "${feature_lowercase}:dialog:confirm"
const val CANCEL = "${feature_lowercase}:dialog:cancel"
}
}

View File

@ -0,0 +1,199 @@
package org.mifos.mobile.feature.${feature_lowercase}
import app.cash.turbine.test
import kotlin.test.BeforeTest
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertNull
import kotlin.test.assertNotNull
import kotlinx.coroutines.test.runTest
import org.mifos.mobile.core.testing.fake.Fake${Feature}Repository
import org.mifos.mobile.core.testing.fixtures.${Feature}Fixtures
import org.mifos.mobile.core.testing.rule.MainDispatcherRule
class ${Feature}ViewModelTest {
// ═══════════════════════════════════════════════════════════════
// SETUP
// ═══════════════════════════════════════════════════════════════
private val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: ${Feature}ViewModel
private lateinit var fakeRepository: Fake${Feature}Repository
@BeforeTest
fun setup() {
fakeRepository = Fake${Feature}Repository()
viewModel = ${Feature}ViewModel(
repository = fakeRepository
)
}
@AfterTest
fun teardown() {
fakeRepository.reset()
}
// ═══════════════════════════════════════════════════════════════
// INITIAL STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `initial state is loading`() = runTest {
viewModel.stateFlow.test {
val state = awaitItem()
assertTrue(state.uiState is ${Feature}UiState.Loading)
}
}
@Test
fun `initial dialog state is null`() = runTest {
viewModel.stateFlow.test {
val state = awaitItem()
assertNull(state.dialogState)
}
}
// ═══════════════════════════════════════════════════════════════
// SUCCESS STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `load success updates state with data`() = runTest {
// Given
val testData = ${Feature}Fixtures.createList(5)
fakeRepository.setLoadSuccess(testData)
// When
viewModel.loadData()
// Then
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is ${Feature}UiState.Success)
assertEquals(testData, (state.uiState as ${Feature}UiState.Success).data)
}
}
@Test
fun `empty data shows success with empty list`() = runTest {
// Given
fakeRepository.setLoadEmpty()
// When
viewModel.loadData()
// Then
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is ${Feature}UiState.Success)
assertTrue((state.uiState as ${Feature}UiState.Success).data.isEmpty())
}
}
// ═══════════════════════════════════════════════════════════════
// ERROR STATE TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `load error shows error state`() = runTest {
// Given
fakeRepository.setLoadError("Network error")
// When
viewModel.loadData()
// Then
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertTrue(state.uiState is ${Feature}UiState.Error)
assertEquals("Network error", (state.uiState as ${Feature}UiState.Error).message)
}
}
// ═══════════════════════════════════════════════════════════════
// ACTION TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `refresh action reloads data`() = runTest {
// Given
fakeRepository.setLoadSuccess(emptyList())
// When
viewModel.loadData()
viewModel.trySendAction(${Feature}Action.Refresh)
// Then
assertEquals(2, fakeRepository.loadCallCount)
}
@Test
fun `item click triggers navigation event`() = runTest {
// Given
val itemId = 1L
// When
viewModel.trySendAction(${Feature}Action.ItemClicked(itemId))
// Then
viewModel.eventFlow.test {
val event = awaitItem()
assertEquals(${Feature}Event.NavigateToDetail(itemId), event)
}
}
// ═══════════════════════════════════════════════════════════════
// DIALOG TESTS
// ═══════════════════════════════════════════════════════════════
@Test
fun `delete action shows confirmation dialog`() = runTest {
// Given
val itemId = 1L
// When
viewModel.trySendAction(${Feature}Action.DeleteClicked(itemId))
// Then
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertNotNull(state.dialogState)
assertTrue(state.dialogState is DialogState.Confirmation)
}
}
@Test
fun `dismiss dialog clears dialog state`() = runTest {
// Given
viewModel.trySendAction(${Feature}Action.DeleteClicked(1L))
// When
viewModel.trySendAction(${Feature}Action.DismissDialog)
// Then
viewModel.stateFlow.test {
val state = expectMostRecentItem()
assertNull(state.dialogState)
}
}
@Test
fun `confirm delete calls repository`() = runTest {
// Given
fakeRepository.setDeleteSuccess()
// When
viewModel.trySendAction(${Feature}Action.ConfirmDelete(itemId = 1L))
// Then
assertEquals(1, fakeRepository.deleteCallCount)
}
// ═══════════════════════════════════════════════════════════════
// TODO: Add more tests for specific feature logic
// ═══════════════════════════════════════════════════════════════
}

View File

@ -0,0 +1,88 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
plugins {
alias(libs.plugins.kmp.library.convention)
}
android {
namespace = "org.mifos.mobile.core.testing"
testOptions {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true
}
}
}
kotlin {
sourceSets {
commonMain.dependencies {
api(projects.core.common)
api(projects.core.data)
api(projects.core.model)
api(projects.core.network)
// Coroutines Test - KMP compatible
api(libs.kotlinx.coroutines.test)
// Koin Test - KMP compatible
api(libs.koin.test)
// Kotlin Test - KMP compatible
api(libs.kotlin.test)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
androidMain.dependencies {
// Android Test
api(libs.androidx.test.ext.junit)
api(libs.androidx.test.rules)
api(libs.androidx.test.espresso.core)
// Note: Compose UI Test dependencies (ui-test-junit4, ui-test-manifest)
// should be added by consuming modules that need them, as they require
// the Compose BOM for version management.
// Turbine for Flow testing
api(libs.turbine)
// Mockito
api(libs.mockito.core)
// Truth assertions
api(libs.truth)
// Koin Android Test
api(libs.koin.test.junit4)
}
iosMain.dependencies {
// iOS-specific test utilities if needed
}
desktopMain.dependencies {
// Desktop-specific test utilities
api(libs.kotlinx.coroutines.swing)
}
jsMain.dependencies {
// JS-specific test utilities
}
nativeMain.dependencies {
// Native-specific test utilities
}
}
}

View File

@ -0,0 +1,84 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing
/**
* Compose UI Test Helper Documentation.
*
* This module provides TestTags and testing utilities. For Compose UI testing,
* add the following dependencies to your module's build.gradle.kts:
*
* ```kotlin
* androidInstrumentedTest.dependencies {
* implementation(libs.androidx.compose.ui.test)
* }
* ```
*
* Then use TestTags with ComposeContentTestRule:
*
* ```kotlin
* import org.mifos.mobile.core.testing.util.TestTags
*
* class LoginScreenTest {
* @get:Rule
* val composeTestRule = createComposeRule()
*
* @Test
* fun loginScreen_displaysAllElements() {
* composeTestRule.setContent {
* LoginScreen()
* }
* composeTestRule.onNodeWithTag(TestTags.Auth.LOGIN_BUTTON).assertIsDisplayed()
* composeTestRule.onNodeWithTag(TestTags.Auth.USERNAME_FIELD).assertIsDisplayed()
* }
*
* @Test
* fun loginButton_disabledWhenCredentialsEmpty() {
* composeTestRule.setContent {
* LoginScreen(state = LoginState(username = "", password = ""))
* }
* composeTestRule.onNodeWithTag(TestTags.Auth.LOGIN_BUTTON).assertIsNotEnabled()
* }
*
* @Test
* fun inputCredentials_enablesLoginButton() {
* composeTestRule.setContent {
* LoginScreen(state = LoginState(username = "user", password = "pass"))
* }
* composeTestRule.onNodeWithTag(TestTags.Auth.LOGIN_BUTTON).assertIsEnabled()
* }
* }
* ```
*
* For adding TestTags to Composables:
*
* ```kotlin
* @Composable
* fun LoginButton(onClick: () -> Unit) {
* Button(
* onClick = onClick,
* modifier = Modifier.testTag(TestTags.Auth.LOGIN_BUTTON)
* ) {
* Text("Login")
* }
* }
* ```
*/
object ComposeTestHelpers {
/**
* Common timeout for waiting for compose idle state in tests.
*/
const val DEFAULT_TIMEOUT_MS = 5000L
/**
* Extended timeout for network operations in UI tests.
*/
const val NETWORK_TIMEOUT_MS = 30000L
}

View File

@ -0,0 +1,49 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.rule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* JUnit4 TestRule for Android that sets the Main dispatcher to a test dispatcher.
*
* Usage:
* ```kotlin
* class MyViewModelTest {
* @get:Rule
* val mainDispatcherRule = MainDispatcherTestRule()
*
* @Test
* fun testSomething() = runTest {
* // Main dispatcher is automatically set to test dispatcher
* }
* }
* ```
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherTestRule(
val testDispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

View File

@ -0,0 +1,43 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.di
import org.koin.core.module.Module
import org.koin.dsl.module
/**
* Koin test module for dependency injection in tests.
*
* Usage:
* ```kotlin
* class MyViewModelTest : KoinTest {
* @get:Rule
* val koinTestRule = KoinTestRule.create {
* modules(testModule, fakeRepositoryModule)
* }
*
* private val viewModel: MyViewModel by inject()
* }
* ```
*/
val testModule: Module = module {
// Common test dependencies go here
}
/**
* Creates a test Koin module with fake repositories.
*
* @param additionalModules Additional modules to include
* @return Combined test module
*/
fun createTestModule(vararg additionalModules: Module): Module = module {
includes(testModule)
includes(*additionalModules)
}

View File

@ -0,0 +1,76 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.fake
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.data.repository.AccountsRepository
import org.mifos.mobile.core.model.entity.client.ClientAccounts
/**
* Fake implementation of [AccountsRepository] for testing.
*
* Usage:
* ```kotlin
* val fakeRepo = FakeAccountsRepository()
*
* // Set accounts data
* fakeRepo.setAccounts(DataState.Success(testAccounts))
*
* // Use in tests
* val viewModel = AccountsViewModel(fakeRepo)
* ```
*/
class FakeAccountsRepository : AccountsRepository {
private val accountsState = MutableStateFlow<DataState<ClientAccounts>>(
DataState.Success(ClientAccounts()),
)
// Track calls for verification
var loadAccountsCallCount = 0
private set
var lastClientId: Long? = null
private set
var lastAccountType: String? = null
private set
fun setAccounts(result: DataState<ClientAccounts>) {
accountsState.value = result
}
fun emitLoading() {
accountsState.value = DataState.Loading
}
fun emitSuccess(accounts: ClientAccounts) {
accountsState.value = DataState.Success(accounts)
}
fun emitError(error: Throwable) {
accountsState.value = DataState.Error(error)
}
fun reset() {
accountsState.value = DataState.Success(ClientAccounts())
loadAccountsCallCount = 0
lastClientId = null
lastAccountType = null
}
override fun loadAccounts(clientId: Long?, accountType: String?): Flow<DataState<ClientAccounts>> {
loadAccountsCallCount++
lastClientId = clientId
lastAccountType = accountType
return accountsState.asStateFlow()
}
}

View File

@ -0,0 +1,136 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.fake
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.data.repository.BeneficiaryRepository
import org.mifos.mobile.core.model.entity.beneficiary.Beneficiary
import org.mifos.mobile.core.model.entity.beneficiary.BeneficiaryPayload
import org.mifos.mobile.core.model.entity.beneficiary.BeneficiaryUpdatePayload
import org.mifos.mobile.core.model.entity.templates.beneficiary.BeneficiaryTemplate
/**
* Fake implementation of [BeneficiaryRepository] for testing.
*
* Usage:
* ```kotlin
* val fakeRepo = FakeBeneficiaryRepository()
*
* // Set beneficiary list
* fakeRepo.setBeneficiaryList(DataState.Success(testBeneficiaries))
*
* // Set create result
* fakeRepo.setCreateResult(DataState.Success("Beneficiary created"))
*
* // Use in tests
* val viewModel = BeneficiaryViewModel(fakeRepo)
* ```
*/
class FakeBeneficiaryRepository : BeneficiaryRepository {
private val beneficiaryTemplateState = MutableStateFlow<DataState<BeneficiaryTemplate>>(
DataState.Success(BeneficiaryTemplate()),
)
private val beneficiaryListState = MutableStateFlow<DataState<List<Beneficiary>>>(
DataState.Success(emptyList()),
)
private var createResult: DataState<String> = DataState.Success("Beneficiary created")
private var updateResult: DataState<String> = DataState.Success("Beneficiary updated")
private var deleteResult: DataState<String> = DataState.Success("Beneficiary deleted")
// Track method calls for verification
var createCallCount = 0
private set
var updateCallCount = 0
private set
var deleteCallCount = 0
private set
var lastCreatedPayload: BeneficiaryPayload? = null
private set
var lastDeletedId: Long? = null
private set
fun setBeneficiaryTemplate(result: DataState<BeneficiaryTemplate>) {
beneficiaryTemplateState.value = result
}
fun setBeneficiaryList(result: DataState<List<Beneficiary>>) {
beneficiaryListState.value = result
}
fun emitBeneficiaryListLoading() {
beneficiaryListState.value = DataState.Loading
}
fun emitBeneficiaryListSuccess(beneficiaries: List<Beneficiary>) {
beneficiaryListState.value = DataState.Success(beneficiaries)
}
fun emitBeneficiaryListError(error: Throwable) {
beneficiaryListState.value = DataState.Error(error)
}
fun setCreateResult(result: DataState<String>) {
createResult = result
}
fun setUpdateResult(result: DataState<String>) {
updateResult = result
}
fun setDeleteResult(result: DataState<String>) {
deleteResult = result
}
fun reset() {
beneficiaryTemplateState.value = DataState.Success(BeneficiaryTemplate())
beneficiaryListState.value = DataState.Success(emptyList())
createResult = DataState.Success("Beneficiary created")
updateResult = DataState.Success("Beneficiary updated")
deleteResult = DataState.Success("Beneficiary deleted")
createCallCount = 0
updateCallCount = 0
deleteCallCount = 0
lastCreatedPayload = null
lastDeletedId = null
}
override fun beneficiaryTemplate(): Flow<DataState<BeneficiaryTemplate>> {
return beneficiaryTemplateState.asStateFlow()
}
override suspend fun createBeneficiary(beneficiaryPayload: BeneficiaryPayload?): DataState<String> {
createCallCount++
lastCreatedPayload = beneficiaryPayload
return createResult
}
override suspend fun updateBeneficiary(
beneficiaryId: Long?,
payload: BeneficiaryUpdatePayload?,
): DataState<String> {
updateCallCount++
return updateResult
}
override suspend fun deleteBeneficiary(beneficiaryId: Long?): DataState<String> {
deleteCallCount++
lastDeletedId = beneficiaryId
return deleteResult
}
override fun beneficiaryList(): Flow<DataState<List<Beneficiary>>> {
return beneficiaryListState.asStateFlow()
}
}

View File

@ -0,0 +1,112 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.fake
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.data.repository.HomeRepository
import org.mifos.mobile.core.model.entity.client.Client
import org.mifos.mobile.core.model.entity.client.ClientAccounts
/**
* Fake implementation of [HomeRepository] for testing.
*
* Usage:
* ```kotlin
* val fakeRepo = FakeHomeRepository()
*
* // Set success response
* fakeRepo.setClientAccounts(DataState.Success(testClientAccounts))
*
* // Emit loading state first, then success
* fakeRepo.emitClientAccountsLoading()
* fakeRepo.emitClientAccountsSuccess(testClientAccounts)
*
* // Use in tests
* val viewModel = HomeViewModel(fakeRepo)
* ```
*/
class FakeHomeRepository : HomeRepository {
private val clientAccountsState = MutableStateFlow<DataState<ClientAccounts>>(
DataState.Success(ClientAccounts()),
)
private val currentClientState = MutableStateFlow<DataState<Client>>(
DataState.Success(createDefaultClient()),
)
private val clientImageState = MutableStateFlow<DataState<String>>(
DataState.Success(""),
)
private val unreadNotificationsState = MutableStateFlow<DataState<Int>>(
DataState.Success(0),
)
fun setClientAccounts(result: DataState<ClientAccounts>) {
clientAccountsState.value = result
}
fun emitClientAccountsLoading() {
clientAccountsState.value = DataState.Loading
}
fun emitClientAccountsSuccess(accounts: ClientAccounts) {
clientAccountsState.value = DataState.Success(accounts)
}
fun emitClientAccountsError(error: Throwable) {
clientAccountsState.value = DataState.Error(error)
}
fun setCurrentClient(result: DataState<Client>) {
currentClientState.value = result
}
fun setClientImage(result: DataState<String>) {
clientImageState.value = result
}
fun setUnreadNotificationsCount(count: Int) {
unreadNotificationsState.value = DataState.Success(count)
}
fun reset() {
clientAccountsState.value = DataState.Success(ClientAccounts())
currentClientState.value = DataState.Success(createDefaultClient())
clientImageState.value = DataState.Success("")
unreadNotificationsState.value = DataState.Success(0)
}
override fun clientAccounts(clientId: Long): Flow<DataState<ClientAccounts>> {
return clientAccountsState.asStateFlow()
}
override fun currentClient(clientId: Long): Flow<DataState<Client>> {
return currentClientState.asStateFlow()
}
override fun clientImage(clientId: Long): Flow<DataState<String>> {
return clientImageState.asStateFlow()
}
override fun unreadNotificationsCount(): Flow<DataState<Int>> {
return unreadNotificationsState.asStateFlow()
}
private fun createDefaultClient(): Client {
return Client(
id = 1,
displayName = "Test User",
firstname = "Test",
lastname = "User",
)
}
}

View File

@ -0,0 +1,106 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.fake
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.data.repository.NotificationRepository
import org.mifos.mobile.core.model.entity.MifosNotification
/**
* Fake implementation of [NotificationRepository] for testing.
*
* Usage:
* ```kotlin
* val fakeRepo = FakeNotificationRepository()
*
* // Set notifications
* fakeRepo.setNotifications(DataState.Success(testNotifications))
*
* // Set unread count
* fakeRepo.setUnreadCount(5)
*
* // Use in tests
* val viewModel = NotificationViewModel(fakeRepo)
* ```
*/
class FakeNotificationRepository : NotificationRepository {
private val notificationsState = MutableStateFlow<DataState<List<MifosNotification>>>(
DataState.Success(emptyList()),
)
private val unreadCountState = MutableStateFlow<DataState<Int>>(
DataState.Success(0),
)
private val savedNotifications = mutableListOf<MifosNotification>()
// Track method calls for verification
var saveCallCount = 0
private set
var deleteOldCallCount = 0
private set
var updateReadStatusCallCount = 0
private set
fun setNotifications(result: DataState<List<MifosNotification>>) {
notificationsState.value = result
}
fun emitNotificationsLoading() {
notificationsState.value = DataState.Loading
}
fun emitNotificationsSuccess(notifications: List<MifosNotification>) {
notificationsState.value = DataState.Success(notifications)
}
fun emitNotificationsError(error: Throwable) {
notificationsState.value = DataState.Error(error)
}
fun setUnreadCount(count: Int) {
unreadCountState.value = DataState.Success(count)
}
fun getSavedNotifications(): List<MifosNotification> = savedNotifications.toList()
fun reset() {
notificationsState.value = DataState.Success(emptyList())
unreadCountState.value = DataState.Success(0)
savedNotifications.clear()
saveCallCount = 0
deleteOldCallCount = 0
updateReadStatusCallCount = 0
}
override fun loadNotifications(): Flow<DataState<List<MifosNotification>>> {
return notificationsState.asStateFlow()
}
override fun getUnReadNotificationCount(): Flow<DataState<Int>> {
return unreadCountState.asStateFlow()
}
override suspend fun saveNotification(notification: MifosNotification) {
saveCallCount++
savedNotifications.add(notification)
}
override suspend fun deleteOldNotifications() {
deleteOldCallCount++
}
override suspend fun updateReadStatus(notification: MifosNotification, isRead: Boolean) {
updateReadStatusCallCount++
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.fake
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.data.repository.TransferRepository
import org.mifos.mobile.core.model.entity.payload.TransferPayload
import org.mifos.mobile.core.model.enums.TransferType
/**
* Fake implementation of [TransferRepository] for testing.
*
* Usage:
* ```kotlin
* val fakeRepo = FakeTransferRepository()
*
* // Set success response
* fakeRepo.setTransferResult(DataState.Success("Transfer successful"))
*
* // Set error response
* fakeRepo.setTransferResult(DataState.Error(Exception("Insufficient funds")))
*
* // Use in tests
* val viewModel = TransferViewModel(fakeRepo)
* ```
*/
class FakeTransferRepository : TransferRepository {
private var transferResult: DataState<String> = DataState.Success("Transfer successful")
// Track method calls for verification
var transferCallCount = 0
private set
var lastTransferPayload: TransferPayload? = null
private set
var lastTransferType: TransferType? = null
private set
fun setTransferResult(result: DataState<String>) {
transferResult = result
}
fun reset() {
transferResult = DataState.Success("Transfer successful")
transferCallCount = 0
lastTransferPayload = null
lastTransferType = null
}
override suspend fun makeTransfer(
payload: TransferPayload,
transferType: TransferType?,
): DataState<String> {
transferCallCount++
lastTransferPayload = payload
lastTransferType = transferType
return transferResult
}
}

View File

@ -0,0 +1,107 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.fake
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.data.repository.UserAuthRepository
import org.mifos.mobile.core.model.entity.User
import org.mifos.mobile.core.model.entity.register.RegisterPayload
/**
* Fake implementation of [UserAuthRepository] for testing.
*
* Usage:
* ```kotlin
* val fakeRepo = FakeUserAuthRepository()
*
* // Set success response
* fakeRepo.setLoginResult(DataState.Success(testUser))
*
* // Set error response
* fakeRepo.setLoginResult(DataState.Error(Exception("Invalid credentials")))
*
* // Use in tests
* val viewModel = LoginViewModel(fakeRepo)
* ```
*/
class FakeUserAuthRepository : UserAuthRepository {
private var loginResult: DataState<User> = DataState.Success(createDefaultUser())
private var registerResult: DataState<String> = DataState.Success("Registration successful")
private var verifyResult: DataState<String> = DataState.Success("Verification successful")
private var updatePasswordResult: DataState<String> = DataState.Success("Password updated")
// Track method calls for verification
var loginCallCount = 0
private set
var lastLoginUsername: String? = null
private set
var lastLoginPassword: String? = null
private set
fun setLoginResult(result: DataState<User>) {
loginResult = result
}
fun setRegisterResult(result: DataState<String>) {
registerResult = result
}
fun setVerifyResult(result: DataState<String>) {
verifyResult = result
}
fun setUpdatePasswordResult(result: DataState<String>) {
updatePasswordResult = result
}
fun reset() {
loginResult = DataState.Success(createDefaultUser())
registerResult = DataState.Success("Registration successful")
verifyResult = DataState.Success("Verification successful")
updatePasswordResult = DataState.Success("Password updated")
loginCallCount = 0
lastLoginUsername = null
lastLoginPassword = null
}
override suspend fun registerUser(registerPayload: RegisterPayload): DataState<String> {
return registerResult
}
override suspend fun login(username: String, password: String): DataState<User> {
loginCallCount++
lastLoginUsername = username
lastLoginPassword = password
return loginResult
}
override suspend fun verifyUser(
authenticationToken: String?,
requestId: String?,
): DataState<String> {
return verifyResult
}
override suspend fun updateAccountPassword(
newPassword: String,
confirmPassword: String,
): DataState<String> {
return updatePasswordResult
}
private fun createDefaultUser(): User {
return User(
userId = 1L,
username = "testuser",
base64EncodedAuthenticationKey = "test-auth-key",
)
}
}

View File

@ -0,0 +1,89 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.fixture
import org.mifos.mobile.core.model.entity.beneficiary.Beneficiary
import org.mifos.mobile.core.model.entity.beneficiary.BeneficiaryPayload
import org.mifos.mobile.core.model.entity.templates.account.AccountType
/**
* Test fixtures for [Beneficiary] entity.
*
* Usage:
* ```kotlin
* val testBeneficiary = BeneficiaryFixture.createDefault()
* val beneficiaryList = BeneficiaryFixture.createList(count = 5)
* val customBeneficiary = BeneficiaryFixture.create(name = "Custom", accountNumber = "123")
* ```
*/
object BeneficiaryFixture {
fun createDefault(): Beneficiary = Beneficiary(
id = 1L,
name = "Test Beneficiary",
officeName = "Test Office",
clientName = "Test Client",
accountNumber = "ACC001",
accountType = AccountTypeFixture.createSavings(),
transferLimit = 10000.0,
)
fun createList(count: Int = 3): List<Beneficiary> = (1..count).map { index ->
create(
id = index.toLong(),
name = "Beneficiary $index",
accountNumber = "ACC${index.toString().padStart(3, '0')}",
)
}
fun create(
id: Long = 1L,
name: String = "Test Beneficiary",
officeName: String = "Test Office",
clientName: String = "Test Client",
accountNumber: String = "ACC001",
transferLimit: Double = 10000.0,
): Beneficiary = Beneficiary(
id = id,
name = name,
officeName = officeName,
clientName = clientName,
accountNumber = accountNumber,
accountType = AccountTypeFixture.createSavings(),
transferLimit = transferLimit,
)
fun createPayload(
name: String = "Test Beneficiary",
accountNumber: String = "ACC001",
transferLimit: Int = 10000,
): BeneficiaryPayload = BeneficiaryPayload(
name = name,
accountNumber = accountNumber,
transferLimit = transferLimit,
)
}
/**
* Helper for creating account type fixtures.
*/
object AccountTypeFixture {
fun createSavings() = AccountType(
id = 2,
code = "accountType.savings",
value = "Savings",
)
fun createLoan() = AccountType(
id = 1,
code = "accountType.loan",
value = "Loan",
)
}

View File

@ -0,0 +1,108 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.fixture
import org.mifos.mobile.core.model.entity.accounts.loan.LoanAccount
import org.mifos.mobile.core.model.entity.accounts.savings.SavingAccount
import org.mifos.mobile.core.model.entity.accounts.share.ShareAccount
import org.mifos.mobile.core.model.entity.client.ClientAccounts
/**
* Test fixtures for [ClientAccounts] entity.
*
* Usage:
* ```kotlin
* val emptyAccounts = ClientAccountsFixture.createEmpty()
* val accountsWithData = ClientAccountsFixture.createWithSampleData()
* val customAccounts = ClientAccountsFixture.create(
* loanAccounts = listOf(loanAccount),
* savingsAccounts = listOf(savingsAccount),
* )
* ```
*/
object ClientAccountsFixture {
fun createEmpty(): ClientAccounts = ClientAccounts(
loanAccounts = emptyList(),
savingsAccounts = emptyList(),
shareAccounts = emptyList(),
)
fun createWithSampleData(): ClientAccounts = ClientAccounts(
loanAccounts = listOf(
createSampleLoanAccount(id = 1L, accountNo = "LOAN001"),
createSampleLoanAccount(id = 2L, accountNo = "LOAN002"),
),
savingsAccounts = listOf(
createSampleSavingsAccount(id = 1L, accountNo = "SAV001"),
createSampleSavingsAccount(id = 2L, accountNo = "SAV002"),
),
shareAccounts = listOf(
createSampleShareAccount(id = 1L, accountNo = "SHARE001"),
),
)
fun createWithLoansOnly(): ClientAccounts = ClientAccounts(
loanAccounts = listOf(
createSampleLoanAccount(id = 1L, accountNo = "LOAN001"),
),
savingsAccounts = emptyList(),
shareAccounts = emptyList(),
)
fun createWithSavingsOnly(): ClientAccounts = ClientAccounts(
loanAccounts = emptyList(),
savingsAccounts = listOf(
createSampleSavingsAccount(id = 1L, accountNo = "SAV001"),
),
shareAccounts = emptyList(),
)
fun create(
loanAccounts: List<LoanAccount> = emptyList(),
savingsAccounts: List<SavingAccount> = emptyList(),
shareAccounts: List<ShareAccount> = emptyList(),
): ClientAccounts = ClientAccounts(
loanAccounts = loanAccounts,
savingsAccounts = savingsAccounts,
shareAccounts = shareAccounts,
)
private fun createSampleLoanAccount(
id: Long,
accountNo: String,
): LoanAccount = LoanAccount(
id = id,
accountNo = accountNo,
productName = "Test Loan Product",
loanProductName = "Test Loan",
currency = null,
timeline = null,
)
private fun createSampleSavingsAccount(
id: Long,
accountNo: String,
): SavingAccount = SavingAccount(
id = id,
accountNo = accountNo,
productName = "Test Savings Product",
accountBalance = 1000.0,
)
private fun createSampleShareAccount(
id: Long,
accountNo: String,
): ShareAccount = ShareAccount(
id = id,
accountNo = accountNo,
productName = "Test Share Product",
)
}

View File

@ -0,0 +1,109 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.fixture
import org.mifos.mobile.core.model.entity.Role
import org.mifos.mobile.core.model.entity.User
/**
* Test fixtures for [User] entity.
*
* Usage:
* ```kotlin
* val testUser = UserFixture.createDefault()
* val adminUser = UserFixture.createAdmin()
* val customUser = UserFixture.create(username = "custom", userId = 100L)
* ```
*/
object UserFixture {
fun createDefault(): User = User(
userId = 1L,
isAuthenticated = true,
username = "testuser",
officeId = 1L,
officeName = "Test Office",
roles = arrayListOf(createDefaultRole()),
base64EncodedAuthenticationKey = "test-auth-key-base64",
permissions = arrayListOf("READ_ACCOUNT", "WRITE_ACCOUNT"),
shouldRenewPassword = false,
isTwoFactorAuthenticationRequired = false,
clients = arrayListOf(1L),
)
fun createAdmin(): User = User(
userId = 1L,
isAuthenticated = true,
username = "admin",
officeId = 1L,
officeName = "Head Office",
roles = arrayListOf(createAdminRole()),
base64EncodedAuthenticationKey = "admin-auth-key-base64",
permissions = arrayListOf("ALL_FUNCTIONS"),
shouldRenewPassword = false,
isTwoFactorAuthenticationRequired = false,
clients = arrayListOf(1L),
)
fun createUnauthenticated(): User = User(
userId = 0L,
isAuthenticated = false,
username = null,
base64EncodedAuthenticationKey = null,
)
fun createRequiresPasswordRenewal(): User = createDefault().copy(
shouldRenewPassword = true,
)
fun createRequires2FA(): User = createDefault().copy(
isTwoFactorAuthenticationRequired = true,
)
fun create(
userId: Long = 1L,
isAuthenticated: Boolean = true,
username: String = "testuser",
officeId: Long = 1L,
officeName: String = "Test Office",
roles: ArrayList<Role> = arrayListOf(createDefaultRole()),
base64EncodedAuthenticationKey: String = "test-auth-key",
permissions: ArrayList<String> = arrayListOf(),
shouldRenewPassword: Boolean = false,
isTwoFactorAuthenticationRequired: Boolean = false,
clients: ArrayList<Long> = arrayListOf(1L),
): User = User(
userId = userId,
isAuthenticated = isAuthenticated,
username = username,
officeId = officeId,
officeName = officeName,
roles = roles,
base64EncodedAuthenticationKey = base64EncodedAuthenticationKey,
permissions = permissions,
shouldRenewPassword = shouldRenewPassword,
isTwoFactorAuthenticationRequired = isTwoFactorAuthenticationRequired,
clients = clients,
)
private fun createDefaultRole(): Role = Role(
id = 1L,
name = "Self Service User",
description = "Default self-service user role",
disabled = false,
)
private fun createAdminRole(): Role = Role(
id = 1L,
name = "Super User",
description = "Administrator role with all permissions",
disabled = false,
)
}

View File

@ -0,0 +1,53 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.rule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
/**
* Main dispatcher rule for coroutine testing.
*
* Usage:
* ```kotlin
* class MyViewModelTest {
* private val testDispatcher = StandardTestDispatcher()
* private val dispatcherRule = MainDispatcherRule(testDispatcher)
*
* @BeforeTest
* fun setup() {
* dispatcherRule.before()
* }
*
* @AfterTest
* fun tearDown() {
* dispatcherRule.after()
* }
* }
* ```
*
* For Android JUnit4, see the Android-specific implementation that extends TestWatcher.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val testDispatcher: TestDispatcher = StandardTestDispatcher(),
) {
fun before() {
Dispatchers.setMain(testDispatcher)
}
fun after() {
Dispatchers.resetMain()
}
}

View File

@ -0,0 +1,112 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.util
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Flow testing utilities for common test scenarios.
*
* Usage:
* ```kotlin
* @Test
* fun `test flow emissions`() = runTest {
* val viewModel = MyViewModel()
*
* viewModel.stateFlow.assertEmits(
* MyState(loading = true),
* MyState(loading = false, data = "result")
* )
* }
* ```
*/
/**
* Collects all emissions from the flow and asserts they match the expected values.
*/
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun <T> Flow<T>.assertEmitsInOrder(
testScope: TestScope,
vararg expected: T,
) {
val emissions = mutableListOf<T>()
val job = testScope.launch(UnconfinedTestDispatcher(testScope.testScheduler)) {
toList(emissions)
}
testScope.advanceUntilIdle()
job.cancel()
assertEquals(
expected.toList(),
emissions.take(expected.size),
"Flow emissions did not match expected order",
)
}
/**
* Asserts that the flow emits at least one value matching the predicate.
*/
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun <T> Flow<T>.assertContainsEmission(
testScope: TestScope,
predicate: (T) -> Boolean,
) {
val emissions = mutableListOf<T>()
val job = testScope.launch(UnconfinedTestDispatcher(testScope.testScheduler)) {
toList(emissions)
}
testScope.advanceUntilIdle()
job.cancel()
assertTrue(
emissions.any(predicate),
"Flow did not emit any value matching the predicate",
)
}
/**
* Gets the first emission from the flow.
*/
suspend fun <T> Flow<T>.firstEmission(): T = first()
/**
* Collects emissions until the predicate is satisfied.
*/
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun <T> Flow<T>.collectUntil(
testScope: TestScope,
predicate: (T) -> Boolean,
): List<T> {
val emissions = mutableListOf<T>()
val job = testScope.launch(UnconfinedTestDispatcher(testScope.testScheduler)) {
collect { value ->
emissions.add(value)
if (predicate(value)) {
return@collect
}
}
}
testScope.advanceUntilIdle()
job.cancel()
return emissions
}

View File

@ -0,0 +1,82 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.util
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest as kotlinxRunTest
/**
* Test coroutine utilities and extensions.
*
* These utilities make it easier to test ViewModels and other
* coroutine-based code.
*/
/**
* Interface for providing test dispatchers to ViewModels.
*/
interface TestDispatcherProvider {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
/**
* Implementation of dispatcher provider using test dispatchers.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class StandardTestDispatcherProvider(
testDispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestDispatcherProvider {
override val main: CoroutineDispatcher = testDispatcher
override val io: CoroutineDispatcher = testDispatcher
override val default: CoroutineDispatcher = testDispatcher
}
/**
* Runs a test with automatic dispatcher setup.
*
* Usage:
* ```kotlin
* @Test
* fun `my test`() = runTestWithDispatcher {
* // Test code with test dispatcher configured
* advanceUntilIdle()
* }
* ```
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun runTestWithDispatcher(
testBody: suspend TestScope.() -> Unit,
) = kotlinxRunTest {
testBody()
}
/**
* Extension to advance time by a specific duration.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun TestScope.advanceBy(milliseconds: Long) {
advanceTimeBy(milliseconds)
}
/**
* Extension to advance until all coroutines are idle.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun TestScope.advanceToIdle() {
advanceUntilIdle()
}

View File

@ -0,0 +1,299 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing.util
/**
* TestTags for UI testing across all features.
*
* Naming convention: {feature}:{component}:{element}
* Example: "auth:login:usernameField"
*
* Usage in Compose:
* ```kotlin
* TextField(
* modifier = Modifier.testTag(TestTags.Auth.USERNAME_FIELD)
* )
* ```
*
* Usage in tests:
* ```kotlin
* composeTestRule.onNodeWithTag(TestTags.Auth.USERNAME_FIELD).assertIsDisplayed()
* ```
*/
object TestTags {
object Auth {
private const val PREFIX = "auth"
// Login Screen
const val LOGIN_SCREEN = "$PREFIX:login:screen"
const val USERNAME_FIELD = "$PREFIX:login:usernameField"
const val PASSWORD_FIELD = "$PREFIX:login:passwordField"
const val LOGIN_BUTTON = "$PREFIX:login:loginButton"
const val ERROR_MESSAGE = "$PREFIX:login:errorMessage"
const val LOADING_INDICATOR = "$PREFIX:login:loading"
const val REGISTER_LINK = "$PREFIX:login:registerLink"
const val FORGOT_PASSWORD_LINK = "$PREFIX:login:forgotPasswordLink"
// Registration Screen
const val REGISTRATION_SCREEN = "$PREFIX:registration:screen"
const val ACCOUNT_NUMBER_FIELD = "$PREFIX:registration:accountNumber"
const val FIRST_NAME_FIELD = "$PREFIX:registration:firstName"
const val LAST_NAME_FIELD = "$PREFIX:registration:lastName"
const val EMAIL_FIELD = "$PREFIX:registration:email"
const val MOBILE_FIELD = "$PREFIX:registration:mobile"
const val REGISTER_BUTTON = "$PREFIX:registration:registerButton"
// OTP Screen
const val OTP_SCREEN = "$PREFIX:otp:screen"
const val OTP_FIELD = "$PREFIX:otp:otpField"
const val VERIFY_BUTTON = "$PREFIX:otp:verifyButton"
const val RESEND_BUTTON = "$PREFIX:otp:resendButton"
// Password Screen
const val PASSWORD_SCREEN = "$PREFIX:password:screen"
const val NEW_PASSWORD_FIELD = "$PREFIX:password:newPassword"
const val CONFIRM_PASSWORD_FIELD = "$PREFIX:password:confirmPassword"
const val SET_PASSWORD_BUTTON = "$PREFIX:password:setButton"
}
object Home {
private const val PREFIX = "home"
const val SCREEN = "$PREFIX:screen"
const val USER_NAME = "$PREFIX:userName"
const val CLIENT_IMAGE = "$PREFIX:clientImage"
const val LOAN_BALANCE = "$PREFIX:loanBalance"
const val SAVINGS_BALANCE = "$PREFIX:savingsBalance"
const val SHARE_BALANCE = "$PREFIX:shareBalance"
const val QUICK_ACTIONS = "$PREFIX:quickActions"
const val RECENT_TRANSACTIONS = "$PREFIX:recentTransactions"
const val REFRESH_INDICATOR = "$PREFIX:refreshIndicator"
}
object Accounts {
private const val PREFIX = "accounts"
const val SCREEN = "$PREFIX:screen"
const val TAB_BAR = "$PREFIX:tabBar"
const val SAVINGS_TAB = "$PREFIX:tab:savings"
const val LOANS_TAB = "$PREFIX:tab:loans"
const val SHARES_TAB = "$PREFIX:tab:shares"
const val ACCOUNT_LIST = "$PREFIX:accountList"
const val ACCOUNT_ITEM = "$PREFIX:accountItem"
const val EMPTY_STATE = "$PREFIX:emptyState"
const val ERROR_STATE = "$PREFIX:errorState"
const val LOADING_STATE = "$PREFIX:loadingState"
}
object SavingsAccount {
private const val PREFIX = "savings"
const val DETAIL_SCREEN = "$PREFIX:detail:screen"
const val ACCOUNT_NUMBER = "$PREFIX:detail:accountNumber"
const val BALANCE = "$PREFIX:detail:balance"
const val STATUS = "$PREFIX:detail:status"
const val TRANSACTION_LIST = "$PREFIX:detail:transactionList"
const val DEPOSIT_BUTTON = "$PREFIX:detail:depositButton"
const val WITHDRAW_BUTTON = "$PREFIX:detail:withdrawButton"
const val TRANSFER_BUTTON = "$PREFIX:detail:transferButton"
const val DEPOSIT_SCREEN = "$PREFIX:deposit:screen"
const val AMOUNT_FIELD = "$PREFIX:deposit:amountField"
const val SUBMIT_BUTTON = "$PREFIX:deposit:submitButton"
const val WITHDRAW_SCREEN = "$PREFIX:withdraw:screen"
}
object LoanAccount {
private const val PREFIX = "loan"
const val DETAIL_SCREEN = "$PREFIX:detail:screen"
const val ACCOUNT_NUMBER = "$PREFIX:detail:accountNumber"
const val PRINCIPAL = "$PREFIX:detail:principal"
const val OUTSTANDING = "$PREFIX:detail:outstanding"
const val STATUS = "$PREFIX:detail:status"
const val SCHEDULE_TAB = "$PREFIX:detail:scheduleTab"
const val TRANSACTIONS_TAB = "$PREFIX:detail:transactionsTab"
const val REPAYMENT_BUTTON = "$PREFIX:detail:repaymentButton"
const val SCHEDULE_SCREEN = "$PREFIX:schedule:screen"
const val SCHEDULE_LIST = "$PREFIX:schedule:list"
const val SCHEDULE_ITEM = "$PREFIX:schedule:item"
const val SUMMARY_SCREEN = "$PREFIX:summary:screen"
}
object ShareAccount {
private const val PREFIX = "share"
const val DETAIL_SCREEN = "$PREFIX:detail:screen"
const val ACCOUNT_NUMBER = "$PREFIX:detail:accountNumber"
const val SHARES_COUNT = "$PREFIX:detail:sharesCount"
const val STATUS = "$PREFIX:detail:status"
}
object Beneficiary {
private const val PREFIX = "beneficiary"
const val LIST_SCREEN = "$PREFIX:list:screen"
const val BENEFICIARY_LIST = "$PREFIX:list:beneficiaryList"
const val BENEFICIARY_ITEM = "$PREFIX:list:beneficiaryItem"
const val ADD_FAB = "$PREFIX:list:addFab"
const val EMPTY_STATE = "$PREFIX:list:emptyState"
const val ADD_SCREEN = "$PREFIX:add:screen"
const val NAME_FIELD = "$PREFIX:add:nameField"
const val ACCOUNT_NUMBER_FIELD = "$PREFIX:add:accountNumberField"
const val TRANSFER_LIMIT_FIELD = "$PREFIX:add:transferLimitField"
const val SAVE_BUTTON = "$PREFIX:add:saveButton"
const val DETAIL_SCREEN = "$PREFIX:detail:screen"
const val EDIT_BUTTON = "$PREFIX:detail:editButton"
const val DELETE_BUTTON = "$PREFIX:detail:deleteButton"
}
object Transfer {
private const val PREFIX = "transfer"
const val SCREEN = "$PREFIX:screen"
const val FROM_ACCOUNT = "$PREFIX:fromAccount"
const val TO_ACCOUNT = "$PREFIX:toAccount"
const val AMOUNT_FIELD = "$PREFIX:amountField"
const val REMARK_FIELD = "$PREFIX:remarkField"
const val TRANSFER_BUTTON = "$PREFIX:transferButton"
const val CONFIRMATION_SCREEN = "$PREFIX:confirmation:screen"
const val CONFIRM_BUTTON = "$PREFIX:confirmation:confirmButton"
const val CANCEL_BUTTON = "$PREFIX:confirmation:cancelButton"
const val SUCCESS_SCREEN = "$PREFIX:success:screen"
const val DONE_BUTTON = "$PREFIX:success:doneButton"
}
object RecentTransaction {
private const val PREFIX = "recentTransaction"
const val SCREEN = "$PREFIX:screen"
const val TRANSACTION_LIST = "$PREFIX:transactionList"
const val TRANSACTION_ITEM = "$PREFIX:transactionItem"
const val EMPTY_STATE = "$PREFIX:emptyState"
const val FILTER_BUTTON = "$PREFIX:filterButton"
}
object Notification {
private const val PREFIX = "notification"
const val SCREEN = "$PREFIX:screen"
const val NOTIFICATION_LIST = "$PREFIX:notificationList"
const val NOTIFICATION_ITEM = "$PREFIX:notificationItem"
const val EMPTY_STATE = "$PREFIX:emptyState"
const val MARK_READ_BUTTON = "$PREFIX:markReadButton"
}
object Settings {
private const val PREFIX = "settings"
const val SCREEN = "$PREFIX:screen"
const val LANGUAGE_OPTION = "$PREFIX:languageOption"
const val THEME_OPTION = "$PREFIX:themeOption"
const val PASSCODE_OPTION = "$PREFIX:passcodeOption"
const val NOTIFICATION_OPTION = "$PREFIX:notificationOption"
const val ABOUT_OPTION = "$PREFIX:aboutOption"
const val LOGOUT_BUTTON = "$PREFIX:logoutButton"
const val CHANGE_PASSWORD_SCREEN = "$PREFIX:changePassword:screen"
const val OLD_PASSWORD_FIELD = "$PREFIX:changePassword:oldPassword"
const val NEW_PASSWORD_FIELD = "$PREFIX:changePassword:newPassword"
const val CONFIRM_PASSWORD_FIELD = "$PREFIX:changePassword:confirmPassword"
const val SUBMIT_BUTTON = "$PREFIX:changePassword:submitButton"
}
object Passcode {
private const val PREFIX = "passcode"
const val SCREEN = "$PREFIX:screen"
const val PASSCODE_DOTS = "$PREFIX:passcodeDots"
const val KEYPAD = "$PREFIX:keypad"
const val DELETE_BUTTON = "$PREFIX:deleteButton"
const val BIOMETRIC_BUTTON = "$PREFIX:biometricButton"
const val FORGOT_PASSCODE_LINK = "$PREFIX:forgotPasscodeLink"
}
object Guarantor {
private const val PREFIX = "guarantor"
const val LIST_SCREEN = "$PREFIX:list:screen"
const val GUARANTOR_LIST = "$PREFIX:list:guarantorList"
const val GUARANTOR_ITEM = "$PREFIX:list:guarantorItem"
const val ADD_FAB = "$PREFIX:list:addFab"
const val ADD_SCREEN = "$PREFIX:add:screen"
const val FIRST_NAME_FIELD = "$PREFIX:add:firstName"
const val LAST_NAME_FIELD = "$PREFIX:add:lastName"
const val SAVE_BUTTON = "$PREFIX:add:saveButton"
const val DETAIL_SCREEN = "$PREFIX:detail:screen"
}
object QR {
private const val PREFIX = "qr"
const val SCREEN = "$PREFIX:screen"
const val QR_IMAGE = "$PREFIX:qrImage"
const val SHARE_BUTTON = "$PREFIX:shareButton"
const val DOWNLOAD_BUTTON = "$PREFIX:downloadButton"
const val SCANNER_SCREEN = "$PREFIX:scanner:screen"
const val CAMERA_PREVIEW = "$PREFIX:scanner:cameraPreview"
const val FLASH_BUTTON = "$PREFIX:scanner:flashButton"
}
object Location {
private const val PREFIX = "location"
const val SCREEN = "$PREFIX:screen"
const val MAP_VIEW = "$PREFIX:mapView"
const val BRANCH_LIST = "$PREFIX:branchList"
const val BRANCH_ITEM = "$PREFIX:branchItem"
}
object ClientCharge {
private const val PREFIX = "clientCharge"
const val SCREEN = "$PREFIX:screen"
const val CHARGE_LIST = "$PREFIX:chargeList"
const val CHARGE_ITEM = "$PREFIX:chargeItem"
const val EMPTY_STATE = "$PREFIX:emptyState"
}
object Dashboard {
private const val PREFIX = "dashboard"
const val SCREEN = "$PREFIX:screen"
const val OVERVIEW_CARD = "$PREFIX:overviewCard"
const val QUICK_ACTIONS = "$PREFIX:quickActions"
}
// Common components used across features
object Common {
private const val PREFIX = "common"
const val LOADING_SCREEN = "$PREFIX:loadingScreen"
const val ERROR_SCREEN = "$PREFIX:errorScreen"
const val RETRY_BUTTON = "$PREFIX:retryButton"
const val BACK_BUTTON = "$PREFIX:backButton"
const val TOOLBAR = "$PREFIX:toolbar"
const val SNACKBAR = "$PREFIX:snackbar"
const val DIALOG = "$PREFIX:dialog"
const val BOTTOM_SHEET = "$PREFIX:bottomSheet"
const val PULL_REFRESH = "$PREFIX:pullRefresh"
}
}

View File

@ -0,0 +1,52 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
/**
* Desktop-specific test utilities.
*
* This file provides Desktop (JVM) specific testing helpers.
* Desktop uses Swing dispatcher for UI operations.
*/
object DesktopTestUtils {
/**
* Sets up dispatchers for Desktop UI testing.
* Call this in @BeforeTest for tests that involve Compose Desktop UI.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun setupTestDispatchers(testDispatcher: TestDispatcher = StandardTestDispatcher()) {
Dispatchers.setMain(testDispatcher)
}
/**
* Resets dispatchers after Desktop UI testing.
* Call this in @AfterTest.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun resetTestDispatchers() {
Dispatchers.resetMain()
}
/**
* Returns the Swing dispatcher for Desktop UI operations.
* Useful for tests that need to interact with Swing event dispatch thread.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun swingDispatcher() = Dispatchers.Swing
}

View File

@ -0,0 +1,42 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing
/**
* iOS-specific test utilities.
*
* This file provides iOS-specific testing helpers for Kotlin/Native.
* The common testing utilities in commonMain (TestTags, Fixtures, Fakes)
* work across all platforms.
*
* For iOS-specific UI testing, use XCTest framework directly
* with accessibility identifiers matching [org.mifos.mobile.core.testing.util.TestTags].
*/
object IosTestUtils {
/**
* Converts a TestTag to an iOS accessibility identifier format.
* TestTags use format: "feature:component:element"
* iOS accessibility identifiers typically use: "feature_component_element"
*/
fun testTagToAccessibilityId(testTag: String): String {
return testTag.replace(":", "_")
}
/**
* Default test timeout duration for iOS tests (in seconds).
*/
const val DEFAULT_TEST_TIMEOUT: Double = 10.0
/**
* Network test timeout duration for iOS tests (in seconds).
*/
const val NETWORK_TEST_TIMEOUT: Double = 30.0
}

View File

@ -0,0 +1,55 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.testing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
/**
* Native-specific test utilities.
*
* This file provides Kotlin/Native specific testing helpers.
* Works for iOS, macOS, Linux, Windows native targets.
*/
object NativeTestUtils {
/**
* Sets up the Main dispatcher for Native testing.
* Call this in @BeforeTest.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun setupMainDispatcher(testDispatcher: TestDispatcher = StandardTestDispatcher()) {
Dispatchers.setMain(testDispatcher)
}
/**
* Resets the Main dispatcher after Native testing.
* Call this in @AfterTest.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun resetMainDispatcher() {
Dispatchers.resetMain()
}
/**
* Creates a time mark for measuring elapsed time.
* Use with [elapsedSince] to measure durations.
*/
fun markNow(): kotlin.time.TimeMark = kotlin.time.TimeSource.Monotonic.markNow()
/**
* Gets the elapsed time in milliseconds since the given mark.
*/
fun elapsedMillisSince(mark: kotlin.time.TimeMark): Long = mark.elapsedNow().inWholeMilliseconds
}

View File

@ -54,7 +54,7 @@ include(":core:network")
include(":core:database") include(":core:database")
include(":core:datastore") include(":core:datastore")
include(":core:qrcode") include(":core:qrcode")
//include(":core:testing") include(":core:testing")
include(":core-base:datastore") include(":core-base:datastore")
include(":core-base:common") include(":core-base:common")