diff --git a/.claude/commands/README.md b/.claude/commands/README.md index 66800a19a..86a8ae5a4 100644 --- a/.claude/commands/README.md +++ b/.claude/commands/README.md @@ -47,6 +47,13 @@ GAP PLANNING (What needs work?) │ └── /gap-planning platform web └── /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 # Show feature list ├── /design [feature] # Full spec review/create @@ -94,6 +101,16 @@ VERIFICATION | `/gap-planning client network` | Network services plan | Service implementation 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 | Command | Purpose | Output | diff --git a/.claude/commands/client.md b/.claude/commands/client.md index b1122164e..34e863a27 100644 --- a/.claude/commands/client.md +++ b/.claude/commands/client.md @@ -1,131 +1,351 @@ # /client - Client Layer Implementation ## 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 [Feature] WORKFLOW │ -├───────────────────────────────────────────────────────────────────┤ -│ │ -│ 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 │ -│ │ -└───────────────────────────────────────────────────────────────────┘ +/client # Show client layer status +/client [Feature] # Implement client layer for feature +/client [Feature] --network # Network layer only (Service) +/client [Feature] --data # Data layer only (Repository) ``` --- -## 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", // 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>", + 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 { get().create() }", + repoDeclaration = "single { RepositoryImp(get()) }" +) +``` + +--- + +## PHASE 2: Network Layer + +### File Locations | Component | Location | |-----------|----------| -| DTOs | `core/network/model/` | -| Service Interface | `core/network/services/` | -| Repository Interface | `core/data/repository/` | -| Repository Impl | `core/data/repositoryImpl/` | -| Network DI | `core/network/di/NetworkModule.kt` | -| Data DI | `core/data/di/DataModule.kt` | +| Service Interface | `core/network/src/commonMain/kotlin/org/mifos/mobile/core/network/services/` | +| API Endpoints | `core/network/src/commonMain/kotlin/org/mifos/mobile/core/network/ApiEndPoints.kt` | +| Network DI | `core/network/src/commonMain/kotlin/org/mifos/mobile/core/network/di/NetworkModule.kt` | ---- - -## Service Pattern +### Service Template (Pattern-Matched) ```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]) - fun getData(): Flow +import de.jensklingenberg.ktorfit.http.Body +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}") - fun getById(@Path("id") id: Long): Flow +interface ${Feature}Service { - @POST(ApiEndPoints.[ENDPOINT]) - suspend fun create(@Body payload: PayloadType): HttpResponse + @GET(ApiEndPoints.${ENDPOINT_CONSTANT}) + fun get${Feature}List(): Flow> - @PUT(ApiEndPoints.[ENDPOINT] + "/{id}") - suspend fun update( + @GET(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}") + 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, - @Body payload: PayloadType, + @Body payload: ${Payload}, ): HttpResponse - @DELETE(ApiEndPoints.[ENDPOINT] + "/{id}") - suspend fun delete(@Path("id") id: Long): HttpResponse + @DELETE(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}") + 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().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 -// core/data/repository/[Feature]Repository.kt -interface [Feature]Repository { - fun getData(): Flow>> - fun getById(id: Long): Flow> - suspend fun create(data: Data): DataState - suspend fun update(id: Long, data: Data): DataState - suspend fun delete(id: Long): DataState +/* + * 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 org.mifos.mobile.core.common.DataState +import org.mifos.mobile.core.model.${Model} + +interface ${Feature}Repository { + fun get${Feature}List(): Flow>> + fun get${Feature}ById(id: Long): Flow> + suspend fun create${Feature}(data: ${Model}): DataState + suspend fun update${Feature}(id: Long, data: ${Model}): DataState + suspend fun delete${Feature}(id: Long): DataState } +``` -// core/data/repositoryImpl/[Feature]RepositoryImpl.kt -class [Feature]RepositoryImpl( - private val service: [Feature]Service, -) : [Feature]Repository { +### Repository Implementation Template - override fun getData(): Flow>> = 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>> = flow { emit(DataState.Loading) try { - val result = service.getData().first() + val result = ${feature}Service.get${Feature}List().first() emit(DataState.Success(result)) } catch (e: Exception) { emit(DataState.Error(e.message ?: "Unknown error")) } } + + override fun get${Feature}ById(id: Long): Flow> = 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 { + 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 { + 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 { + 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 -// core/network/di/NetworkModule.kt -val networkModule = module { - single<[Feature]Service> { get().create<[Feature]Service>() } -} +### Build Commands -// core/data/di/DataModule.kt -val dataModule = module { - single<[Feature]Repository> { [Feature]RepositoryImpl(get()) } -} +```bash +# Build network module +./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 ``` -┌──────────────────────────────────────────────────────────────────────┐ -│ ✅ CLIENT LAYER COMPLETE │ -├──────────────────────────────────────────────────────────────────────┤ -│ │ -│ Created/Updated: │ -│ ├─ core/network/services/[Feature]Service.kt │ -│ ├─ core/data/repository/[Feature]Repository.kt │ -│ └─ core/data/repositoryImpl/[Feature]RepositoryImpl.kt │ -│ │ -│ Registered in DI: │ -│ ├─ NetworkModule: [Feature]Service ✅ │ -│ └─ DataModule: [Feature]Repository ✅ │ -│ │ -│ 🔨 BUILD: :core:network ✅ :core:data ✅ │ -│ 🧹 LINT: spotlessApply ✅ │ -│ │ -├──────────────────────────────────────────────────────────────────────┤ -│ NEXT STEP: │ -│ Run: /feature [Feature] │ -└──────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ✅ CLIENT LAYER COMPLETE │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 📚 O(1) Context Used: │ +│ ├─ FEATURE_MAP.md → Checked existing: [existing services/repos] │ +│ ├─ API_INDEX.md → Mapped [n] endpoints │ +│ └─ API.md → Feature endpoints: [list] │ +│ │ +│ 📊 Pattern Matching: │ +│ ├─ Service pattern from: BeneficiaryService.kt │ +│ └─ Repository pattern from: BeneficiaryRepositoryImp.kt │ +│ │ +│ 🔧 Network Layer: │ +│ ├─ ${Feature}Service.kt [CREATED|SKIPPED] │ +│ ├─ ApiEndPoints.${CONSTANT} [ADDED|EXISTS] │ +│ └─ NetworkModule registration [ADDED|EXISTS] │ +│ │ +│ 🔧 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 | diff --git a/.claude/commands/design.md b/.claude/commands/design.md index 92cffc8ca..a8a2c621e 100644 --- a/.claude/commands/design.md +++ b/.claude/commands/design.md @@ -1,4 +1,4 @@ -# /design - Feature Specification +# /design - Feature Specification (O(1) Enhanced) ## Purpose 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 ``` -/design → Show feature list -/design [Feature] → Full spec review/create -/design [Feature] add [section] → Add specific section -/design [Feature] improve → Suggest improvements -/design [Feature] mockup → Generate Figma mockups for feature (NEW) -/design mockup → Generate Figma mockups for all features (NEW) +/design # Show feature list with status (O(1) +/design [Feature] # Full spec review/create +/design [Feature] add [section] # Add specific section +/design [Feature] improve # Suggest improvements +/design [Feature] mockup # Generate Figma mockups for feature +/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` -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 claude mcp list ``` -**AI Design Tools Available**: +### AI Design Tools -| Tool | MCP | Best For | Setup Command | -|------|:---:|----------|---------------| -| **Google Stitch** | ✅ | 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` | -| Uizard | ❌ | Quick prototypes | Manual (web only) | -| Visily | ❌ | Component-focused | Manual (web only) | +| Tool | MCP | Best For | Setup | +|------|:---:|----------|-------| +| **Google Stitch** | YES | Material Design 3, Android/KMP | `claude mcp add stitch-ai -- npx -y stitch-ai-mcp` | +| **Figma** | YES | Team collaboration | `claude mcp add figma -- npx -y figma-mcp --token TOKEN` | +| Uizard | NO | Quick prototypes | Manual (web) | +| Visily | NO | Component-focused | Manual (web) | **Recommended**: Google Stitch (MD3 native, has MCP) -**MCP Resources**: -- 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: +### Tool Selection Prompt (If Not Configured) ``` -🎨 Select AI Design Tool: +Select AI Design Tool: 1. Google Stitch (Recommended) - Material Design 3 native 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) ``` -#### Workflow +--- -1. Check MCP connection status -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 Structure -**Output Files**: ``` features/[Feature]/mockups/ -├── PROMPTS.md # AI tool prompts (format based on selection) -├── design-tokens.json # Structured design tokens -└── FIGMA_LINKS.md # Figma URLs (user fills after export) ++-- PROMPTS_FIGMA.md # Figma-specific prompts ++-- PROMPTS_STITCH.md # Google Stitch prompts ++-- 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. -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) +## PROMPTS_STITCH.md Format ```markdown -# [Feature] - AI Mockup Prompts +# [Feature] - Google Stitch Prompts > **Generated from**: features/[feature]/MOCKUP.md > **Generated on**: [DATE] -> **AI Tool**: Google Stitch (recommended) +> **AI Tool**: Google Stitch ## Screen 1: [Screen Name] @@ -156,7 +256,7 @@ Mifos Mobile - Self-service banking app for viewing accounts and transactions. - [Section details from MOCKUP.md] **Style Guidelines:** -- Primary Gradient: #667EEA → #764BA2 +- Primary Gradient: #667EEA -> #764BA2 - Surface: #FFFBFE - Typography: Inter font family - Spacing: 16px standard padding @@ -164,67 +264,51 @@ Mifos Mobile - Self-service banking app for viewing accounts and transactions. --- -## Model Recommendation - -**This command is optimized for Opus** for complex architectural decisions and comprehensive specification writing. - ---- - -## Key Files +## Main Workflow: `/design [Feature]` ``` -claude-product-cycle/design-spec-layer/ -├── STATUS.md # All features status -├── _shared/ -│ ├── PATTERNS.md # Implementation patterns -│ └── API_REFERENCE.md # Fineract API reference -└── features/[feature]/ - ├── SPEC.md # What to build (UI, flows) - ├── API.md # APIs needed - └── STATUS.md # Feature implementation status -``` - ---- - -## Workflow - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ /design [Feature] WORKFLOW │ -├───────────────────────────────────────────────────────────────────┤ -│ │ -│ STEP 1: GATHER CONTEXT │ -│ ├─→ Read claude-product-cycle/design-spec-layer/STATUS.md │ -│ ├─→ Read features/[feature]/SPEC.md (if exists) │ -│ ├─→ Read features/[feature]/API.md (if exists) │ -│ ├─→ Read actual code in feature/[feature]/ │ -│ └─→ Read server-layer/FINERACT_API.md │ -│ │ -│ STEP 2: ANALYZE │ -│ ├─→ Compare current spec vs implementation │ -│ ├─→ Identify gaps, outdated sections, missing features │ -│ ├─→ Research best practices for similar apps │ -│ └─→ Report findings to user │ -│ │ -│ STEP 3: UPDATE SPEC.md │ -│ ├─→ Update/add sections with ASCII mockups │ -│ ├─→ Define state model │ -│ ├─→ Define user actions │ -│ └─→ Add changelog entry │ -│ │ -│ STEP 4: UPDATE API.md │ -│ ├─→ 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 │ -│ │ -└───────────────────────────────────────────────────────────────────┘ ++-------------------------------------------------------------------------+ +| /design [Feature] WORKFLOW | ++-------------------------------------------------------------------------+ +| | +| PHASE 0: O(1) CONTEXT LOADING | +| +--> Read FEATURES_INDEX.md --> Feature exists? Status? | +| +--> Read MOCKUPS_INDEX.md --> Mockup status | +| +--> Read API_INDEX.md --> Related endpoints | +| | +| PHASE 1: DETERMINE ACTION | +| +--> If feature NOT in index: Create new feature | +| +--> If SPEC missing: Create SPEC.md | +| +--> If API missing: Create API.md | +| +--> If exists: Update/improve existing | +| | +| PHASE 2: GATHER CONTEXT (O(1) Paths) | +| +--> Read features/[feature]/SPEC.md | +| +--> Read features/[feature]/API.md | +| +--> Read features/[feature]/STATUS.md | +| +--> Lookup endpoints from API_INDEX.md | +| +--> Read actual code: feature/[feature]/ (if exists) | +| | +| PHASE 3: ANALYZE | +| +--> Compare current spec vs implementation | +| +--> Identify gaps, outdated sections | +| +--> Check API availability in API_INDEX | +| +--> Report findings to user | +| | +| PHASE 4: UPDATE FILES | +| +--> Update/create SPEC.md with ASCII mockups | +| +--> Update/create API.md with endpoints | +| +--> Update feature STATUS.md | +| | +| PHASE 5: INDEX UPDATE (Mandatory) | +| +--> Update FEATURES_INDEX.md (status columns) | +| +--> Update design-spec-layer/STATUS.md | +| | +| PHASE 6: OUTPUT SUMMARY | +| +--> Implementation requirements | +| +--> Next command suggestion | +| | ++-------------------------------------------------------------------------+ ``` --- @@ -254,17 +338,15 @@ claude-product-cycle/design-spec-layer/ ### 2.1 ASCII Mockup -``` -┌─────────────────────────────────────────┐ -│ ← Back [Title] ⋮ │ ← TopBar -├─────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────┐ │ -│ │ Section 1 │ │ -│ └─────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────┘ -``` ++-------------------------------------------+ +| <- Back [Title] : | <- TopBar ++-------------------------------------------+ +| | +| +-----------------------------------+ | +| | Section 1 | | +| +-----------------------------------+ | +| | ++-------------------------------------------+ ### 2.2 Sections Table @@ -285,7 +367,6 @@ claude-product-cycle/design-spec-layer/ ## 4. State Model -```kotlin @Immutable data class [Feature]State( val isLoading: Boolean = false, @@ -298,7 +379,6 @@ sealed interface [Feature]ScreenState { data object Success : [Feature]ScreenState data class Error(val message: StringResource) : [Feature]ScreenState } -``` --- @@ -306,7 +386,7 @@ sealed interface [Feature]ScreenState { | 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] **Request**: -``` Headers: Authorization: Basic {token} Fineract-Platform-TenantId: {tenant} -``` **Response**: -```json { "field": "value" } -``` **Kotlin DTO**: -```kotlin @Serializable data class [Name]Dto( @SerialName("field") val field: String, ) -``` -**Status**: ✅ Implemented / ❌ Missing +**Status**: Implemented / Missing --- @@ -372,7 +446,7 @@ data class [Name]Dto( | 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: ``` -┌───────────────────────────────────────────────────────────────────┐ -│ IMPLEMENTATION REQUIREMENTS │ -│ Ready for /implement in Sonnet session │ -├───────────────────────────────────────────────────────────────────┤ -│ │ -│ FEATURE: [Feature Name] │ -│ SPEC UPDATED: features/[feature]/SPEC.md │ -│ │ -│ ════════════════════════════════════════════════════════════════ │ -│ │ -│ CLIENT WORK NEEDED: │ -│ [ ] Network: [DTO/Service changes] │ -│ [ ] Data: [Repository changes] │ -│ │ -│ FEATURE WORK NEEDED: │ -│ [ ] ViewModel: [changes] │ -│ [ ] Screen: [changes] │ -│ [ ] Components: [new components] │ -│ │ -│ ════════════════════════════════════════════════════════════════ │ -│ │ -│ NEXT STEP: │ -│ Run: /implement [Feature] │ -│ │ -└───────────────────────────────────────────────────────────────────┘ ++=========================================================================+ +| IMPLEMENTATION REQUIREMENTS | +| Ready for /implement in Sonnet session | ++=========================================================================+ +| | +| FEATURE: [Feature Name] | +| SPEC UPDATED: features/[feature]/SPEC.md | +| | +| ================================================================ | +| | +| CLIENT WORK NEEDED: | +| [ ] Network: [DTO/Service changes] | +| [ ] Data: [Repository changes] | +| | +| FEATURE WORK NEEDED: | +| [ ] ViewModel: [changes] | +| [ ] Screen: [changes] | +| [ ] Components: [new components] | +| | +| ================================================================ | +| | +| INDEXES UPDATED: | +| [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: - -| Feature | Status | Last Updated | Command | -|---------|--------|--------------|---------| -| auth | ✅ Done | - | /design auth | -| home | ✅ Done | - | /design home | -| accounts | ✅ Done | - | /design accounts | -| loan-account | ✅ Done | - | /design loan-account | -| savings-account | ✅ Done | - | /design savings-account | -| share-account | ✅ Done | - | /design share-account | -| beneficiary | ✅ Done | - | /design beneficiary | -| transfer | ✅ Done | - | /design transfer | -| recent-transaction | ✅ Done | - | /design recent-transaction | -| notification | ✅ Done | - | /design notification | -| settings | ✅ Done | - | /design settings | -| passcode | ✅ Done | - | /design passcode | -| guarantor | ✅ Done | - | /design guarantor | -| qr | ✅ Done | - | /design qr | -| location | ✅ Done | - | /design location | -| client-charge | ✅ Done | - | /design client-charge | - -Which feature do you want to design? ++-------------------------------------------------------------------------+ +| ERROR: Feature '[name]' not found | ++-------------------------------------------------------------------------+ +| | +| The feature '[name]' does not exist in FEATURES_INDEX.md | +| | +| OPTIONS: | +| 1. Create new feature: /design [name] | +| 2. Check available features: /design | +| 3. Similar features: [suggestions based on name] | +| | ++-------------------------------------------------------------------------+ +``` + +### Invalid Sub-command + +``` ++-------------------------------------------------------------------------+ +| ERROR: Invalid sub-command '[sub]' | ++-------------------------------------------------------------------------+ +| | +| 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 ``` diff --git a/.claude/commands/feature.md b/.claude/commands/feature.md index 62a81ac25..efe3ecd98 100644 --- a/.claude/commands/feature.md +++ b/.claude/commands/feature.md @@ -1,112 +1,241 @@ # /feature - Feature/UI Layer Implementation ## Purpose -Implement the feature/UI layer including ViewModel, Screen, Components, and Navigation. + +Implement the feature/UI layer using O(1) lookup and pattern detection. Creates ViewModel (MVI), Screen (Compose), Navigation, and DI with TestTags built-in and code matching existing codebase conventions. --- -## Workflow +## Command Variants ``` -┌───────────────────────────────────────────────────────────────────┐ -│ /feature [Feature] WORKFLOW │ -├───────────────────────────────────────────────────────────────────┤ -│ │ -│ STEP 1: READ SPEC │ -│ ├─→ Read features/[feature]/SPEC.md │ -│ ├─→ Extract UI sections, state model, user actions │ -│ └─→ Read _shared/PATTERNS.md for MVI pattern │ -│ │ -│ STEP 2: CHECK PREREQUISITES │ -│ ├─→ Verify client layer exists (Repository) │ -│ └─→ If missing, suggest: /client [Feature] first │ -│ │ -│ STEP 3: CREATE VIEWMODEL │ -│ ├─→ Define State data class │ -│ ├─→ Define Event sealed interface │ -│ ├─→ Define Action sealed interface │ -│ ├─→ Implement handleAction() │ -│ └─→ Implement data loading logic │ -│ │ -│ STEP 4: CREATE SCREEN │ -│ ├─→ Create main Screen composable │ -│ ├─→ Handle state rendering │ -│ ├─→ Handle event collection │ -│ └─→ Connect to ViewModel actions │ -│ │ -│ STEP 5: CREATE COMPONENTS │ -│ ├─→ Extract reusable components │ -│ └─→ Add @Preview annotations │ -│ │ -│ STEP 6: CREATE NAVIGATION │ -│ ├─→ Define navigation route │ -│ └─→ Register in navigation graph │ -│ │ -│ STEP 7: REGISTER DI │ -│ ├─→ Create [Feature]Module.kt │ -│ └─→ Register ViewModel │ -│ │ -│ STEP 8: BUILD & VERIFY │ -│ ├─→ ./gradlew :feature:[name]:build │ -│ └─→ ./gradlew spotlessApply detekt │ -│ │ -└───────────────────────────────────────────────────────────────────┘ +/feature # Show feature layer status +/feature [Feature] # Implement feature layer +/feature [Feature] --vm # ViewModel only +/feature [Feature] --ui # Screen only +/feature [Feature] --nav # Navigation only ``` --- -## File Locations +## Workflow with O(1) Optimization ``` -feature/[name]/src/commonMain/kotlin/org/mifos/mobile/feature/[name]/ -├── [Feature]ViewModel.kt # MVI ViewModel -├── [Feature]Screen.kt # Main screen composable -├── components/ # UI components -│ └── [Component].kt -├── navigation/ -│ └── [Feature]Navigation.kt # Navigation definition -└── di/ - └── [Feature]Module.kt # Koin module +┌─────────────────────────────────────────────────────────────────────────────┐ +│ /feature [Feature] - O(1) OPTIMIZED WORKFLOW │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ PHASE 0: O(1) CONTEXT LOADING │ +│ ├─→ Read MODULES_INDEX.md → Check if module exists │ +│ ├─→ Read SCREENS_INDEX.md → Get existing screens/VMs │ +│ ├─→ Read FEATURE_MAP.md → Get repository dependencies │ +│ ├─→ Read features/[name]/SPEC.md → Get UI requirements │ +│ └─→ Read features/[name]/mockups/ → Get design tokens (if available) │ +│ │ +│ PHASE 1: PATTERN DETECTION │ +│ ├─→ Read existing ViewModel → Extract MVI pattern │ +│ ├─→ Read existing Screen → Extract Composable pattern │ +│ ├─→ Read existing Navigation → Extract route pattern │ +│ └─→ Read existing TestTags → Extract tag naming convention │ +│ │ +│ PHASE 2: VIEWMODEL │ +│ ├─→ Generate State class → From SPEC.md state fields │ +│ ├─→ Generate Event sealed interface → From SPEC.md navigation │ +│ ├─→ Generate Action sealed interface → From SPEC.md user actions │ +│ ├─→ Implement handleAction() → Pattern-matched │ +│ └─→ Implement data loading → Using repository │ +│ │ +│ PHASE 3: SCREEN + TESTTAGS + DESIGN TOKENS │ +│ ├─→ Generate TestTags object → feature:component pattern │ +│ ├─→ Generate main Screen composable → With testTag modifiers │ +│ ├─→ Generate Content composable → State-driven rendering │ +│ ├─→ Generate state composables → Loading, Success, Error, Empty │ +│ └─→ Apply design tokens (Phase 3.5) → If mockups/design-tokens.json exists│ +│ │ +│ PHASE 3.5: DESIGN TOKEN INTEGRATION (if tokens exist) │ +│ ├─→ Read DESIGN_TOKENS_INDEX.md → Check if feature has tokens │ +│ ├─→ Read design-tokens.json → Parse colors, typography, components│ +│ ├─→ Generate ${Feature}Theme.kt → Feature-specific colors/gradients │ +│ ├─→ Apply component specs → Button heights, radii, shadows │ +│ └─→ Add animation modifiers → If animations defined │ +│ │ +│ PHASE 4: NAVIGATION + DI │ +│ ├─→ Generate NavGraphBuilder ext → Type-safe navigation │ +│ ├─→ Generate Route data class → @Serializable │ +│ ├─→ Generate Koin module → viewModelOf() │ +│ └─→ Register in app navigation → Update navigation graph │ +│ │ +│ PHASE 5: BUILD & UPDATE INDEXES │ +│ ├─→ ./gradlew :feature:[name]:build │ +│ ├─→ ./gradlew spotlessApply detekt │ +│ ├─→ Update MODULES_INDEX.md → Add/update module entry │ +│ └─→ Update SCREENS_INDEX.md → Add screen entries │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` --- -## ViewModel Pattern (MVI) +## PHASE 0: O(1) Context Loading + +### Files to Read + +| File | Purpose | Data Extracted | +|------|---------|----------------| +| `feature-layer/MODULES_INDEX.md` | Module existence | moduleExists, vmCount, screenCount | +| `feature-layer/SCREENS_INDEX.md` | Screen details | existingScreens[], existingVMs[] | +| `client-layer/FEATURE_MAP.md` | Repository deps | repositories[] | +| `design-spec-layer/features/[name]/SPEC.md` | UI requirements | screens[], states[], actions[], events[] | +| `design-spec-layer/features/[name]/mockups/design-tokens.json` | Design tokens | colors, spacing, typography | +| `design-spec-layer/DESIGN_TOKENS_INDEX.md` | Token availability | hasTokens, format, components | + +### Context Object Built ```kotlin -internal class [Feature]ViewModel( - private val repository: [Feature]Repository, -) : BaseViewModel<[Feature]State, [Feature]Event, [Feature]Action>( - initialState = [Feature]State() +val context = FeatureContext( + feature = "beneficiary", + + // From MODULES_INDEX.md + moduleExists = true, + existingVMs = 4, + existingScreens = 4, + + // From FEATURE_MAP.md + repository = "BeneficiaryRepository", + + // From SPEC.md + screens = ["List", "Add", "Edit", "Detail"], + states = ["Loading", "Success", "Error", "Empty"], + actions = ["Retry", "Add", "Edit", "Delete", "Select", "Confirm"], + events = ["NavigateToDetail", "NavigateToAdd", "NavigateBack", "ShowSnackbar"], + + // From design-tokens.json (optional) + designTokens = DesignTokens( + primaryColor = "0xFF1A73E8", + spacing = mapOf("small" to 8, "medium" to 16, "large" to 24) + ) +) +``` + +--- + +## PHASE 1: Pattern Detection + +### Reference Files + +``` +1. ViewModel Reference: + feature/home/src/commonMain/.../viewmodel/HomeViewModel.kt + +2. Screen Reference: + feature/home/src/commonMain/.../ui/HomeScreen.kt + +3. Navigation Reference: + feature/home/src/commonMain/.../navigation/HomeNavigation.kt + +4. DI Reference: + feature/home/src/commonMain/.../di/HomeModule.kt +``` + +### Extracted Patterns + +```kotlin +// ViewModel Pattern +val vmPattern = ViewModelPattern( + baseClass = "BaseViewModel<${Feature}State, ${Feature}Event, ${Feature}Action>", + stateAnnotation = "@Immutable", + initBlock = "init { load${Feature}() }", + handleAction = "override fun handleAction(action: ${Feature}Action)", + updateState = "updateState { it.copy(...) }", + sendEvent = "sendEvent(${Feature}Event.NavigateTo...)" +) + +// Screen Pattern +val screenPattern = ScreenPattern( + viewModelParam = "viewModel: ${Feature}ViewModel = koinViewModel()", + stateCollection = "val state by viewModel.stateFlow.collectAsStateWithLifecycle()", + eventCollection = "LaunchedEffect(Unit) { viewModel.eventFlow.collect { ... } }", + contentCall = "${Feature}Content(state = state, onAction = viewModel::sendAction)", + testTagModifier = "Modifier.testTag(${Feature}TestTags.SCREEN)" +) + +// TestTag Pattern +val testTagPattern = TestTagPattern( + objectName = "${Feature}TestTags", + screenTag = "${feature}:screen", + componentTag = "${feature}:{component}", + itemTag = "${feature}:item:{id}" +) +``` + +--- + +## PHASE 2: ViewModel + +### File Location + +``` +feature/[name]/src/commonMain/kotlin/org/mifos/mobile/feature/[package]/viewmodel/${Feature}ViewModel.kt +``` + +### ViewModel Template (MVI Pattern) + +```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.feature.${package}.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.mifos.mobile.core.common.DataState +import org.mifos.mobile.core.data.repository.${Feature}Repository +import org.mifos.mobile.core.ui.base.BaseViewModel + +internal class ${Feature}ViewModel( + private val ${feature}Repository: ${Feature}Repository, +) : BaseViewModel<${Feature}State, ${Feature}Event, ${Feature}Action>( + initialState = ${Feature}State() ) { init { - loadData() + load${Feature}() } - override fun handleAction(action: [Feature]Action) { + override fun handleAction(action: ${Feature}Action) { when (action) { - is [Feature]Action.Retry -> loadData() - is [Feature]Action.OnItemClick -> handleItemClick(action.id) + is ${Feature}Action.Retry -> load${Feature}() + is ${Feature}Action.OnItemClick -> handleItemClick(action.id) + // ... from SPEC.md actions } } - private fun loadData() { + private fun load${Feature}() { viewModelScope.launch { - repository.getData() + ${feature}Repository.get${Feature}List() .collect { dataState -> when (dataState) { is DataState.Loading -> updateState { - it.copy(uiState = [Feature]ScreenState.Loading) + it.copy(uiState = ${Feature}UiState.Loading) } is DataState.Success -> updateState { it.copy( - uiState = [Feature]ScreenState.Success, + uiState = if (dataState.data.isEmpty()) { + ${Feature}UiState.Empty + } else { + ${Feature}UiState.Success + }, data = dataState.data ) } is DataState.Error -> updateState { - it.copy(uiState = [Feature]ScreenState.Error(dataState.message)) + it.copy(uiState = ${Feature}UiState.Error(dataState.message)) } } } @@ -114,115 +243,646 @@ internal class [Feature]ViewModel( } private fun handleItemClick(id: Long) { - sendEvent([Feature]Event.NavigateToDetail(id)) + sendEvent(${Feature}Event.NavigateToDetail(id)) } } -// State +// State - fields from SPEC.md @Immutable -data class [Feature]State( - val data: List = emptyList(), - val uiState: [Feature]ScreenState = [Feature]ScreenState.Loading, +data class ${Feature}State( + val data: List<${Item}> = emptyList(), + val uiState: ${Feature}UiState = ${Feature}UiState.Loading, + val isRefreshing: Boolean = false, + // ... additional fields from SPEC.md ) -sealed interface [Feature]ScreenState { - data object Loading : [Feature]ScreenState - data object Success : [Feature]ScreenState - data class Error(val message: String) : [Feature]ScreenState +// UI States +sealed interface ${Feature}UiState { + data object Loading : ${Feature}UiState + data object Success : ${Feature}UiState + data object Empty : ${Feature}UiState + data class Error(val message: String) : ${Feature}UiState } -// Events (one-time navigation/toasts) -sealed interface [Feature]Event { - data class NavigateToDetail(val id: Long) : [Feature]Event - data object NavigateBack : [Feature]Event +// Events - one-time navigation/effects from SPEC.md +sealed interface ${Feature}Event { + data class NavigateToDetail(val id: Long) : ${Feature}Event + data object NavigateToAdd : ${Feature}Event + data object NavigateBack : ${Feature}Event + data class ShowSnackbar(val message: String) : ${Feature}Event } -// Actions (user interactions) -sealed interface [Feature]Action { - data object Retry : [Feature]Action - data class OnItemClick(val id: Long) : [Feature]Action +// Actions - user interactions from SPEC.md +sealed interface ${Feature}Action { + data object Retry : ${Feature}Action + data object Refresh : ${Feature}Action + data class OnItemClick(val id: Long) : ${Feature}Action + data object OnAddClick : ${Feature}Action + // ... from SPEC.md } ``` --- -## Screen Pattern +## PHASE 3: Screen + TestTags + +### File Locations + +``` +feature/[name]/src/commonMain/kotlin/org/mifos/mobile/feature/[package]/ +├── ui/ +│ ├── ${Feature}Screen.kt +│ └── ${Feature}TestTags.kt +└── components/ + └── ${Feature}Item.kt +``` + +### TestTags Template + +```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.feature.${package}.ui + +/** + * Test tags for ${Feature} screen components. + * Pattern: feature:component[:identifier] + * + * Usage in tests: + * ``` + * composeTestRule.onNodeWithTag(${Feature}TestTags.SCREEN).assertIsDisplayed() + * composeTestRule.onNodeWithTag(${Feature}TestTags.itemTag(123)).performClick() + * ``` + */ +internal object ${Feature}TestTags { + // Screen + const val SCREEN = "${feature}:screen" + const val TOP_BAR = "${feature}:topBar" + const val CONTENT = "${feature}:content" + + // States + const val LOADING = "${feature}:loading" + const val ERROR = "${feature}:error" + const val EMPTY = "${feature}:empty" + const val LIST = "${feature}:list" + + // Actions + const val RETRY_BUTTON = "${feature}:retryButton" + const val ADD_BUTTON = "${feature}:addButton" + const val REFRESH = "${feature}:refresh" + + // Items (dynamic) + private const val ITEM_PREFIX = "${feature}:item:" + fun itemTag(id: Long) = "$ITEM_PREFIX$id" + fun itemTag(id: String) = "$ITEM_PREFIX$id" + + // Item components + const val ITEM_TITLE = "${feature}:item:title" + const val ITEM_SUBTITLE = "${feature}:item:subtitle" + const val ITEM_ICON = "${feature}:item:icon" +} +``` + +--- + +## PHASE 3.5: Design Token Integration + +### When to Apply + +Design tokens are applied when `design-spec-layer/DESIGN_TOKENS_INDEX.md` shows the feature has tokens. + +### Check Token Availability + +```kotlin +// From DESIGN_TOKENS_INDEX.md +val hasTokens = feature in ["auth", "dashboard", "settings", "guarantor", "qr", "passcode", "location", "client-charge"] +val tokenFormat = when (feature) { + "auth" -> "google-stitch" + else -> "md3" +} +``` + +### Token Integration Steps + +``` +IF hasTokens THEN + 1. Read design-tokens.json from features/[feature]/mockups/ + 2. Parse token format (google-stitch vs md3) + 3. Extract relevant tokens: + - colors → Custom colors or gradients + - typography → Font sizes/weights (usually use MD3 defaults) + - spacing → Map to DesignToken.spacing + - radius → Map to DesignToken.shapes + - components → Apply specs to generated components + - animations → Add animation modifiers + 4. Generate ${Feature}Theme.kt if custom colors/gradients needed + 5. Apply tokens in Screen generation +END +``` + +### File: ${Feature}Theme.kt (Generated if Custom Colors) + +```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.feature.${package}.theme + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +/** + * Design tokens from features/${feature}/mockups/design-tokens.json + * Generated: [DATE] + * Format: [google-stitch | md3] + */ +object ${Feature}Theme { + // Gradient (from google-stitch format) + val primaryGradient = Brush.linearGradient( + colors = listOf( + Color(0xFF667EEA), // tokens.colors.primary.gradient.start + Color(0xFF764BA2) // tokens.colors.primary.gradient.end + ), + start = Offset(0f, 0f), + end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY) + ) + + // Semantic colors + object Colors { + val success = Color(0xFF00D09C) + val error = Color(0xFFFF4757) + val warning = Color(0xFFFFB800) + val info = Color(0xFF667EEA) + } + + // Component specs (if defined) + object Components { + val buttonHeight = 56.dp + val buttonRadius = 16.dp + val inputHeight = 56.dp + val inputRadius = 12.dp + val cardRadius = 16.dp + } +} +``` + +### Apply Gradient to Button + +```kotlin +// Without tokens (default): +Button( + onClick = onClick, + modifier = modifier, +) { + Text(text) +} + +// With tokens (gradient): +Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), + modifier = modifier + .height(${Feature}Theme.Components.buttonHeight) + .background( + brush = ${Feature}Theme.primaryGradient, + shape = RoundedCornerShape(${Feature}Theme.Components.buttonRadius) + ), +) { + Text( + text = text, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) +} +``` + +### Apply Component Specs + +When design-tokens.json has component specs: + +```json +{ + "components": [ + { + "id": "primary-button", + "specs": { + "height": "56dp", + "radius": "16dp", + "background": "gradient", + "textSize": "16sp", + "textWeight": "600" + } + } + ] +} +``` + +Generated component: ```kotlin @Composable -fun [Feature]Screen( - viewModel: [Feature]ViewModel = koinViewModel(), +fun ${Feature}PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Button( + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), + modifier = modifier + .height(56.dp) // from specs.height + .background( + brush = ${Feature}Theme.primaryGradient, // from specs.background = "gradient" + shape = RoundedCornerShape(16.dp) // from specs.radius + ), + shape = RoundedCornerShape(16.dp), + ) { + Text( + text = text, + fontSize = 16.sp, // from specs.textSize + fontWeight = FontWeight.SemiBold, // from specs.textWeight = "600" + color = Color.White, + ) + } +} +``` + +### Apply Animations (If Defined) + +When design-tokens.json has animations: + +```json +{ + "animations": { + "buttonPress": { "scale": "0.98", "duration": "100ms" }, + "errorShake": { "translateX": "[-10, 10, -5, 5, 0]", "duration": "300ms" } + } +} +``` + +Add animation utilities: + +```kotlin +// ${Feature}Animations.kt +object ${Feature}Animations { + // Button press animation + fun Modifier.buttonPressAnimation( + interactionSource: InteractionSource, + ): Modifier = composed { + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.98f else 1f, + animationSpec = tween(durationMillis = 100) + ) + this.scale(scale) + } + + // Error shake animation + @Composable + fun Modifier.errorShake( + trigger: Boolean, + ): Modifier { + val offsetX by animateFloatAsState( + targetValue = if (trigger) 0f else 0f, + animationSpec = keyframes { + durationMillis = 300 + -10f at 0 + 10f at 75 + -5f at 150 + 5f at 225 + 0f at 300 + } + ) + return this.offset(x = offsetX.dp) + } +} +``` + +### Token Format Detection + +```kotlin +// In Phase 0 +val tokenFormat = when { + designTokens?.tool == "google-stitch" -> TokenFormat.GOOGLE_STITCH + designTokens?.tokens?.colors?.containsKey("primary") == true -> TokenFormat.MD3 + else -> null +} + +// Google Stitch format has: +// - tokens.colors.primary.gradient (with start, end, angle) +// - tokens.colors.surface.light / .dark +// - animations block + +// MD3 format has: +// - tokens.colors.primary (direct color string) +// - tokens.typography with MD3 scale names +``` + +### Output Template (With Tokens) + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ 🎨 DESIGN TOKENS APPLIED │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Source: features/${feature}/mockups/design-tokens.json │ +│ Format: [google-stitch | md3] │ +│ │ +│ 📊 Tokens Applied: │ +│ ├─ Colors: [count] custom colors │ +│ ├─ Gradients: [count] gradient definitions │ +│ ├─ Components: [count] with specs │ +│ └─ Animations: [count] animation definitions │ +│ │ +│ 📁 Files Generated: │ +│ ├─ theme/${Feature}Theme.kt [CREATED] │ +│ ├─ theme/${Feature}Animations.kt [CREATED if animations exist] │ +│ └─ components/${Feature}Button.kt [CREATED if component specs exist] │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### Screen Template + +```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.feature.${package}.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifos.mobile.core.designsystem.component.* +import org.mifos.mobile.feature.${package}.viewmodel.* + +@Composable +fun ${Feature}Screen( + viewModel: ${Feature}ViewModel = koinViewModel(), onNavigateBack: () -> Unit, onNavigateToDetail: (Long) -> Unit, + onNavigateToAdd: () -> Unit, + modifier: Modifier = Modifier, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - // Handle one-time events LaunchedEffect(Unit) { viewModel.eventFlow.collect { event -> when (event) { - is [Feature]Event.NavigateBack -> onNavigateBack() - is [Feature]Event.NavigateToDetail -> onNavigateToDetail(event.id) + is ${Feature}Event.NavigateBack -> onNavigateBack() + is ${Feature}Event.NavigateToDetail -> onNavigateToDetail(event.id) + is ${Feature}Event.NavigateToAdd -> onNavigateToAdd() + is ${Feature}Event.ShowSnackbar -> { + // Handle snackbar + } } } } - [Feature]Content( + ${Feature}ScreenContent( state = state, onAction = viewModel::sendAction, + modifier = modifier.testTag(${Feature}TestTags.SCREEN), ) } @Composable -private fun [Feature]Content( - state: [Feature]State, - onAction: ([Feature]Action) -> Unit, +internal fun ${Feature}ScreenContent( + state: ${Feature}State, + onAction: (${Feature}Action) -> Unit, + modifier: Modifier = Modifier, ) { - when (state.uiState) { - is [Feature]ScreenState.Loading -> LoadingContent() - is [Feature]ScreenState.Success -> SuccessContent( - data = state.data, - onItemClick = { onAction([Feature]Action.OnItemClick(it)) } - ) - is [Feature]ScreenState.Error -> ErrorContent( - message = state.uiState.message, - onRetry = { onAction([Feature]Action.Retry) } - ) + Scaffold( + topBar = { + MifosTopAppBar( + title = "${Feature}", + onNavigationClick = { onAction(${Feature}Action.NavigateBack) }, + modifier = Modifier.testTag(${Feature}TestTags.TOP_BAR), + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { onAction(${Feature}Action.OnAddClick) }, + modifier = Modifier.testTag(${Feature}TestTags.ADD_BUTTON), + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + } + }, + modifier = modifier, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .testTag(${Feature}TestTags.CONTENT), + ) { + when (state.uiState) { + is ${Feature}UiState.Loading -> { + ${Feature}Loading( + modifier = Modifier.testTag(${Feature}TestTags.LOADING), + ) + } + is ${Feature}UiState.Success -> { + ${Feature}Success( + data = state.data, + onItemClick = { onAction(${Feature}Action.OnItemClick(it)) }, + modifier = Modifier.testTag(${Feature}TestTags.LIST), + ) + } + is ${Feature}UiState.Empty -> { + ${Feature}Empty( + onAddClick = { onAction(${Feature}Action.OnAddClick) }, + modifier = Modifier.testTag(${Feature}TestTags.EMPTY), + ) + } + is ${Feature}UiState.Error -> { + ${Feature}Error( + message = state.uiState.message, + onRetry = { onAction(${Feature}Action.Retry) }, + modifier = Modifier.testTag(${Feature}TestTags.ERROR), + ) + } + } + } } } + +@Composable +private fun ${Feature}Loading(modifier: Modifier = Modifier) { + MifosLoadingWheel(modifier = modifier) +} + +@Composable +private fun ${Feature}Success( + data: List<${Item}>, + onItemClick: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + items( + items = data, + key = { it.id } + ) { item -> + ${Feature}Item( + item = item, + onClick = { onItemClick(item.id) }, + modifier = Modifier.testTag(${Feature}TestTags.itemTag(item.id)), + ) + } + } +} + +@Composable +private fun ${Feature}Empty( + onAddClick: () -> Unit, + modifier: Modifier = Modifier, +) { + MifosEmptyContent( + title = "No ${Feature} Found", + message = "Add your first ${feature}", + buttonText = "Add ${Feature}", + onButtonClick = onAddClick, + modifier = modifier, + ) +} + +@Composable +private fun ${Feature}Error( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + MifosErrorContent( + message = message, + onRetry = onRetry, + modifier = modifier, + retryButtonModifier = Modifier.testTag(${Feature}TestTags.RETRY_BUTTON), + ) +} ``` --- -## Navigation Pattern +## PHASE 4: Navigation + DI + +### Navigation Template ```kotlin -// Navigation definition -fun NavGraphBuilder.[feature]Screen( +/* + * 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.feature.${package}.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable +import org.mifos.mobile.feature.${package}.ui.${Feature}Screen + +@Serializable +data object ${Feature}Route + +@Serializable +data class ${Feature}DetailRoute(val id: Long) + +fun NavGraphBuilder.${feature}Screen( onNavigateBack: () -> Unit, onNavigateToDetail: (Long) -> Unit, + onNavigateToAdd: () -> Unit, ) { - composable<[Feature]Route> { - [Feature]Screen( + composable<${Feature}Route> { + ${Feature}Screen( onNavigateBack = onNavigateBack, onNavigateToDetail = onNavigateToDetail, + onNavigateToAdd = onNavigateToAdd, ) } } -// Route -@Serializable -data object [Feature]Route +fun NavController.navigateTo${Feature}() { + navigate(${Feature}Route) +} + +fun NavController.navigateTo${Feature}Detail(id: Long) { + navigate(${Feature}DetailRoute(id)) +} +``` + +### DI Module Template + +```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.feature.${package}.di + +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module +import org.mifos.mobile.feature.${package}.viewmodel.${Feature}ViewModel + +val ${feature}Module = module { + viewModelOf(::${Feature}ViewModel) +} ``` --- -## DI Module Pattern +## PHASE 5: Build & Update Indexes -```kotlin -val [feature]Module = module { - viewModelOf(::[Feature]ViewModel) -} +### Build Commands + +```bash +# Build feature module +./gradlew :feature:${name}:build + +# Format and lint +./gradlew spotlessApply detekt --no-configuration-cache +``` + +### Update MODULES_INDEX.md + +```markdown +| ${n} | ${module} | feature/${module} | ✅ | ${vmCount} | ${screenCount} | +``` + +### Update SCREENS_INDEX.md + +```markdown +### ${module} (${screenCount} screens) + +| Screen | ViewModel | File | +|--------|-----------|------| +| ${Feature}Screen | ${Feature}ViewModel | ui/${Feature}Screen.kt | ``` --- @@ -230,25 +890,226 @@ val [feature]Module = module { ## Output Template ``` -┌──────────────────────────────────────────────────────────────────────┐ -│ ✅ FEATURE LAYER COMPLETE │ -├──────────────────────────────────────────────────────────────────────┤ -│ │ -│ Created/Updated: │ -│ ├─ feature/[name]/[Feature]ViewModel.kt │ -│ ├─ feature/[name]/[Feature]Screen.kt │ -│ ├─ feature/[name]/components/*.kt │ -│ ├─ feature/[name]/navigation/[Feature]Navigation.kt │ -│ └─ feature/[name]/di/[Feature]Module.kt │ -│ │ -│ Navigation: │ -│ └─ Route registered ✅ │ -│ │ -│ 🔨 BUILD: :feature:[name] ✅ │ -│ 🧹 LINT: spotlessApply ✅ detekt ✅ │ -│ │ -├──────────────────────────────────────────────────────────────────────┤ -│ NEXT STEP: │ -│ Run: /verify [Feature] │ -└──────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ✅ FEATURE LAYER COMPLETE │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 📚 O(1) Context Used: │ +│ ├─ MODULES_INDEX.md → Verified module: [exists/new] │ +│ ├─ SCREENS_INDEX.md → Existing screens: [count] │ +│ ├─ FEATURE_MAP.md → Repository: ${Feature}Repository │ +│ └─ SPEC.md → Mapped [n] screens, [n] states, [n] actions │ +│ │ +│ 📊 Pattern Matching: │ +│ ├─ ViewModel pattern from: HomeViewModel.kt │ +│ └─ Screen pattern from: HomeScreen.kt │ +│ │ +│ 🔧 ViewModel: │ +│ └─ ${Feature}ViewModel.kt [CREATED|UPDATED] │ +│ ├─ State: ${Feature}State │ +│ ├─ Events: [count] defined │ +│ └─ Actions: [count] defined │ +│ │ +│ 🎨 Screen: │ +│ ├─ ${Feature}Screen.kt [CREATED|UPDATED] │ +│ ├─ ${Feature}TestTags.kt [CREATED] │ +│ └─ components/ [count] files │ +│ │ +│ 🏷️ TestTags Generated: │ +│ ├─ ${feature}:screen │ +│ ├─ ${feature}:loading │ +│ ├─ ${feature}:error │ +│ ├─ ${feature}:empty │ +│ ├─ ${feature}:list │ +│ ├─ ${feature}:item:{id} │ +│ └─ ... [n] total tags │ +│ │ +│ 🧭 Navigation: │ +│ ├─ ${Feature}Navigation.kt [CREATED|UPDATED] │ +│ └─ Routes: ${Feature}Route, ${Feature}DetailRoute │ +│ │ +│ 📦 DI: │ +│ └─ ${Feature}Module.kt [CREATED|UPDATED] │ +│ │ +│ 📋 Indexes Updated: │ +│ ├─ MODULES_INDEX.md [UPDATED] │ +│ └─ SCREENS_INDEX.md [UPDATED] │ +│ │ +│ 🔨 BUILD: :feature:${name} ✅ │ +│ 🧹 LINT: spotlessApply ✅ detekt ✅ │ +│ │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ NEXT STEPS: │ +│ • Verify: /verify ${Feature} │ +│ • Test: /verify-tests ${Feature} │ +│ • Full E2E: /implement ${Feature} (if client layer also needed) │ +└──────────────────────────────────────────────────────────────────────────────┘ ``` + +--- + +## Feature Status (No Argument) + +When `/feature` called without arguments, read MODULES_INDEX.md and SCREENS_INDEX.md: + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ 📋 FEATURE LAYER STATUS (from MODULES_INDEX.md) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Summary: 23 modules | 63 screens | 49 ViewModels | 21 DI modules │ +│ │ +│ | Module | VMs | Screens | DI | Status | Command │ │ +│ |-----------------|:---:|:-------:|:--:|------------|-------------------| │ +│ | auth | 5 | 6 | ✅ | ✅ Complete | /feature auth │ │ +│ | home | 1 | 1 | ✅ | ✅ Complete | /feature home │ │ +│ | accounts | 3 | 3 | ✅ | ✅ Complete | /feature accounts │ │ +│ | beneficiary | 4 | 4 | ✅ | ✅ Complete | /feature beneficiary ││ +│ | ... │ +│ │ +│ Commands: │ +│ • /feature [name] → Implement feature layer │ +│ • /gap-analysis feature → Check for gaps │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Error Handling + +### Missing Repository + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ⚠️ MISSING PREREQUISITE: Repository │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Feature: ${feature} │ +│ Expected: ${Feature}Repository in FEATURE_MAP.md │ +│ Found: Not registered │ +│ │ +│ The ViewModel requires a repository for data operations. │ +│ │ +│ Options: │ +│ • c / client → Run /client ${feature} first (recommended) │ +│ • s / skip → Continue without repository (limited functionality) │ +│ • a / abort → Cancel implementation │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +### Missing SPEC.md + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ⚠️ MISSING SPECIFICATION │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Feature: ${feature} │ +│ Expected: design-spec-layer/features/${feature}/SPEC.md │ +│ Found: File missing │ +│ │ +│ SPEC.md defines screens, states, and actions for code generation. │ +│ │ +│ Options: │ +│ • d / design → Run /design ${feature} first (recommended) │ +│ • m / manual → Use default template (basic CRUD) │ +│ • a / abort → Cancel implementation │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +### Build Failure + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ❌ BUILD FAILED: :feature:${name} │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Error: Unresolved reference: ${Feature}Repository │ +│ │ +│ 📍 Root Cause: │ +│ Repository not registered in RepositoryModule │ +│ │ +│ 📍 Auto-Fix: │ +│ Add to core/data/di/RepositoryModule.kt: │ +│ single<${Feature}Repository> { ${Feature}RepositoryImp(get()) } │ +│ │ +│ Options: │ +│ • f / fix → Apply fix and rebuild │ +│ • c / client → Run /client ${feature} to create full client layer │ +│ • a / abort → Stop implementation │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## TestTag Reference + +### Naming Convention + +``` +Pattern: feature:component[:identifier] + +Examples: +- beneficiary:screen # Main screen +- beneficiary:loading # Loading state +- beneficiary:error # Error state +- beneficiary:list # List content +- beneficiary:item:123 # Specific item by ID +- beneficiary:addButton # Action button +- beneficiary:retryButton # Retry action +``` + +### Test Usage Example + +```kotlin +@Test +fun beneficiaryScreen_showsLoadingState() { + composeTestRule.setContent { + BeneficiaryScreenContent( + state = BeneficiaryState(uiState = BeneficiaryUiState.Loading), + onAction = {} + ) + } + + composeTestRule + .onNodeWithTag(BeneficiaryTestTags.LOADING) + .assertIsDisplayed() +} + +@Test +fun beneficiaryScreen_itemClick_navigatesToDetail() { + val actions = mutableListOf() + + composeTestRule.setContent { + BeneficiaryScreenContent( + state = BeneficiaryState( + uiState = BeneficiaryUiState.Success, + data = listOf(Beneficiary(id = 123, name = "Test")) + ), + onAction = { actions.add(it) } + ) + } + + composeTestRule + .onNodeWithTag(BeneficiaryTestTags.itemTag(123)) + .performClick() + + assertEquals(BeneficiaryAction.OnItemClick(123), actions.first()) +} +``` + +--- + +## Related Commands + +| Command | Purpose | +|---------|---------| +| `/client [Feature]` | Client layer (Repository) | +| `/implement [Feature]` | Full E2E (Client + Feature) | +| `/verify [Feature]` | Verify implementation vs spec | +| `/verify-tests [Feature]` | Run tests | +| `/gap-analysis feature` | Check feature layer gaps | diff --git a/.claude/commands/gap-planning.md b/.claude/commands/gap-planning.md index 16f38dfbd..e117f42f0 100644 --- a/.claude/commands/gap-planning.md +++ b/.claude/commands/gap-planning.md @@ -344,3 +344,105 @@ For each gap found: 5. **Prioritize** - P0 → P1 → P2 6. **Provide verification** - Checklist for each plan 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 | diff --git a/.claude/commands/gap-status.md b/.claude/commands/gap-status.md new file mode 100644 index 000000000..a67fb4111 --- /dev/null +++ b/.claude/commands/gap-status.md @@ -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/ +``` diff --git a/.claude/commands/implement.md b/.claude/commands/implement.md index 640d1024b..c8eaa7d59 100644 --- a/.claude/commands/implement.md +++ b/.claude/commands/implement.md @@ -1,237 +1,1148 @@ # /implement - E2E Feature Implementation ## Purpose -Full end-to-end implementation of a feature including client layer (Network + Data) and feature layer (UI). + +Full end-to-end implementation using O(1) lookup and pattern detection. Implements client layer (Network + Data) and feature layer (UI) with automatic code generation matching existing codebase conventions. --- ## Command Variants ``` -/implement → Show feature status list -/implement [Feature] → Full E2E implementation -/implement [Feature] --quick → Skip validations -/implement [Feature] --no-git → Skip git integration -/implement improve [Feature] → Improve existing feature +/implement # Show feature status list +/implement [Feature] # Full E2E implementation +/implement [Feature] --quick # Skip checkpoints +/implement [Feature] --no-git # Skip git integration +/implement improve [Feature] # Improve existing feature ``` --- -## E2E Pipeline +## E2E Pipeline with O(1) Optimization ``` -┌─────────────────────────────────────────────────────────────────────┐ -│ /implement [Feature] - E2E AUTOMATED PIPELINE │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ ✅ Git Integration - Auto branch, commits after each phase │ -│ ✅ Dependency Check - Validate all dependencies before start │ -│ ✅ Auto-Build - Gradle build after each layer │ -│ ✅ Lint & Format - Run detekt, spotless │ -│ ✅ Checkpoints - Review/improve after each layer │ -│ ✅ Progress Dashboard - Real-time progress tracking │ -│ │ -│ FULL PIPELINE: │ -│ ┌───────┐ ┌────────┐ ┌────────┐ ┌─────────┐ ┌───────┐ │ -│ │ GIT │─▶│VALIDATE│─▶│ CLIENT │─▶│ FEATURE │─▶│ BUILD │ │ -│ └───────┘ └────────┘ └───┬────┘ └────┬────┘ └───┬───┘ │ -│ branch deps │ │ │ │ -│ [checkpoint] [checkpoint] [commit] │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ /implement [Feature] - O(1) OPTIMIZED PIPELINE │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ PHASE 0: CONTEXT LOADING (O(1)) ~50-200 lines instead of scanning │ +│ ├─→ Read FEATURE_MAP.md → Get services + repositories │ +│ ├─→ Read MODULES_INDEX.md → Get module structure │ +│ ├─→ Read SCREENS_INDEX.md → Get existing screens/VMs │ +│ └─→ Read feature/*/SPEC.md + API.md → Get requirements │ +│ │ +│ PHASE 1: PATTERN DETECTION Match existing conventions │ +│ ├─→ Read existing ViewModel → Extract State/Event/Action pattern│ +│ ├─→ Read existing Screen → Extract Composable pattern │ +│ ├─→ Read existing Repository → Extract DataState pattern │ +│ └─→ Store conventions in memory → Apply to generated code │ +│ │ +│ PHASE 2: CLIENT LAYER Services + Repositories │ +│ ├─→ Check if exists in FEATURE_MAP → Skip or create │ +│ ├─→ Generate with pattern matching → Matches existing code style │ +│ ├─→ Register in DI → NetworkModule + RepositoryModule │ +│ ├─→ Build: ./gradlew :core:network:build :core:data:build │ +│ └─→ ⏸️ CHECKPOINT │ +│ │ +│ PHASE 3: FEATURE LAYER ViewModel + Screen + Navigation │ +│ ├─→ Generate ViewModel (MVI) → With testTags built-in │ +│ ├─→ Generate Screen → With design tokens if available │ +│ ├─→ Generate Navigation → Type-safe routes │ +│ ├─→ Register in DI → Feature Koin module │ +│ ├─→ Build: ./gradlew :feature:[name]:build │ +│ └─→ ⏸️ CHECKPOINT │ +│ │ +│ PHASE 4: FINALIZE Update indexes + status │ +│ ├─→ Update FEATURE_MAP.md → Add new mappings │ +│ ├─→ Update MODULES_INDEX.md → Add module entry │ +│ ├─→ Update SCREENS_INDEX.md → Add screen entries │ +│ ├─→ Update STATUS.md files → Mark as implemented │ +│ └─→ Final build: ./gradlew build │ +│ │ +│ PHASE 5: TEST STUBS (TDD Support) Generate test scaffolding │ +│ ├─→ Generate ViewModel test → commonTest with Turbine │ +│ ├─→ Generate Screen test → androidInstrumentedTest │ +│ ├─→ Generate Fake repository → For testing isolation │ +│ ├─→ Update TESTING_STATUS.md → Mark stubs created │ +│ └─→ ⏸️ CHECKPOINT → Review generated tests │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` --- -## Key Files +## PHASE 0: O(1) Context Loading -1. `claude-product-cycle/design-spec-layer/features/[feature]/SPEC.md` - What to build -2. `claude-product-cycle/design-spec-layer/features/[feature]/API.md` - APIs needed -3. `claude-product-cycle/design-spec-layer/_shared/PATTERNS.md` - Implementation patterns +### Step 0.1: Read Index Files + +```markdown +## Files to Read (O(1) - ~200 lines total instead of scanning 1000s) + +| File | Purpose | Data Extracted | +|------|---------|----------------| +| `claude-product-cycle/client-layer/FEATURE_MAP.md` | Service/Repo mapping | services[], repositories[] | +| `claude-product-cycle/feature-layer/MODULES_INDEX.md` | Module structure | moduleExists, vmCount, screenCount | +| `claude-product-cycle/feature-layer/SCREENS_INDEX.md` | Screen details | existingScreens[], existingViewModels[] | +| `claude-product-cycle/design-spec-layer/features/[name]/SPEC.md` | Requirements | screens[], actions[], states[] | +| `claude-product-cycle/design-spec-layer/features/[name]/API.md` | Endpoints | endpoints[], dtos[] | +| `claude-product-cycle/testing-layer/TEST_PATTERNS.md` | Test patterns | testPatterns[], conventions | +| `claude-product-cycle/testing-layer/TEST_TAGS_INDEX.md` | TestTag specs | testTags[], namingConvention | +``` + +### Step 0.2: Build Context Object + +```kotlin +// Conceptual context built from O(1) reads +val context = ImplementContext( + feature = "beneficiary", + + // From FEATURE_MAP.md + services = ["BeneficiaryService"], + repositories = ["BeneficiaryRepository"], + + // From MODULES_INDEX.md + moduleExists = true, + moduleHasVMs = 4, + moduleHasScreens = 4, + + // From SPEC.md + requiredScreens = ["List", "Add", "Edit", "Detail"], + requiredStates = ["Loading", "Success", "Error", "Empty"], + requiredActions = ["Retry", "Add", "Edit", "Delete", "Select"], + + // From API.md + endpoints = [ + GET("/beneficiaries"), + POST("/beneficiaries"), + PUT("/beneficiaries/{id}"), + DELETE("/beneficiaries/{id}") + ] +) +``` --- -## Implementation Flow +## PHASE 1: Pattern Detection + +### Step 1.1: Read Reference Files + +**Select reference files from existing code:** ``` -┌─────────────────────────────────────────────────────────────────────┐ -│ E2E IMPLEMENTATION PIPELINE │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ PHASE 0: GIT SETUP │ -│ ├─→ Check working directory is clean │ -│ ├─→ Create branch: git checkout -b feature/{name} │ -│ └─→ [AUTO-CONTINUE] │ -│ │ -│ PHASE 1: DEPENDENCY VALIDATION │ -│ ├─→ Read SPEC.md + API.md │ -│ ├─→ Check required services exist │ -│ ├─→ Check Kotlin dependencies available │ -│ ├─→ Identify gaps │ -│ └─→ [AUTO-CONTINUE if all deps satisfied] │ -│ │ -│ PHASE 2: CLIENT LAYER │ -│ ├─→ Create/update DTOs in core/network/model/ (if needed) │ -│ ├─→ Create/update Service in core/network/services/ │ -│ ├─→ Create/update Repository in core/data/repository/ │ -│ ├─→ Register in DI modules │ -│ ├─→ 🔨 BUILD: ./gradlew :core:network:build :core:data:build │ -│ ├─→ 🧹 LINT: spotlessApply │ -│ ├─→ 📝 COMMIT: git commit -m "feat({name}): Add client layer" │ -│ └─→ ⏸️ CHECKPOINT: Client Summary + Options │ -│ │ -│ PHASE 3: FEATURE LAYER │ -│ ├─→ Create ViewModel (State, Event, Action) │ -│ ├─→ Create Screen (Compose UI) │ -│ ├─→ Create Components │ -│ ├─→ Create Navigation │ -│ ├─→ Register in DI module │ -│ ├─→ 🔨 BUILD: ./gradlew :feature:{name}:build │ -│ ├─→ 🧹 LINT: spotlessApply detekt │ -│ ├─→ 📝 COMMIT: git commit -m "feat({name}): Add feature layer" │ -│ └─→ ⏸️ CHECKPOINT: Feature Summary + Options │ -│ │ -│ PHASE 4: FINALIZE │ -│ ├─→ Update feature's STATUS.md │ -│ ├─→ Update main STATUS.md │ -│ ├─→ 🔨 FINAL BUILD: ./gradlew build │ -│ └─→ 📝 COMMIT: git commit -m "docs({name}): Update status" │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ +1. ViewModel Reference: + feature/home/src/commonMain/.../viewmodel/HomeViewModel.kt + +2. Screen Reference: + feature/home/src/commonMain/.../ui/HomeScreen.kt + +3. Repository Reference: + core/data/src/commonMain/.../repository/HomeRepository.kt + core/data/src/commonMain/.../repository/HomeRepositoryImp.kt +``` + +### Step 1.2: Extract Patterns + +```kotlin +// Extracted ViewModel Pattern +val vmPattern = ViewModelPattern( + baseClass = "BaseViewModel", + stateAnnotation = "@Immutable", + screenStatePattern = "sealed interface ${Feature}ScreenState", + eventPattern = "sealed interface ${Feature}Event", + actionPattern = "sealed interface ${Feature}Action", + handleActionPattern = "override fun handleAction(action: ${Feature}Action)", + dataLoadingPattern = "viewModelScope.launch { repository.method().collect { ... } }" +) + +// Extracted Screen Pattern +val screenPattern = ScreenPattern( + koinViewModel = "koinViewModel()", + stateCollection = "collectAsStateWithLifecycle()", + eventCollection = "LaunchedEffect(Unit) { viewModel.eventFlow.collect { ... } }", + contentSeparation = "${Feature}Content(state, onAction)", + previewAnnotation = "@Preview\n@Composable\nfun ${Feature}Preview()" +) + +// Extracted Repository Pattern +val repoPattern = RepositoryPattern( + returnType = "Flow>", + loadingEmit = "emit(DataState.Loading)", + successEmit = "emit(DataState.Success(data))", + errorEmit = "emit(DataState.Error(e.message ?: \"Unknown error\"))" +) +``` + +--- + +## PHASE 2: Client Layer + +### Step 2.1: Check Existing (O(1)) + +From FEATURE_MAP.md, check if services/repositories exist: + +```markdown +## Decision Matrix + +| Component | Exists | Action | +|-----------|:------:|--------| +| BeneficiaryService | ✅ | Skip creation | +| BeneficiaryRepository | ✅ | Skip creation | +| NewFeatureService | ❌ | CREATE | +| NewFeatureRepository | ❌ | CREATE | +``` + +### Step 2.2: Generate Service (if needed) + +**Pattern-matched code generation:** + +```kotlin +// Generated from SPEC.md + API.md + pattern detection +interface ${Feature}Service { + + @GET(ApiEndPoints.${ENDPOINT_CONSTANT}) + fun get${Feature}List(): Flow> + + @GET(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}") + 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, + @Body payload: ${Payload}, + ): HttpResponse + + @DELETE(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}") + suspend fun delete${Feature}(@Path("id") id: Long): HttpResponse +} +``` + +### Step 2.3: Generate Repository (if needed) + +**Interface:** +```kotlin +interface ${Feature}Repository { + fun get${Feature}List(): Flow>> + fun get${Feature}ById(id: Long): Flow> + suspend fun create${Feature}(data: ${Model}): DataState + suspend fun update${Feature}(id: Long, data: ${Model}): DataState + suspend fun delete${Feature}(id: Long): DataState +} +``` + +**Implementation (pattern-matched):** +```kotlin +class ${Feature}RepositoryImp( + private val service: ${Feature}Service, +) : ${Feature}Repository { + + override fun get${Feature}List(): Flow>> = flow { + emit(DataState.Loading) + try { + val result = service.get${Feature}List().first() + emit(DataState.Success(result)) + } catch (e: Exception) { + emit(DataState.Error(e.message ?: "Unknown error")) + } + } + // ... other methods following same pattern +} +``` + +### Step 2.4: Register DI + +**NetworkModule.kt:** +```kotlin +single<${Feature}Service> { get().create<${Feature}Service>() } +``` + +**RepositoryModule.kt:** +```kotlin +single<${Feature}Repository> { ${Feature}RepositoryImp(get()) } +``` + +### Step 2.5: Build & Verify + +```bash +./gradlew :core:network:build :core:data:build +./gradlew spotlessApply --no-configuration-cache +``` + +--- + +## PHASE 3: Feature Layer + +### Step 3.1: Generate ViewModel (MVI Pattern) + +```kotlin +internal class ${Feature}ViewModel( + private val repository: ${Feature}Repository, +) : BaseViewModel<${Feature}State, ${Feature}Event, ${Feature}Action>( + initialState = ${Feature}State() +) { + + init { + load${Feature}() + } + + override fun handleAction(action: ${Feature}Action) { + when (action) { + is ${Feature}Action.Retry -> load${Feature}() + is ${Feature}Action.OnItemClick -> handleItemClick(action.id) + // ... from SPEC.md actions + } + } + + private fun load${Feature}() { + viewModelScope.launch { + repository.get${Feature}List() + .collect { dataState -> + when (dataState) { + is DataState.Loading -> updateState { + it.copy(uiState = ${Feature}ScreenState.Loading) + } + is DataState.Success -> updateState { + it.copy( + uiState = ${Feature}ScreenState.Success, + data = dataState.data + ) + } + is DataState.Error -> updateState { + it.copy(uiState = ${Feature}ScreenState.Error(dataState.message)) + } + } + } + } + } +} + +// State - from SPEC.md +@Immutable +data class ${Feature}State( + val data: List<${Item}> = emptyList(), + val uiState: ${Feature}ScreenState = ${Feature}ScreenState.Loading, + // ... from SPEC.md state fields +) + +sealed interface ${Feature}ScreenState { + data object Loading : ${Feature}ScreenState + data object Success : ${Feature}ScreenState + data class Error(val message: String) : ${Feature}ScreenState +} + +// Events - from SPEC.md navigation +sealed interface ${Feature}Event { + data class NavigateToDetail(val id: Long) : ${Feature}Event + data object NavigateBack : ${Feature}Event +} + +// Actions - from SPEC.md user interactions +sealed interface ${Feature}Action { + data object Retry : ${Feature}Action + data class OnItemClick(val id: Long) : ${Feature}Action + // ... from SPEC.md +} +``` + +### Step 3.2: Generate Screen with TestTags + +```kotlin +// TestTags (generated for testing) +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 ITEM_PREFIX = "${feature}:item:" // + id + const val RETRY_BUTTON = "${feature}:retry" + const val ADD_BUTTON = "${feature}:add" +} + +@Composable +fun ${Feature}Screen( + viewModel: ${Feature}ViewModel = koinViewModel(), + onNavigateBack: () -> Unit, + onNavigateToDetail: (Long) -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.eventFlow.collect { event -> + when (event) { + is ${Feature}Event.NavigateBack -> onNavigateBack() + is ${Feature}Event.NavigateToDetail -> onNavigateToDetail(event.id) + } + } + } + + ${Feature}Content( + state = state, + onAction = viewModel::sendAction, + modifier = Modifier.testTag(${Feature}TestTags.SCREEN) + ) +} + +@Composable +private fun ${Feature}Content( + state: ${Feature}State, + onAction: (${Feature}Action) -> Unit, + modifier: Modifier = Modifier, +) { + when (state.uiState) { + is ${Feature}ScreenState.Loading -> { + MifosLoadingWheel( + modifier = Modifier.testTag(${Feature}TestTags.LOADING) + ) + } + is ${Feature}ScreenState.Success -> { + ${Feature}SuccessContent( + data = state.data, + onItemClick = { onAction(${Feature}Action.OnItemClick(it)) }, + modifier = Modifier.testTag(${Feature}TestTags.LIST) + ) + } + is ${Feature}ScreenState.Error -> { + MifosErrorContent( + message = state.uiState.message, + onRetry = { onAction(${Feature}Action.Retry) }, + modifier = Modifier.testTag(${Feature}TestTags.ERROR) + ) + } + } +} +``` + +### Step 3.3: Generate Navigation + +```kotlin +fun NavGraphBuilder.${feature}Screen( + onNavigateBack: () -> Unit, + onNavigateToDetail: (Long) -> Unit, +) { + composable<${Feature}Route> { + ${Feature}Screen( + onNavigateBack = onNavigateBack, + onNavigateToDetail = onNavigateToDetail, + ) + } +} + +@Serializable +data object ${Feature}Route +``` + +### Step 3.4: Generate DI Module + +```kotlin +val ${feature}Module = module { + viewModelOf(::${Feature}ViewModel) +} +``` + +### Step 3.5: Build & Verify + +```bash +./gradlew :feature:${name}:build +./gradlew spotlessApply detekt --no-configuration-cache +``` + +--- + +## PHASE 4: Finalize + +### Step 4.1: Update O(1) Index Files + +**FEATURE_MAP.md:** +```markdown +| ${feature} | ${Service} | ${Repository} | Notes | +``` + +**MODULES_INDEX.md:** +```markdown +| ${n} | ${module} | feature/${module} | ✅ | ${vmCount} | ${screenCount} | +``` + +**SCREENS_INDEX.md:** +```markdown +### ${module} (${screenCount} screens) + +| Screen | ViewModel | File | +|--------|-----------|------| +| ${Screen}Screen | ${Screen}ViewModel | ui/${Screen}Screen.kt | +``` + +### Step 4.2: Update STATUS.md Files + +**Feature STATUS.md:** +```markdown +| Component | Status | Notes | +|-----------|:------:|-------| +| Client Layer | ✅ | Service + Repository | +| Feature Layer | ✅ | ViewModel + Screen | +| Navigation | ✅ | Registered | +| DI | ✅ | Module registered | +``` + +### Step 4.3: Final Build + +```bash +./gradlew build +git add . +git commit -m "feat(${feature}): complete E2E implementation" +``` + +--- + +## PHASE 5: Test Stub Generation (TDD Support) + +### Step 5.1: Generate ViewModel Test Stub + +**Location**: `feature/${name}/src/commonTest/kotlin/.../viewmodel/${Feature}ViewModelTest.kt` + +```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.feature.${package}.viewmodel + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import org.mifos.mobile.core.testing.fake.Fake${Feature}Repository +import org.mifos.mobile.core.testing.rule.MainDispatcherRule +import org.mifos.mobile.feature.${package}.${Feature}ScreenState +import org.mifos.mobile.feature.${package}.${Feature}Action +import org.mifos.mobile.feature.${package}.${Feature}Event +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * ViewModel tests for ${Feature} + * + * Generated by /implement command + * Run: ./gradlew :feature:${name}:test + */ +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, + ) + } + + // =========================================== + // INITIAL STATE TESTS + // =========================================== + + @Test + fun `initial state is loading`() = runTest { + viewModel.stateFlow.test { + val state = awaitItem() + assertTrue(state.uiState is ${Feature}ScreenState.Loading) + } + } + + // =========================================== + // DATA LOADING TESTS + // =========================================== + + @Test + fun `when data loads successfully, state is success`() = runTest { + // Given + fakeRepository.setSuccessResponse(/* test data */) + + // When + viewModel.handleAction(${Feature}Action.Retry) + + // Then + viewModel.stateFlow.test { + val state = expectMostRecentItem() + assertTrue(state.uiState is ${Feature}ScreenState.Success) + } + } + + @Test + fun `when data load fails, state is error`() = runTest { + // Given + fakeRepository.setErrorResponse("Network error") + + // When + viewModel.handleAction(${Feature}Action.Retry) + + // Then + viewModel.stateFlow.test { + val state = expectMostRecentItem() + assertTrue(state.uiState is ${Feature}ScreenState.Error) + } + } + + @Test + fun `when data is empty, state is empty`() = runTest { + // Given + fakeRepository.setEmptyResponse() + + // When + viewModel.handleAction(${Feature}Action.Retry) + + // Then + viewModel.stateFlow.test { + val state = expectMostRecentItem() + // TODO: Check for Empty state if defined + } + } + + // =========================================== + // ACTION TESTS + // =========================================== + + @Test + fun `retry action triggers reload`() = runTest { + // Given + fakeRepository.setSuccessResponse(/* test data */) + + // When + viewModel.handleAction(${Feature}Action.Retry) + + // Then + assertEquals(1, fakeRepository.loadCallCount) + } + + // TODO: Add more action tests based on SPEC.md + // @Test fun `onItemClick action triggers navigation event`() + // @Test fun `onAddClick action triggers navigate to add`() + + // =========================================== + // EVENT TESTS + // =========================================== + + @Test + fun `item click emits navigation event`() = runTest { + viewModel.eventFlow.test { + // When + viewModel.handleAction(${Feature}Action.OnItemClick(itemId = 1L)) + + // Then + val event = awaitItem() + assertTrue(event is ${Feature}Event.NavigateToDetail) + } + } +} +``` + +### Step 5.2: Generate Screen Test Stub + +**Location**: `feature/${name}/src/androidInstrumentedTest/kotlin/.../ui/${Feature}ScreenTest.kt` + +```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.feature.${package}.ui + +import androidx.compose.ui.test.assertIsDisplayed +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 org.junit.Rule +import org.junit.Test +import org.mifos.mobile.feature.${package}.${Feature}State +import org.mifos.mobile.feature.${package}.${Feature}ScreenState +import org.mifos.mobile.feature.${package}.${Feature}Action + +/** + * UI tests for ${Feature}Screen + * + * Generated by /implement command + * Run: ./gradlew :feature:${name}:connectedDebugAndroidTest + */ +class ${Feature}ScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + // =========================================== + // LOADING STATE TESTS + // =========================================== + + @Test + fun loadingState_displaysLoadingIndicator() { + // Given + val state = ${Feature}State( + uiState = ${Feature}ScreenState.Loading + ) + + // When + composeTestRule.setContent { + ${Feature}Content( + state = state, + onAction = {} + ) + } + + // Then + composeTestRule + .onNodeWithTag(${Feature}TestTags.LOADING) + .assertIsDisplayed() + } + + // =========================================== + // SUCCESS STATE TESTS + // =========================================== + + @Test + fun successState_displaysList() { + // Given + val state = ${Feature}State( + uiState = ${Feature}ScreenState.Success, + data = listOf(/* test data */) + ) + + // When + composeTestRule.setContent { + ${Feature}Content( + state = state, + onAction = {} + ) + } + + // Then + composeTestRule + .onNodeWithTag(${Feature}TestTags.LIST) + .assertIsDisplayed() + } + + @Test + fun successState_itemClickTriggersAction() { + // Given + var actionReceived: ${Feature}Action? = null + val state = ${Feature}State( + uiState = ${Feature}ScreenState.Success, + data = listOf(/* test data with id=1 */) + ) + + // When + composeTestRule.setContent { + ${Feature}Content( + state = state, + onAction = { actionReceived = it } + ) + } + + composeTestRule + .onNodeWithTag("${feature}:item:1") + .performClick() + + // Then + assertTrue(actionReceived is ${Feature}Action.OnItemClick) + } + + // =========================================== + // ERROR STATE TESTS + // =========================================== + + @Test + fun errorState_displaysErrorMessage() { + // Given + val errorMessage = "Network error" + val state = ${Feature}State( + uiState = ${Feature}ScreenState.Error(errorMessage) + ) + + // When + composeTestRule.setContent { + ${Feature}Content( + state = state, + onAction = {} + ) + } + + // Then + composeTestRule + .onNodeWithTag(${Feature}TestTags.ERROR) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(errorMessage) + .assertIsDisplayed() + } + + @Test + fun errorState_retryButtonTriggersAction() { + // Given + var actionReceived: ${Feature}Action? = null + val state = ${Feature}State( + uiState = ${Feature}ScreenState.Error("Error") + ) + + // When + composeTestRule.setContent { + ${Feature}Content( + state = state, + onAction = { actionReceived = it } + ) + } + + composeTestRule + .onNodeWithTag(${Feature}TestTags.RETRY_BUTTON) + .performClick() + + // Then + assertEquals(${Feature}Action.Retry, actionReceived) + } + + // =========================================== + // EMPTY STATE TESTS (if applicable) + // =========================================== + + // TODO: Add empty state test if defined in SPEC.md + // @Test fun emptyState_displaysEmptyIllustration() +} +``` + +### Step 5.3: Generate Fake Repository + +**Location**: `core/testing/src/commonMain/kotlin/.../fake/Fake${Feature}Repository.kt` + +```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.testing.fake + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.mifos.mobile.core.common.DataState +import org.mifos.mobile.core.data.repository.${Feature}Repository +import org.mifos.mobile.core.model.entity.${Model} + +/** + * Fake repository for testing ${Feature} + * + * Generated by /implement command + */ +class Fake${Feature}Repository : ${Feature}Repository { + + // Track call counts for verification + var loadCallCount = 0 + private set + + // Configurable responses + private var response: DataState> = DataState.Loading + + fun setSuccessResponse(data: List<${Model}>) { + response = DataState.Success(data) + } + + fun setErrorResponse(message: String) { + response = DataState.Error(message) + } + + fun setEmptyResponse() { + response = DataState.Success(emptyList()) + } + + fun reset() { + loadCallCount = 0 + response = DataState.Loading + } + + // Repository implementation + override fun get${Feature}List(): Flow>> = flow { + loadCallCount++ + emit(DataState.Loading) + emit(response) + } + + override fun get${Feature}ById(id: Long): Flow> = flow { + emit(DataState.Loading) + when (val currentResponse = response) { + is DataState.Success -> { + val item = currentResponse.data.find { it.id == id } + if (item != null) { + emit(DataState.Success(item)) + } else { + emit(DataState.Error("Item not found")) + } + } + is DataState.Error -> emit(DataState.Error(currentResponse.message)) + is DataState.Loading -> emit(DataState.Loading) + } + } + + override suspend fun create${Feature}(data: ${Model}): DataState { + return DataState.Success(Unit) + } + + override suspend fun update${Feature}(id: Long, data: ${Model}): DataState { + return DataState.Success(Unit) + } + + override suspend fun delete${Feature}(id: Long): DataState { + return DataState.Success(Unit) + } +} +``` + +### Step 5.4: Update TESTING_STATUS.md + +Add entry to `feature-layer/TESTING_STATUS.md`: + +```markdown +| ${feature} | ${vmCount} | ${vmCount} | ${screenCount} | 0 | Stubs | +``` + +### TEST Checkpoint + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ 🧪 TEST STUBS GENERATED (Phase 5) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 📁 Files Created: │ +│ ├─ feature/${name}/src/commonTest/.../viewmodel/${Feature}ViewModelTest.kt │ +│ ├─ feature/${name}/src/androidInstrumentedTest/.../ui/${Feature}ScreenTest.kt│ +│ └─ core/testing/src/commonMain/.../fake/Fake${Feature}Repository.kt │ +│ │ +│ 🧪 Test Stubs Generated: │ +│ ├─ ViewModel tests: [n] stubs (initial, loading, success, error, actions) │ +│ ├─ Screen tests: [n] stubs (loading, success, error, interactions) │ +│ └─ Fake repository: Full implementation for testing │ +│ │ +│ 📊 TestTags Used: │ +│ ├─ ${feature}:screen │ +│ ├─ ${feature}:loading │ +│ ├─ ${feature}:error │ +│ ├─ ${feature}:list │ +│ ├─ ${feature}:item:{id} │ +│ └─ ${feature}:retry │ +│ │ +│ 📋 Next Steps: │ +│ 1. Run tests: ./gradlew :feature:${name}:test │ +│ 2. Fill in TODO comments with actual test data │ +│ 3. Add more tests based on SPEC.md requirements │ +│ │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ Options: │ +│ • c / continue → Finalize implementation │ +│ • r / run → Run generated tests │ +│ • v / view [file] → View specific test file │ +│ • s / skip → Skip test generation │ +└──────────────────────────────────────────────────────────────────────────────┘ ``` --- ## Checkpoint Templates -### After CLIENT Layer: +### CLIENT Checkpoint ``` -┌──────────────────────────────────────────────────────────────────────┐ -│ ✅ CLIENT LAYER COMPLETE │ -├──────────────────────────────────────────────────────────────────────┤ -│ │ -│ Created/Updated Files: │ -│ ├─ core/network/model/[Feature]Dto.kt │ -│ ├─ core/network/services/[Feature]Service.kt │ -│ ├─ core/data/repository/[Feature]Repository.kt │ -│ └─ core/data/repositoryImpl/[Feature]RepositoryImpl.kt │ -│ │ -│ Registered in DI: │ -│ ├─ NetworkModule: [Feature]Service ✅ │ -│ └─ DataModule: [Feature]Repository ✅ │ -│ │ -│ 🔨 BUILD: :core:network ✅ :core:data ✅ │ -│ 🧹 LINT: spotlessApply ✅ │ -│ 📝 COMMIT: feat([feature]): Add client layer │ -│ │ -├──────────────────────────────────────────────────────────────────────┤ -│ Options: │ -│ • c / continue → Proceed to FEATURE layer │ -│ • i / improve → Describe what to improve │ -│ • v / view → Show file content │ -└──────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ✅ CLIENT LAYER COMPLETE (Phase 2) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 📚 O(1) Context Used: │ +│ ├─ FEATURE_MAP.md → Identified existing: [services], [repos] │ +│ └─ API.md → Mapped [n] endpoints │ +│ │ +│ 🔧 Created/Updated: │ +│ ├─ core/network/services/${Feature}Service.kt [CREATED|UPDATED|SKIPPED] │ +│ ├─ core/data/repository/${Feature}Repository.kt [CREATED|UPDATED|SKIPPED] │ +│ ├─ core/data/repository/${Feature}RepositoryImp.kt │ +│ └─ DI Modules [REGISTERED] │ +│ │ +│ 📊 Pattern Matching: │ +│ └─ Applied patterns from: HomeRepositoryImp.kt │ +│ │ +│ 🔨 BUILD: :core:network ✅ :core:data ✅ │ +│ 🧹 LINT: spotlessApply ✅ │ +│ │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ Options: │ +│ • c / continue → Proceed to FEATURE layer │ +│ • i / improve → Describe improvements │ +│ • v / view → Show generated file │ +└──────────────────────────────────────────────────────────────────────────────┘ ``` -### After FEATURE Layer: +### FEATURE Checkpoint ``` -┌──────────────────────────────────────────────────────────────────────┐ -│ ✅ FEATURE LAYER COMPLETE │ -├──────────────────────────────────────────────────────────────────────┤ -│ │ -│ Created/Updated Files: │ -│ ├─ feature/[name]/[Feature]ViewModel.kt │ -│ ├─ feature/[name]/[Feature]Screen.kt │ -│ ├─ feature/[name]/components/*.kt │ -│ ├─ feature/[name]/navigation/[Feature]Navigation.kt │ -│ └─ feature/[name]/di/[Feature]Module.kt │ -│ │ -│ Navigation: │ -│ └─ Route registered ✅ │ -│ │ -│ 🔨 BUILD: :feature:[name] ✅ │ -│ 🧹 LINT: spotlessApply ✅ detekt ✅ │ -│ 📝 COMMIT: feat([feature]): Add feature layer │ -│ │ -├──────────────────────────────────────────────────────────────────────┤ -│ Options: │ -│ • c / continue → Complete implementation, update status │ -│ • i / improve → Describe improvement │ -│ • v / view [file] → Show specific file content │ -└──────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ✅ FEATURE LAYER COMPLETE (Phase 3) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 📚 O(1) Context Used: │ +│ ├─ MODULES_INDEX.md → Verified module structure │ +│ ├─ SCREENS_INDEX.md → Identified [n] existing screens │ +│ └─ SPEC.md → Mapped [n] screens, [n] states, [n] actions │ +│ │ +│ 🔧 Created/Updated: │ +│ ├─ feature/${name}/viewmodel/${Feature}ViewModel.kt │ +│ ├─ feature/${name}/ui/${Feature}Screen.kt │ +│ ├─ feature/${name}/navigation/${Feature}Navigation.kt │ +│ └─ feature/${name}/di/${Feature}Module.kt │ +│ │ +│ 🏷️ TestTags Generated: │ +│ ├─ ${feature}:screen │ +│ ├─ ${feature}:loading │ +│ ├─ ${feature}:error │ +│ └─ ${feature}:list │ +│ │ +│ 📊 Pattern Matching: │ +│ └─ Applied patterns from: HomeViewModel.kt, HomeScreen.kt │ +│ │ +│ 🔨 BUILD: :feature:${name} ✅ │ +│ 🧹 LINT: spotlessApply ✅ detekt ✅ │ +│ │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ Options: │ +│ • c / continue → Finalize and update indexes │ +│ • i / improve → Describe improvements │ +│ • v / view [file] → Show specific file │ +│ • t / test → Show generated TestTags │ +└──────────────────────────────────────────────────────────────────────────────┘ ``` --- -## Final Report Template +## Final Report ``` -╔══════════════════════════════════════════════════════════════════════╗ -║ /implement [Feature] - COMPLETE ║ -╠══════════════════════════════════════════════════════════════════════╣ -║ ║ -║ ✅ PHASE 0: GIT SETUP ║ -║ └─ Branch: feature/[name] ║ -║ ║ -║ ✅ PHASE 1: DEPENDENCY VALIDATION ║ -║ └─ All dependencies satisfied ║ -║ ║ -║ ✅ PHASE 2: CLIENT LAYER ║ -║ ├─ Files: [count] created/updated ║ -║ ├─ Build: :core:network ✅ :core:data ✅ ║ -║ └─ Commit: feat([feature]): Add client layer ║ -║ ║ -║ ✅ PHASE 3: FEATURE LAYER ║ -║ ├─ Files: [count] created/updated ║ -║ ├─ Build: :feature:[name] ✅ ║ -║ └─ Commit: feat([feature]): Add feature layer ║ -║ ║ -║ ✅ PHASE 4: FINALIZE ║ -║ ├─ Updated: STATUS.md ║ -║ ├─ Final Build: ./gradlew build ✅ ║ -║ └─ Commit: docs([feature]): Update status ║ -║ ║ -╠══════════════════════════════════════════════════════════════════════╣ -║ 📊 SUMMARY ║ -║ ├─ Files: +[count] created, ~[count] modified ║ -║ ├─ Commits: [count] ║ -║ └─ Errors: 0 ║ -║ ║ -╠══════════════════════════════════════════════════════════════════════╣ -║ 🎉 IMPLEMENTATION COMPLETE ║ -║ ║ -║ Next steps: ║ -║ • Push branch: git push -u origin feature/[name] ║ -║ • Create PR: gh pr create ║ -║ • Verify: /verify [Feature] ║ -║ ║ -╚══════════════════════════════════════════════════════════════════════╝ +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ /implement ${Feature} - COMPLETE ║ +╠═══════════════════════════════════════════════════════════════════════════════╣ +║ ║ +║ 📊 O(1) OPTIMIZATION METRICS ║ +║ ├─ Index files read: 4 files (~200 lines) ║ +║ ├─ Directory scans avoided: ~50 ║ +║ └─ Pattern files read: 3 references ║ +║ ║ +║ ✅ PHASE 0: CONTEXT LOADING ║ +║ └─ Loaded from: FEATURE_MAP, MODULES_INDEX, SCREENS_INDEX, SPEC, API ║ +║ ║ +║ ✅ PHASE 1: PATTERN DETECTION ║ +║ └─ Patterns from: HomeViewModel, HomeScreen, HomeRepository ║ +║ ║ +║ ✅ PHASE 2: CLIENT LAYER ║ +║ ├─ Files: [n] created, [n] updated ║ +║ └─ Build: :core:network ✅ :core:data ✅ ║ +║ ║ +║ ✅ PHASE 3: FEATURE LAYER ║ +║ ├─ Files: [n] created ║ +║ ├─ TestTags: [n] generated ║ +║ └─ Build: :feature:${name} ✅ ║ +║ ║ +║ ✅ PHASE 4: FINALIZE ║ +║ ├─ Updated: FEATURE_MAP.md, MODULES_INDEX.md, SCREENS_INDEX.md ║ +║ └─ Final Build: ./gradlew build ✅ ║ +║ ║ +║ ✅ PHASE 5: TEST STUBS ║ +║ ├─ ViewModel test: ${Feature}ViewModelTest.kt ✅ ║ +║ ├─ Screen test: ${Feature}ScreenTest.kt ✅ ║ +║ ├─ Fake repository: Fake${Feature}Repository.kt ✅ ║ +║ └─ Updated: TESTING_STATUS.md ✅ ║ +║ ║ +╠═══════════════════════════════════════════════════════════════════════════════╣ +║ 📊 SUMMARY ║ +║ ├─ Files Created: [n] ║ +║ ├─ Files Updated: [n] ║ +║ ├─ TestTags Generated: [n] ║ +║ ├─ Test Stubs Generated: [n] ║ +║ └─ Index Files Updated: 4 (incl. TESTING_STATUS) ║ +║ ║ +╠═══════════════════════════════════════════════════════════════════════════════╣ +║ 🎉 IMPLEMENTATION COMPLETE ║ +║ ║ +║ Next steps: ║ +║ • Verify: /verify ${Feature} ║ +║ • Test: /verify-tests ${Feature} ║ +║ • Push: git push -u origin feature/${feature} ║ +║ ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ ``` --- -## Cross-Update Rules +## Feature List (No Argument) -After ANY implementation: -1. Update feature's `STATUS.md` -2. Update main `claude-product-cycle/design-spec-layer/STATUS.md` -3. Add changelog entries +When `/implement` called without arguments, read MODULES_INDEX.md: + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ 📋 FEATURES - Implementation Status (from MODULES_INDEX.md) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ | # | Feature | Client | Feature | Gaps | Command │ │ +│ |:-:|-----------------|:------:|:-------:|:----:|----------------------| │ +│ | 1 | auth | ✅ | ✅ | 0 | /implement auth │ │ +│ | 2 | home | ✅ | ✅ | 0 | /implement home │ │ +│ | 3 | accounts | ✅ | ✅ | 0 | /implement accounts │ │ +│ | 4 | beneficiary | ✅ | ✅ | 0 | /implement beneficiary│ │ +│ | 5 | transfer | ✅ | ✅ | 0 | /implement transfer │ │ +│ | ... │ +│ │ +│ Which feature? (Or type feature name directly) │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` --- -## If No Feature Name Provided +## Error Handling -Show feature list: +### Build Failure ``` -📋 FEATURES AVAILABLE FOR IMPLEMENTATION: - -| Feature | Status | Client | Feature | Command | -|---------|--------|--------|---------|---------| -| auth | ✅ Done | ✅ | ✅ | /implement auth | -| home | ✅ Done | ✅ | ✅ | /implement home | -... - -Which feature do you want to implement? +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ❌ BUILD FAILED │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Module: :core:network │ +│ Error: Unresolved reference: ApiEndPoints.BENEFICIARIES │ +│ │ +│ 📍 Auto-Fix Suggestion: │ +│ Add to core/network/ApiEndPoints.kt: │ +│ const val BENEFICIARIES = "beneficiaries" │ +│ │ +│ Options: │ +│ • f / fix → Apply auto-fix and rebuild │ +│ • m / manual → Show what to fix manually │ +│ • a / abort → Stop implementation │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ ``` + +--- + +## Related Commands + +| Command | Purpose | +|---------|---------| +| `/client [Feature]` | Client layer only | +| `/feature [Feature]` | Feature layer only | +| `/verify [Feature]` | Verify implementation vs spec | +| `/verify-tests [Feature]` | Run tests | +| `/gap-analysis` | Check what needs implementation | diff --git a/.claude/commands/verify-tests.md b/.claude/commands/verify-tests.md index 1f8278998..fed337877 100644 --- a/.claude/commands/verify-tests.md +++ b/.claude/commands/verify-tests.md @@ -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 ``` -/verify-tests # Run all tests, show status -/verify-tests auth # Run auth feature tests -/verify-tests auth unit # Run auth ViewModel tests only -/verify-tests auth ui # Run auth UI tests only -/verify-tests auth integration # Run auth integration tests -/verify-tests auth screenshot # Run auth screenshot tests +/verify-tests # Show test status dashboard (O(1)) +/verify-tests [feature] # Run all tests for feature +/verify-tests [feature] unit # Run ViewModel tests only +/verify-tests [feature] ui # Run UI tests only +/verify-tests [feature] integration # Run integration tests +/verify-tests [feature] screenshot # Run screenshot tests /verify-tests client # Run all client layer tests /verify-tests feature # Run all feature layer 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 | Type | Command | Tests | Passed | Failed | Status | |------|---------|:-----:|:------:|:------:|:------:| -| Unit | `./gradlew :feature:auth:test` | 45 | 45 | 0 | ✅ | -| UI | `./gradlew :feature:auth:connectedDebugAndroidTest` | 25 | 23 | 2 | ⚠️ | -| Integration | `./gradlew :cmp-android:connectedDebugAndroidTest` | 8 | 8 | 0 | ✅ | -| Screenshot | `./gradlew :core:designsystem:compareRoborazziDebug` | 12 | 12 | 0 | ✅ | +| Unit | `./gradlew :feature:auth:test` | 45 | 45 | 0 | [x] | +| UI | `./gradlew :feature:auth:connectedDebugAndroidTest` | 25 | 23 | 2 | [!] | ## Failed Tests @@ -45,137 +250,87 @@ Run and verify tests for features across the project. | Component | Coverage | Target | Status | |-----------|:--------:|:------:|:------:| -| ViewModel | 85% | 80% | ✅ | -| Screen | 72% | 60% | ✅ | -| Repository | 90% | 80% | ✅ | +| ViewModel | 85% | 80% | [x] Pass | +| Screen | 72% | 60% | [x] Pass | +| 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 -3. Re-run: `/verify-tests auth` - -╚══════════════════════════════════════════════════════════════════════════════╝ ++---------+----------------------------------------------------------+ +| NEXT STEPS | ++---------+----------------------------------------------------------+ +| 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 - -# Specific module -./gradlew :feature:auth:test -./gradlew :core:data:test - -# With coverage -./gradlew test jacocoTestReport +``` ++-------------------------------------------------------------------------+ +| ERROR: Feature '[name]' not found | ++-------------------------------------------------------------------------+ +| | +| The feature '[name]' does not exist in MODULES_INDEX.md | +| | +| Available features: | +| auth, home, accounts, beneficiary, loan-account, savings-account, | +| 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 - -# Specific feature -./gradlew :feature:auth:connectedDebugAndroidTest +``` ++-------------------------------------------------------------------------+ +| WARNING: No tests found for '[feature]' | ++-------------------------------------------------------------------------+ +| | +| 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) - -```bash -# Record golden images -./gradlew :core:designsystem:recordRoborazziDebug - -# Compare against golden images -./gradlew :core:designsystem:compareRoborazziDebug - -# View differences -open build/reports/roborazzi/ ++-------------------------------------------------------------------------+ +| ERROR: Gradle build failed | ++-------------------------------------------------------------------------+ +| | +| Command: ./gradlew :feature:[module]:test | +| Exit code: 1 | +| | +| Error output: | +| [Gradle error message] | +| | +| Suggestions: | +| 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 | 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 -- `/gap-analysis testing` - View testing status -- `/gap-planning testing [layer]` - Plan test implementation -- `/verify [feature]` - Verify implementation vs spec +| Command | Purpose | +|---------|---------| +| `/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 diff --git a/.claude/commands/verify.md b/.claude/commands/verify.md index 7f4081195..148c63670 100644 --- a/.claude/commands/verify.md +++ b/.claude/commands/verify.md @@ -1,169 +1,736 @@ # /verify - Implementation Verification ## 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 [Feature] WORKFLOW │ -├───────────────────────────────────────────────────────────────────┤ -│ │ -│ PHASE 1: READ SPEC │ -│ ├─→ Read features/[feature]/SPEC.md │ -│ ├─→ 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" │ -│ │ -└───────────────────────────────────────────────────────────────────┘ +/verify # Show all features verification status +/verify [Feature] # Full verification for feature +/verify [Feature] --quick # Skip detailed code analysis +/verify [Feature] --spec # Verify spec completeness only +/verify [Feature] --code # Verify code completeness only +/verify all # Verify all features (summary) ``` --- -## Verification Checklist - -### 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 Pipeline with O(1) Optimization ``` -╔══════════════════════════════════════════════════════════════════════╗ -║ ✅ VERIFICATION COMPLETE - [Feature] ║ -╠══════════════════════════════════════════════════════════════════════╣ -║ ║ -║ UI SECTIONS: ✅ All 5 sections implemented ║ -║ USER ACTIONS: ✅ All 8 actions handled ║ -║ STATE MODEL: ✅ Matches specification ║ -║ API INTEGRATION: ✅ All 3 endpoints called ║ -║ DI REGISTRATION: ✅ Complete ║ -║ NAVIGATION: ✅ Configured ║ -║ ║ -║ RESULT: Feature fully implements specification ║ -║ ║ -╚══════════════════════════════════════════════════════════════════════╝ -``` - -### Gaps Found: - -``` -╔══════════════════════════════════════════════════════════════════════╗ -║ ⚠️ VERIFICATION COMPLETE - GAPS FOUND ║ -╠══════════════════════════════════════════════════════════════════════╣ -║ ║ -║ FEATURE: [Feature] ║ -║ SPEC: claude-product-cycle/design-spec-layer/features/[feature]/SPEC.md║ -║ ║ -╠══════════════════════════════════════════════════════════════════════╣ -║ GAPS IDENTIFIED ║ -╠══════════════════════════════════════════════════════════════════════╣ -║ ║ -║ UI SECTIONS (2 gaps): ║ -║ ├─ ❌ Empty state not implemented ║ -║ └─ ❌ Pull-to-refresh missing ║ -║ ║ -║ USER ACTIONS (1 gap): ║ -║ └─ ❌ Filter action not handled ║ -║ ║ -║ API INTEGRATION (1 gap): ║ -║ └─ ❌ /self/endpoint not called ║ -║ ║ -║ TOTAL GAPS: 4 ║ -║ ║ -╠══════════════════════════════════════════════════════════════════════╣ -║ 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] ║ -║ ║ -╚══════════════════════════════════════════════════════════════════════╝ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ /verify [Feature] - O(1) OPTIMIZED PIPELINE │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ PHASE 0: O(1) CONTEXT LOADING │ +│ ├─→ Read FEATURES_INDEX.md → Feature exists? Spec status? │ +│ ├─→ Read FEATURE_MAP.md → Expected services/repos │ +│ ├─→ Read MODULES_INDEX.md → Expected VMs/Screens │ +│ ├─→ Read SCREENS_INDEX.md → Screen-ViewModel mapping │ +│ └─→ Read API_INDEX.md → Expected endpoints │ +│ │ +│ 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 │ +│ │ +│ 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 │ +│ ├─→ Check Service exists → From FEATURE_MAP.md path │ +│ ├─→ Check Repository exists → From FEATURE_MAP.md path │ +│ └─→ Build implementation checklist → What DOES exist │ +│ │ +│ PHASE 3: DEEP VERIFICATION (if not --quick) │ +│ ├─→ Read ViewModel code → Check State/Event/Action │ +│ ├─→ Read Screen code → Check UI states, TestTags │ +│ ├─→ Compare SPEC actions vs code → All actions handled? │ +│ ├─→ Compare SPEC states vs code → All states rendered? │ +│ └─→ Check DI registration → Koin modules complete? │ +│ │ +│ PHASE 4: GAP DETECTION │ +│ ├─→ Compare requirement vs impl → Identify missing items │ +│ ├─→ Categorize gaps by severity → P0 (critical) → P2 (polish) │ +│ ├─→ Generate fix suggestions → Actionable steps │ +│ └─→ Calculate verification score → Percentage complete │ +│ │ +│ PHASE 5: REPORT & UPDATE │ +│ ├─→ Generate verification report → Structured output │ +│ ├─→ Update STATUS.md (optional) → If user approves │ +│ └─→ Suggest next command → /implement or /gap-planning │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` --- -## Key Files to Compare +## PHASE 0: O(1) Context Loading -| Spec File | Code Files | -|-----------|------------| -| features/[feature]/SPEC.md | feature/[name]/*ViewModel.kt | -| | feature/[name]/*Screen.kt | -| | feature/[name]/components/*.kt | -| features/[feature]/API.md | core/network/services/*Service.kt | -| | core/data/repository/*Repository.kt | +### Files to Read (~500 lines total instead of scanning) + +| File | Purpose | Data Extracted | +|------|---------|----------------| +| `design-spec-layer/FEATURES_INDEX.md` | Feature inventory | featureExists, specStatus | +| `client-layer/FEATURE_MAP.md` | Service/Repo mapping | expectedServices[], expectedRepos[] | +| `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: -1. `features/[feature]/STATUS.md` - Feature status -2. `claude-product-cycle/design-spec-layer/STATUS.md` - Main tracker +### Read Specification Files + +``` +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 | diff --git a/claude-product-cycle/CURRENT_WORK.md b/claude-product-cycle/CURRENT_WORK.md index d3895bbe7..e566d3550 100644 --- a/claude-product-cycle/CURRENT_WORK.md +++ b/claude-product-cycle/CURRENT_WORK.md @@ -1,8 +1,8 @@ # Current Work -**Last Updated**: 2026-01-03 +**Last Updated**: 2026-01-05 **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 | |---|------|---------|:------:|-------|-------| -| 1 | Mockup Generation | home | ⏳ Next | features/home/mockups/ | Run `/design home mockup` | -| 2 | Mockup Generation | auth | ✅ Done | features/auth/mockups/ | PROMPTS.md + design-tokens.json | -| 3 | v2.0 UI Implementation | dashboard | Planned | feature/dashboard/ | After mockups done | -| 4 | MCP Integration | design | ✅ Done | TOOL_CONFIG.md | Stitch MCP installed | -| 5 | Commands README | commands | ✅ Done | .claude/commands/README.md | Full reference | +| 1 | Command Rewrite | implement | ✅ Done | .claude/commands/implement.md | O(1) + Pattern Detection | +| 2 | Command Rewrite | client | ✅ Done | .claude/commands/client.md | O(1) + Pattern Detection | +| 3 | Command Rewrite | feature | ✅ Done | .claude/commands/feature.md | O(1) + TestTags | +| 4 | Command Rewrite | verify | ✅ Done | .claude/commands/verify.md | O(1) + Gap Detection | +| 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 -### 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%) - ✅ dashboard - mockups generated -- ✅ auth - mockups generated (this session) +- ✅ auth - mockups generated - ⏳ home - next - ⏳ 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**: ``` /gap-analysis design mockup # See mockup progress (2/17) @@ -56,22 +100,28 @@ figma: ⚠️ Needs authentication /design [feature] mockup # Generate mockups for feature ``` -### Dashboard Feature (After Mockups) - -**Status**: Waiting for all mockups to be generated - --- ## Recently Completed | 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 | MCP integration | design | Added tool selection, installed stitch-ai | | 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-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 1. This file (`CURRENT_WORK.md`) -2. `.claude/commands/README.md` - Full command reference -3. `design-spec-layer/TOOL_CONFIG.md` - AI tool settings -4. `features/auth/mockups/` - Example of generated mockups +2. `.claude/commands/implement.md` - E2E implementation with **Phase 5 Test Stubs** +3. `.claude/commands/feature.md` - Feature layer with **Phase 3.5 Token Integration** +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 - `/session-start` - Load this context -- `/gap-analysis design mockup` - See mockup progress -- `/design home mockup` - Generate next feature mockups -- `claude mcp list` - Check MCP status +- `/gap-analysis` - Quick overview of all layers +- `/implement [feature]` - Full E2E implementation (updated) +- `/client [feature]` - Client layer only (updated) +- `/feature [feature]` - Feature layer only (updated) +- `/verify [feature]` - Verify implementation vs spec -### MCP Setup (if needed) -```bash -# Google Stitch (already installed) -claude mcp add stitch-ai -- npx -y stitch-ai-mcp - -# Figma (optional) -claude mcp add figma # Follow auth flow -``` +### O(1) Index Files (Core Context) +| File | Purpose | Lines | +|------|---------|:-----:| +| FEATURE_MAP.md | Service → Feature mapping | ~170 | +| MODULES_INDEX.md | All feature modules | ~115 | +| SCREENS_INDEX.md | All 63 screens | ~270 | +| 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 - KMP: Android, iOS, Desktop, Web - DI: Koin modules per feature - Navigation: Jetbrains Compose Navigation - Network: Ktorfit services +- State: MVI pattern (State, Event, Action) +- Testing: TestTags pattern (feature:component:id) --- ## Resume Instructions 1. Run `/session-start` to load context -2. Check MCP: `claude mcp list` -3. Run `/gap-analysis design mockup` to see progress -4. Run `/design home mockup` to continue mockup generation -5. Repeat for remaining 14 features +2. Run `/gap-analysis` to see current status +3. Test new commands: + - `/implement` - Should show feature list with O(1 lookup + - `/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 | |------|-------|---------| +| 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 | 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) diff --git a/claude-product-cycle/MIGRATION_PLAN.md b/claude-product-cycle/MIGRATION_PLAN.md new file mode 100644 index 000000000..712b63b54 --- /dev/null +++ b/claude-product-cycle/MIGRATION_PLAN.md @@ -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 +``` diff --git a/claude-product-cycle/TEST_STUBS_GUIDE.md b/claude-product-cycle/TEST_STUBS_GUIDE.md new file mode 100644 index 000000000..0bb502afe --- /dev/null +++ b/claude-product-cycle/TEST_STUBS_GUIDE.md @@ -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> = 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>> = 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 +``` diff --git a/claude-product-cycle/design-spec-layer/DESIGN_TOKENS_INDEX.md b/claude-product-cycle/design-spec-layer/DESIGN_TOKENS_INDEX.md new file mode 100644 index 000000000..64b597394 --- /dev/null +++ b/claude-product-cycle/design-spec-layer/DESIGN_TOKENS_INDEX.md @@ -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 +``` diff --git a/claude-product-cycle/plans/PLANS_INDEX.md b/claude-product-cycle/plans/PLANS_INDEX.md new file mode 100644 index 000000000..8f0827c4b --- /dev/null +++ b/claude-product-cycle/plans/PLANS_INDEX.md @@ -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 | diff --git a/claude-product-cycle/testing-layer/FAKE_REPOS_INDEX.md b/claude-product-cycle/testing-layer/FAKE_REPOS_INDEX.md new file mode 100644 index 000000000..944ed21fd --- /dev/null +++ b/claude-product-cycle/testing-layer/FAKE_REPOS_INDEX.md @@ -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> = DataState.Loading + private var createResponse: DataState<${Model}> = DataState.Loading + private var deleteResponse: DataState = 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>> = flow { + loadCallCount++ + emit(loadResponse) + } + + override fun create${Model}(payload: ${Model}Payload): Flow> = flow { + createCallCount++ + emit(createResponse) + } + + override fun delete${Model}(id: Long): Flow> = 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 = DataState.Loading + private var registerResponse: DataState = DataState.Loading + private var otpResponse: DataState = 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> = flow { + loginCallCount++ + emit(loginResponse) + } + + override fun register(payload: RegisterPayload): Flow> = flow { + registerCallCount++ + emit(registerResponse) + } + + override fun verifyOtp(otp: String): Flow> = 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 = DataState.Loading + private var userResponse: DataState = 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> = flow { + loadAccountsCallCount++ + emit(accountsResponse) + } + + override fun getCurrentUser(): Flow> = 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> = DataState.Loading + private var createResponse: DataState = DataState.Loading + private var updateResponse: DataState = DataState.Loading + private var deleteResponse: DataState = DataState.Loading + + fun setLoadSuccess(data: List = 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>> = flow { + loadCallCount++ + emit(loadResponse) + } + + override fun createBeneficiary(payload: BeneficiaryPayload): Flow> = flow { + createCallCount++ + emit(createResponse) + } + + override fun updateBeneficiary(id: Long, payload: BeneficiaryPayload): Flow> = flow { + updateCallCount++ + emit(updateResponse) + } + + override fun deleteBeneficiary(id: Long): Flow> = 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 = DataState.Loading + private var transferResponse: DataState = 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> = flow { + loadTemplateCallCount++ + emit(templateResponse) + } + + override fun makeTransfer(payload: TransferPayload): Flow> = 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> = DataState.Loading + private var markReadResponse: DataState = DataState.Loading + + fun setLoadSuccess(data: List = 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>> = flow { + loadCallCount++ + emit(loadResponse) + } + + override fun markAsRead(id: Long): Flow> = 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 = themeFlow + override fun getLanguage(): Flow = languageFlow + override fun isPasscodeEnabled(): Flow = 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 +``` diff --git a/claude-product-cycle/testing-layer/LAYER_STATUS.md b/claude-product-cycle/testing-layer/LAYER_STATUS.md new file mode 100644 index 000000000..a8ddd4490 --- /dev/null +++ b/claude-product-cycle/testing-layer/LAYER_STATUS.md @@ -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 diff --git a/claude-product-cycle/testing-layer/TEST_FIXTURES_INDEX.md b/claude-product-cycle/testing-layer/TEST_FIXTURES_INDEX.md new file mode 100644 index 000000000..6877b04cb --- /dev/null +++ b/claude-product-cycle/testing-layer/TEST_FIXTURES_INDEX.md @@ -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 = + (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() + 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 = listOf(AccountFixtures.createSavingAccount()), + beneficiaries: List = 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 = + (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() +} +``` + +--- + +## 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 +``` diff --git a/claude-product-cycle/testing-layer/TEST_PATTERNS.md b/claude-product-cycle/testing-layer/TEST_PATTERNS.md new file mode 100644 index 000000000..dc389581d --- /dev/null +++ b/claude-product-cycle/testing-layer/TEST_PATTERNS.md @@ -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> = DataState.Loading + + fun setSuccessResponse(data: List<${Model}>) { + response = DataState.Success(data) + } + + fun setErrorResponse(message: String) { + response = DataState.Error(message) + } + + override fun getData(): Flow>> = 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() + + @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 +``` diff --git a/claude-product-cycle/testing-layer/TEST_TAGS_INDEX.md b/claude-product-cycle/testing-layer/TEST_TAGS_INDEX.md new file mode 100644 index 000000000..e34cee29d --- /dev/null +++ b/claude-product-cycle/testing-layer/TEST_TAGS_INDEX.md @@ -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 +``` diff --git a/claude-product-cycle/testing-layer/patterns/fake-repository.md b/claude-product-cycle/testing-layer/patterns/fake-repository.md new file mode 100644 index 000000000..f2f535831 --- /dev/null +++ b/claude-product-cycle/testing-layer/patterns/fake-repository.md @@ -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> = 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 = 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>> = flow { + loadCallCount++ + emit(loadResponse) + } + + override fun get${Feature}(id: Long): Flow> = flow { + emit(singleResponse) + } + + override fun create${Feature}(payload: ${Model}Payload): Flow> = flow { + createCallCount++ + lastCreatePayload = payload + emit(createResponse) + } + + override fun update${Feature}(id: Long, payload: ${Model}Payload): Flow> = flow { + updateCallCount++ + emit(updateResponse) + } + + override fun delete${Feature}(id: Long): Flow> = 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>>() + private var responseIndex = 0 + + fun addResponse(response: DataState>) { + responses.add(response) + } + + override fun getItems(): Flow>> = 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>> = 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 | diff --git a/claude-product-cycle/testing-layer/patterns/integration-test.md b/claude-product-cycle/testing-layer/patterns/integration-test.md new file mode 100644 index 000000000..d4bb8ffd7 --- /dev/null +++ b/claude-product-cycle/testing-layer/patterns/integration-test.md @@ -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() + + @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 | diff --git a/claude-product-cycle/testing-layer/patterns/screen-test.md b/claude-product-cycle/testing-layer/patterns/screen-test.md new file mode 100644 index 000000000..f8c702d70 --- /dev/null +++ b/claude-product-cycle/testing-layer/patterns/screen-test.md @@ -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 | diff --git a/claude-product-cycle/testing-layer/patterns/screenshot-test.md b/claude-product-cycle/testing-layer/patterns/screenshot-test.md new file mode 100644 index 000000000..f65d4bf33 --- /dev/null +++ b/claude-product-cycle/testing-layer/patterns/screenshot-test.md @@ -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) diff --git a/claude-product-cycle/testing-layer/patterns/viewmodel-test.md b/claude-product-cycle/testing-layer/patterns/viewmodel-test.md new file mode 100644 index 000000000..4644ccd32 --- /dev/null +++ b/claude-product-cycle/testing-layer/patterns/viewmodel-test.md @@ -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 | diff --git a/claude-product-cycle/testing-layer/templates/FakeRepository.kt.template b/claude-product-cycle/testing-layer/templates/FakeRepository.kt.template new file mode 100644 index 000000000..622f71f92 --- /dev/null +++ b/claude-product-cycle/testing-layer/templates/FakeRepository.kt.template @@ -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> = 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 = 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>> = flow { + loadCallCount++ + emit(loadResponse) + } + + override fun get${Feature}(id: Long): Flow> = flow { + getByIdCallCount++ + lastGetId = id + emit(singleResponse) + } + + override fun create${Feature}(payload: ${Model}Payload): Flow> = flow { + createCallCount++ + lastCreatePayload = payload + emit(createResponse) + } + + override fun update${Feature}(id: Long, payload: ${Model}Payload): Flow> = flow { + updateCallCount++ + emit(updateResponse) + } + + override fun delete${Feature}(id: Long): Flow> = 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 + } +} diff --git a/claude-product-cycle/testing-layer/templates/ScreenTest.kt.template b/claude-product-cycle/testing-layer/templates/ScreenTest.kt.template new file mode 100644 index 000000000..070bcd696 --- /dev/null +++ b/claude-product-cycle/testing-layer/templates/ScreenTest.kt.template @@ -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 + // ═══════════════════════════════════════════════════════════════ +} diff --git a/claude-product-cycle/testing-layer/templates/TestTags.kt.template b/claude-product-cycle/testing-layer/templates/TestTags.kt.template new file mode 100644 index 000000000..9620dd7c5 --- /dev/null +++ b/claude-product-cycle/testing-layer/templates/TestTags.kt.template @@ -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" + } +} diff --git a/claude-product-cycle/testing-layer/templates/ViewModelTest.kt.template b/claude-product-cycle/testing-layer/templates/ViewModelTest.kt.template new file mode 100644 index 000000000..8c0e3d1da --- /dev/null +++ b/claude-product-cycle/testing-layer/templates/ViewModelTest.kt.template @@ -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 + // ═══════════════════════════════════════════════════════════════ +} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts new file mode 100644 index 000000000..c9994a752 --- /dev/null +++ b/core/testing/build.gradle.kts @@ -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 + } + } +} diff --git a/core/testing/src/androidMain/kotlin/org/mifos/mobile/core/testing/ComposeTestHelpers.kt b/core/testing/src/androidMain/kotlin/org/mifos/mobile/core/testing/ComposeTestHelpers.kt new file mode 100644 index 000000000..293d733c7 --- /dev/null +++ b/core/testing/src/androidMain/kotlin/org/mifos/mobile/core/testing/ComposeTestHelpers.kt @@ -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 +} diff --git a/core/testing/src/androidMain/kotlin/org/mifos/mobile/core/testing/rule/MainDispatcherTestRule.kt b/core/testing/src/androidMain/kotlin/org/mifos/mobile/core/testing/rule/MainDispatcherTestRule.kt new file mode 100644 index 000000000..7484b72c0 --- /dev/null +++ b/core/testing/src/androidMain/kotlin/org/mifos/mobile/core/testing/rule/MainDispatcherTestRule.kt @@ -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() + } +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/di/TestModule.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/di/TestModule.kt new file mode 100644 index 000000000..b0f7cfff2 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/di/TestModule.kt @@ -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) +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeAccountsRepository.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeAccountsRepository.kt new file mode 100644 index 000000000..933f7bdf4 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeAccountsRepository.kt @@ -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.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) { + 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> { + loadAccountsCallCount++ + lastClientId = clientId + lastAccountType = accountType + return accountsState.asStateFlow() + } +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeBeneficiaryRepository.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeBeneficiaryRepository.kt new file mode 100644 index 000000000..1f2196f56 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeBeneficiaryRepository.kt @@ -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.Success(BeneficiaryTemplate()), + ) + private val beneficiaryListState = MutableStateFlow>>( + DataState.Success(emptyList()), + ) + + private var createResult: DataState = DataState.Success("Beneficiary created") + private var updateResult: DataState = DataState.Success("Beneficiary updated") + private var deleteResult: DataState = 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) { + beneficiaryTemplateState.value = result + } + + fun setBeneficiaryList(result: DataState>) { + beneficiaryListState.value = result + } + + fun emitBeneficiaryListLoading() { + beneficiaryListState.value = DataState.Loading + } + + fun emitBeneficiaryListSuccess(beneficiaries: List) { + beneficiaryListState.value = DataState.Success(beneficiaries) + } + + fun emitBeneficiaryListError(error: Throwable) { + beneficiaryListState.value = DataState.Error(error) + } + + fun setCreateResult(result: DataState) { + createResult = result + } + + fun setUpdateResult(result: DataState) { + updateResult = result + } + + fun setDeleteResult(result: DataState) { + 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> { + return beneficiaryTemplateState.asStateFlow() + } + + override suspend fun createBeneficiary(beneficiaryPayload: BeneficiaryPayload?): DataState { + createCallCount++ + lastCreatedPayload = beneficiaryPayload + return createResult + } + + override suspend fun updateBeneficiary( + beneficiaryId: Long?, + payload: BeneficiaryUpdatePayload?, + ): DataState { + updateCallCount++ + return updateResult + } + + override suspend fun deleteBeneficiary(beneficiaryId: Long?): DataState { + deleteCallCount++ + lastDeletedId = beneficiaryId + return deleteResult + } + + override fun beneficiaryList(): Flow>> { + return beneficiaryListState.asStateFlow() + } +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeHomeRepository.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeHomeRepository.kt new file mode 100644 index 000000000..5f378ed71 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeHomeRepository.kt @@ -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.Success(ClientAccounts()), + ) + private val currentClientState = MutableStateFlow>( + DataState.Success(createDefaultClient()), + ) + private val clientImageState = MutableStateFlow>( + DataState.Success(""), + ) + private val unreadNotificationsState = MutableStateFlow>( + DataState.Success(0), + ) + + fun setClientAccounts(result: DataState) { + 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) { + currentClientState.value = result + } + + fun setClientImage(result: DataState) { + 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> { + return clientAccountsState.asStateFlow() + } + + override fun currentClient(clientId: Long): Flow> { + return currentClientState.asStateFlow() + } + + override fun clientImage(clientId: Long): Flow> { + return clientImageState.asStateFlow() + } + + override fun unreadNotificationsCount(): Flow> { + return unreadNotificationsState.asStateFlow() + } + + private fun createDefaultClient(): Client { + return Client( + id = 1, + displayName = "Test User", + firstname = "Test", + lastname = "User", + ) + } +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeNotificationRepository.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeNotificationRepository.kt new file mode 100644 index 000000000..5ae9a6c1e --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeNotificationRepository.kt @@ -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.Success(emptyList()), + ) + private val unreadCountState = MutableStateFlow>( + DataState.Success(0), + ) + + private val savedNotifications = mutableListOf() + + // Track method calls for verification + var saveCallCount = 0 + private set + var deleteOldCallCount = 0 + private set + var updateReadStatusCallCount = 0 + private set + + fun setNotifications(result: DataState>) { + notificationsState.value = result + } + + fun emitNotificationsLoading() { + notificationsState.value = DataState.Loading + } + + fun emitNotificationsSuccess(notifications: List) { + 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 = 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>> { + return notificationsState.asStateFlow() + } + + override fun getUnReadNotificationCount(): Flow> { + 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++ + } +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeTransferRepository.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeTransferRepository.kt new file mode 100644 index 000000000..054613fa1 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeTransferRepository.kt @@ -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 = 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) { + transferResult = result + } + + fun reset() { + transferResult = DataState.Success("Transfer successful") + transferCallCount = 0 + lastTransferPayload = null + lastTransferType = null + } + + override suspend fun makeTransfer( + payload: TransferPayload, + transferType: TransferType?, + ): DataState { + transferCallCount++ + lastTransferPayload = payload + lastTransferType = transferType + return transferResult + } +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeUserAuthRepository.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeUserAuthRepository.kt new file mode 100644 index 000000000..f389e8107 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/FakeUserAuthRepository.kt @@ -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 = DataState.Success(createDefaultUser()) + private var registerResult: DataState = DataState.Success("Registration successful") + private var verifyResult: DataState = DataState.Success("Verification successful") + private var updatePasswordResult: DataState = 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) { + loginResult = result + } + + fun setRegisterResult(result: DataState) { + registerResult = result + } + + fun setVerifyResult(result: DataState) { + verifyResult = result + } + + fun setUpdatePasswordResult(result: DataState) { + 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 { + return registerResult + } + + override suspend fun login(username: String, password: String): DataState { + loginCallCount++ + lastLoginUsername = username + lastLoginPassword = password + return loginResult + } + + override suspend fun verifyUser( + authenticationToken: String?, + requestId: String?, + ): DataState { + return verifyResult + } + + override suspend fun updateAccountPassword( + newPassword: String, + confirmPassword: String, + ): DataState { + return updatePasswordResult + } + + private fun createDefaultUser(): User { + return User( + userId = 1L, + username = "testuser", + base64EncodedAuthenticationKey = "test-auth-key", + ) + } +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixture/BeneficiaryFixture.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixture/BeneficiaryFixture.kt new file mode 100644 index 000000000..6f40bcf70 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixture/BeneficiaryFixture.kt @@ -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 = (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", + ) +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixture/ClientAccountsFixture.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixture/ClientAccountsFixture.kt new file mode 100644 index 000000000..713408734 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixture/ClientAccountsFixture.kt @@ -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 = emptyList(), + savingsAccounts: List = emptyList(), + shareAccounts: List = 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", + ) +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixture/UserFixture.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixture/UserFixture.kt new file mode 100644 index 000000000..1929fcc20 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixture/UserFixture.kt @@ -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 = arrayListOf(createDefaultRole()), + base64EncodedAuthenticationKey: String = "test-auth-key", + permissions: ArrayList = arrayListOf(), + shouldRenewPassword: Boolean = false, + isTwoFactorAuthenticationRequired: Boolean = false, + clients: ArrayList = 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, + ) +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/rule/MainDispatcherRule.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/rule/MainDispatcherRule.kt new file mode 100644 index 000000000..b1db47da8 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/rule/MainDispatcherRule.kt @@ -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() + } +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/util/FlowTestExtensions.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/util/FlowTestExtensions.kt new file mode 100644 index 000000000..abbf4bdfd --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/util/FlowTestExtensions.kt @@ -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 Flow.assertEmitsInOrder( + testScope: TestScope, + vararg expected: T, +) { + val emissions = mutableListOf() + 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 Flow.assertContainsEmission( + testScope: TestScope, + predicate: (T) -> Boolean, +) { + val emissions = mutableListOf() + 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 Flow.firstEmission(): T = first() + +/** + * Collects emissions until the predicate is satisfied. + */ +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun Flow.collectUntil( + testScope: TestScope, + predicate: (T) -> Boolean, +): List { + val emissions = mutableListOf() + val job = testScope.launch(UnconfinedTestDispatcher(testScope.testScheduler)) { + collect { value -> + emissions.add(value) + if (predicate(value)) { + return@collect + } + } + } + + testScope.advanceUntilIdle() + job.cancel() + + return emissions +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/util/TestCoroutineExtensions.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/util/TestCoroutineExtensions.kt new file mode 100644 index 000000000..dca50afec --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/util/TestCoroutineExtensions.kt @@ -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() +} diff --git a/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/util/TestTags.kt b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/util/TestTags.kt new file mode 100644 index 000000000..735ff605a --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/util/TestTags.kt @@ -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" + } +} diff --git a/core/testing/src/desktopMain/kotlin/org/mifos/mobile/core/testing/DesktopTestUtils.kt b/core/testing/src/desktopMain/kotlin/org/mifos/mobile/core/testing/DesktopTestUtils.kt new file mode 100644 index 000000000..d8def7b0b --- /dev/null +++ b/core/testing/src/desktopMain/kotlin/org/mifos/mobile/core/testing/DesktopTestUtils.kt @@ -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 +} diff --git a/core/testing/src/iosMain/kotlin/org/mifos/mobile/core/testing/IosTestUtils.kt b/core/testing/src/iosMain/kotlin/org/mifos/mobile/core/testing/IosTestUtils.kt new file mode 100644 index 000000000..71bca808e --- /dev/null +++ b/core/testing/src/iosMain/kotlin/org/mifos/mobile/core/testing/IosTestUtils.kt @@ -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 +} diff --git a/core/testing/src/nativeMain/kotlin/org/mifos/mobile/core/testing/NativeTestUtils.kt b/core/testing/src/nativeMain/kotlin/org/mifos/mobile/core/testing/NativeTestUtils.kt new file mode 100644 index 000000000..d60862d66 --- /dev/null +++ b/core/testing/src/nativeMain/kotlin/org/mifos/mobile/core/testing/NativeTestUtils.kt @@ -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 +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 42e83e9a7..e6d7e6ff3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -54,7 +54,7 @@ include(":core:network") include(":core:database") include(":core:datastore") include(":core:qrcode") -//include(":core:testing") +include(":core:testing") include(":core-base:datastore") include(":core-base:common")