mirror of
https://github.com/openMF/mifos-mobile.git
synced 2026-02-06 11:26:51 +00:00
feat(testing): add core:testing module with fakes, fixtures, and TestTags (#3059)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cd556ffc99
commit
b351148ac2
@ -47,6 +47,13 @@ GAP PLANNING (What needs work?)
|
|||||||
│ └── /gap-planning platform web
|
│ └── /gap-planning platform web
|
||||||
└── /gap-planning [feature] # Plan specific feature (all layers)
|
└── /gap-planning [feature] # Plan specific feature (all layers)
|
||||||
|
|
||||||
|
GAP STATUS (Track plan progress)
|
||||||
|
├── /gap-status # Show all active plans
|
||||||
|
├── /gap-status [plan-name] # Show specific plan progress
|
||||||
|
├── /gap-status complete [plan] # Mark plan as complete
|
||||||
|
├── /gap-status pause [plan] # Pause a plan
|
||||||
|
└── /gap-status resume [plan] # Resume a paused plan
|
||||||
|
|
||||||
DESIGN LAYER (Specifications & Mockups)
|
DESIGN LAYER (Specifications & Mockups)
|
||||||
├── /design # Show feature list
|
├── /design # Show feature list
|
||||||
├── /design [feature] # Full spec review/create
|
├── /design [feature] # Full spec review/create
|
||||||
@ -94,6 +101,16 @@ VERIFICATION
|
|||||||
| `/gap-planning client network` | Network services plan | Service implementation tasks |
|
| `/gap-planning client network` | Network services plan | Service implementation tasks |
|
||||||
| `/gap-planning feature [name]` | Feature implementation plan | v2.0 UI update tasks |
|
| `/gap-planning feature [name]` | Feature implementation plan | v2.0 UI update tasks |
|
||||||
|
|
||||||
|
### Gap Status
|
||||||
|
|
||||||
|
| Command | Purpose | Output |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| `/gap-status` | Show all active plans | Summary table with progress bars |
|
||||||
|
| `/gap-status [plan]` | Show specific plan | Detailed steps, current step, progress log |
|
||||||
|
| `/gap-status complete [plan]` | Mark plan done | Move to completed, update index |
|
||||||
|
| `/gap-status pause [plan]` | Pause a plan | Mark as paused with reason |
|
||||||
|
| `/gap-status resume [plan]` | Resume plan | Mark as active again |
|
||||||
|
|
||||||
### Design Layer
|
### Design Layer
|
||||||
|
|
||||||
| Command | Purpose | Output |
|
| Command | Purpose | Output |
|
||||||
|
|||||||
@ -1,131 +1,351 @@
|
|||||||
# /client - Client Layer Implementation
|
# /client - Client Layer Implementation
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Implement the client layer (Network + Data) for a feature. This includes DTOs, Services, and Repositories.
|
|
||||||
|
Implement the client layer (Network + Data) using O(1) lookup and pattern detection. Creates Services, Repositories, and DI registration with code matching existing codebase conventions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workflow
|
## Command Variants
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────────────────────────────────────────────────────────┐
|
/client # Show client layer status
|
||||||
│ /client [Feature] WORKFLOW │
|
/client [Feature] # Implement client layer for feature
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
/client [Feature] --network # Network layer only (Service)
|
||||||
│ │
|
/client [Feature] --data # Data layer only (Repository)
|
||||||
│ STEP 1: READ SPEC │
|
|
||||||
│ ├─→ Read features/[feature]/SPEC.md │
|
|
||||||
│ ├─→ Read features/[feature]/API.md │
|
|
||||||
│ └─→ Read server-layer/FINERACT_API.md │
|
|
||||||
│ │
|
|
||||||
│ STEP 2: CHECK EXISTING CODE │
|
|
||||||
│ ├─→ Check core/network/services/ for existing service │
|
|
||||||
│ ├─→ Check core/data/repository/ for existing repository │
|
|
||||||
│ └─→ Identify what needs to be created/updated │
|
|
||||||
│ │
|
|
||||||
│ STEP 3: NETWORK LAYER │
|
|
||||||
│ ├─→ Create/update DTOs in core/network/model/ (if needed) │
|
|
||||||
│ ├─→ Create/update Service interface in core/network/services/ │
|
|
||||||
│ └─→ Register in NetworkModule │
|
|
||||||
│ │
|
|
||||||
│ STEP 4: DATA LAYER │
|
|
||||||
│ ├─→ Create/update Repository interface │
|
|
||||||
│ ├─→ Create/update RepositoryImpl │
|
|
||||||
│ └─→ Register in DataModule │
|
|
||||||
│ │
|
|
||||||
│ STEP 5: BUILD & VERIFY │
|
|
||||||
│ ├─→ ./gradlew :core:network:build │
|
|
||||||
│ ├─→ ./gradlew :core:data:build │
|
|
||||||
│ └─→ ./gradlew spotlessApply │
|
|
||||||
│ │
|
|
||||||
└───────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## File Locations
|
## Workflow with O(1) Optimization
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ /client [Feature] - O(1) OPTIMIZED WORKFLOW │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ PHASE 0: O(1) CONTEXT LOADING │
|
||||||
|
│ ├─→ Read FEATURE_MAP.md → Check if service/repo exist │
|
||||||
|
│ ├─→ Read API_INDEX.md → Get endpoint definitions │
|
||||||
|
│ ├─→ Read features/[name]/API.md → Get feature-specific endpoints │
|
||||||
|
│ └─→ Read features/[name]/SPEC.md → Get data requirements │
|
||||||
|
│ │
|
||||||
|
│ PHASE 1: PATTERN DETECTION │
|
||||||
|
│ ├─→ Read existing Service → Extract interface pattern │
|
||||||
|
│ ├─→ Read existing Repository → Extract implementation pattern │
|
||||||
|
│ └─→ Read NetworkModule/DataModule → Extract DI pattern │
|
||||||
|
│ │
|
||||||
|
│ PHASE 2: NETWORK LAYER (if needed) │
|
||||||
|
│ ├─→ Check FEATURE_MAP for existing → Skip if exists │
|
||||||
|
│ ├─→ Create Service interface → Pattern-matched code │
|
||||||
|
│ └─→ Register in NetworkModule → DI registration │
|
||||||
|
│ │
|
||||||
|
│ PHASE 3: DATA LAYER (if needed) │
|
||||||
|
│ ├─→ Check FEATURE_MAP for existing → Skip if exists │
|
||||||
|
│ ├─→ Create Repository interface → Pattern-matched code │
|
||||||
|
│ ├─→ Create RepositoryImpl → Pattern-matched code │
|
||||||
|
│ └─→ Register in RepositoryModule → DI registration │
|
||||||
|
│ │
|
||||||
|
│ PHASE 4: BUILD & VERIFY │
|
||||||
|
│ ├─→ ./gradlew :core:network:build │
|
||||||
|
│ ├─→ ./gradlew :core:data:build │
|
||||||
|
│ ├─→ ./gradlew spotlessApply │
|
||||||
|
│ └─→ Update FEATURE_MAP.md → Maintain O(1) index │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 0: O(1) Context Loading
|
||||||
|
|
||||||
|
### Files to Read
|
||||||
|
|
||||||
|
| File | Purpose | Data Extracted |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| `client-layer/FEATURE_MAP.md` | Service/Repo inventory | existingServices[], existingRepos[] |
|
||||||
|
| `server-layer/API_INDEX.md` | All API endpoints | endpoints[], dtos[] |
|
||||||
|
| `design-spec-layer/features/[name]/API.md` | Feature endpoints | featureEndpoints[] |
|
||||||
|
| `design-spec-layer/features/[name]/SPEC.md` | Data requirements | models[], fields[] |
|
||||||
|
|
||||||
|
### Decision Matrix (from FEATURE_MAP.md lookup)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| Component | Exists | Action |
|
||||||
|
|-----------|:------:|--------|
|
||||||
|
| ${Feature}Service | ✅/❌ | SKIP/CREATE |
|
||||||
|
| ${Feature}Repository | ✅/❌ | SKIP/CREATE |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 1: Pattern Detection
|
||||||
|
|
||||||
|
### Reference Files
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Service Reference:
|
||||||
|
core/network/src/commonMain/.../services/BeneficiaryService.kt
|
||||||
|
|
||||||
|
2. Repository Reference:
|
||||||
|
core/data/src/commonMain/.../repository/BeneficiaryRepository.kt
|
||||||
|
core/data/src/commonMain/.../repository/BeneficiaryRepositoryImp.kt
|
||||||
|
|
||||||
|
3. DI Reference:
|
||||||
|
core/network/src/commonMain/.../di/NetworkModule.kt
|
||||||
|
core/data/src/commonMain/.../di/RepositoryModule.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extracted Patterns
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Service Pattern
|
||||||
|
val servicePattern = ServicePattern(
|
||||||
|
returnFlow = "Flow<Type>", // GET returns Flow
|
||||||
|
returnSuspend = "HttpResponse", // POST/PUT/DELETE returns HttpResponse
|
||||||
|
pathAnnotation = "@Path(\"id\")",
|
||||||
|
bodyAnnotation = "@Body",
|
||||||
|
endpointConstant = "ApiEndPoints.CONSTANT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository Pattern
|
||||||
|
val repoPattern = RepositoryPattern(
|
||||||
|
interfaceReturn = "Flow<DataState<T>>",
|
||||||
|
implUsesFlow = "= flow { emit(...) }",
|
||||||
|
loadingEmit = "emit(DataState.Loading)",
|
||||||
|
successEmit = "emit(DataState.Success(data))",
|
||||||
|
errorEmit = "emit(DataState.Error(e.message ?: \"Unknown error\"))"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DI Pattern
|
||||||
|
val diPattern = DiPattern(
|
||||||
|
serviceDeclaration = "single<Service> { get<Ktorfit>().create<Service>() }",
|
||||||
|
repoDeclaration = "single<Repository> { RepositoryImp(get()) }"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2: Network Layer
|
||||||
|
|
||||||
|
### File Locations
|
||||||
|
|
||||||
| Component | Location |
|
| Component | Location |
|
||||||
|-----------|----------|
|
|-----------|----------|
|
||||||
| DTOs | `core/network/model/` |
|
| Service Interface | `core/network/src/commonMain/kotlin/org/mifos/mobile/core/network/services/` |
|
||||||
| Service Interface | `core/network/services/` |
|
| API Endpoints | `core/network/src/commonMain/kotlin/org/mifos/mobile/core/network/ApiEndPoints.kt` |
|
||||||
| Repository Interface | `core/data/repository/` |
|
| Network DI | `core/network/src/commonMain/kotlin/org/mifos/mobile/core/network/di/NetworkModule.kt` |
|
||||||
| Repository Impl | `core/data/repositoryImpl/` |
|
|
||||||
| Network DI | `core/network/di/NetworkModule.kt` |
|
|
||||||
| Data DI | `core/data/di/DataModule.kt` |
|
|
||||||
|
|
||||||
---
|
### Service Template (Pattern-Matched)
|
||||||
|
|
||||||
## Service Pattern
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// core/network/services/[Feature]Service.kt
|
/*
|
||||||
interface [Feature]Service {
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.network.services
|
||||||
|
|
||||||
@GET(ApiEndPoints.[ENDPOINT])
|
import de.jensklingenberg.ktorfit.http.Body
|
||||||
fun getData(): Flow<DataType>
|
import de.jensklingenberg.ktorfit.http.DELETE
|
||||||
|
import de.jensklingenberg.ktorfit.http.GET
|
||||||
|
import de.jensklingenberg.ktorfit.http.POST
|
||||||
|
import de.jensklingenberg.ktorfit.http.PUT
|
||||||
|
import de.jensklingenberg.ktorfit.http.Path
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.mifos.mobile.core.network.ApiEndPoints
|
||||||
|
import org.mifos.mobile.core.network.model.${Dto}
|
||||||
|
|
||||||
@GET(ApiEndPoints.[ENDPOINT] + "/{id}")
|
interface ${Feature}Service {
|
||||||
fun getById(@Path("id") id: Long): Flow<DataType>
|
|
||||||
|
|
||||||
@POST(ApiEndPoints.[ENDPOINT])
|
@GET(ApiEndPoints.${ENDPOINT_CONSTANT})
|
||||||
suspend fun create(@Body payload: PayloadType): HttpResponse
|
fun get${Feature}List(): Flow<List<${Dto}>>
|
||||||
|
|
||||||
@PUT(ApiEndPoints.[ENDPOINT] + "/{id}")
|
@GET(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}")
|
||||||
suspend fun update(
|
fun get${Feature}ById(@Path("id") id: Long): Flow<${Dto}>
|
||||||
|
|
||||||
|
@POST(ApiEndPoints.${ENDPOINT_CONSTANT})
|
||||||
|
suspend fun create${Feature}(@Body payload: ${Payload}): HttpResponse
|
||||||
|
|
||||||
|
@PUT(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}")
|
||||||
|
suspend fun update${Feature}(
|
||||||
@Path("id") id: Long,
|
@Path("id") id: Long,
|
||||||
@Body payload: PayloadType,
|
@Body payload: ${Payload},
|
||||||
): HttpResponse
|
): HttpResponse
|
||||||
|
|
||||||
@DELETE(ApiEndPoints.[ENDPOINT] + "/{id}")
|
@DELETE(ApiEndPoints.${ENDPOINT_CONSTANT} + "/{id}")
|
||||||
suspend fun delete(@Path("id") id: Long): HttpResponse
|
suspend fun delete${Feature}(@Path("id") id: Long): HttpResponse
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Endpoint Constant (if needed)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ApiEndPoints.kt
|
||||||
|
object ApiEndPoints {
|
||||||
|
// ... existing constants
|
||||||
|
const val ${ENDPOINT_CONSTANT} = "${endpoint_path}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register in NetworkModule
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// NetworkModule.kt
|
||||||
|
val networkModule = module {
|
||||||
|
// ... existing registrations
|
||||||
|
single<${Feature}Service> { get<Ktorfit>().create<${Feature}Service>() }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Repository Pattern
|
## PHASE 3: Data Layer
|
||||||
|
|
||||||
|
### File Locations
|
||||||
|
|
||||||
|
| Component | Location |
|
||||||
|
|-----------|----------|
|
||||||
|
| Repository Interface | `core/data/src/commonMain/kotlin/org/mifos/mobile/core/data/repository/` |
|
||||||
|
| Repository Impl | `core/data/src/commonMain/kotlin/org/mifos/mobile/core/data/repository/` |
|
||||||
|
| Data DI | `core/data/src/commonMain/kotlin/org/mifos/mobile/core/data/di/RepositoryModule.kt` |
|
||||||
|
|
||||||
|
### Repository Interface Template
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// core/data/repository/[Feature]Repository.kt
|
/*
|
||||||
interface [Feature]Repository {
|
* Copyright 2024 Mifos Initiative
|
||||||
fun getData(): Flow<DataState<List<Data>>>
|
*
|
||||||
fun getById(id: Long): Flow<DataState<Data>>
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
suspend fun create(data: Data): DataState<Unit>
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
suspend fun update(id: Long, data: Data): DataState<Unit>
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
suspend fun delete(id: Long): DataState<Unit>
|
*/
|
||||||
|
package org.mifos.mobile.core.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
import org.mifos.mobile.core.model.${Model}
|
||||||
|
|
||||||
|
interface ${Feature}Repository {
|
||||||
|
fun get${Feature}List(): Flow<DataState<List<${Model}>>>
|
||||||
|
fun get${Feature}ById(id: Long): Flow<DataState<${Model}>>
|
||||||
|
suspend fun create${Feature}(data: ${Model}): DataState<Unit>
|
||||||
|
suspend fun update${Feature}(id: Long, data: ${Model}): DataState<Unit>
|
||||||
|
suspend fun delete${Feature}(id: Long): DataState<Unit>
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
// core/data/repositoryImpl/[Feature]RepositoryImpl.kt
|
### Repository Implementation Template
|
||||||
class [Feature]RepositoryImpl(
|
|
||||||
private val service: [Feature]Service,
|
|
||||||
) : [Feature]Repository {
|
|
||||||
|
|
||||||
override fun getData(): Flow<DataState<List<Data>>> = flow {
|
```kotlin
|
||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
import org.mifos.mobile.core.model.${Model}
|
||||||
|
import org.mifos.mobile.core.network.services.${Feature}Service
|
||||||
|
|
||||||
|
class ${Feature}RepositoryImp(
|
||||||
|
private val ${feature}Service: ${Feature}Service,
|
||||||
|
) : ${Feature}Repository {
|
||||||
|
|
||||||
|
override fun get${Feature}List(): Flow<DataState<List<${Model}>>> = flow {
|
||||||
emit(DataState.Loading)
|
emit(DataState.Loading)
|
||||||
try {
|
try {
|
||||||
val result = service.getData().first()
|
val result = ${feature}Service.get${Feature}List().first()
|
||||||
emit(DataState.Success(result))
|
emit(DataState.Success(result))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
emit(DataState.Error(e.message ?: "Unknown error"))
|
emit(DataState.Error(e.message ?: "Unknown error"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun get${Feature}ById(id: Long): Flow<DataState<${Model}>> = flow {
|
||||||
|
emit(DataState.Loading)
|
||||||
|
try {
|
||||||
|
val result = ${feature}Service.get${Feature}ById(id).first()
|
||||||
|
emit(DataState.Success(result))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emit(DataState.Error(e.message ?: "Unknown error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun create${Feature}(data: ${Model}): DataState<Unit> {
|
||||||
|
return try {
|
||||||
|
${feature}Service.create${Feature}(data.toPayload())
|
||||||
|
DataState.Success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DataState.Error(e.message ?: "Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun update${Feature}(id: Long, data: ${Model}): DataState<Unit> {
|
||||||
|
return try {
|
||||||
|
${feature}Service.update${Feature}(id, data.toPayload())
|
||||||
|
DataState.Success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DataState.Error(e.message ?: "Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete${Feature}(id: Long): DataState<Unit> {
|
||||||
|
return try {
|
||||||
|
${feature}Service.delete${Feature}(id)
|
||||||
|
DataState.Success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DataState.Error(e.message ?: "Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register in RepositoryModule
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// RepositoryModule.kt
|
||||||
|
val repositoryModule = module {
|
||||||
|
// ... existing registrations
|
||||||
|
single<${Feature}Repository> { ${Feature}RepositoryImp(get()) }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## DI Registration
|
## PHASE 4: Build & Verify
|
||||||
|
|
||||||
```kotlin
|
### Build Commands
|
||||||
// core/network/di/NetworkModule.kt
|
|
||||||
val networkModule = module {
|
|
||||||
single<[Feature]Service> { get<Ktorfit>().create<[Feature]Service>() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// core/data/di/DataModule.kt
|
```bash
|
||||||
val dataModule = module {
|
# Build network module
|
||||||
single<[Feature]Repository> { [Feature]RepositoryImpl(get()) }
|
./gradlew :core:network:build
|
||||||
}
|
|
||||||
|
# Build data module
|
||||||
|
./gradlew :core:data:build
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
./gradlew spotlessApply --no-configuration-cache
|
||||||
|
|
||||||
|
# Run detekt
|
||||||
|
./gradlew detekt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update FEATURE_MAP.md
|
||||||
|
|
||||||
|
Add new entry to maintain O(1) lookup:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| ${feature} | ${Feature}Service | ${Feature}Repository | ${Notes} |
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -133,24 +353,150 @@ val dataModule = module {
|
|||||||
## Output Template
|
## Output Template
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│ ✅ CLIENT LAYER COMPLETE │
|
│ ✅ CLIENT LAYER COMPLETE │
|
||||||
├──────────────────────────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ Created/Updated: │
|
│ 📚 O(1) Context Used: │
|
||||||
│ ├─ core/network/services/[Feature]Service.kt │
|
│ ├─ FEATURE_MAP.md → Checked existing: [existing services/repos] │
|
||||||
│ ├─ core/data/repository/[Feature]Repository.kt │
|
│ ├─ API_INDEX.md → Mapped [n] endpoints │
|
||||||
│ └─ core/data/repositoryImpl/[Feature]RepositoryImpl.kt │
|
│ └─ API.md → Feature endpoints: [list] │
|
||||||
│ │
|
│ │
|
||||||
│ Registered in DI: │
|
│ 📊 Pattern Matching: │
|
||||||
│ ├─ NetworkModule: [Feature]Service ✅ │
|
│ ├─ Service pattern from: BeneficiaryService.kt │
|
||||||
│ └─ DataModule: [Feature]Repository ✅ │
|
│ └─ Repository pattern from: BeneficiaryRepositoryImp.kt │
|
||||||
│ │
|
│ │
|
||||||
│ 🔨 BUILD: :core:network ✅ :core:data ✅ │
|
│ 🔧 Network Layer: │
|
||||||
│ 🧹 LINT: spotlessApply ✅ │
|
│ ├─ ${Feature}Service.kt [CREATED|SKIPPED] │
|
||||||
│ │
|
│ ├─ ApiEndPoints.${CONSTANT} [ADDED|EXISTS] │
|
||||||
├──────────────────────────────────────────────────────────────────────┤
|
│ └─ NetworkModule registration [ADDED|EXISTS] │
|
||||||
│ NEXT STEP: │
|
│ │
|
||||||
│ Run: /feature [Feature] │
|
│ 🔧 Data Layer: │
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
│ ├─ ${Feature}Repository.kt [CREATED|SKIPPED] │
|
||||||
|
│ ├─ ${Feature}RepositoryImp.kt [CREATED|SKIPPED] │
|
||||||
|
│ └─ RepositoryModule registration [ADDED|EXISTS] │
|
||||||
|
│ │
|
||||||
|
│ 📋 Index Updated: │
|
||||||
|
│ └─ FEATURE_MAP.md [UPDATED] │
|
||||||
|
│ │
|
||||||
|
│ 🔨 BUILD: │
|
||||||
|
│ ├─ :core:network ✅ │
|
||||||
|
│ └─ :core:data ✅ │
|
||||||
|
│ │
|
||||||
|
│ 🧹 LINT: spotlessApply ✅ │
|
||||||
|
│ │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ NEXT STEP: │
|
||||||
|
│ Run: /feature ${Feature} │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client Status (No Argument)
|
||||||
|
|
||||||
|
When `/client` called without arguments, read FEATURE_MAP.md:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 📋 CLIENT LAYER STATUS (from FEATURE_MAP.md) │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Summary: 13 services | 17 repositories | 2 DI modules │
|
||||||
|
│ │
|
||||||
|
│ | Feature | Service | Repository | Status │ │
|
||||||
|
│ |-----------------|-------------------|--------------------|-----------│ │
|
||||||
|
│ | auth | AuthenticationSvc | UserAuthRepository | ✅ Complete│ │
|
||||||
|
│ | home | ClientService | HomeRepository | ✅ Complete│ │
|
||||||
|
│ | accounts | ClientService | AccountsRepository | ✅ Complete│ │
|
||||||
|
│ | beneficiary | BeneficiaryService| BeneficiaryRepo | ✅ Complete│ │
|
||||||
|
│ | ... │
|
||||||
|
│ │
|
||||||
|
│ Commands: │
|
||||||
|
│ • /client [feature] → Implement client layer │
|
||||||
|
│ • /gap-analysis client → Check for gaps │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Missing API Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ⚠️ MISSING API ENDPOINT │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Feature: ${feature} │
|
||||||
|
│ Expected: API.md with endpoint definitions │
|
||||||
|
│ Found: File missing or empty │
|
||||||
|
│ │
|
||||||
|
│ Options: │
|
||||||
|
│ • d / design → Run /design ${feature} api first │
|
||||||
|
│ • m / manual → Enter endpoints manually │
|
||||||
|
│ • a / abort → Cancel implementation │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Failure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ❌ BUILD FAILED: :core:network │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Error: Unresolved reference: ${Dto} │
|
||||||
|
│ │
|
||||||
|
│ 📍 Auto-Fix Suggestion: │
|
||||||
|
│ Create DTO in core/network/model/: │
|
||||||
|
│ │
|
||||||
|
│ ```kotlin │
|
||||||
|
│ @Serializable │
|
||||||
|
│ data class ${Dto}( │
|
||||||
|
│ val id: Long, │
|
||||||
|
│ // ... fields from API.md │
|
||||||
|
│ ) │
|
||||||
|
│ ``` │
|
||||||
|
│ │
|
||||||
|
│ Options: │
|
||||||
|
│ • f / fix → Create DTO and rebuild │
|
||||||
|
│ • m / manual → Show full DTO template │
|
||||||
|
│ • a / abort → Stop implementation │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
### O(1) Index Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `client-layer/FEATURE_MAP.md` | Service/Repository inventory |
|
||||||
|
| `server-layer/API_INDEX.md` | All API endpoints |
|
||||||
|
| `client-layer/LAYER_STATUS.md` | Implementation status |
|
||||||
|
|
||||||
|
### Reference Code
|
||||||
|
|
||||||
|
| Component | Reference File |
|
||||||
|
|-----------|----------------|
|
||||||
|
| Service | `BeneficiaryService.kt` |
|
||||||
|
| Repository | `BeneficiaryRepositoryImp.kt` |
|
||||||
|
| DI | `NetworkModule.kt`, `RepositoryModule.kt` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/feature [Feature]` | Feature layer (ViewModel + Screen) |
|
||||||
|
| `/implement [Feature]` | Full E2E (Client + Feature) |
|
||||||
|
| `/gap-analysis client` | Check client layer gaps |
|
||||||
|
| `/verify [Feature]` | Verify implementation |
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# /design - Feature Specification
|
# /design - Feature Specification (O(1) Enhanced)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Create or update feature specifications (SPEC.md + API.md) that define what to build and how to build it.
|
Create or update feature specifications (SPEC.md + API.md) that define what to build and how to build it.
|
||||||
@ -8,52 +8,196 @@ Create or update feature specifications (SPEC.md + API.md) that define what to b
|
|||||||
## Command Variants
|
## Command Variants
|
||||||
|
|
||||||
```
|
```
|
||||||
/design → Show feature list
|
/design # Show feature list with status (O(1)
|
||||||
/design [Feature] → Full spec review/create
|
/design [Feature] # Full spec review/create
|
||||||
/design [Feature] add [section] → Add specific section
|
/design [Feature] add [section] # Add specific section
|
||||||
/design [Feature] improve → Suggest improvements
|
/design [Feature] improve # Suggest improvements
|
||||||
/design [Feature] mockup → Generate Figma mockups for feature (NEW)
|
/design [Feature] mockup # Generate Figma mockups for feature
|
||||||
/design mockup → Generate Figma mockups for all features (NEW)
|
/design mockup # Generate Figma mockups for all features
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mockup Sub-Command
|
## O(1) Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| /design WORKFLOW (O(1) ENHANCED) |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| |
|
||||||
|
| PHASE 0: O(1) CONTEXT LOADING (~300 lines total) |
|
||||||
|
| +--> Read FEATURES_INDEX.md --> Feature exists? SPEC/API status? |
|
||||||
|
| +--> Read MOCKUPS_INDEX.md --> Mockup status (4 file types) |
|
||||||
|
| +--> Read API_INDEX.md --> All endpoints for reference |
|
||||||
|
| +--> O(1) path: features/[name]/ --> Direct file access |
|
||||||
|
| |
|
||||||
|
| PHASE 1: FEATURE STATUS (From Index) |
|
||||||
|
| +--> Check if feature exists in FEATURES_INDEX |
|
||||||
|
| +--> Get SPEC/API/STATUS/Mockups status from index |
|
||||||
|
| +--> Determine: Create new vs Update existing |
|
||||||
|
| |
|
||||||
|
| PHASE 2: GATHER CONTEXT (O(1) Paths) |
|
||||||
|
| +--> Read features/[feature]/SPEC.md (if exists) |
|
||||||
|
| +--> Read features/[feature]/API.md (if exists) |
|
||||||
|
| +--> Read features/[feature]/STATUS.md (if exists) |
|
||||||
|
| +--> Lookup API endpoints from API_INDEX.md |
|
||||||
|
| |
|
||||||
|
| PHASE 3: ANALYZE & UPDATE |
|
||||||
|
| +--> Compare current spec vs requirements |
|
||||||
|
| +--> Identify gaps, outdated sections |
|
||||||
|
| +--> Update/create spec files |
|
||||||
|
| |
|
||||||
|
| PHASE 4: INDEX UPDATE (Mandatory) |
|
||||||
|
| +--> Update FEATURES_INDEX.md (if new feature) |
|
||||||
|
| +--> Update STATUS.md (layer status) |
|
||||||
|
| +--> Update feature STATUS.md |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: O(1) Context Loading
|
||||||
|
|
||||||
|
### Index Files to Read
|
||||||
|
|
||||||
|
| File | Purpose | Lines |
|
||||||
|
|------|---------|:-----:|
|
||||||
|
| `design-spec-layer/FEATURES_INDEX.md` | All features + SPEC/API status | ~120 |
|
||||||
|
| `design-spec-layer/MOCKUPS_INDEX.md` | Mockup completion matrix | ~150 |
|
||||||
|
| `server-layer/API_INDEX.md` | All API endpoints | ~400 |
|
||||||
|
|
||||||
|
### O(1) Path Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
features/[name]/SPEC.md # Specification
|
||||||
|
features/[name]/API.md # API requirements
|
||||||
|
features/[name]/STATUS.md # Feature status
|
||||||
|
features/[name]/MOCKUP.md # v2.0 ASCII mockup
|
||||||
|
features/[name]/mockups/ # Generated mockup files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If No Feature Name Provided
|
||||||
|
|
||||||
|
Read from FEATURES_INDEX.md and show:
|
||||||
|
|
||||||
|
```
|
||||||
|
+========================================================================+
|
||||||
|
| DESIGN LAYER - FEATURE STATUS (O(1) Lookup) |
|
||||||
|
+========================================================================+
|
||||||
|
|
||||||
|
| # | Feature | SPEC | API | STATUS | Mockups | Command |
|
||||||
|
|:-:|---------|:----:|:---:|:------:|:-------:|---------|
|
||||||
|
| 1 | accounts | [s] | [a] | [st] | [m] | /design accounts |
|
||||||
|
| 2 | auth | [s] | [a] | [st] | [m] | /design auth |
|
||||||
|
| ... (all from FEATURES_INDEX.md)
|
||||||
|
|
||||||
|
Legend: [s]=SPEC [a]=API [st]=STATUS [m]=Mockups
|
||||||
|
|
||||||
|
**Design Progress**: {complete}/{total} features ({percentage}%)
|
||||||
|
|
||||||
|
+------------------------------------------------------------------------+
|
||||||
|
| QUICK ACTIONS |
|
||||||
|
+------------------------------------------------------------------------+
|
||||||
|
| Create/Update Spec | /design [feature] |
|
||||||
|
| Generate Mockups | /design [feature] mockup |
|
||||||
|
| All Mockups | /design mockup |
|
||||||
|
| Improve Feature | /design [feature] improve |
|
||||||
|
+------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mockup Sub-Command (O(1) Enhanced)
|
||||||
|
|
||||||
### `/design [Feature] mockup`
|
### `/design [Feature] mockup`
|
||||||
|
|
||||||
Generates Figma-ready mockups from the feature's MOCKUP.md specification.
|
```
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| /design [Feature] mockup WORKFLOW |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| |
|
||||||
|
| PHASE 0: O(1) STATUS CHECK |
|
||||||
|
| +--> Read MOCKUPS_INDEX.md |
|
||||||
|
| +--> Check feature row: FIGMA | PROMPTS_FIGMA | PROMPTS_STITCH | tokens|
|
||||||
|
| +--> Identify: What exists? What's missing? |
|
||||||
|
| |
|
||||||
|
| PHASE 1: MCP & TOOL CHECK |
|
||||||
|
| +--> Check MCP: claude mcp list |
|
||||||
|
| +--> If stitch-ai configured: Use Google Stitch |
|
||||||
|
| +--> If figma configured: Use Figma MCP |
|
||||||
|
| +--> Otherwise: Ask user to select tool |
|
||||||
|
| |
|
||||||
|
| PHASE 2: READ MOCKUP.md |
|
||||||
|
| +--> Read features/[feature]/MOCKUP.md (v2.0 ASCII design) |
|
||||||
|
| +--> Parse screen layouts, components, colors |
|
||||||
|
| +--> Identify all screens and UI elements |
|
||||||
|
| |
|
||||||
|
| PHASE 3: GENERATE OUTPUTS |
|
||||||
|
| +--> If missing: Generate PROMPTS_FIGMA.md |
|
||||||
|
| +--> If missing: Generate PROMPTS_STITCH.md |
|
||||||
|
| +--> If missing: Generate design-tokens.json |
|
||||||
|
| +--> Skip files that already exist (from MOCKUPS_INDEX) |
|
||||||
|
| |
|
||||||
|
| PHASE 4: INDEX UPDATE |
|
||||||
|
| +--> Update MOCKUPS_INDEX.md with new status |
|
||||||
|
| +--> Update FEATURES_INDEX.md Mockups column |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
**Before Running**: Check MCP connections and select AI tool:
|
### `/design mockup` (All Features)
|
||||||
|
|
||||||
#### Step 0: Check MCP & Select Tool
|
Uses O(1) lookup from MOCKUPS_INDEX.md to identify all gaps:
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| MOCKUP GENERATION STATUS (from MOCKUPS_INDEX.md) |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
|
||||||
|
| Feature | FIGMA | PROMPTS_F | PROMPTS_S | Tokens | Status |
|
||||||
|
|---------|:-----:|:---------:|:---------:|:------:|--------|
|
||||||
|
| auth | [x] | [x] | [x] | [x] | Complete |
|
||||||
|
| dashboard | [ ] | [x] | [x] | [x] | Need FIGMA |
|
||||||
|
| accounts | [ ] | [x] | [x] | [ ] | Need FIGMA, tokens |
|
||||||
|
| ... (from MOCKUPS_INDEX)
|
||||||
|
|
||||||
|
**Summary**:
|
||||||
|
- Complete: {n} features
|
||||||
|
- Need FIGMA_LINKS: {n} features
|
||||||
|
- Need Prompts: {n} features
|
||||||
|
- Need Tokens: {n} features
|
||||||
|
|
||||||
|
**Next Step**: Generate missing files for [first-incomplete-feature]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool Selection
|
||||||
|
|
||||||
|
### Check MCP First
|
||||||
|
|
||||||
**Check MCP Status**:
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp list
|
claude mcp list
|
||||||
```
|
```
|
||||||
|
|
||||||
**AI Design Tools Available**:
|
### AI Design Tools
|
||||||
|
|
||||||
| Tool | MCP | Best For | Setup Command |
|
| Tool | MCP | Best For | Setup |
|
||||||
|------|:---:|----------|---------------|
|
|------|:---:|----------|-------|
|
||||||
| **Google Stitch** | ✅ | Material Design 3, Android/KMP | `claude mcp add stitch-ai -- npx -y stitch-ai-mcp` |
|
| **Google Stitch** | YES | Material Design 3, Android/KMP | `claude mcp add stitch-ai -- npx -y stitch-ai-mcp` |
|
||||||
| **Figma** | ✅ | Team collaboration, custom designs | `claude mcp add figma -- npx -y figma-mcp --token TOKEN` |
|
| **Figma** | YES | Team collaboration | `claude mcp add figma -- npx -y figma-mcp --token TOKEN` |
|
||||||
| Uizard | ❌ | Quick prototypes | Manual (web only) |
|
| Uizard | NO | Quick prototypes | Manual (web) |
|
||||||
| Visily | ❌ | Component-focused | Manual (web only) |
|
| Visily | NO | Component-focused | Manual (web) |
|
||||||
|
|
||||||
**Recommended**: Google Stitch (MD3 native, has MCP)
|
**Recommended**: Google Stitch (MD3 native, has MCP)
|
||||||
|
|
||||||
**MCP Resources**:
|
### Tool Selection Prompt (If Not Configured)
|
||||||
- Google Stitch MCP: [github.com/StitchAI/stitch-ai-mcp](https://github.com/StitchAI/stitch-ai-mcp)
|
|
||||||
- Stitch Web: [stitch.withgoogle.com](https://stitch.withgoogle.com/)
|
|
||||||
|
|
||||||
#### Tool Selection (Ask User)
|
|
||||||
|
|
||||||
When running `/design [feature] mockup`, prompt user to select tool:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
🎨 Select AI Design Tool:
|
Select AI Design Tool:
|
||||||
|
|
||||||
1. Google Stitch (Recommended) - Material Design 3 native
|
1. Google Stitch (Recommended) - Material Design 3 native
|
||||||
MCP: claude mcp add stitch-ai -- npx -y stitch-ai-mcp
|
MCP: claude mcp add stitch-ai -- npx -y stitch-ai-mcp
|
||||||
@ -71,72 +215,28 @@ When running `/design [feature] mockup`, prompt user to select tool:
|
|||||||
Which tool? (1-4, default: 1)
|
Which tool? (1-4, default: 1)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Workflow
|
---
|
||||||
|
|
||||||
1. Check MCP connection status
|
## Output Files Structure
|
||||||
2. Ask user to select AI design tool (or use configured default)
|
|
||||||
3. Read `features/[Feature]/MOCKUP.md` (v2.0 ASCII design)
|
|
||||||
4. Generate `features/[Feature]/mockups/PROMPTS.md` (tool-specific prompts)
|
|
||||||
5. Generate `features/[Feature]/mockups/design-tokens.json` (structured tokens)
|
|
||||||
6. If MCP connected: Offer to send directly to tool
|
|
||||||
7. Output next steps for user
|
|
||||||
|
|
||||||
**Output Files**:
|
|
||||||
```
|
```
|
||||||
features/[Feature]/mockups/
|
features/[Feature]/mockups/
|
||||||
├── PROMPTS.md # AI tool prompts (format based on selection)
|
+-- PROMPTS_FIGMA.md # Figma-specific prompts
|
||||||
├── design-tokens.json # Structured design tokens
|
+-- PROMPTS_STITCH.md # Google Stitch prompts
|
||||||
└── FIGMA_LINKS.md # Figma URLs (user fills after export)
|
+-- design-tokens.json # Structured design tokens
|
||||||
|
+-- FIGMA_LINKS.md # Figma URLs (user fills after export)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `/design mockup`
|
---
|
||||||
|
|
||||||
Generates mockups for ALL features that don't have mockups/ directory yet.
|
## PROMPTS_STITCH.md Format
|
||||||
Shows progress and allows resuming where left off.
|
|
||||||
|
|
||||||
**First Run**: Will ask to select AI tool and configure MCP if not already done.
|
|
||||||
|
|
||||||
### Mockup Generation Workflow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────────────────────────────────────────┐
|
|
||||||
│ /design [Feature] mockup WORKFLOW │
|
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ STEP 1: READ MOCKUP.md │
|
|
||||||
│ ├─→ Read features/[feature]/MOCKUP.md (v2.0 ASCII design) │
|
|
||||||
│ ├─→ Parse screen layouts, components, colors │
|
|
||||||
│ └─→ Identify all screens and UI elements │
|
|
||||||
│ │
|
|
||||||
│ STEP 2: GENERATE PROMPTS.md │
|
|
||||||
│ ├─→ Create Google Stitch prompts for each screen │
|
|
||||||
│ ├─→ Include: colors, typography, spacing, components │
|
|
||||||
│ ├─→ Follow Material Design 3 guidelines │
|
|
||||||
│ └─→ Write to features/[feature]/mockups/PROMPTS.md │
|
|
||||||
│ │
|
|
||||||
│ STEP 3: GENERATE design-tokens.json │
|
|
||||||
│ ├─→ Extract color tokens (primary, surface, error, success) │
|
|
||||||
│ ├─→ Extract typography tokens │
|
|
||||||
│ ├─→ Extract spacing and radius tokens │
|
|
||||||
│ ├─→ List components and screens │
|
|
||||||
│ └─→ Write to features/[feature]/mockups/design-tokens.json │
|
|
||||||
│ │
|
|
||||||
│ STEP 4: OUTPUT NEXT STEPS │
|
|
||||||
│ ├─→ Instructions to use Google Stitch │
|
|
||||||
│ ├─→ How to export to Figma │
|
|
||||||
│ └─→ Remind to update FIGMA_LINKS.md │
|
|
||||||
│ │
|
|
||||||
└───────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### PROMPTS.md Format (Google Stitch)
|
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# [Feature] - AI Mockup Prompts
|
# [Feature] - Google Stitch Prompts
|
||||||
|
|
||||||
> **Generated from**: features/[feature]/MOCKUP.md
|
> **Generated from**: features/[feature]/MOCKUP.md
|
||||||
> **Generated on**: [DATE]
|
> **Generated on**: [DATE]
|
||||||
> **AI Tool**: Google Stitch (recommended)
|
> **AI Tool**: Google Stitch
|
||||||
|
|
||||||
## Screen 1: [Screen Name]
|
## Screen 1: [Screen Name]
|
||||||
|
|
||||||
@ -156,7 +256,7 @@ Mifos Mobile - Self-service banking app for viewing accounts and transactions.
|
|||||||
- [Section details from MOCKUP.md]
|
- [Section details from MOCKUP.md]
|
||||||
|
|
||||||
**Style Guidelines:**
|
**Style Guidelines:**
|
||||||
- Primary Gradient: #667EEA → #764BA2
|
- Primary Gradient: #667EEA -> #764BA2
|
||||||
- Surface: #FFFBFE
|
- Surface: #FFFBFE
|
||||||
- Typography: Inter font family
|
- Typography: Inter font family
|
||||||
- Spacing: 16px standard padding
|
- Spacing: 16px standard padding
|
||||||
@ -164,67 +264,51 @@ Mifos Mobile - Self-service banking app for viewing accounts and transactions.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Model Recommendation
|
## Main Workflow: `/design [Feature]`
|
||||||
|
|
||||||
**This command is optimized for Opus** for complex architectural decisions and comprehensive specification writing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
```
|
```
|
||||||
claude-product-cycle/design-spec-layer/
|
+-------------------------------------------------------------------------+
|
||||||
├── STATUS.md # All features status
|
| /design [Feature] WORKFLOW |
|
||||||
├── _shared/
|
+-------------------------------------------------------------------------+
|
||||||
│ ├── PATTERNS.md # Implementation patterns
|
| |
|
||||||
│ └── API_REFERENCE.md # Fineract API reference
|
| PHASE 0: O(1) CONTEXT LOADING |
|
||||||
└── features/[feature]/
|
| +--> Read FEATURES_INDEX.md --> Feature exists? Status? |
|
||||||
├── SPEC.md # What to build (UI, flows)
|
| +--> Read MOCKUPS_INDEX.md --> Mockup status |
|
||||||
├── API.md # APIs needed
|
| +--> Read API_INDEX.md --> Related endpoints |
|
||||||
└── STATUS.md # Feature implementation status
|
| |
|
||||||
```
|
| PHASE 1: DETERMINE ACTION |
|
||||||
|
| +--> If feature NOT in index: Create new feature |
|
||||||
---
|
| +--> If SPEC missing: Create SPEC.md |
|
||||||
|
| +--> If API missing: Create API.md |
|
||||||
## Workflow
|
| +--> If exists: Update/improve existing |
|
||||||
|
| |
|
||||||
```
|
| PHASE 2: GATHER CONTEXT (O(1) Paths) |
|
||||||
┌───────────────────────────────────────────────────────────────────┐
|
| +--> Read features/[feature]/SPEC.md |
|
||||||
│ /design [Feature] WORKFLOW │
|
| +--> Read features/[feature]/API.md |
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
| +--> Read features/[feature]/STATUS.md |
|
||||||
│ │
|
| +--> Lookup endpoints from API_INDEX.md |
|
||||||
│ STEP 1: GATHER CONTEXT │
|
| +--> Read actual code: feature/[feature]/ (if exists) |
|
||||||
│ ├─→ Read claude-product-cycle/design-spec-layer/STATUS.md │
|
| |
|
||||||
│ ├─→ Read features/[feature]/SPEC.md (if exists) │
|
| PHASE 3: ANALYZE |
|
||||||
│ ├─→ Read features/[feature]/API.md (if exists) │
|
| +--> Compare current spec vs implementation |
|
||||||
│ ├─→ Read actual code in feature/[feature]/ │
|
| +--> Identify gaps, outdated sections |
|
||||||
│ └─→ Read server-layer/FINERACT_API.md │
|
| +--> Check API availability in API_INDEX |
|
||||||
│ │
|
| +--> Report findings to user |
|
||||||
│ STEP 2: ANALYZE │
|
| |
|
||||||
│ ├─→ Compare current spec vs implementation │
|
| PHASE 4: UPDATE FILES |
|
||||||
│ ├─→ Identify gaps, outdated sections, missing features │
|
| +--> Update/create SPEC.md with ASCII mockups |
|
||||||
│ ├─→ Research best practices for similar apps │
|
| +--> Update/create API.md with endpoints |
|
||||||
│ └─→ Report findings to user │
|
| +--> Update feature STATUS.md |
|
||||||
│ │
|
| |
|
||||||
│ STEP 3: UPDATE SPEC.md │
|
| PHASE 5: INDEX UPDATE (Mandatory) |
|
||||||
│ ├─→ Update/add sections with ASCII mockups │
|
| +--> Update FEATURES_INDEX.md (status columns) |
|
||||||
│ ├─→ Define state model │
|
| +--> Update design-spec-layer/STATUS.md |
|
||||||
│ ├─→ Define user actions │
|
| |
|
||||||
│ └─→ Add changelog entry │
|
| PHASE 6: OUTPUT SUMMARY |
|
||||||
│ │
|
| +--> Implementation requirements |
|
||||||
│ STEP 4: UPDATE API.md │
|
| +--> Next command suggestion |
|
||||||
│ ├─→ List all required endpoints │
|
| |
|
||||||
│ ├─→ Define request/response structures │
|
+-------------------------------------------------------------------------+
|
||||||
│ └─→ Note any missing endpoints │
|
|
||||||
│ │
|
|
||||||
│ STEP 5: CROSS-UPDATE (MANDATORY) │
|
|
||||||
│ ├─→ features/[feature]/STATUS.md │
|
|
||||||
│ └─→ claude-product-cycle/design-spec-layer/STATUS.md │
|
|
||||||
│ │
|
|
||||||
│ STEP 6: GENERATE IMPLEMENTATION SUMMARY │
|
|
||||||
│ └─→ Output clear requirements for /implement │
|
|
||||||
│ │
|
|
||||||
└───────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -254,17 +338,15 @@ claude-product-cycle/design-spec-layer/
|
|||||||
|
|
||||||
### 2.1 ASCII Mockup
|
### 2.1 ASCII Mockup
|
||||||
|
|
||||||
```
|
+-------------------------------------------+
|
||||||
┌─────────────────────────────────────────┐
|
| <- Back [Title] : | <- TopBar
|
||||||
│ ← Back [Title] ⋮ │ ← TopBar
|
+-------------------------------------------+
|
||||||
├─────────────────────────────────────────┤
|
| |
|
||||||
│ │
|
| +-----------------------------------+ |
|
||||||
│ ┌─────────────────────────────────┐ │
|
| | Section 1 | |
|
||||||
│ │ Section 1 │ │
|
| +-----------------------------------+ |
|
||||||
│ └─────────────────────────────────┘ │
|
| |
|
||||||
│ │
|
+-------------------------------------------+
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Sections Table
|
### 2.2 Sections Table
|
||||||
|
|
||||||
@ -285,7 +367,6 @@ claude-product-cycle/design-spec-layer/
|
|||||||
|
|
||||||
## 4. State Model
|
## 4. State Model
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class [Feature]State(
|
data class [Feature]State(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@ -298,7 +379,6 @@ sealed interface [Feature]ScreenState {
|
|||||||
data object Success : [Feature]ScreenState
|
data object Success : [Feature]ScreenState
|
||||||
data class Error(val message: StringResource) : [Feature]ScreenState
|
data class Error(val message: StringResource) : [Feature]ScreenState
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -306,7 +386,7 @@ sealed interface [Feature]ScreenState {
|
|||||||
|
|
||||||
| Endpoint | Method | Purpose | Status |
|
| Endpoint | Method | Purpose | Status |
|
||||||
|----------|--------|---------|--------|
|
|----------|--------|---------|--------|
|
||||||
| /self/[path] | GET | [Description] | ✅ Exists |
|
| /self/[path] | GET | [Description] | Exists |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -343,28 +423,22 @@ sealed interface [Feature]ScreenState {
|
|||||||
**Description**: [What this endpoint does]
|
**Description**: [What this endpoint does]
|
||||||
|
|
||||||
**Request**:
|
**Request**:
|
||||||
```
|
|
||||||
Headers:
|
Headers:
|
||||||
Authorization: Basic {token}
|
Authorization: Basic {token}
|
||||||
Fineract-Platform-TenantId: {tenant}
|
Fineract-Platform-TenantId: {tenant}
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```json
|
|
||||||
{
|
{
|
||||||
"field": "value"
|
"field": "value"
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
**Kotlin DTO**:
|
**Kotlin DTO**:
|
||||||
```kotlin
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class [Name]Dto(
|
data class [Name]Dto(
|
||||||
@SerialName("field") val field: String,
|
@SerialName("field") val field: String,
|
||||||
)
|
)
|
||||||
```
|
|
||||||
|
|
||||||
**Status**: ✅ Implemented / ❌ Missing
|
**Status**: Implemented / Missing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -372,7 +446,7 @@ data class [Name]Dto(
|
|||||||
|
|
||||||
| Endpoint | Service | Repository | Status |
|
| Endpoint | Service | Repository | Status |
|
||||||
|----------|---------|------------|--------|
|
|----------|---------|------------|--------|
|
||||||
| /self/[path] | [Name]Service | [Name]Repository | ✅ |
|
| /self/[path] | [Name]Service | [Name]Repository | Done |
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -382,60 +456,130 @@ data class [Name]Dto(
|
|||||||
After completing design, output:
|
After completing design, output:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────────────────────────────────────────────────────────┐
|
+=========================================================================+
|
||||||
│ IMPLEMENTATION REQUIREMENTS │
|
| IMPLEMENTATION REQUIREMENTS |
|
||||||
│ Ready for /implement in Sonnet session │
|
| Ready for /implement in Sonnet session |
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
+=========================================================================+
|
||||||
│ │
|
| |
|
||||||
│ FEATURE: [Feature Name] │
|
| FEATURE: [Feature Name] |
|
||||||
│ SPEC UPDATED: features/[feature]/SPEC.md │
|
| SPEC UPDATED: features/[feature]/SPEC.md |
|
||||||
│ │
|
| |
|
||||||
│ ════════════════════════════════════════════════════════════════ │
|
| ================================================================ |
|
||||||
│ │
|
| |
|
||||||
│ CLIENT WORK NEEDED: │
|
| CLIENT WORK NEEDED: |
|
||||||
│ [ ] Network: [DTO/Service changes] │
|
| [ ] Network: [DTO/Service changes] |
|
||||||
│ [ ] Data: [Repository changes] │
|
| [ ] Data: [Repository changes] |
|
||||||
│ │
|
| |
|
||||||
│ FEATURE WORK NEEDED: │
|
| FEATURE WORK NEEDED: |
|
||||||
│ [ ] ViewModel: [changes] │
|
| [ ] ViewModel: [changes] |
|
||||||
│ [ ] Screen: [changes] │
|
| [ ] Screen: [changes] |
|
||||||
│ [ ] Components: [new components] │
|
| [ ] Components: [new components] |
|
||||||
│ │
|
| |
|
||||||
│ ════════════════════════════════════════════════════════════════ │
|
| ================================================================ |
|
||||||
│ │
|
| |
|
||||||
│ NEXT STEP: │
|
| INDEXES UPDATED: |
|
||||||
│ Run: /implement [Feature] │
|
| [x] FEATURES_INDEX.md - Status updated |
|
||||||
│ │
|
| [x] design-spec-layer/STATUS.md - Layer status |
|
||||||
└───────────────────────────────────────────────────────────────────┘
|
| [x] features/[feature]/STATUS.md - Feature status |
|
||||||
|
| |
|
||||||
|
| ================================================================ |
|
||||||
|
| |
|
||||||
|
| NEXT STEP: |
|
||||||
|
| Run: /implement [Feature] |
|
||||||
|
| |
|
||||||
|
+=========================================================================+
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## If No Feature Name Provided
|
## Feature Reference (From FEATURES_INDEX.md)
|
||||||
|
|
||||||
Show feature list:
|
| # | Feature | Design Dir | Feature Dir |
|
||||||
|
|:-:|---------|------------|-------------|
|
||||||
|
| 1 | accounts | features/accounts/ | feature/account/ |
|
||||||
|
| 2 | auth | features/auth/ | feature/auth/ |
|
||||||
|
| 3 | beneficiary | features/beneficiary/ | feature/beneficiary/ |
|
||||||
|
| 4 | client-charge | features/client-charge/ | feature/user-profile/ |
|
||||||
|
| 5 | dashboard | features/dashboard/ | feature/dashboard/ |
|
||||||
|
| 6 | guarantor | features/guarantor/ | feature/guarantor/ |
|
||||||
|
| 7 | home | features/home/ | feature/home/ |
|
||||||
|
| 8 | loan-account | features/loan-account/ | feature/loan-account/ |
|
||||||
|
| 9 | location | features/location/ | feature/location/ |
|
||||||
|
| 10 | notification | features/notification/ | feature/notification/ |
|
||||||
|
| 11 | passcode | features/passcode/ | libs/mifos-passcode/ |
|
||||||
|
| 12 | qr | features/qr/ | feature/qr-code/ |
|
||||||
|
| 13 | recent-transaction | features/recent-transaction/ | feature/recent-transaction/ |
|
||||||
|
| 14 | savings-account | features/savings-account/ | feature/savings-account/ |
|
||||||
|
| 15 | settings | features/settings/ | feature/settings/ |
|
||||||
|
| 16 | share-account | features/share-account/ | feature/share-account/ |
|
||||||
|
| 17 | transfer | features/transfer/ | feature/transfer-process/ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Feature Not Found
|
||||||
|
|
||||||
```
|
```
|
||||||
📋 FEATURES AVAILABLE FOR DESIGN:
|
+-------------------------------------------------------------------------+
|
||||||
|
| ERROR: Feature '[name]' not found |
|
||||||
| Feature | Status | Last Updated | Command |
|
+-------------------------------------------------------------------------+
|
||||||
|---------|--------|--------------|---------|
|
| |
|
||||||
| auth | ✅ Done | - | /design auth |
|
| The feature '[name]' does not exist in FEATURES_INDEX.md |
|
||||||
| home | ✅ Done | - | /design home |
|
| |
|
||||||
| accounts | ✅ Done | - | /design accounts |
|
| OPTIONS: |
|
||||||
| loan-account | ✅ Done | - | /design loan-account |
|
| 1. Create new feature: /design [name] |
|
||||||
| savings-account | ✅ Done | - | /design savings-account |
|
| 2. Check available features: /design |
|
||||||
| share-account | ✅ Done | - | /design share-account |
|
| 3. Similar features: [suggestions based on name] |
|
||||||
| beneficiary | ✅ Done | - | /design beneficiary |
|
| |
|
||||||
| transfer | ✅ Done | - | /design transfer |
|
+-------------------------------------------------------------------------+
|
||||||
| recent-transaction | ✅ Done | - | /design recent-transaction |
|
```
|
||||||
| notification | ✅ Done | - | /design notification |
|
|
||||||
| settings | ✅ Done | - | /design settings |
|
### Invalid Sub-command
|
||||||
| passcode | ✅ Done | - | /design passcode |
|
|
||||||
| guarantor | ✅ Done | - | /design guarantor |
|
```
|
||||||
| qr | ✅ Done | - | /design qr |
|
+-------------------------------------------------------------------------+
|
||||||
| location | ✅ Done | - | /design location |
|
| ERROR: Invalid sub-command '[sub]' |
|
||||||
| client-charge | ✅ Done | - | /design client-charge |
|
+-------------------------------------------------------------------------+
|
||||||
|
| |
|
||||||
Which feature do you want to design?
|
| Valid sub-commands: |
|
||||||
|
| - mockup : Generate mockup prompts |
|
||||||
|
| - improve : Suggest improvements |
|
||||||
|
| - add [x] : Add specific section |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Recommendation
|
||||||
|
|
||||||
|
**This command is optimized for Opus** for complex architectural decisions and comprehensive specification writing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/gap-analysis design` | See design layer gaps |
|
||||||
|
| `/gap-analysis design mockup` | See mockup gaps specifically |
|
||||||
|
| `/implement [feature]` | Implement the designed feature |
|
||||||
|
| `/verify [feature]` | Verify implementation vs spec |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-product-cycle/design-spec-layer/
|
||||||
|
+-- FEATURES_INDEX.md # O(1) feature lookup
|
||||||
|
+-- MOCKUPS_INDEX.md # O(1) mockup status
|
||||||
|
+-- STATUS.md # Layer status
|
||||||
|
+-- features/[feature]/
|
||||||
|
+-- SPEC.md # What to build (UI, flows)
|
||||||
|
+-- API.md # APIs needed
|
||||||
|
+-- STATUS.md # Feature implementation status
|
||||||
|
+-- MOCKUP.md # v2.0 ASCII mockup
|
||||||
|
+-- mockups/ # Generated mockup files
|
||||||
```
|
```
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -344,3 +344,105 @@ For each gap found:
|
|||||||
5. **Prioritize** - P0 → P1 → P2
|
5. **Prioritize** - P0 → P1 → P2
|
||||||
6. **Provide verification** - Checklist for each plan
|
6. **Provide verification** - Checklist for each plan
|
||||||
7. **NO interactive questions** - Show everything, user decides
|
7. **NO interactive questions** - Show everything, user decides
|
||||||
|
8. **Save plan to file** - Persist for tracking (see below)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan Persistence
|
||||||
|
|
||||||
|
When creating a detailed plan (with parameters), **save it to a file** for tracking:
|
||||||
|
|
||||||
|
### Save Location
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-product-cycle/plans/active/[target]-[type].md
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `/gap-planning design mockup` → `plans/active/design-mockup.md`
|
||||||
|
- `/gap-planning testing auth` → `plans/active/testing-auth.md`
|
||||||
|
- `/gap-planning feature beneficiary` → `plans/active/feature-beneficiary.md`
|
||||||
|
- `/gap-planning platform web` → `plans/active/platform-web.md`
|
||||||
|
|
||||||
|
### Plan File Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Plan: [Target Description]
|
||||||
|
|
||||||
|
**Created**: YYYY-MM-DD
|
||||||
|
**Status**: 🔄 Active
|
||||||
|
**Command**: /gap-planning [args]
|
||||||
|
**Progress**: 0/N steps (0%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
[Brief description of what this plan accomplishes]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **Step 1**: [Description]
|
||||||
|
- Sub-task 1
|
||||||
|
- Sub-task 2
|
||||||
|
- Command: `[execution command]`
|
||||||
|
- Files: `path/to/expected/files`
|
||||||
|
|
||||||
|
- [ ] **Step 2**: [Description]
|
||||||
|
- Sub-task 1
|
||||||
|
- Command: `[execution command]`
|
||||||
|
|
||||||
|
[... more steps ...]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] All expected files exist
|
||||||
|
- [ ] Tests pass (if applicable)
|
||||||
|
- [ ] Index files updated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
| Date | Step | Action | Notes |
|
||||||
|
|------|:----:|--------|-------|
|
||||||
|
| YYYY-MM-DD | 0 | Created | Plan initialized |
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update PLANS_INDEX.md
|
||||||
|
|
||||||
|
After creating a plan file, also update `plans/PLANS_INDEX.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Active Plans
|
||||||
|
|
||||||
|
| # | Plan | Target | Progress | Current Step | Created |
|
||||||
|
|:-:|------|--------|:--------:|--------------|---------|
|
||||||
|
| 1 | design-mockup | Design mockups | [░░░░░░░░░░] 0% (0/10) | Step 1 | 2026-01-05 |
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Progress
|
||||||
|
|
||||||
|
After plan is saved, show:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Plan saved to: plans/active/[name].md
|
||||||
|
|
||||||
|
Track progress with: /gap-status [name]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/gap-analysis` | Identify gaps (run first) |
|
||||||
|
| `/gap-planning` | Create implementation plans (this command) |
|
||||||
|
| `/gap-status` | Track plan progress |
|
||||||
|
| `/implement` | Execute implementation |
|
||||||
|
| `/verify` | Confirm completion |
|
||||||
|
|||||||
339
.claude/commands/gap-status.md
Normal file
339
.claude/commands/gap-status.md
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
# /gap-status - Plan Progress Tracking
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Track progress on implementation plans created by `/gap-planning`. Shows current step, completed steps, and what's next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/gap-status # Show all active plans summary
|
||||||
|
/gap-status [plan-name] # Show detailed progress for plan
|
||||||
|
/gap-status design # Show design layer plans
|
||||||
|
/gap-status testing # Show testing layer plans
|
||||||
|
/gap-status feature [name] # Show feature-specific plan
|
||||||
|
/gap-status complete [plan] # Mark plan as complete
|
||||||
|
/gap-status pause [plan] # Pause a plan
|
||||||
|
/gap-status resume [plan] # Resume a paused plan
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ /gap-status WORKFLOW │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ PHASE 0: O(1) CONTEXT LOADING │
|
||||||
|
│ ├─→ Read plans/PLANS_INDEX.md → Get all plans overview │
|
||||||
|
│ ├─→ Read plans/active/*.md → Get active plan details │
|
||||||
|
│ └─→ Count completed steps → Calculate progress │
|
||||||
|
│ │
|
||||||
|
│ PHASE 1: DETERMINE OUTPUT │
|
||||||
|
│ ├─→ If no args: Show all active plans summary │
|
||||||
|
│ ├─→ If [plan-name]: Show detailed plan progress │
|
||||||
|
│ └─→ If action (complete/pause/resume): Update plan status │
|
||||||
|
│ │
|
||||||
|
│ PHASE 2: GENERATE REPORT │
|
||||||
|
│ ├─→ Progress bars for each plan │
|
||||||
|
│ ├─→ Current step highlight │
|
||||||
|
│ ├─→ Next steps preview │
|
||||||
|
│ └─→ Suggested commands │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output: All Plans Summary (No Args)
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ MIFOS MOBILE - PLAN STATUS ║
|
||||||
|
╠══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
|
||||||
|
## 🔄 Active Plans
|
||||||
|
|
||||||
|
| # | Plan | Progress | Current Step | Last Updated |
|
||||||
|
|:-:|------|:--------:|--------------|--------------|
|
||||||
|
| 1 | design-mockup | [████████░░] 80% (8/10) | Step 9: transfer mockups | 2026-01-05 |
|
||||||
|
| 2 | testing-auth | [████░░░░░░] 40% (4/10) | Step 5: LoginViewModel tests | 2026-01-05 |
|
||||||
|
| 3 | feature-dashboard | [██░░░░░░░░] 20% (2/10) | Step 3: Create DashboardViewModel | 2026-01-04 |
|
||||||
|
|
||||||
|
## ⏸️ Paused Plans
|
||||||
|
|
||||||
|
| # | Plan | Progress | Paused At | Reason |
|
||||||
|
|:-:|------|:--------:|-----------|--------|
|
||||||
|
| - | (none) | - | - | - |
|
||||||
|
|
||||||
|
## ✅ Recently Completed
|
||||||
|
|
||||||
|
| # | Plan | Steps | Completed |
|
||||||
|
|:-:|------|:-----:|-----------|
|
||||||
|
| 1 | client-layer | 12/12 | 2026-01-03 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| View plan details | `/gap-status [plan-name]` |
|
||||||
|
| Continue implementation | `/implement [target]` |
|
||||||
|
| Mark complete | `/gap-status complete [plan]` |
|
||||||
|
| Create new plan | `/gap-planning [target]` |
|
||||||
|
|
||||||
|
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output: Specific Plan (With Args)
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ PLAN: design-mockup ║
|
||||||
|
╠══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
|
||||||
|
**Status**: 🔄 Active
|
||||||
|
**Progress**: [████████░░] 80% (8/10 steps)
|
||||||
|
**Created**: 2026-01-03
|
||||||
|
**Last Updated**: 2026-01-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
| # | Step | Status | Description |
|
||||||
|
|:-:|------|:------:|-------------|
|
||||||
|
| 1 | ✅ | Done | Generate auth mockups |
|
||||||
|
| 2 | ✅ | Done | Generate home mockups |
|
||||||
|
| 3 | ✅ | Done | Generate accounts mockups |
|
||||||
|
| 4 | ✅ | Done | Generate beneficiary mockups |
|
||||||
|
| 5 | ✅ | Done | Generate loan-account mockups |
|
||||||
|
| 6 | ✅ | Done | Generate savings-account mockups |
|
||||||
|
| 7 | ✅ | Done | Generate share-account mockups |
|
||||||
|
| 8 | ✅ | Done | Generate notification mockups |
|
||||||
|
| 9 | 🔄 | **Current** | Generate transfer mockups |
|
||||||
|
| 10 | ⬜ | Pending | Generate recent-transaction mockups |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Step Details
|
||||||
|
|
||||||
|
### Step 9: Generate transfer mockups
|
||||||
|
|
||||||
|
**Target**: `design-spec-layer/features/transfer/mockups/`
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Run `/design transfer mockup`
|
||||||
|
- [ ] Review generated PROMPTS.md
|
||||||
|
- [ ] Execute prompts in Google Stitch
|
||||||
|
- [ ] Save design-tokens.json
|
||||||
|
- [ ] Update MOCKUPS_INDEX.md
|
||||||
|
|
||||||
|
**Expected Files**:
|
||||||
|
```
|
||||||
|
features/transfer/mockups/
|
||||||
|
├── PROMPTS.md
|
||||||
|
├── PROMPTS_FIGMA.md (if MCP)
|
||||||
|
├── design-tokens.json
|
||||||
|
└── screenshots/ (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Step Preview
|
||||||
|
|
||||||
|
### Step 10: Generate recent-transaction mockups
|
||||||
|
|
||||||
|
**Target**: `design-spec-layer/features/recent-transaction/mockups/`
|
||||||
|
|
||||||
|
Same process as Step 9 but for recent-transaction feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
| Date | Step | Action |
|
||||||
|
|------|:----:|--------|
|
||||||
|
| 2026-01-05 | 8 | Completed notification mockups |
|
||||||
|
| 2026-01-05 | 9 | Started transfer mockups |
|
||||||
|
| 2026-01-04 | 5-7 | Completed account mockups |
|
||||||
|
| 2026-01-03 | 1-4 | Initial mockups complete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| Execute current step | `/design transfer mockup` |
|
||||||
|
| Mark step complete | Update plan file, re-run `/gap-status` |
|
||||||
|
| Pause plan | `/gap-status pause design-mockup` |
|
||||||
|
| Mark plan complete | `/gap-status complete design-mockup` |
|
||||||
|
|
||||||
|
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: O(1) Context Loading
|
||||||
|
|
||||||
|
### Files to Read
|
||||||
|
|
||||||
|
| File | Purpose | Data Extracted |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| `plans/PLANS_INDEX.md` | Plan inventory | activePlans[], completedPlans[] |
|
||||||
|
| `plans/active/[plan].md` | Plan details | steps[], currentStep, progress |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan File Structure
|
||||||
|
|
||||||
|
When `/gap-planning` creates a plan, it saves to `plans/active/[name].md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Plan: Design Layer - Mockups
|
||||||
|
|
||||||
|
**Created**: 2026-01-03
|
||||||
|
**Status**: 🔄 Active
|
||||||
|
**Command**: /gap-planning design mockup
|
||||||
|
**Progress**: 8/10 steps (80%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Generate mockups for all features missing UI designs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [x] **Step 1**: Generate auth mockups
|
||||||
|
- Run `/design auth mockup`
|
||||||
|
- Files: `features/auth/mockups/`
|
||||||
|
- Completed: 2026-01-03
|
||||||
|
|
||||||
|
- [x] **Step 2**: Generate home mockups
|
||||||
|
- Run `/design home mockup`
|
||||||
|
- Files: `features/home/mockups/`
|
||||||
|
- Completed: 2026-01-04
|
||||||
|
|
||||||
|
- [ ] **Step 9**: Generate transfer mockups ← CURRENT
|
||||||
|
- Run `/design transfer mockup`
|
||||||
|
- Files: `features/transfer/mockups/`
|
||||||
|
|
||||||
|
- [ ] **Step 10**: Generate recent-transaction mockups
|
||||||
|
- Run `/design recent-transaction mockup`
|
||||||
|
- Files: `features/recent-transaction/mockups/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
| Date | Step | Action | Notes |
|
||||||
|
|------|:----:|--------|-------|
|
||||||
|
| 2026-01-05 | 8 | ✅ Completed | notification mockups done |
|
||||||
|
| 2026-01-05 | 9 | 🔄 Started | transfer in progress |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step Status Icons
|
||||||
|
|
||||||
|
| Icon | Status | Meaning |
|
||||||
|
|:----:|--------|---------|
|
||||||
|
| ✅ | Done | Step completed |
|
||||||
|
| 🔄 | Current | Currently working on |
|
||||||
|
| ⬜ | Pending | Not started |
|
||||||
|
| ⏸️ | Blocked | Waiting on dependency |
|
||||||
|
| ❌ | Failed | Step failed, needs retry |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### When `/gap-status` is called (no args):
|
||||||
|
|
||||||
|
1. Read `plans/PLANS_INDEX.md`
|
||||||
|
2. For each active plan, read `plans/active/[plan].md`
|
||||||
|
3. Count `[x]` vs `[ ]` checkboxes to calculate progress
|
||||||
|
4. Display summary table with progress bars
|
||||||
|
|
||||||
|
### When `/gap-status [plan]` is called:
|
||||||
|
|
||||||
|
1. Read `plans/active/[plan].md`
|
||||||
|
2. Find current step (first `[ ]` after last `[x]`)
|
||||||
|
3. Display detailed view with:
|
||||||
|
- All steps with status
|
||||||
|
- Current step details
|
||||||
|
- Next step preview
|
||||||
|
- Progress log
|
||||||
|
|
||||||
|
### When `/gap-status complete [plan]` is called:
|
||||||
|
|
||||||
|
1. Read `plans/active/[plan].md`
|
||||||
|
2. Update status to "✅ Completed"
|
||||||
|
3. Move file to `plans/completed/[plan].md`
|
||||||
|
4. Update `plans/PLANS_INDEX.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration with Other Commands
|
||||||
|
|
||||||
|
| When This Runs | Update Plan |
|
||||||
|
|----------------|-------------|
|
||||||
|
| `/gap-planning [target]` | Create new plan file |
|
||||||
|
| `/implement` checkpoint | Update related plan step |
|
||||||
|
| `/design [feature] mockup` | Update mockup plan step |
|
||||||
|
| `/verify [feature]` | Update verification plan |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Bar Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
100% = [██████████] | 50% = [█████░░░░░]
|
||||||
|
90% = [█████████░] | 40% = [████░░░░░░]
|
||||||
|
80% = [████████░░] | 30% = [███░░░░░░░]
|
||||||
|
70% = [███████░░░] | 20% = [██░░░░░░░░]
|
||||||
|
60% = [██████░░░░] | 10% = [█░░░░░░░░░]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Creating and Tracking a Plan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create plan
|
||||||
|
/gap-planning design mockup
|
||||||
|
# → Creates plans/active/design-mockup.md with 10 steps
|
||||||
|
|
||||||
|
# 2. Check status
|
||||||
|
/gap-status
|
||||||
|
# → Shows design-mockup at 0% (0/10)
|
||||||
|
|
||||||
|
# 3. Work on step 1
|
||||||
|
/design auth mockup
|
||||||
|
# → Completes auth mockups
|
||||||
|
|
||||||
|
# 4. Update plan (manual or via command)
|
||||||
|
# Edit plans/active/design-mockup.md, mark step 1 as [x]
|
||||||
|
|
||||||
|
# 5. Check status again
|
||||||
|
/gap-status design-mockup
|
||||||
|
# → Shows 10% (1/10), current step is now step 2
|
||||||
|
|
||||||
|
# 6. Continue until done
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# 7. Mark complete
|
||||||
|
/gap-status complete design-mockup
|
||||||
|
# → Moves to plans/completed/
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,16 +1,16 @@
|
|||||||
# Verify Tests Command
|
# /verify-tests - Test Verification (O(1) Enhanced)
|
||||||
|
|
||||||
Run and verify tests for features across the project.
|
Run and verify tests for features across the project with O(1) status lookups.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/verify-tests # Run all tests, show status
|
/verify-tests # Show test status dashboard (O(1))
|
||||||
/verify-tests auth # Run auth feature tests
|
/verify-tests [feature] # Run all tests for feature
|
||||||
/verify-tests auth unit # Run auth ViewModel tests only
|
/verify-tests [feature] unit # Run ViewModel tests only
|
||||||
/verify-tests auth ui # Run auth UI tests only
|
/verify-tests [feature] ui # Run UI tests only
|
||||||
/verify-tests auth integration # Run auth integration tests
|
/verify-tests [feature] integration # Run integration tests
|
||||||
/verify-tests auth screenshot # Run auth screenshot tests
|
/verify-tests [feature] screenshot # Run screenshot tests
|
||||||
/verify-tests client # Run all client layer tests
|
/verify-tests client # Run all client layer tests
|
||||||
/verify-tests feature # Run all feature layer tests
|
/verify-tests feature # Run all feature layer tests
|
||||||
/verify-tests platform # Run all platform tests
|
/verify-tests platform # Run all platform tests
|
||||||
@ -18,21 +18,226 @@ Run and verify tests for features across the project.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Output Format
|
## O(1) Workflow
|
||||||
|
|
||||||
```
|
```
|
||||||
╔══════════════════════════════════════════════════════════════════════════════╗
|
+-------------------------------------------------------------------------+
|
||||||
║ VERIFY TESTS - [target] ║
|
| /verify-tests WORKFLOW (O(1) ENHANCED) |
|
||||||
╠══════════════════════════════════════════════════════════════════════════════╣
|
+-------------------------------------------------------------------------+
|
||||||
|
| |
|
||||||
|
| PHASE 0: O(1) CONTEXT LOADING |
|
||||||
|
| +--> Read feature-layer/TESTING_STATUS.md --> VM/Screen test status|
|
||||||
|
| +--> Read client-layer/TESTING_STATUS.md --> Repository test status|
|
||||||
|
| +--> Read platform-layer/TESTING_STATUS.md --> E2E/Screenshot status|
|
||||||
|
| +--> Read feature-layer/MODULES_INDEX.md --> Feature paths |
|
||||||
|
| |
|
||||||
|
| PHASE 1: DETERMINE TEST SCOPE |
|
||||||
|
| +--> If no args: Show test dashboard from indexes |
|
||||||
|
| +--> If [feature]: Get paths from MODULES_INDEX |
|
||||||
|
| +--> If [layer]: Get layer test config |
|
||||||
|
| |
|
||||||
|
| PHASE 2: EXECUTE TESTS |
|
||||||
|
| +--> Build appropriate Gradle command |
|
||||||
|
| +--> Run tests via Bash |
|
||||||
|
| +--> Capture output |
|
||||||
|
| |
|
||||||
|
| PHASE 3: PARSE RESULTS |
|
||||||
|
| +--> Extract: passed, failed, skipped |
|
||||||
|
| +--> Extract: failure details |
|
||||||
|
| +--> Calculate coverage (if available) |
|
||||||
|
| |
|
||||||
|
| PHASE 4: UPDATE STATUS |
|
||||||
|
| +--> Update TESTING_STATUS.md with new results |
|
||||||
|
| +--> Log test run timestamp |
|
||||||
|
| |
|
||||||
|
| PHASE 5: REPORT |
|
||||||
|
| +--> Show results with next steps |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: O(1) Context Loading
|
||||||
|
|
||||||
|
### Index Files to Read
|
||||||
|
|
||||||
|
| File | Purpose | Lines |
|
||||||
|
|------|---------|:-----:|
|
||||||
|
| `testing-layer/LAYER_STATUS.md` | **Primary** test dashboard | ~200 |
|
||||||
|
| `testing-layer/TEST_PATTERNS.md` | Test patterns & conventions | ~300 |
|
||||||
|
| `testing-layer/TEST_TAGS_INDEX.md` | TestTag specifications | ~350 |
|
||||||
|
| `testing-layer/TEST_FIXTURES_INDEX.md` | Test fixtures inventory | ~250 |
|
||||||
|
| `testing-layer/FAKE_REPOS_INDEX.md` | Fake repositories status | ~200 |
|
||||||
|
| `feature-layer/MODULES_INDEX.md` | Feature → Path mapping | ~115 |
|
||||||
|
|
||||||
|
### O(1) Path Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/[module]/src/commonTest/ # Unit tests (ViewModel)
|
||||||
|
feature/[module]/src/androidInstrumentedTest/ # UI tests (Screen)
|
||||||
|
core/data/src/commonTest/ # Repository tests
|
||||||
|
cmp-android/src/androidTest/ # E2E integration tests
|
||||||
|
core/designsystem/src/test/ # Screenshot tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If No Arguments: Test Dashboard
|
||||||
|
|
||||||
|
Read from TESTING_STATUS.md files and show:
|
||||||
|
|
||||||
|
```
|
||||||
|
+=========================================================================+
|
||||||
|
| TEST STATUS DASHBOARD (O(1)) |
|
||||||
|
+=========================================================================+
|
||||||
|
|
||||||
|
## Layer Summary
|
||||||
|
|
||||||
|
| Layer | Tests | Passed | Failed | Coverage | Status |
|
||||||
|
|-------|:-----:|:------:|:------:|:--------:|:------:|
|
||||||
|
| Client | 14 | 14 | 0 | 82% | [======= ] |
|
||||||
|
| Feature | 0 | 0 | 0 | 0% | [ ] |
|
||||||
|
| Platform | 0 | 0 | 0 | 0% | [ ] |
|
||||||
|
|
||||||
|
## Feature Testing Matrix (from TESTING_STATUS.md)
|
||||||
|
|
||||||
|
| Feature | VMs | VM Tests | Screens | UI Tests | Status |
|
||||||
|
|---------|:---:|:--------:|:-------:|:--------:|:------:|
|
||||||
|
| auth | 5 | 0 | 6 | 0 | [ ] Not Started |
|
||||||
|
| home | 1 | 0 | 1 | 0 | [ ] Not Started |
|
||||||
|
| accounts | 3 | 0 | 3 | 0 | [ ] Not Started |
|
||||||
|
| ... (from feature-layer/TESTING_STATUS.md)
|
||||||
|
|
||||||
|
## Repository Testing (from client-layer/TESTING_STATUS.md)
|
||||||
|
|
||||||
|
| Repository | Tests | Success | Error | Empty | Status |
|
||||||
|
|------------|:-----:|:-------:|:-----:|:-----:|:------:|
|
||||||
|
| AccountsRepository | 2 | [x] | [ ] | [ ] | Partial |
|
||||||
|
| UserAuthRepository | 0 | [ ] | [ ] | [ ] | Not Started |
|
||||||
|
| ... (from client-layer/TESTING_STATUS.md)
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| Run all tests | `./gradlew test` |
|
||||||
|
| Run feature tests | `/verify-tests [feature]` |
|
||||||
|
| Run client tests | `/verify-tests client` |
|
||||||
|
| Check gaps | `/gap-analysis testing` |
|
||||||
|
|
||||||
|
+=========================================================================+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Test Mapping (from MODULES_INDEX.md)
|
||||||
|
|
||||||
|
| # | Feature | Module Path | Unit Test Path | UI Test Path |
|
||||||
|
|:-:|---------|-------------|----------------|--------------|
|
||||||
|
| 1 | auth | feature/auth | feature/auth/src/commonTest/ | feature/auth/src/androidInstrumentedTest/ |
|
||||||
|
| 2 | home | feature/home | feature/home/src/commonTest/ | feature/home/src/androidInstrumentedTest/ |
|
||||||
|
| 3 | accounts | feature/accounts | feature/accounts/src/commonTest/ | feature/accounts/src/androidInstrumentedTest/ |
|
||||||
|
| 4 | beneficiary | feature/beneficiary | feature/beneficiary/src/commonTest/ | feature/beneficiary/src/androidInstrumentedTest/ |
|
||||||
|
| 5 | loan-account | feature/loan-account | feature/loan-account/src/commonTest/ | feature/loan-account/src/androidInstrumentedTest/ |
|
||||||
|
| 6 | savings-account | feature/savings-account | feature/savings-account/src/commonTest/ | feature/savings-account/src/androidInstrumentedTest/ |
|
||||||
|
| 7 | share-account | feature/share-account | feature/share-account/src/commonTest/ | feature/share-account/src/androidInstrumentedTest/ |
|
||||||
|
| 8 | transfer | feature/transfer-process | feature/transfer-process/src/commonTest/ | feature/transfer-process/src/androidInstrumentedTest/ |
|
||||||
|
| 9 | recent-transaction | feature/recent-transaction | feature/recent-transaction/src/commonTest/ | feature/recent-transaction/src/androidInstrumentedTest/ |
|
||||||
|
| 10 | notification | feature/notification | feature/notification/src/commonTest/ | feature/notification/src/androidInstrumentedTest/ |
|
||||||
|
| 11 | settings | feature/settings | feature/settings/src/commonTest/ | feature/settings/src/androidInstrumentedTest/ |
|
||||||
|
| 12 | passcode | libs/mifos-passcode | libs/mifos-passcode/src/commonTest/ | libs/mifos-passcode/src/androidInstrumentedTest/ |
|
||||||
|
| 13 | guarantor | feature/guarantor | feature/guarantor/src/commonTest/ | feature/guarantor/src/androidInstrumentedTest/ |
|
||||||
|
| 14 | qr | feature/qr-code | feature/qr-code/src/commonTest/ | feature/qr-code/src/androidInstrumentedTest/ |
|
||||||
|
| 15 | location | feature/location | feature/location/src/commonTest/ | feature/location/src/androidInstrumentedTest/ |
|
||||||
|
| 16 | user-profile | feature/user-profile | feature/user-profile/src/commonTest/ | feature/user-profile/src/androidInstrumentedTest/ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Type Commands
|
||||||
|
|
||||||
|
### `/verify-tests [feature]` - All Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests (ViewModel)
|
||||||
|
./gradlew :feature:[module]:test
|
||||||
|
|
||||||
|
# UI tests (Screen) - requires emulator
|
||||||
|
./gradlew :feature:[module]:connectedDebugAndroidTest
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/verify-tests [feature] unit` - ViewModel Only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :feature:[module]:test
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/verify-tests [feature] ui` - Screen Only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :feature:[module]:connectedDebugAndroidTest
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/verify-tests [feature] integration` - E2E Flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :cmp-android:connectedDebugAndroidTest \
|
||||||
|
-Pandroid.testInstrumentationRunnerArguments.class=org.mifos.mobile.[Feature]FlowTest
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/verify-tests [feature] screenshot` - Visual
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compare against golden images
|
||||||
|
./gradlew :core:designsystem:compareRoborazziDebug
|
||||||
|
|
||||||
|
# Record new golden images
|
||||||
|
./gradlew :core:designsystem:recordRoborazziDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer Test Commands
|
||||||
|
|
||||||
|
### `/verify-tests client` - Repository Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :core:data:test
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/verify-tests feature` - All Feature Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew feature:test
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/verify-tests platform` - Platform Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# E2E tests
|
||||||
|
./gradlew :cmp-android:connectedDebugAndroidTest
|
||||||
|
|
||||||
|
# Screenshot tests
|
||||||
|
./gradlew :core:designsystem:compareRoborazziDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
### After Running Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
+=========================================================================+
|
||||||
|
| VERIFY TESTS - [target] |
|
||||||
|
+=========================================================================+
|
||||||
|
|
||||||
## Test Execution
|
## Test Execution
|
||||||
|
|
||||||
| Type | Command | Tests | Passed | Failed | Status |
|
| Type | Command | Tests | Passed | Failed | Status |
|
||||||
|------|---------|:-----:|:------:|:------:|:------:|
|
|------|---------|:-----:|:------:|:------:|:------:|
|
||||||
| Unit | `./gradlew :feature:auth:test` | 45 | 45 | 0 | ✅ |
|
| Unit | `./gradlew :feature:auth:test` | 45 | 45 | 0 | [x] |
|
||||||
| UI | `./gradlew :feature:auth:connectedDebugAndroidTest` | 25 | 23 | 2 | ⚠️ |
|
| UI | `./gradlew :feature:auth:connectedDebugAndroidTest` | 25 | 23 | 2 | [!] |
|
||||||
| Integration | `./gradlew :cmp-android:connectedDebugAndroidTest` | 8 | 8 | 0 | ✅ |
|
|
||||||
| Screenshot | `./gradlew :core:designsystem:compareRoborazziDebug` | 12 | 12 | 0 | ✅ |
|
|
||||||
|
|
||||||
## Failed Tests
|
## Failed Tests
|
||||||
|
|
||||||
@ -45,137 +250,87 @@ Run and verify tests for features across the project.
|
|||||||
|
|
||||||
| Component | Coverage | Target | Status |
|
| Component | Coverage | Target | Status |
|
||||||
|-----------|:--------:|:------:|:------:|
|
|-----------|:--------:|:------:|:------:|
|
||||||
| ViewModel | 85% | 80% | ✅ |
|
| ViewModel | 85% | 80% | [x] Pass |
|
||||||
| Screen | 72% | 60% | ✅ |
|
| Screen | 72% | 60% | [x] Pass |
|
||||||
| Repository | 90% | 80% | ✅ |
|
| Repository | 90% | 80% | [x] Pass |
|
||||||
|
|
||||||
---
|
## Index Updated
|
||||||
|
|
||||||
## Next Steps
|
[x] feature-layer/TESTING_STATUS.md - Updated test counts
|
||||||
|
[x] Last run: [timestamp]
|
||||||
|
|
||||||
1. Fix failing tests: `/gap-planning auth testing`
|
+---------+----------------------------------------------------------+
|
||||||
2. Increase coverage: Add tests for uncovered paths
|
| NEXT STEPS |
|
||||||
3. Re-run: `/verify-tests auth`
|
+---------+----------------------------------------------------------+
|
||||||
|
| 1 | Fix failing tests: LoginScreenTest.kt:45, :32 |
|
||||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
| 2 | Increase coverage: Add tests for uncovered paths |
|
||||||
|
| 3 | Re-run: /verify-tests auth |
|
||||||
|
+---------+----------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Test Commands Reference
|
## Error Handling
|
||||||
|
|
||||||
### Unit Tests (ViewModel + Repository)
|
### Feature Not Found
|
||||||
|
|
||||||
```bash
|
```
|
||||||
# All unit tests
|
+-------------------------------------------------------------------------+
|
||||||
./gradlew test
|
| ERROR: Feature '[name]' not found |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
# Specific module
|
| |
|
||||||
./gradlew :feature:auth:test
|
| The feature '[name]' does not exist in MODULES_INDEX.md |
|
||||||
./gradlew :core:data:test
|
| |
|
||||||
|
| Available features: |
|
||||||
# With coverage
|
| auth, home, accounts, beneficiary, loan-account, savings-account, |
|
||||||
./gradlew test jacocoTestReport
|
| share-account, transfer, recent-transaction, notification, settings, |
|
||||||
|
| passcode, guarantor, qr, location, user-profile |
|
||||||
|
| |
|
||||||
|
| Did you mean: [closest match]? |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
### UI Tests (Compose)
|
### No Tests Found
|
||||||
|
|
||||||
```bash
|
```
|
||||||
# All UI tests (requires emulator/device)
|
+-------------------------------------------------------------------------+
|
||||||
./gradlew connectedDebugAndroidTest
|
| WARNING: No tests found for '[feature]' |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
# Specific feature
|
| |
|
||||||
./gradlew :feature:auth:connectedDebugAndroidTest
|
| Test directory: feature/[module]/src/commonTest/ |
|
||||||
|
| Status: Empty |
|
||||||
|
| |
|
||||||
|
| To create tests: |
|
||||||
|
| 1. Run /gap-planning [feature] testing |
|
||||||
|
| 2. Follow TDD pattern in TESTING_STATUS.md |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
### Integration Tests (E2E)
|
### Gradle Error
|
||||||
|
|
||||||
```bash
|
|
||||||
# Full E2E tests
|
|
||||||
./gradlew :cmp-android:connectedDebugAndroidTest
|
|
||||||
|
|
||||||
# Specific test class
|
|
||||||
./gradlew :cmp-android:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=org.mifos.mobile.AuthFlowTest
|
|
||||||
```
|
```
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
### Screenshot Tests (Roborazzi)
|
| ERROR: Gradle build failed |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
```bash
|
| |
|
||||||
# Record golden images
|
| Command: ./gradlew :feature:[module]:test |
|
||||||
./gradlew :core:designsystem:recordRoborazziDebug
|
| Exit code: 1 |
|
||||||
|
| |
|
||||||
# Compare against golden images
|
| Error output: |
|
||||||
./gradlew :core:designsystem:compareRoborazziDebug
|
| [Gradle error message] |
|
||||||
|
| |
|
||||||
# View differences
|
| Suggestions: |
|
||||||
open build/reports/roborazzi/
|
| 1. Check compilation errors: ./gradlew :feature:[module]:compileKotlin |
|
||||||
|
| 2. Clean build: ./gradlew clean |
|
||||||
|
| 3. Check dependencies: ./gradlew :feature:[module]:dependencies |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Instructions for Claude
|
|
||||||
|
|
||||||
### Step 1: Determine Test Scope
|
|
||||||
|
|
||||||
| Parameter | Test Type | Gradle Command |
|
|
||||||
|-----------|-----------|----------------|
|
|
||||||
| (none) | All tests | `./gradlew test connectedDebugAndroidTest` |
|
|
||||||
| `[feature]` | Feature tests | `./gradlew :feature:[name]:test` |
|
|
||||||
| `[feature] unit` | ViewModel only | `./gradlew :feature:[name]:test` |
|
|
||||||
| `[feature] ui` | Screen tests | `./gradlew :feature:[name]:connectedDebugAndroidTest` |
|
|
||||||
| `[feature] integration` | E2E flow | `./gradlew :cmp-android:connectedDebugAndroidTest` |
|
|
||||||
| `[feature] screenshot` | Visual | `./gradlew :core:designsystem:compareRoborazziDebug` |
|
|
||||||
| `client` | Repositories | `./gradlew :core:data:test` |
|
|
||||||
| `feature` | All ViewModels | `./gradlew feature:test` |
|
|
||||||
| `platform` | All E2E | `./gradlew :cmp-android:connectedDebugAndroidTest` |
|
|
||||||
|
|
||||||
### Step 2: Execute Tests
|
|
||||||
|
|
||||||
Run the appropriate Gradle command and capture output.
|
|
||||||
|
|
||||||
### Step 3: Parse Results
|
|
||||||
|
|
||||||
From Gradle output, extract:
|
|
||||||
- Total tests run
|
|
||||||
- Tests passed
|
|
||||||
- Tests failed
|
|
||||||
- Failed test names and errors
|
|
||||||
- Coverage percentages (if available)
|
|
||||||
|
|
||||||
### Step 4: Generate Report
|
|
||||||
|
|
||||||
Display results in the formatted output above.
|
|
||||||
|
|
||||||
### Step 5: Suggest Next Steps
|
|
||||||
|
|
||||||
Based on results:
|
|
||||||
- If all pass: "All tests passing. Coverage: X%"
|
|
||||||
- If failures: List fixes needed with file paths
|
|
||||||
- If low coverage: Suggest adding tests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature Test Mapping
|
|
||||||
|
|
||||||
| Feature | Unit Test Path | UI Test Path |
|
|
||||||
|---------|----------------|--------------|
|
|
||||||
| auth | `feature/auth/src/commonTest/` | `feature/auth/src/androidInstrumentedTest/` |
|
|
||||||
| home | `feature/home/src/commonTest/` | `feature/home/src/androidInstrumentedTest/` |
|
|
||||||
| accounts | `feature/account/src/commonTest/` | `feature/account/src/androidInstrumentedTest/` |
|
|
||||||
| beneficiary | `feature/beneficiary/src/commonTest/` | `feature/beneficiary/src/androidInstrumentedTest/` |
|
|
||||||
| loan-account | `feature/loan-account/src/commonTest/` | `feature/loan-account/src/androidInstrumentedTest/` |
|
|
||||||
| savings-account | `feature/savings-account/src/commonTest/` | `feature/savings-account/src/androidInstrumentedTest/` |
|
|
||||||
| transfer | `feature/transfer-process/src/commonTest/` | `feature/transfer-process/src/androidInstrumentedTest/` |
|
|
||||||
| notification | `feature/notification/src/commonTest/` | `feature/notification/src/androidInstrumentedTest/` |
|
|
||||||
| settings | `feature/settings/src/commonTest/` | `feature/settings/src/androidInstrumentedTest/` |
|
|
||||||
| qr | `feature/qr-code/src/commonTest/` | `feature/qr-code/src/androidInstrumentedTest/` |
|
|
||||||
| guarantor | `feature/guarantor/src/commonTest/` | `feature/guarantor/src/androidInstrumentedTest/` |
|
|
||||||
| passcode | `libs/mifos-passcode/src/commonTest/` | `libs/mifos-passcode/src/androidInstrumentedTest/` |
|
|
||||||
| location | `feature/location/src/commonTest/` | `feature/location/src/androidInstrumentedTest/` |
|
|
||||||
| user-profile | `feature/user-profile/src/commonTest/` | `feature/user-profile/src/androidInstrumentedTest/` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coverage Targets
|
## Coverage Targets
|
||||||
|
|
||||||
| Component | Minimum | Target | Excellent |
|
| Component | Minimum | Target | Excellent |
|
||||||
@ -188,10 +343,104 @@ Based on results:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TestTag System (from TESTING_STATUS.md)
|
||||||
|
|
||||||
|
### Pattern: `feature:component:element`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object TestTags {
|
||||||
|
object Auth {
|
||||||
|
const val SCREEN = "auth:screen"
|
||||||
|
const val USERNAME_FIELD = "auth:username"
|
||||||
|
const val PASSWORD_FIELD = "auth:password"
|
||||||
|
const val LOGIN_BUTTON = "auth:loginButton"
|
||||||
|
const val ERROR_MESSAGE = "auth:error"
|
||||||
|
const val LOADING_INDICATOR = "auth:loading"
|
||||||
|
}
|
||||||
|
// ... for all features
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Test Flows (from platform-layer/TESTING_STATUS.md)
|
||||||
|
|
||||||
|
| # | Flow | Screens | Tests | Status |
|
||||||
|
|:-:|------|:-------:|:-----:|:------:|
|
||||||
|
| 1 | Login -> Passcode -> Home | 3 | 0 | [ ] |
|
||||||
|
| 2 | Registration -> OTP -> Login | 4 | 0 | [ ] |
|
||||||
|
| 3 | Home -> Account Details | 2 | 0 | [ ] |
|
||||||
|
| 4 | Home -> Transfer -> Confirm | 3 | 0 | [ ] |
|
||||||
|
| 5 | Home -> Beneficiary -> Add | 2 | 0 | [ ] |
|
||||||
|
| 6 | Settings -> Change Password | 2 | 0 | [ ] |
|
||||||
|
| 7 | Loan -> Schedule -> Summary | 3 | 0 | [ ] |
|
||||||
|
| 8 | QR -> Scan -> Transfer | 3 | 0 | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Related Commands
|
## Related Commands
|
||||||
|
|
||||||
- `/gap-analysis testing` - View testing status
|
| Command | Purpose |
|
||||||
- `/gap-planning testing [layer]` - Plan test implementation
|
|---------|---------|
|
||||||
- `/verify [feature]` - Verify implementation vs spec
|
| `/gap-analysis testing` | View all testing gaps |
|
||||||
|
| `/gap-analysis [layer] testing` | Layer-specific test gaps |
|
||||||
|
| `/gap-planning [feature] testing` | Plan test implementation |
|
||||||
|
| `/verify [feature]` | Verify implementation vs spec |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-product-cycle/
|
||||||
|
+-- feature-layer/
|
||||||
|
| +-- TESTING_STATUS.md # O(1) ViewModel/Screen test status
|
||||||
|
| +-- MODULES_INDEX.md # Feature -> path mapping
|
||||||
|
+-- client-layer/
|
||||||
|
| +-- TESTING_STATUS.md # O(1) Repository test status
|
||||||
|
+-- platform-layer/
|
||||||
|
| +-- TESTING_STATUS.md # O(1) E2E/Screenshot status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gradle Commands Reference
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All unit tests
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
# Specific module
|
||||||
|
./gradlew :feature:auth:test
|
||||||
|
./gradlew :core:data:test
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
./gradlew test jacocoTestReport
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All UI tests (requires emulator/device)
|
||||||
|
./gradlew connectedDebugAndroidTest
|
||||||
|
|
||||||
|
# Specific feature
|
||||||
|
./gradlew :feature:auth:connectedDebugAndroidTest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshot Tests (Roborazzi)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Record golden images
|
||||||
|
./gradlew :core:designsystem:recordRoborazziDebug
|
||||||
|
|
||||||
|
# Compare against golden images
|
||||||
|
./gradlew :core:designsystem:compareRoborazziDebug
|
||||||
|
|
||||||
|
# View differences
|
||||||
|
open build/reports/roborazzi/
|
||||||
|
```
|
||||||
|
|
||||||
ARGUMENTS: $ARGUMENTS
|
ARGUMENTS: $ARGUMENTS
|
||||||
|
|||||||
@ -1,169 +1,736 @@
|
|||||||
# /verify - Implementation Verification
|
# /verify - Implementation Verification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Validate that the implementation matches the specification. Identify gaps between SPEC.md and actual code.
|
|
||||||
|
Validate implementation matches specification using O(1) lookup. Compares SPEC.md requirements against actual code and identifies gaps with actionable fixes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workflow
|
## Command Variants
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────────────────────────────────────────────────────────┐
|
/verify # Show all features verification status
|
||||||
│ /verify [Feature] WORKFLOW │
|
/verify [Feature] # Full verification for feature
|
||||||
├───────────────────────────────────────────────────────────────────┤
|
/verify [Feature] --quick # Skip detailed code analysis
|
||||||
│ │
|
/verify [Feature] --spec # Verify spec completeness only
|
||||||
│ PHASE 1: READ SPEC │
|
/verify [Feature] --code # Verify code completeness only
|
||||||
│ ├─→ Read features/[feature]/SPEC.md │
|
/verify all # Verify all features (summary)
|
||||||
│ ├─→ Extract all UI sections │
|
|
||||||
│ ├─→ Extract all user actions │
|
|
||||||
│ ├─→ Extract state model │
|
|
||||||
│ └─→ Extract API requirements │
|
|
||||||
│ │
|
|
||||||
│ PHASE 2: CHECK ACTUAL CODE │
|
|
||||||
│ ├─→ Read feature/[name]/*ViewModel.kt │
|
|
||||||
│ ├─→ Read feature/[name]/*Screen.kt │
|
|
||||||
│ ├─→ Read feature/[name]/components/*.kt │
|
|
||||||
│ ├─→ Read core/network/services/*Service.kt │
|
|
||||||
│ └─→ Read core/data/repository/*Repository.kt │
|
|
||||||
│ │
|
|
||||||
│ PHASE 3: COMPARE SPEC VS CODE │
|
|
||||||
│ ├─→ All sections from spec implemented? │
|
|
||||||
│ ├─→ All user actions handled? │
|
|
||||||
│ ├─→ State model matches? │
|
|
||||||
│ ├─→ All API calls present? │
|
|
||||||
│ └─→ DI registration complete? │
|
|
||||||
│ │
|
|
||||||
│ PHASE 4: CHECK LAYER INTEGRITY │
|
|
||||||
│ ├─→ Network → Data → Feature flow correct? │
|
|
||||||
│ ├─→ No layer violations? │
|
|
||||||
│ └─→ Navigation configured? │
|
|
||||||
│ │
|
|
||||||
│ PHASE 5: GENERATE REPORT │
|
|
||||||
│ ├─→ List all gaps found │
|
|
||||||
│ ├─→ List suggestions for improvement │
|
|
||||||
│ └─→ Output: Gap report or "✅ Feature verified" │
|
|
||||||
│ │
|
|
||||||
└───────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Verification Checklist
|
## Verification Pipeline with O(1) Optimization
|
||||||
|
|
||||||
### UI Sections
|
|
||||||
- [ ] All sections from SPEC.md ASCII mockup present in Screen
|
|
||||||
- [ ] Loading state handled
|
|
||||||
- [ ] Error state handled
|
|
||||||
- [ ] Empty state handled (if applicable)
|
|
||||||
|
|
||||||
### User Actions
|
|
||||||
- [ ] All actions from SPEC.md handled in ViewModel
|
|
||||||
- [ ] Actions trigger correct events/state changes
|
|
||||||
- [ ] Navigation works correctly
|
|
||||||
|
|
||||||
### State Model
|
|
||||||
- [ ] State class matches SPEC.md definition
|
|
||||||
- [ ] All required fields present
|
|
||||||
- [ ] Correct default values
|
|
||||||
|
|
||||||
### API Integration
|
|
||||||
- [ ] All required endpoints called
|
|
||||||
- [ ] Error handling for API failures
|
|
||||||
- [ ] Loading states during API calls
|
|
||||||
|
|
||||||
### DI Registration
|
|
||||||
- [ ] ViewModel registered in module
|
|
||||||
- [ ] Repository registered in module
|
|
||||||
- [ ] Service registered in module
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
- [ ] Route defined
|
|
||||||
- [ ] Screen registered in nav graph
|
|
||||||
- [ ] Navigation parameters correct
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Output Templates
|
|
||||||
|
|
||||||
### All Good:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
╔══════════════════════════════════════════════════════════════════════╗
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
║ ✅ VERIFICATION COMPLETE - [Feature] ║
|
│ /verify [Feature] - O(1) OPTIMIZED PIPELINE │
|
||||||
╠══════════════════════════════════════════════════════════════════════╣
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
║ ║
|
│ │
|
||||||
║ UI SECTIONS: ✅ All 5 sections implemented ║
|
│ PHASE 0: O(1) CONTEXT LOADING │
|
||||||
║ USER ACTIONS: ✅ All 8 actions handled ║
|
│ ├─→ Read FEATURES_INDEX.md → Feature exists? Spec status? │
|
||||||
║ STATE MODEL: ✅ Matches specification ║
|
│ ├─→ Read FEATURE_MAP.md → Expected services/repos │
|
||||||
║ API INTEGRATION: ✅ All 3 endpoints called ║
|
│ ├─→ Read MODULES_INDEX.md → Expected VMs/Screens │
|
||||||
║ DI REGISTRATION: ✅ Complete ║
|
│ ├─→ Read SCREENS_INDEX.md → Screen-ViewModel mapping │
|
||||||
║ NAVIGATION: ✅ Configured ║
|
│ └─→ Read API_INDEX.md → Expected endpoints │
|
||||||
║ ║
|
│ │
|
||||||
║ RESULT: Feature fully implements specification ║
|
│ PHASE 1: SPEC ANALYSIS │
|
||||||
║ ║
|
│ ├─→ Read features/[name]/SPEC.md → Extract requirements │
|
||||||
╚══════════════════════════════════════════════════════════════════════╝
|
│ ├─→ Read features/[name]/API.md → Extract API requirements │
|
||||||
```
|
│ ├─→ Read features/[name]/STATUS.md → Current status claims │
|
||||||
|
│ └─→ Build requirement checklist → What SHOULD exist │
|
||||||
### Gaps Found:
|
│ │
|
||||||
|
│ PHASE 2: CODE ANALYSIS (O(1) paths from indexes) │
|
||||||
```
|
│ ├─→ Check ViewModel exists → From SCREENS_INDEX.md path │
|
||||||
╔══════════════════════════════════════════════════════════════════════╗
|
│ ├─→ Check Screen exists → From SCREENS_INDEX.md path │
|
||||||
║ ⚠️ VERIFICATION COMPLETE - GAPS FOUND ║
|
│ ├─→ Check Service exists → From FEATURE_MAP.md path │
|
||||||
╠══════════════════════════════════════════════════════════════════════╣
|
│ ├─→ Check Repository exists → From FEATURE_MAP.md path │
|
||||||
║ ║
|
│ └─→ Build implementation checklist → What DOES exist │
|
||||||
║ FEATURE: [Feature] ║
|
│ │
|
||||||
║ SPEC: claude-product-cycle/design-spec-layer/features/[feature]/SPEC.md║
|
│ PHASE 3: DEEP VERIFICATION (if not --quick) │
|
||||||
║ ║
|
│ ├─→ Read ViewModel code → Check State/Event/Action │
|
||||||
╠══════════════════════════════════════════════════════════════════════╣
|
│ ├─→ Read Screen code → Check UI states, TestTags │
|
||||||
║ GAPS IDENTIFIED ║
|
│ ├─→ Compare SPEC actions vs code → All actions handled? │
|
||||||
╠══════════════════════════════════════════════════════════════════════╣
|
│ ├─→ Compare SPEC states vs code → All states rendered? │
|
||||||
║ ║
|
│ └─→ Check DI registration → Koin modules complete? │
|
||||||
║ UI SECTIONS (2 gaps): ║
|
│ │
|
||||||
║ ├─ ❌ Empty state not implemented ║
|
│ PHASE 4: GAP DETECTION │
|
||||||
║ └─ ❌ Pull-to-refresh missing ║
|
│ ├─→ Compare requirement vs impl → Identify missing items │
|
||||||
║ ║
|
│ ├─→ Categorize gaps by severity → P0 (critical) → P2 (polish) │
|
||||||
║ USER ACTIONS (1 gap): ║
|
│ ├─→ Generate fix suggestions → Actionable steps │
|
||||||
║ └─ ❌ Filter action not handled ║
|
│ └─→ Calculate verification score → Percentage complete │
|
||||||
║ ║
|
│ │
|
||||||
║ API INTEGRATION (1 gap): ║
|
│ PHASE 5: REPORT & UPDATE │
|
||||||
║ └─ ❌ /self/endpoint not called ║
|
│ ├─→ Generate verification report → Structured output │
|
||||||
║ ║
|
│ ├─→ Update STATUS.md (optional) → If user approves │
|
||||||
║ TOTAL GAPS: 4 ║
|
│ └─→ Suggest next command → /implement or /gap-planning │
|
||||||
║ ║
|
│ │
|
||||||
╠══════════════════════════════════════════════════════════════════════╣
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
║ SUGGESTED FIXES ║
|
|
||||||
╠══════════════════════════════════════════════════════════════════════╣
|
|
||||||
║ ║
|
|
||||||
║ 1. Add EmptyContent composable in Screen ║
|
|
||||||
║ 2. Add SwipeRefresh wrapper in Screen ║
|
|
||||||
║ 3. Add FilterAction and handleFilter() in ViewModel ║
|
|
||||||
║ 4. Add endpoint call in Repository ║
|
|
||||||
║ ║
|
|
||||||
╠══════════════════════════════════════════════════════════════════════╣
|
|
||||||
║ NEXT STEP ║
|
|
||||||
║ ║
|
|
||||||
║ Run: /implement [Feature] ║
|
|
||||||
║ Or fix gaps manually and run: /verify [Feature] ║
|
|
||||||
║ ║
|
|
||||||
╚══════════════════════════════════════════════════════════════════════╝
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Files to Compare
|
## PHASE 0: O(1) Context Loading
|
||||||
|
|
||||||
| Spec File | Code Files |
|
### Files to Read (~500 lines total instead of scanning)
|
||||||
|-----------|------------|
|
|
||||||
| features/[feature]/SPEC.md | feature/[name]/*ViewModel.kt |
|
| File | Purpose | Data Extracted |
|
||||||
| | feature/[name]/*Screen.kt |
|
|------|---------|----------------|
|
||||||
| | feature/[name]/components/*.kt |
|
| `design-spec-layer/FEATURES_INDEX.md` | Feature inventory | featureExists, specStatus |
|
||||||
| features/[feature]/API.md | core/network/services/*Service.kt |
|
| `client-layer/FEATURE_MAP.md` | Service/Repo mapping | expectedServices[], expectedRepos[] |
|
||||||
| | core/data/repository/*Repository.kt |
|
| `feature-layer/MODULES_INDEX.md` | Module structure | expectedVMs, expectedScreens |
|
||||||
|
| `feature-layer/SCREENS_INDEX.md` | Screen details | screenPaths[], vmPaths[] |
|
||||||
|
| `server-layer/API_INDEX.md` | Endpoint inventory | expectedEndpoints[] |
|
||||||
|
| `testing-layer/TEST_TAGS_INDEX.md` | TestTag specs | expectedTags[], namingPattern |
|
||||||
|
| `testing-layer/LAYER_STATUS.md` | Test coverage | testCoverage, fakeRepos |
|
||||||
|
|
||||||
|
### Context Object Built
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val context = VerifyContext(
|
||||||
|
feature = "beneficiary",
|
||||||
|
|
||||||
|
// From FEATURES_INDEX.md
|
||||||
|
specExists = true,
|
||||||
|
specStatus = "✅ Complete",
|
||||||
|
|
||||||
|
// From FEATURE_MAP.md
|
||||||
|
expectedServices = ["BeneficiaryService"],
|
||||||
|
expectedRepositories = ["BeneficiaryRepository"],
|
||||||
|
|
||||||
|
// From MODULES_INDEX.md
|
||||||
|
expectedVMs = 4,
|
||||||
|
expectedScreens = 4,
|
||||||
|
|
||||||
|
// From SCREENS_INDEX.md
|
||||||
|
screens = [
|
||||||
|
Screen("BeneficiaryListScreen", "BeneficiaryListViewModel"),
|
||||||
|
Screen("BeneficiaryDetailScreen", "BeneficiaryDetailViewModel"),
|
||||||
|
Screen("BeneficiaryApplicationScreen", "BeneficiaryApplicationViewModel"),
|
||||||
|
Screen("BeneficiaryApplicationConfirmationScreen", "BeneficiaryApplicationConfirmationViewModel")
|
||||||
|
],
|
||||||
|
|
||||||
|
// From API_INDEX.md
|
||||||
|
expectedEndpoints = [
|
||||||
|
"GET /beneficiaries",
|
||||||
|
"POST /beneficiaries",
|
||||||
|
"PUT /beneficiaries/{id}",
|
||||||
|
"DELETE /beneficiaries/{id}"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Status Update
|
## PHASE 1: Spec Analysis
|
||||||
|
|
||||||
After verification, update:
|
### Read Specification Files
|
||||||
1. `features/[feature]/STATUS.md` - Feature status
|
|
||||||
2. `claude-product-cycle/design-spec-layer/STATUS.md` - Main tracker
|
```
|
||||||
|
design-spec-layer/features/[feature]/
|
||||||
|
├── SPEC.md → UI sections, user actions, state model
|
||||||
|
├── API.md → Required endpoints, DTOs
|
||||||
|
└── STATUS.md → Claimed implementation status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extract Requirements from SPEC.md
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val specRequirements = SpecRequirements(
|
||||||
|
// From SPEC.md Section 2: Screen Layout
|
||||||
|
uiSections = ["Header", "List", "EmptyState", "ErrorState", "LoadingState"],
|
||||||
|
|
||||||
|
// From SPEC.md Section 3: User Interactions
|
||||||
|
userActions = [
|
||||||
|
Action("Retry", "Reload data on error"),
|
||||||
|
Action("PullRefresh", "Refresh list"),
|
||||||
|
Action("ItemClick", "Navigate to detail"),
|
||||||
|
Action("AddClick", "Navigate to add form"),
|
||||||
|
Action("DeleteClick", "Delete with confirmation")
|
||||||
|
],
|
||||||
|
|
||||||
|
// From SPEC.md Section 4: State Model
|
||||||
|
stateFields = ["data", "uiState", "isRefreshing", "selectedItem"],
|
||||||
|
screenStates = ["Loading", "Success", "Error", "Empty"],
|
||||||
|
|
||||||
|
// From SPEC.md Section 5: API Requirements
|
||||||
|
apiEndpoints = ["GET /beneficiaries", "POST /beneficiaries", ...]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2: Code Analysis (O(1) Paths)
|
||||||
|
|
||||||
|
### File Paths from Index Files
|
||||||
|
|
||||||
|
| Component | Path Source | Example Path |
|
||||||
|
|-----------|-------------|--------------|
|
||||||
|
| ViewModel | SCREENS_INDEX.md | `feature/beneficiary/.../viewmodel/BeneficiaryListViewModel.kt` |
|
||||||
|
| Screen | SCREENS_INDEX.md | `feature/beneficiary/.../ui/BeneficiaryListScreen.kt` |
|
||||||
|
| Service | FEATURE_MAP.md | `core/network/.../services/BeneficiaryService.kt` |
|
||||||
|
| Repository | FEATURE_MAP.md | `core/data/.../repository/BeneficiaryRepository.kt` |
|
||||||
|
| DI Module | MODULES_INDEX.md | `feature/beneficiary/.../di/BeneficiaryModule.kt` |
|
||||||
|
|
||||||
|
### Check File Existence
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val codeAnalysis = CodeAnalysis(
|
||||||
|
// File existence checks
|
||||||
|
viewModelsExist = [true, true, true, true], // 4/4
|
||||||
|
screensExist = [true, true, true, true], // 4/4
|
||||||
|
serviceExists = true,
|
||||||
|
repositoryExists = true,
|
||||||
|
diModuleExists = true,
|
||||||
|
|
||||||
|
// Navigation check
|
||||||
|
navigationRegistered = true,
|
||||||
|
|
||||||
|
// TestTags check
|
||||||
|
testTagsExist = false // Gap detected!
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 3: Deep Verification
|
||||||
|
|
||||||
|
### ViewModel Verification
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Read ViewModel and check:
|
||||||
|
val vmVerification = ViewModelVerification(
|
||||||
|
// State class
|
||||||
|
hasStateClass = true,
|
||||||
|
stateFieldsMatch = compareFields(spec.stateFields, vm.stateFields),
|
||||||
|
missingStateFields = ["selectedItem"], // Gap!
|
||||||
|
|
||||||
|
// Screen states
|
||||||
|
hasScreenStates = true,
|
||||||
|
screenStatesMatch = compareStates(spec.screenStates, vm.screenStates),
|
||||||
|
missingScreenStates = [],
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
hasActionInterface = true,
|
||||||
|
actionsMatch = compareActions(spec.userActions, vm.actions),
|
||||||
|
missingActions = ["DeleteClick"], // Gap!
|
||||||
|
|
||||||
|
// Events
|
||||||
|
hasEventInterface = true,
|
||||||
|
eventsImplemented = true
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screen Verification
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Read Screen and check:
|
||||||
|
val screenVerification = ScreenVerification(
|
||||||
|
// UI states rendered
|
||||||
|
hasLoadingState = true,
|
||||||
|
hasSuccessState = true,
|
||||||
|
hasErrorState = true,
|
||||||
|
hasEmptyState = false, // Gap!
|
||||||
|
|
||||||
|
// TestTags
|
||||||
|
hasTestTags = false, // Gap!
|
||||||
|
testTagsObject = null,
|
||||||
|
|
||||||
|
// Event collection
|
||||||
|
collectsEvents = true,
|
||||||
|
|
||||||
|
// Content separation
|
||||||
|
hasContentComposable = true
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### DI Verification
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val diVerification = DiVerification(
|
||||||
|
viewModelRegistered = true,
|
||||||
|
repositoryRegistered = true,
|
||||||
|
serviceRegistered = true
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 4: Gap Detection
|
||||||
|
|
||||||
|
### Gap Categories
|
||||||
|
|
||||||
|
| Severity | Description | Examples |
|
||||||
|
|:--------:|-------------|----------|
|
||||||
|
| P0 | Critical - App won't work | Missing ViewModel, Service not registered |
|
||||||
|
| P1 | Major - Feature incomplete | Missing action handler, Empty state |
|
||||||
|
| P2 | Minor - Polish needed | Missing TestTags, Missing Preview |
|
||||||
|
|
||||||
|
### Gap Report Structure
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val gaps = GapReport(
|
||||||
|
feature = "beneficiary",
|
||||||
|
score = 85, // 85% complete
|
||||||
|
|
||||||
|
p0Gaps = [], // None - critical items present
|
||||||
|
|
||||||
|
p1Gaps = [
|
||||||
|
Gap(
|
||||||
|
category = "ViewModel",
|
||||||
|
item = "DeleteClick action",
|
||||||
|
specReference = "SPEC.md Section 3.5",
|
||||||
|
suggestedFix = "Add DeleteClick to BeneficiaryAction sealed interface"
|
||||||
|
),
|
||||||
|
Gap(
|
||||||
|
category = "Screen",
|
||||||
|
item = "Empty state",
|
||||||
|
specReference = "SPEC.md Section 2.4",
|
||||||
|
suggestedFix = "Add BeneficiaryEmpty composable when data.isEmpty()"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
|
||||||
|
p2Gaps = [
|
||||||
|
Gap(
|
||||||
|
category = "Testing",
|
||||||
|
item = "TestTags object",
|
||||||
|
specReference = "Testing standards",
|
||||||
|
suggestedFix = "Add BeneficiaryTestTags object with feature:component pattern"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 5: Report Generation
|
||||||
|
|
||||||
|
### Full Verification Report
|
||||||
|
|
||||||
|
```
|
||||||
|
╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ /verify beneficiary - VERIFICATION REPORT ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ 📊 VERIFICATION SCORE: 85% [████████░░] ║
|
||||||
|
║ ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ 📚 O(1) CONTEXT LOADED ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ FEATURES_INDEX.md → Feature exists: ✅ Spec status: ✅ Complete ║
|
||||||
|
║ FEATURE_MAP.md → Services: 1 expected Repos: 1 expected ║
|
||||||
|
║ MODULES_INDEX.md → VMs: 4 expected Screens: 4 expected ║
|
||||||
|
║ SCREENS_INDEX.md → 4 screen-VM mappings found ║
|
||||||
|
║ API_INDEX.md → 4 endpoints expected ║
|
||||||
|
║ ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ✅ PASSING CHECKS ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ CLIENT LAYER: ║
|
||||||
|
║ ├─ BeneficiaryService.kt ✅ Exists ║
|
||||||
|
║ ├─ BeneficiaryRepository.kt ✅ Exists ║
|
||||||
|
║ ├─ BeneficiaryRepositoryImp.kt ✅ Exists ║
|
||||||
|
║ ├─ NetworkModule registration ✅ Registered ║
|
||||||
|
║ └─ RepositoryModule registration ✅ Registered ║
|
||||||
|
║ ║
|
||||||
|
║ FEATURE LAYER: ║
|
||||||
|
║ ├─ BeneficiaryListViewModel.kt ✅ Exists ║
|
||||||
|
║ ├─ BeneficiaryDetailViewModel.kt ✅ Exists ║
|
||||||
|
║ ├─ BeneficiaryApplicationViewModel.kt ✅ Exists ║
|
||||||
|
║ ├─ BeneficiaryApplicationConfirmationVM.kt ✅ Exists ║
|
||||||
|
║ ├─ 4 Screen files ✅ All exist ║
|
||||||
|
║ ├─ BeneficiaryModule.kt ✅ DI registered ║
|
||||||
|
║ └─ Navigation ✅ Configured ║
|
||||||
|
║ ║
|
||||||
|
║ STATE MODEL: ║
|
||||||
|
║ ├─ State class ✅ Defined ║
|
||||||
|
║ ├─ ScreenState sealed interface ✅ Loading/Success/Error ║
|
||||||
|
║ ├─ Event sealed interface ✅ Navigation events ║
|
||||||
|
║ └─ Action sealed interface ✅ User actions ║
|
||||||
|
║ ║
|
||||||
|
║ API INTEGRATION: ║
|
||||||
|
║ ├─ GET /beneficiaries ✅ Called ║
|
||||||
|
║ ├─ POST /beneficiaries ✅ Called ║
|
||||||
|
║ ├─ PUT /beneficiaries/{id} ✅ Called ║
|
||||||
|
║ └─ DELETE /beneficiaries/{id} ✅ Called ║
|
||||||
|
║ ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ⚠️ GAPS FOUND (3) ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ P1 - MAJOR (2): ║
|
||||||
|
║ ┌────────────────────────────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ Gap: Empty state not implemented │ ║
|
||||||
|
║ │ Spec: SPEC.md Section 2.4 - "Show empty illustration when no data" │ ║
|
||||||
|
║ │ File: feature/beneficiary/.../ui/BeneficiaryListScreen.kt │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ 📍 Fix: │ ║
|
||||||
|
║ │ Add to BeneficiaryListScreen: │ ║
|
||||||
|
║ │ ```kotlin │ ║
|
||||||
|
║ │ is BeneficiaryUiState.Empty -> { │ ║
|
||||||
|
║ │ BeneficiaryEmpty( │ ║
|
||||||
|
║ │ onAddClick = { onAction(BeneficiaryAction.OnAddClick) } │ ║
|
||||||
|
║ │ ) │ ║
|
||||||
|
║ │ } │ ║
|
||||||
|
║ │ ``` │ ║
|
||||||
|
║ └────────────────────────────────────────────────────────────────────────┘ ║
|
||||||
|
║ ║
|
||||||
|
║ ┌────────────────────────────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ Gap: selectedItem state field missing │ ║
|
||||||
|
║ │ Spec: SPEC.md Section 4.1 - State includes selectedItem for delete │ ║
|
||||||
|
║ │ File: feature/beneficiary/.../viewmodel/BeneficiaryListViewModel.kt │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ 📍 Fix: │ ║
|
||||||
|
║ │ Add to BeneficiaryState: │ ║
|
||||||
|
║ │ ```kotlin │ ║
|
||||||
|
║ │ val selectedItem: Beneficiary? = null, │ ║
|
||||||
|
║ │ ``` │ ║
|
||||||
|
║ └────────────────────────────────────────────────────────────────────────┘ ║
|
||||||
|
║ ║
|
||||||
|
║ P2 - MINOR (1): ║
|
||||||
|
║ ┌────────────────────────────────────────────────────────────────────────┐ ║
|
||||||
|
║ │ Gap: TestTags object missing │ ║
|
||||||
|
║ │ Spec: Testing standards - All screens should have TestTags │ ║
|
||||||
|
║ │ File: feature/beneficiary/.../ui/BeneficiaryTestTags.kt (create) │ ║
|
||||||
|
║ │ │ ║
|
||||||
|
║ │ 📍 Fix: Run /feature beneficiary --tags to generate │ ║
|
||||||
|
║ └────────────────────────────────────────────────────────────────────────┘ ║
|
||||||
|
║ ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ 📋 SUMMARY ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ | Category | Expected | Found | Score | ║
|
||||||
|
║ |---------------|:--------:|:-----:|:-----:| ║
|
||||||
|
║ | Client Layer | 5 | 5 | 100% | ║
|
||||||
|
║ | Feature Layer | 10 | 9 | 90% | ║
|
||||||
|
║ | State Model | 8 | 7 | 87% | ║
|
||||||
|
║ | API Calls | 4 | 4 | 100% | ║
|
||||||
|
║ | Testing | 2 | 1 | 50% | ║
|
||||||
|
║ |---------------|----------|-------|-------| ║
|
||||||
|
║ | TOTAL | 29 | 26 | 85% | ║
|
||||||
|
║ ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ 🎯 NEXT STEPS ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ Options: ║
|
||||||
|
║ • f / fix → Run /implement beneficiary to auto-fix gaps ║
|
||||||
|
║ • m / manual → Fix gaps manually using suggestions above ║
|
||||||
|
║ • u / update → Update STATUS.md to reflect current state ║
|
||||||
|
║ • i / ignore → Mark gaps as intentional (document reason) ║
|
||||||
|
║ ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## All Features Verification (No Argument)
|
||||||
|
|
||||||
|
When `/verify` called without arguments, show summary from index files:
|
||||||
|
|
||||||
|
```
|
||||||
|
╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ /verify - ALL FEATURES VERIFICATION STATUS ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ Data from: FEATURES_INDEX.md, MODULES_INDEX.md, FEATURE_MAP.md ║
|
||||||
|
║ ║
|
||||||
|
║ | # | Feature | Spec | Client | Feature | Score | Gaps | ║
|
||||||
|
║ |:-:|-------------------|:----:|:------:|:-------:|:-----:|:----:| ║
|
||||||
|
║ | 1 | auth | ✅ | ✅ | ✅ | 95% | 1 | ║
|
||||||
|
║ | 2 | home | ✅ | ✅ | ✅ | 100% | 0 | ║
|
||||||
|
║ | 3 | accounts | ✅ | ✅ | ✅ | 98% | 1 | ║
|
||||||
|
║ | 4 | beneficiary | ✅ | ✅ | ✅ | 85% | 3 | ║
|
||||||
|
║ | 5 | loan-account | ✅ | ✅ | ✅ | 92% | 2 | ║
|
||||||
|
║ | 6 | savings-account | ✅ | ✅ | ✅ | 90% | 2 | ║
|
||||||
|
║ | 7 | share-account | ✅ | ✅ | ✅ | 88% | 2 | ║
|
||||||
|
║ | 8 | transfer | ✅ | ✅ | ✅ | 95% | 1 | ║
|
||||||
|
║ | 9 | recent-transaction| ✅ | ✅ | ✅ | 100% | 0 | ║
|
||||||
|
║ | 10| notification | ✅ | ✅ | ✅ | 100% | 0 | ║
|
||||||
|
║ | 11| settings | ✅ | ✅ | ✅ | 85% | 3 | ║
|
||||||
|
║ | 12| passcode | ✅ | - | ✅ | 100% | 0 | ║
|
||||||
|
║ | 13| guarantor | ✅ | ✅ | ✅ | 90% | 2 | ║
|
||||||
|
║ | 14| qr | ✅ | - | ✅ | 95% | 1 | ║
|
||||||
|
║ | 15| location | ✅ | - | ✅ | 80% | 2 | ║
|
||||||
|
║ | 16| client-charge | ✅ | ✅ | ✅ | 92% | 1 | ║
|
||||||
|
║ | 17| dashboard | ⚠️ | ❌ | ❌ | 20% | 8 | ║
|
||||||
|
║ ║
|
||||||
|
║ OVERALL: 89% verified | Total Gaps: 29 ║
|
||||||
|
║ ║
|
||||||
|
║ Commands: ║
|
||||||
|
║ • /verify [feature] → Detailed verification ║
|
||||||
|
║ • /verify all --fix → Show all gaps with fixes ║
|
||||||
|
║ • /gap-planning feature → Plan to fix gaps ║
|
||||||
|
║ ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist (Quick Reference)
|
||||||
|
|
||||||
|
### Client Layer Checks
|
||||||
|
|
||||||
|
| Check | Source | Verification |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| Service exists | FEATURE_MAP.md | File exists at path |
|
||||||
|
| Repository exists | FEATURE_MAP.md | File exists at path |
|
||||||
|
| RepositoryImpl exists | FEATURE_MAP.md | File exists at path |
|
||||||
|
| NetworkModule registration | NetworkModule.kt | Contains service binding |
|
||||||
|
| RepositoryModule registration | RepositoryModule.kt | Contains repo binding |
|
||||||
|
|
||||||
|
### Feature Layer Checks
|
||||||
|
|
||||||
|
| Check | Source | Verification |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| ViewModel exists | SCREENS_INDEX.md | File exists at path |
|
||||||
|
| Screen exists | SCREENS_INDEX.md | File exists at path |
|
||||||
|
| DI Module exists | MODULES_INDEX.md | File exists at path |
|
||||||
|
| Navigation registered | Navigation graph | Contains route |
|
||||||
|
|
||||||
|
### State Model Checks
|
||||||
|
|
||||||
|
| Check | Source | Verification |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| State class defined | ViewModel file | `data class ${Feature}State` |
|
||||||
|
| ScreenState sealed | ViewModel file | `sealed interface ${Feature}UiState` |
|
||||||
|
| Event sealed | ViewModel file | `sealed interface ${Feature}Event` |
|
||||||
|
| Action sealed | ViewModel file | `sealed interface ${Feature}Action` |
|
||||||
|
| handleAction implemented | ViewModel file | `override fun handleAction` |
|
||||||
|
|
||||||
|
### UI State Checks
|
||||||
|
|
||||||
|
| Check | Source | Verification |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| Loading state | Screen file | `${Feature}UiState.Loading` branch |
|
||||||
|
| Success state | Screen file | `${Feature}UiState.Success` branch |
|
||||||
|
| Error state | Screen file | `${Feature}UiState.Error` branch |
|
||||||
|
| Empty state | Screen file | `${Feature}UiState.Empty` branch (if in spec) |
|
||||||
|
|
||||||
|
### Testing Checks
|
||||||
|
|
||||||
|
| Check | Source | Verification |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| TestTags object | Screen directory | `${Feature}TestTags.kt` exists |
|
||||||
|
| testTag modifiers | Screen file | `Modifier.testTag()` used |
|
||||||
|
| TestTag naming | TestTags object | Follows `feature:component:id` pattern |
|
||||||
|
| All states tagged | Screen file | Loading, Success, Error have tags |
|
||||||
|
| Interactive elements | Screen file | Buttons, inputs have tags |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TestTag Validation (Enhanced)
|
||||||
|
|
||||||
|
### TestTag Naming Convention
|
||||||
|
|
||||||
|
Pattern: `feature:component:element`
|
||||||
|
|
||||||
|
| Component | Pattern | Example |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| Screen | `{feature}:screen` | `beneficiary:screen` |
|
||||||
|
| Loading | `{feature}:loading` | `beneficiary:loading` |
|
||||||
|
| Error | `{feature}:error` | `beneficiary:error` |
|
||||||
|
| List | `{feature}:list` | `beneficiary:list` |
|
||||||
|
| Item | `{feature}:item:{id}` | `beneficiary:item:123` |
|
||||||
|
| Button | `{feature}:{action}` | `beneficiary:retry`, `beneficiary:add` |
|
||||||
|
| Input | `{feature}:input:{name}` | `auth:input:username` |
|
||||||
|
|
||||||
|
### TestTag Validation Rules
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val testTagValidation = TestTagValidation(
|
||||||
|
// Required TestTags (P2 if missing)
|
||||||
|
required = [
|
||||||
|
"${feature}:screen",
|
||||||
|
"${feature}:loading",
|
||||||
|
"${feature}:error",
|
||||||
|
],
|
||||||
|
|
||||||
|
// Recommended TestTags (suggestions only)
|
||||||
|
recommended = [
|
||||||
|
"${feature}:list", // For list screens
|
||||||
|
"${feature}:item:{id}", // For list items
|
||||||
|
"${feature}:retry", // For error retry
|
||||||
|
"${feature}:empty", // For empty state
|
||||||
|
],
|
||||||
|
|
||||||
|
// Validate naming convention
|
||||||
|
namingConvention = regex("^[a-z-]+:[a-z-]+(?::[a-z0-9-]+)?$")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### TestTag Validation Report
|
||||||
|
|
||||||
|
```
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ 🏷️ TESTTAG VALIDATION ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ TestTags Object: ${Feature}TestTags.kt ║
|
||||||
|
║ Location: feature/${name}/.../ui/${Feature}TestTags.kt ║
|
||||||
|
║ Status: [✅ EXISTS | ❌ MISSING] ║
|
||||||
|
║ ║
|
||||||
|
║ Required Tags: ║
|
||||||
|
║ ├─ ${feature}:screen [✅ Found | ❌ Missing] ║
|
||||||
|
║ ├─ ${feature}:loading [✅ Found | ❌ Missing] ║
|
||||||
|
║ └─ ${feature}:error [✅ Found | ❌ Missing] ║
|
||||||
|
║ ║
|
||||||
|
║ Screen Usage: ║
|
||||||
|
║ ├─ ${Feature}Screen.kt testTag() calls: [n] ║
|
||||||
|
║ ├─ ${Feature}Content.kt testTag() calls: [n] ║
|
||||||
|
║ └─ Total coverage: [n] / [expected] ║
|
||||||
|
║ ║
|
||||||
|
║ Naming Convention: ║
|
||||||
|
║ ├─ Valid tags: [n] ║
|
||||||
|
║ └─ Invalid tags: [list of non-conforming tags] ║
|
||||||
|
║ ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||||
|
```
|
||||||
|
|
||||||
|
### TestTag Gap Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gap: TestTags object missing │
|
||||||
|
│ Severity: P2 (Testing) │
|
||||||
|
│ File: feature/${name}/.../ui/${Feature}TestTags.kt (create) │
|
||||||
|
│ │
|
||||||
|
│ 📍 Fix: Generate TestTags │
|
||||||
|
│ ```kotlin │
|
||||||
|
│ internal object ${Feature}TestTags { │
|
||||||
|
│ const val SCREEN = "${feature}:screen" │
|
||||||
|
│ const val LOADING = "${feature}:loading" │
|
||||||
|
│ const val ERROR = "${feature}:error" │
|
||||||
|
│ const val LIST = "${feature}:list" │
|
||||||
|
│ const val RETRY_BUTTON = "${feature}:retry" │
|
||||||
|
│ const val ITEM_PREFIX = "${feature}:item:" // + id │
|
||||||
|
│ } │
|
||||||
|
│ ``` │
|
||||||
|
│ │
|
||||||
|
│ Command: /feature ${feature} --tags │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gap: Missing testTag modifiers in Screen │
|
||||||
|
│ Severity: P2 (Testing) │
|
||||||
|
│ File: feature/${name}/.../ui/${Feature}Screen.kt │
|
||||||
|
│ │
|
||||||
|
│ 📍 Fix: Add testTag modifiers to composables │
|
||||||
|
│ ```kotlin │
|
||||||
|
│ // Loading state │
|
||||||
|
│ MifosLoadingWheel( │
|
||||||
|
│ modifier = Modifier.testTag(${Feature}TestTags.LOADING) │
|
||||||
|
│ ) │
|
||||||
|
│ │
|
||||||
|
│ // Error state │
|
||||||
|
│ MifosErrorContent( │
|
||||||
|
│ modifier = Modifier.testTag(${Feature}TestTags.ERROR) │
|
||||||
|
│ ) │
|
||||||
|
│ │
|
||||||
|
│ // List │
|
||||||
|
│ LazyColumn( │
|
||||||
|
│ modifier = Modifier.testTag(${Feature}TestTags.LIST) │
|
||||||
|
│ ) │
|
||||||
|
│ ``` │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gap: TestTag naming doesn't follow convention │
|
||||||
|
│ Severity: P2 (Polish) │
|
||||||
|
│ File: feature/${name}/.../ui/${Feature}TestTags.kt │
|
||||||
|
│ │
|
||||||
|
│ Found: "BeneficiaryScreen", "LoadingIndicator" │
|
||||||
|
│ Expected: "beneficiary:screen", "beneficiary:loading" │
|
||||||
|
│ │
|
||||||
|
│ 📍 Fix: Update to feature:component:element pattern │
|
||||||
|
│ ```kotlin │
|
||||||
|
│ // Before (invalid) │
|
||||||
|
│ const val SCREEN = "BeneficiaryScreen" │
|
||||||
|
│ const val LOADING = "LoadingIndicator" │
|
||||||
|
│ │
|
||||||
|
│ // After (valid) │
|
||||||
|
│ const val SCREEN = "beneficiary:screen" │
|
||||||
|
│ const val LOADING = "beneficiary:loading" │
|
||||||
|
│ ``` │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### TestTag Scoring
|
||||||
|
|
||||||
|
| Criterion | Weight | Passed | Score |
|
||||||
|
|-----------|:------:|:------:|:-----:|
|
||||||
|
| TestTags object exists | 40% | ✅/❌ | x/40 |
|
||||||
|
| Required tags defined | 30% | n/3 | x/30 |
|
||||||
|
| testTag() modifiers used | 20% | n/m | x/20 |
|
||||||
|
| Naming convention | 10% | n/n | x/10 |
|
||||||
|
| **Total** | 100% | | x/100 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Feature Not Found
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ❌ FEATURE NOT FOUND │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Feature: "xyz" │
|
||||||
|
│ Checked: FEATURES_INDEX.md │
|
||||||
|
│ │
|
||||||
|
│ Did you mean one of these? │
|
||||||
|
│ • beneficiary │
|
||||||
|
│ • beneficiary-detail │
|
||||||
|
│ │
|
||||||
|
│ Or run /verify to see all features. │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spec Missing
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ⚠️ SPEC MISSING │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Feature: dashboard │
|
||||||
|
│ Expected: design-spec-layer/features/dashboard/SPEC.md │
|
||||||
|
│ Found: File does not exist │
|
||||||
|
│ │
|
||||||
|
│ Cannot verify without specification. │
|
||||||
|
│ │
|
||||||
|
│ Options: │
|
||||||
|
│ • d / design → Run /design dashboard to create spec │
|
||||||
|
│ • c / code → Verify code only (--code flag) │
|
||||||
|
│ • a / abort → Cancel verification │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O(1) File Reference
|
||||||
|
|
||||||
|
| Index File | Data Used For |
|
||||||
|
|------------|---------------|
|
||||||
|
| `design-spec-layer/FEATURES_INDEX.md` | Feature list, spec status |
|
||||||
|
| `client-layer/FEATURE_MAP.md` | Service/Repository paths |
|
||||||
|
| `feature-layer/MODULES_INDEX.md` | Module structure, VM/Screen counts |
|
||||||
|
| `feature-layer/SCREENS_INDEX.md` | Screen-ViewModel mappings, file paths |
|
||||||
|
| `server-layer/API_INDEX.md` | Expected API endpoints |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/implement [Feature]` | Fix gaps automatically |
|
||||||
|
| `/gap-analysis [Feature]` | Broader gap analysis |
|
||||||
|
| `/gap-planning [Feature]` | Plan fixes for gaps |
|
||||||
|
| `/design [Feature]` | Update specification |
|
||||||
|
| `/verify-tests [Feature]` | Verify test coverage |
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# Current Work
|
# Current Work
|
||||||
|
|
||||||
**Last Updated**: 2026-01-03
|
**Last Updated**: 2026-01-05
|
||||||
**Branch**: feature/design-specifications
|
**Branch**: feature/design-specifications
|
||||||
**Session Note**: Generated auth mockups, added MCP integration for AI design tools
|
**Session Note**: Phase 4 Complete - core:testing module created with fakes, fixtures, TestTags
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -10,45 +10,89 @@
|
|||||||
|
|
||||||
| # | Task | Feature | Status | Files | Notes |
|
| # | Task | Feature | Status | Files | Notes |
|
||||||
|---|------|---------|:------:|-------|-------|
|
|---|------|---------|:------:|-------|-------|
|
||||||
| 1 | Mockup Generation | home | ⏳ Next | features/home/mockups/ | Run `/design home mockup` |
|
| 1 | Command Rewrite | implement | ✅ Done | .claude/commands/implement.md | O(1) + Pattern Detection |
|
||||||
| 2 | Mockup Generation | auth | ✅ Done | features/auth/mockups/ | PROMPTS.md + design-tokens.json |
|
| 2 | Command Rewrite | client | ✅ Done | .claude/commands/client.md | O(1) + Pattern Detection |
|
||||||
| 3 | v2.0 UI Implementation | dashboard | Planned | feature/dashboard/ | After mockups done |
|
| 3 | Command Rewrite | feature | ✅ Done | .claude/commands/feature.md | O(1) + TestTags |
|
||||||
| 4 | MCP Integration | design | ✅ Done | TOOL_CONFIG.md | Stitch MCP installed |
|
| 4 | Command Rewrite | verify | ✅ Done | .claude/commands/verify.md | O(1) + Gap Detection |
|
||||||
| 5 | Commands README | commands | ✅ Done | .claude/commands/README.md | Full reference |
|
| 5 | Command Rewrite | design | ✅ Done | .claude/commands/design.md | O(1) + Mockup Status |
|
||||||
|
| 6 | Command Rewrite | verify-tests | ✅ Done | .claude/commands/verify-tests.md | O(1) + Test Status |
|
||||||
|
| 7 | Design Token Integration | feature | ✅ Done | .claude/commands/feature.md | Phase 3.5 Token Integration |
|
||||||
|
| 8 | Design Tokens Index | design-spec | ✅ Done | DESIGN_TOKENS_INDEX.md | O(1) token lookup |
|
||||||
|
| 9 | Test Stub Generation | implement | ✅ Done | .claude/commands/implement.md | Phase 5: Auto-generate tests |
|
||||||
|
| 10 | TestTag Validation | verify | ✅ Done | .claude/commands/verify.md | Enhanced TestTag checks |
|
||||||
|
| 11 | Test Stubs Guide | docs | ✅ Done | TEST_STUBS_GUIDE.md | TDD reference |
|
||||||
|
| 12 | core:testing Module | testing | ✅ Done | core/testing/ | Fakes, fixtures, TestTags |
|
||||||
|
| 13 | Mockup Generation | home | ⏳ Next | features/home/mockups/ | Run `/design home mockup` |
|
||||||
|
| 14 | v2.0 UI Implementation | dashboard | Planned | feature/dashboard/ | After mockups done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
### Design Layer - Phase 2: Mockup Generation
|
### Phase 1, 2 & 3 Complete: Commands + Design Integration + Testing Automation
|
||||||
|
|
||||||
|
**All 6 core commands now use O(1 lookup pattern**:
|
||||||
|
|
||||||
|
| Command | Index Files Used | Key Features |
|
||||||
|
|---------|------------------|--------------|
|
||||||
|
| `/implement` | FEATURE_MAP, MODULES_INDEX, SCREENS_INDEX | Pattern detection, TestTags |
|
||||||
|
| `/client` | FEATURE_MAP, API_INDEX | Service/Repository patterns |
|
||||||
|
| `/feature` | MODULES_INDEX, SCREENS_INDEX, DESIGN_TOKENS_INDEX | MVI pattern, TestTags, **Token Integration** |
|
||||||
|
| `/verify` | FEATURES_INDEX, FEATURE_MAP, MODULES_INDEX, SCREENS_INDEX, API_INDEX | Gap detection, verification scoring |
|
||||||
|
| `/design` | FEATURES_INDEX, MOCKUPS_INDEX, API_INDEX | Mockup status, tool selection |
|
||||||
|
| `/verify-tests` | TESTING_STATUS.md (all layers), MODULES_INDEX | Test dashboard, coverage tracking |
|
||||||
|
|
||||||
|
**Phase 2: Design Token Integration**:
|
||||||
|
- Created `DESIGN_TOKENS_INDEX.md` for O(1) token lookup
|
||||||
|
- Added Phase 3.5 to `/feature` command
|
||||||
|
- Supports both Google Stitch and MD3 token formats
|
||||||
|
- Auto-generates `${Feature}Theme.kt` with gradients/colors
|
||||||
|
- Auto-generates `${Feature}Animations.kt` if animations defined
|
||||||
|
- Maps tokens to existing `DesignToken` system
|
||||||
|
|
||||||
|
**Features with Design Tokens**: 8/17
|
||||||
|
- ✅ auth (google-stitch) - gradients, animations
|
||||||
|
- ✅ dashboard (md3) - components
|
||||||
|
- ✅ settings, guarantor, qr, passcode, location, client-charge (md3)
|
||||||
|
|
||||||
|
**Phase 3: Testing Automation**:
|
||||||
|
- Added Phase 5 to `/implement` command for test stub generation
|
||||||
|
- Enhanced `/verify` command with TestTag validation
|
||||||
|
- Created `TEST_STUBS_GUIDE.md` documentation
|
||||||
|
- Auto-generates: ViewModel tests, Screen tests, Fake repositories
|
||||||
|
- Validates: TestTag naming convention (`feature:component:id`)
|
||||||
|
- Supports TDD workflow: Red → Green → Refactor
|
||||||
|
|
||||||
|
**Testing Layer (6th Layer)**:
|
||||||
|
- Created `testing-layer/` with O(1) index files
|
||||||
|
- `LAYER_STATUS.md` - Test coverage dashboard (17 features)
|
||||||
|
- `TEST_PATTERNS.md` - ViewModel, Screen, Fake, Integration, Screenshot patterns
|
||||||
|
- `TEST_TAGS_INDEX.md` - TestTag specifications for all features
|
||||||
|
- `TEST_FIXTURES_INDEX.md` - Test fixture inventory
|
||||||
|
- `FAKE_REPOS_INDEX.md` - Fake repository inventory
|
||||||
|
- `patterns/` - Detailed pattern files (viewmodel-test.md, screen-test.md, etc.)
|
||||||
|
- `templates/` - Code templates (.kt.template files)
|
||||||
|
|
||||||
|
**Phase 4: core:testing Module** (NEW):
|
||||||
|
- Enabled `core:testing` in settings.gradle.kts
|
||||||
|
- Created KMP module with commonMain/androidMain source sets
|
||||||
|
- **TestTags**: Complete tags for all 17 features (auth, home, accounts, etc.)
|
||||||
|
- **Fake Repositories**: FakeUserAuthRepository, FakeHomeRepository, FakeAccountsRepository, FakeBeneficiaryRepository, FakeTransferRepository, FakeNotificationRepository
|
||||||
|
- **Test Fixtures**: UserFixture, ClientAccountsFixture, BeneficiaryFixture
|
||||||
|
- **Test Utils**: MainDispatcherRule, FlowTestExtensions, TestCoroutineExtensions
|
||||||
|
- **DI Module**: TestModule for Koin test setup
|
||||||
|
- Module compiles successfully ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Layer - Phase 2: Mockup Generation
|
||||||
|
|
||||||
**Progress**: 2/17 features (12%)
|
**Progress**: 2/17 features (12%)
|
||||||
- ✅ dashboard - mockups generated
|
- ✅ dashboard - mockups generated
|
||||||
- ✅ auth - mockups generated (this session)
|
- ✅ auth - mockups generated
|
||||||
- ⏳ home - next
|
- ⏳ home - next
|
||||||
- ⏳ 14 more features pending
|
- ⏳ 14 more features pending
|
||||||
|
|
||||||
**MCP Status**:
|
|
||||||
```
|
|
||||||
stitch-ai: ✅ Installed (restart to connect)
|
|
||||||
figma: ⚠️ Needs authentication
|
|
||||||
```
|
|
||||||
|
|
||||||
**What was done this session**:
|
|
||||||
- Generated auth mockups (8 screens)
|
|
||||||
- Added AI tool selection to `/design [feature] mockup`
|
|
||||||
- Added MCP setup prompts
|
|
||||||
- Created TOOL_CONFIG.md
|
|
||||||
- Installed Google Stitch MCP
|
|
||||||
- Updated commands README with full reference
|
|
||||||
|
|
||||||
**What's next** (15 features pending):
|
|
||||||
1. Restart Claude Code to activate Stitch MCP
|
|
||||||
2. Run `/design home mockup` to generate home mockups
|
|
||||||
3. Continue through remaining features
|
|
||||||
4. Use Google Stitch to generate visual designs
|
|
||||||
5. Export to Figma
|
|
||||||
|
|
||||||
**Commands**:
|
**Commands**:
|
||||||
```
|
```
|
||||||
/gap-analysis design mockup # See mockup progress (2/17)
|
/gap-analysis design mockup # See mockup progress (2/17)
|
||||||
@ -56,22 +100,28 @@ figma: ⚠️ Needs authentication
|
|||||||
/design [feature] mockup # Generate mockups for feature
|
/design [feature] mockup # Generate mockups for feature
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dashboard Feature (After Mockups)
|
|
||||||
|
|
||||||
**Status**: Waiting for all mockups to be generated
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recently Completed
|
## Recently Completed
|
||||||
|
|
||||||
| Date | Task | Feature | Outcome |
|
| Date | Task | Feature | Outcome |
|
||||||
|------|------|---------|---------|
|
|------|------|---------|---------|
|
||||||
|
| 2026-01-05 | core:testing Module | core/testing/ | Fakes, fixtures, TestTags - compiles ✅ |
|
||||||
|
| 2026-01-05 | /gap-status command | commands | Plan progress tracking |
|
||||||
|
| 2026-01-05 | Testing Layer (6th Layer) | testing-layer/ | Full O(1) test infrastructure |
|
||||||
|
| 2026-01-05 | Phase 3: Testing Automation | implement/verify | Test stubs + TestTag validation |
|
||||||
|
| 2026-01-05 | TEST_STUBS_GUIDE.md | docs | TDD reference documentation |
|
||||||
|
| 2026-01-05 | Phase 2: Design Token Integration | feature | Phase 3.5 + DESIGN_TOKENS_INDEX.md |
|
||||||
|
| 2026-01-05 | Command Rewrite | verify | O(1) + Gap Detection + Verification Score |
|
||||||
|
| 2026-01-05 | Command Rewrite | design | O(1) + Mockup Status + Tool Selection |
|
||||||
|
| 2026-01-05 | Command Rewrite | verify-tests | O(1) + Test Dashboard + Coverage Tracking |
|
||||||
|
| 2026-01-05 | Command Rewrite | implement | O(1) + Pattern Detection + TestTags |
|
||||||
|
| 2026-01-05 | Command Rewrite | client | O(1) + Pattern Detection |
|
||||||
|
| 2026-01-05 | Command Rewrite | feature | O(1) + Pattern Detection + TestTags |
|
||||||
| 2026-01-03 | Auth mockups | auth | Generated PROMPTS.md + design-tokens.json |
|
| 2026-01-03 | Auth mockups | auth | Generated PROMPTS.md + design-tokens.json |
|
||||||
| 2026-01-03 | MCP integration | design | Added tool selection, installed stitch-ai |
|
| 2026-01-03 | MCP integration | design | Added tool selection, installed stitch-ai |
|
||||||
| 2026-01-03 | Commands README | commands | Full reference with all sub-commands |
|
| 2026-01-03 | Commands README | commands | Full reference with all sub-commands |
|
||||||
| 2026-01-03 | Sub-section support | gap-analysis | Added {layer} {sub-section} syntax |
|
| 2026-01-03 | Sub-section support | gap-analysis | Added {layer} {sub-section} syntax |
|
||||||
| 2026-01-03 | Sub-section support | gap-planning | Added {layer} {sub-section} syntax |
|
|
||||||
| 2026-01-03 | Sub-section templates | templates | Created 14 templates in subsection/ |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -79,40 +129,56 @@ figma: ⚠️ Needs authentication
|
|||||||
|
|
||||||
### Key Files to Read
|
### Key Files to Read
|
||||||
1. This file (`CURRENT_WORK.md`)
|
1. This file (`CURRENT_WORK.md`)
|
||||||
2. `.claude/commands/README.md` - Full command reference
|
2. `.claude/commands/implement.md` - E2E implementation with **Phase 5 Test Stubs**
|
||||||
3. `design-spec-layer/TOOL_CONFIG.md` - AI tool settings
|
3. `.claude/commands/feature.md` - Feature layer with **Phase 3.5 Token Integration**
|
||||||
4. `features/auth/mockups/` - Example of generated mockups
|
4. `.claude/commands/verify.md` - Verification with O(1) + **TestTag Validation**
|
||||||
|
5. `.claude/commands/design.md` - Design with O(1) + Mockup Status
|
||||||
|
6. `.claude/commands/verify-tests.md` - Test verification with O(1)
|
||||||
|
7. `TEST_STUBS_GUIDE.md` - TDD test stub reference
|
||||||
|
8. `core/testing/` - **NEW** Testing module with fakes, fixtures, TestTags
|
||||||
|
9. `design-spec-layer/DESIGN_TOKENS_INDEX.md` - O(1) token lookup
|
||||||
|
10. `client-layer/FEATURE_MAP.md` - Service/Repository mapping
|
||||||
|
11. `feature-layer/MODULES_INDEX.md` - Module inventory
|
||||||
|
12. `feature-layer/SCREENS_INDEX.md` - Screen inventory
|
||||||
|
|
||||||
### Key Commands
|
### Key Commands
|
||||||
- `/session-start` - Load this context
|
- `/session-start` - Load this context
|
||||||
- `/gap-analysis design mockup` - See mockup progress
|
- `/gap-analysis` - Quick overview of all layers
|
||||||
- `/design home mockup` - Generate next feature mockups
|
- `/implement [feature]` - Full E2E implementation (updated)
|
||||||
- `claude mcp list` - Check MCP status
|
- `/client [feature]` - Client layer only (updated)
|
||||||
|
- `/feature [feature]` - Feature layer only (updated)
|
||||||
|
- `/verify [feature]` - Verify implementation vs spec
|
||||||
|
|
||||||
### MCP Setup (if needed)
|
### O(1) Index Files (Core Context)
|
||||||
```bash
|
| File | Purpose | Lines |
|
||||||
# Google Stitch (already installed)
|
|------|---------|:-----:|
|
||||||
claude mcp add stitch-ai -- npx -y stitch-ai-mcp
|
| FEATURE_MAP.md | Service → Feature mapping | ~170 |
|
||||||
|
| MODULES_INDEX.md | All feature modules | ~115 |
|
||||||
# Figma (optional)
|
| SCREENS_INDEX.md | All 63 screens | ~270 |
|
||||||
claude mcp add figma # Follow auth flow
|
| API_INDEX.md | All API endpoints | ~400 |
|
||||||
```
|
| FEATURES_INDEX.md | All 17 features | ~100 |
|
||||||
|
| DESIGN_TOKENS_INDEX.md | **NEW** Design tokens per feature | ~150 |
|
||||||
|
|
||||||
### Architecture Notes
|
### Architecture Notes
|
||||||
- KMP: Android, iOS, Desktop, Web
|
- KMP: Android, iOS, Desktop, Web
|
||||||
- DI: Koin modules per feature
|
- DI: Koin modules per feature
|
||||||
- Navigation: Jetbrains Compose Navigation
|
- Navigation: Jetbrains Compose Navigation
|
||||||
- Network: Ktorfit services
|
- Network: Ktorfit services
|
||||||
|
- State: MVI pattern (State, Event, Action)
|
||||||
|
- Testing: TestTags pattern (feature:component:id)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Resume Instructions
|
## Resume Instructions
|
||||||
|
|
||||||
1. Run `/session-start` to load context
|
1. Run `/session-start` to load context
|
||||||
2. Check MCP: `claude mcp list`
|
2. Run `/gap-analysis` to see current status
|
||||||
3. Run `/gap-analysis design mockup` to see progress
|
3. Test new commands:
|
||||||
4. Run `/design home mockup` to continue mockup generation
|
- `/implement` - Should show feature list with O(1 lookup
|
||||||
5. Repeat for remaining 14 features
|
- `/client [feature]` - Should show service/repo status
|
||||||
|
- `/feature [feature]` - Should show module/screen status
|
||||||
|
4. Continue mockup generation with `/design home mockup`
|
||||||
|
5. Eventually test full E2E with `/implement [new-feature]`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -120,5 +186,26 @@ claude mcp add figma # Follow auth flow
|
|||||||
|
|
||||||
| Date | Focus | Outcome |
|
| Date | Focus | Outcome |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
|
| 2026-01-05 | Phase 4: core:testing Module | Created core/testing/ with fakes, fixtures, TestTags ✅ |
|
||||||
|
| 2026-01-05 | Testing Layer (6th Layer) | Created testing-layer/ with O(1 indexes, patterns, templates |
|
||||||
|
| 2026-01-05 | Phase 3: Testing Automation | Test stubs in /implement, TestTag validation in /verify |
|
||||||
|
| 2026-01-05 | Phase 2: Design-Code Integration | Token integration in /feature, DESIGN_TOKENS_INDEX.md |
|
||||||
|
| 2026-01-05 | Phase 1: Command Rewrite | All 6 core commands now O(1): verify, design, verify-tests |
|
||||||
|
| 2026-01-05 | Command rewrite | /implement, /client, /feature with O(1) + patterns |
|
||||||
| 2026-01-03 | Mockup generation | Auth mockups done, MCP integrated, 2/17 complete |
|
| 2026-01-03 | Mockup generation | Auth mockups done, MCP integrated, 2/17 complete |
|
||||||
| 2026-01-03 | Command refactoring | Created template system, 5-layer structure |
|
| 2026-01-03 | Command refactoring | Created template system, 5-layer structure |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Next
|
||||||
|
|
||||||
|
1. **Write First Tests**: Use `core:testing` module to write ViewModel tests for auth feature
|
||||||
|
2. **Continue Mockups**: Generate mockups for remaining 15 features (`/design home mockup`)
|
||||||
|
3. **Dashboard Feature**: After mockups, implement new dashboard
|
||||||
|
4. **Phase 5: Session Persistence** (gap-planning roadmap)
|
||||||
|
- Intelligent session checkpoints
|
||||||
|
- Context continuity across restarts
|
||||||
|
5. **Phase 6: Documentation Integration** (gap-planning roadmap)
|
||||||
|
- Auto-generate README updates
|
||||||
|
- Architecture diagram generation
|
||||||
|
6. **Migration**: Move claude-product-cycle to separate repo (after validation)
|
||||||
|
|||||||
560
claude-product-cycle/MIGRATION_PLAN.md
Normal file
560
claude-product-cycle/MIGRATION_PLAN.md
Normal file
@ -0,0 +1,560 @@
|
|||||||
|
# Claude Product Cycle - Template Migration Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Move claude-product-cycle architecture to `kmp-project-template` so all derived projects inherit the framework while maintaining project-specific implementations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
```
|
||||||
|
kmp-project-template/ # Base template (no claude-product-cycle)
|
||||||
|
├── mifos-mobile/ # Has full claude-product-cycle
|
||||||
|
├── mobile-wallet/ # No claude-product-cycle
|
||||||
|
├── android-client/ # No claude-product-cycle
|
||||||
|
└── mifos-x-group-banking/ # No claude-product-cycle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Target State
|
||||||
|
|
||||||
|
```
|
||||||
|
kmp-project-template/
|
||||||
|
├── .claude/
|
||||||
|
│ └── commands/ # Framework commands (parameterized)
|
||||||
|
├── claude-product-cycle/
|
||||||
|
│ ├── _templates/ # Reusable templates
|
||||||
|
│ ├── _framework/ # Architecture documentation
|
||||||
|
│ └── PROJECT_SETUP.md # How to configure for a project
|
||||||
|
│
|
||||||
|
├── mifos-mobile/ # Extends template
|
||||||
|
│ └── claude-product-cycle/
|
||||||
|
│ ├── design-spec-layer/ # Project-specific specs
|
||||||
|
│ ├── server-layer/ # Project-specific APIs
|
||||||
|
│ ├── client-layer/ # Project-specific mappings
|
||||||
|
│ ├── feature-layer/ # Project-specific features
|
||||||
|
│ └── platform-layer/ # Project-specific platforms
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Decision
|
||||||
|
|
||||||
|
### What Goes in Template (Reusable)
|
||||||
|
|
||||||
|
| Component | Purpose | Sync Strategy |
|
||||||
|
|-----------|---------|---------------|
|
||||||
|
| Commands Framework | `/gap-analysis`, `/gap-planning`, etc. | Override in projects |
|
||||||
|
| Layer Templates | SPEC.md, API.md structure | Copy on init |
|
||||||
|
| Index Templates | FEATURES_INDEX.md structure | Generate per project |
|
||||||
|
| Testing Framework | Test patterns, TestTag system | Inherit |
|
||||||
|
| Architecture Docs | 5-layer lifecycle, patterns | Reference |
|
||||||
|
|
||||||
|
### What Stays in Projects (Specific)
|
||||||
|
|
||||||
|
| Component | Purpose | Example |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| Feature Specs | SPEC.md content | "Login with biometrics" |
|
||||||
|
| API Documentation | Actual endpoints | `/self/authentication` |
|
||||||
|
| Mockups | UI designs | Figma links, tokens |
|
||||||
|
| Implementation Status | Progress tracking | ✅/⚠️/❌ per feature |
|
||||||
|
| Feature List | Project features | auth, home, transfer... |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Directory Structure
|
||||||
|
|
||||||
|
### Template Repository (`kmp-project-template`)
|
||||||
|
|
||||||
|
```
|
||||||
|
kmp-project-template/
|
||||||
|
├── .claude/
|
||||||
|
│ ├── commands/
|
||||||
|
│ │ ├── gap-analysis.md # Parameterized framework
|
||||||
|
│ │ ├── gap-planning.md # Parameterized framework
|
||||||
|
│ │ ├── design.md # Parameterized framework
|
||||||
|
│ │ ├── implement.md # Parameterized framework
|
||||||
|
│ │ ├── verify.md # Parameterized framework
|
||||||
|
│ │ ├── verify-tests.md # Parameterized framework
|
||||||
|
│ │ ├── session-start.md # Session management
|
||||||
|
│ │ ├── session-end.md # Session management
|
||||||
|
│ │ └── projectstatus.md # Project overview
|
||||||
|
│ └── settings.json # Default Claude settings
|
||||||
|
│
|
||||||
|
├── claude-product-cycle/
|
||||||
|
│ ├── _framework/
|
||||||
|
│ │ ├── 5-LAYER-LIFECYCLE.md # Architecture overview
|
||||||
|
│ │ ├── O1-LOOKUP-PATTERN.md # Index file strategy
|
||||||
|
│ │ ├── COMMAND-REFERENCE.md # All commands explained
|
||||||
|
│ │ └── TESTING-STRATEGY.md # TDD approach
|
||||||
|
│ │
|
||||||
|
│ ├── _templates/
|
||||||
|
│ │ ├── design-spec-layer/
|
||||||
|
│ │ │ ├── FEATURES_INDEX.template.md
|
||||||
|
│ │ │ ├── MOCKUPS_INDEX.template.md
|
||||||
|
│ │ │ ├── TESTING_STATUS.template.md
|
||||||
|
│ │ │ └── feature/
|
||||||
|
│ │ │ ├── SPEC.template.md
|
||||||
|
│ │ │ ├── API.template.md
|
||||||
|
│ │ │ └── STATUS.template.md
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── server-layer/
|
||||||
|
│ │ │ ├── API_INDEX.template.md
|
||||||
|
│ │ │ ├── API_REFERENCE.template.md
|
||||||
|
│ │ │ ├── TESTING_STATUS.template.md
|
||||||
|
│ │ │ └── endpoints/
|
||||||
|
│ │ │ └── CATEGORY.template.md
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── client-layer/
|
||||||
|
│ │ │ ├── FEATURE_MAP.template.md
|
||||||
|
│ │ │ ├── LAYER_STATUS.template.md
|
||||||
|
│ │ │ └── TESTING_STATUS.template.md
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── feature-layer/
|
||||||
|
│ │ │ ├── MODULES_INDEX.template.md
|
||||||
|
│ │ │ ├── SCREENS_INDEX.template.md
|
||||||
|
│ │ │ ├── LAYER_STATUS.template.md
|
||||||
|
│ │ │ └── TESTING_STATUS.template.md
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── platform-layer/
|
||||||
|
│ │ │ ├── LAYER_STATUS.template.md
|
||||||
|
│ │ │ ├── TESTING_STATUS.template.md
|
||||||
|
│ │ │ └── platforms/
|
||||||
|
│ │ │ ├── ANDROID.template.md
|
||||||
|
│ │ │ ├── IOS.template.md
|
||||||
|
│ │ │ ├── DESKTOP.template.md
|
||||||
|
│ │ │ └── WEB.template.md
|
||||||
|
│ │ │
|
||||||
|
│ │ └── gap-analysis/
|
||||||
|
│ │ ├── dashboard.template.md
|
||||||
|
│ │ └── layer-*.template.md
|
||||||
|
│ │
|
||||||
|
│ ├── PROJECT_CONFIG.md # How to configure
|
||||||
|
│ ├── SYNC_GUIDE.md # How to sync updates
|
||||||
|
│ └── CHANGELOG.md # Version history
|
||||||
|
│
|
||||||
|
└── CLAUDE.md # References claude-product-cycle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Derived Project (`mifos-mobile`, `mobile-wallet`, etc.)
|
||||||
|
|
||||||
|
```
|
||||||
|
mifos-mobile/
|
||||||
|
├── .claude/
|
||||||
|
│ ├── commands/ # Can override template commands
|
||||||
|
│ │ └── custom-command.md # Project-specific commands
|
||||||
|
│ └── settings.json # Project-specific settings
|
||||||
|
│
|
||||||
|
├── claude-product-cycle/
|
||||||
|
│ ├── PROJECT.md # Project identity & config
|
||||||
|
│ │ - name: "Mifos Mobile"
|
||||||
|
│ │ - type: "Self-Service Banking"
|
||||||
|
│ │ - features: [auth, home, accounts, ...]
|
||||||
|
│ │ - api_base: "https://server/fineract-provider/api/v1/self/"
|
||||||
|
│ │
|
||||||
|
│ ├── design-spec-layer/
|
||||||
|
│ │ ├── FEATURES_INDEX.md # Generated from template
|
||||||
|
│ │ ├── MOCKUPS_INDEX.md # Project-specific content
|
||||||
|
│ │ ├── STATUS.md
|
||||||
|
│ │ ├── TESTING_STATUS.md
|
||||||
|
│ │ └── features/
|
||||||
|
│ │ ├── auth/
|
||||||
|
│ │ │ ├── SPEC.md # Project-specific
|
||||||
|
│ │ │ ├── API.md # Project-specific
|
||||||
|
│ │ │ ├── STATUS.md
|
||||||
|
│ │ │ └── mockups/
|
||||||
|
│ │ ├── home/
|
||||||
|
│ │ └── ... (all project features)
|
||||||
|
│ │
|
||||||
|
│ ├── server-layer/
|
||||||
|
│ │ ├── API_INDEX.md # Project-specific endpoints
|
||||||
|
│ │ ├── API_REFERENCE.md
|
||||||
|
│ │ ├── TESTING_STATUS.md
|
||||||
|
│ │ └── endpoints/ # Project-specific
|
||||||
|
│ │
|
||||||
|
│ ├── client-layer/
|
||||||
|
│ │ ├── FEATURE_MAP.md # Project-specific mappings
|
||||||
|
│ │ ├── LAYER_STATUS.md
|
||||||
|
│ │ └── TESTING_STATUS.md
|
||||||
|
│ │
|
||||||
|
│ ├── feature-layer/
|
||||||
|
│ │ ├── MODULES_INDEX.md # Project-specific modules
|
||||||
|
│ │ ├── SCREENS_INDEX.md # Project-specific screens
|
||||||
|
│ │ ├── LAYER_STATUS.md
|
||||||
|
│ │ └── TESTING_STATUS.md
|
||||||
|
│ │
|
||||||
|
│ └── platform-layer/
|
||||||
|
│ ├── LAYER_STATUS.md # Project-specific
|
||||||
|
│ ├── TESTING_STATUS.md
|
||||||
|
│ └── platforms/
|
||||||
|
│
|
||||||
|
└── CLAUDE.md # Project-specific instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Configuration (`PROJECT.md`)
|
||||||
|
|
||||||
|
Each derived project has a `PROJECT.md` that configures the framework:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Project Configuration
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| name | Mifos Mobile |
|
||||||
|
| type | Self-Service Banking |
|
||||||
|
| repo | openMF/mifos-mobile |
|
||||||
|
| template_version | 1.0.0 |
|
||||||
|
|
||||||
|
## API Configuration
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| base_url | https://{server}/fineract-provider/api/v1/self/ |
|
||||||
|
| auth_type | Basic + Tenant Header |
|
||||||
|
| demo_server | tt.mifos.community |
|
||||||
|
| demo_user | maria / password |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| # | Feature | Design Dir | Feature Dir |
|
||||||
|
|:-:|---------|------------|-------------|
|
||||||
|
| 1 | auth | features/auth/ | feature/auth/ |
|
||||||
|
| 2 | home | features/home/ | feature/home/ |
|
||||||
|
| ... | ... | ... | ... |
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
| Platform | Module | Status |
|
||||||
|
|----------|--------|--------|
|
||||||
|
| Android | cmp-android | Primary |
|
||||||
|
| iOS | cmp-ios | CocoaPods |
|
||||||
|
| Desktop | cmp-desktop | JVM |
|
||||||
|
| Web | cmp-web | Experimental |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Parameterization
|
||||||
|
|
||||||
|
Commands in template read from `PROJECT.md` to adapt behavior:
|
||||||
|
|
||||||
|
### Template Command (gap-analysis.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Gap Analysis Command
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Step 0: Read Project Config
|
||||||
|
|
||||||
|
Read `claude-product-cycle/PROJECT.md` to get:
|
||||||
|
- Feature list
|
||||||
|
- API base URL
|
||||||
|
- Platform configuration
|
||||||
|
|
||||||
|
### Step 1: Read O(1) Index Files
|
||||||
|
[... rest of command using project config ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Override Mechanism
|
||||||
|
|
||||||
|
Projects can override commands by creating same file in `.claude/commands/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Template: kmp-project-template/.claude/commands/gap-analysis.md
|
||||||
|
Override: mifos-mobile/.claude/commands/gap-analysis.md (takes precedence)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Strategy
|
||||||
|
|
||||||
|
### Option A: Git Subtree (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In derived project, add template as subtree
|
||||||
|
git subtree add --prefix=claude-product-cycle/_framework \
|
||||||
|
https://github.com/openMF/kmp-project-template.git \
|
||||||
|
main --squash
|
||||||
|
|
||||||
|
# Pull updates from template
|
||||||
|
git subtree pull --prefix=claude-product-cycle/_framework \
|
||||||
|
https://github.com/openMF/kmp-project-template.git \
|
||||||
|
main --squash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Single repo, no submodule complexity
|
||||||
|
- Can modify locally if needed
|
||||||
|
- Easy to pull updates
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Subtree history can get messy
|
||||||
|
- Manual pull required
|
||||||
|
|
||||||
|
### Option B: Git Submodule
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add template as submodule
|
||||||
|
git submodule add https://github.com/openMF/kmp-project-template.git \
|
||||||
|
claude-product-cycle/_framework
|
||||||
|
|
||||||
|
# Update submodule
|
||||||
|
git submodule update --remote
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Clear separation
|
||||||
|
- Explicit versioning
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Submodule complexity
|
||||||
|
- Extra clone steps
|
||||||
|
|
||||||
|
### Option C: Copy + Version Tag (Simplest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy template files on setup
|
||||||
|
cp -r kmp-project-template/claude-product-cycle/_framework/ \
|
||||||
|
mifos-mobile/claude-product-cycle/_framework/
|
||||||
|
|
||||||
|
# Track version in PROJECT.md
|
||||||
|
template_version: 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Simplest to understand
|
||||||
|
- No git complexity
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Manual sync
|
||||||
|
- Can diverge
|
||||||
|
|
||||||
|
### Recommendation: Option A (Git Subtree)
|
||||||
|
|
||||||
|
Best balance of simplicity and maintainability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Steps
|
||||||
|
|
||||||
|
### Phase 1: Prepare Template (Week 1)
|
||||||
|
|
||||||
|
1. **Create framework docs in template**
|
||||||
|
```bash
|
||||||
|
mkdir -p kmp-project-template/claude-product-cycle/_framework
|
||||||
|
mkdir -p kmp-project-template/claude-product-cycle/_templates
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Extract reusable content from mifos-mobile**
|
||||||
|
- Copy command files to template
|
||||||
|
- Parameterize hardcoded values
|
||||||
|
- Create template files with `{{PLACEHOLDER}}`
|
||||||
|
|
||||||
|
3. **Create PROJECT_CONFIG.md template**
|
||||||
|
|
||||||
|
4. **Test in template repo**
|
||||||
|
|
||||||
|
### Phase 2: Migrate mifos-mobile (Week 2)
|
||||||
|
|
||||||
|
1. **Restructure claude-product-cycle**
|
||||||
|
```bash
|
||||||
|
# Move framework to _framework/
|
||||||
|
# Keep project-specific in layer folders
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create PROJECT.md with mifos-mobile config**
|
||||||
|
|
||||||
|
3. **Update commands to read from PROJECT.md**
|
||||||
|
|
||||||
|
4. **Verify all commands work**
|
||||||
|
|
||||||
|
### Phase 3: Rollout to Other Projects (Week 3-4)
|
||||||
|
|
||||||
|
1. **mobile-wallet (mifos-pay)**
|
||||||
|
- Clone template structure
|
||||||
|
- Create PROJECT.md
|
||||||
|
- Generate initial index files
|
||||||
|
- Add feature specs
|
||||||
|
|
||||||
|
2. **android-client (field officer)**
|
||||||
|
- Same process
|
||||||
|
|
||||||
|
3. **mifos-x-group-banking**
|
||||||
|
- Same process
|
||||||
|
|
||||||
|
### Phase 4: Establish Sync Process (Week 5)
|
||||||
|
|
||||||
|
1. **Document sync procedure**
|
||||||
|
2. **Create GitHub Action for version check**
|
||||||
|
3. **Add CHANGELOG.md to template**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project-Specific Customizations
|
||||||
|
|
||||||
|
### mifos-mobile (Self-Service)
|
||||||
|
|
||||||
|
| Aspect | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| User Type | End User (Client) |
|
||||||
|
| API Prefix | `/self/` |
|
||||||
|
| Features | 17 (auth, accounts, transfer, etc.) |
|
||||||
|
| Auth | Username/Password + Passcode |
|
||||||
|
|
||||||
|
### mobile-wallet (Mifos Pay)
|
||||||
|
|
||||||
|
| Aspect | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| User Type | Wallet User |
|
||||||
|
| API Prefix | `/wallet/` (different API) |
|
||||||
|
| Features | wallet, send, receive, history, etc. |
|
||||||
|
| Auth | Phone + OTP |
|
||||||
|
|
||||||
|
### android-client (Field Officer)
|
||||||
|
|
||||||
|
| Aspect | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| User Type | Staff (Field Officer) |
|
||||||
|
| API Prefix | `/` (full API access) |
|
||||||
|
| Features | clients, loans, groups, collections, etc. |
|
||||||
|
| Auth | Username/Password + Staff permissions |
|
||||||
|
|
||||||
|
### mifos-x-group-banking
|
||||||
|
|
||||||
|
| Aspect | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| User Type | Group Leader |
|
||||||
|
| API Prefix | `/groups/` |
|
||||||
|
| Features | groups, meetings, attendance, collections |
|
||||||
|
| Auth | Username/Password + Group permissions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template Versioning
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# CHANGELOG.md (in template)
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-02-01
|
||||||
|
### Added
|
||||||
|
- Testing documentation templates
|
||||||
|
- /verify-tests command
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improved gap-analysis comprehensive view
|
||||||
|
|
||||||
|
## [1.0.0] - 2025-01-05
|
||||||
|
### Added
|
||||||
|
- Initial 5-layer lifecycle framework
|
||||||
|
- O(1) lookup pattern
|
||||||
|
- All command templates
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Compatibility
|
||||||
|
|
||||||
|
| Template Version | mifos-mobile | mobile-wallet | android-client |
|
||||||
|
|------------------|--------------|---------------|----------------|
|
||||||
|
| 1.0.0 | ✅ | - | - |
|
||||||
|
| 1.1.0 | ✅ | ✅ | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Create in Template
|
||||||
|
|
||||||
|
### Priority 1: Framework Documentation
|
||||||
|
|
||||||
|
| File | Purpose | Lines |
|
||||||
|
|------|---------|:-----:|
|
||||||
|
| `_framework/5-LAYER-LIFECYCLE.md` | Core architecture | ~150 |
|
||||||
|
| `_framework/O1-LOOKUP-PATTERN.md` | Index strategy | ~100 |
|
||||||
|
| `_framework/COMMAND-REFERENCE.md` | All commands | ~200 |
|
||||||
|
| `_framework/TESTING-STRATEGY.md` | TDD approach | ~150 |
|
||||||
|
|
||||||
|
### Priority 2: Templates
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `_templates/design-spec-layer/*.template.md` | Design layer templates |
|
||||||
|
| `_templates/server-layer/*.template.md` | Server layer templates |
|
||||||
|
| `_templates/client-layer/*.template.md` | Client layer templates |
|
||||||
|
| `_templates/feature-layer/*.template.md` | Feature layer templates |
|
||||||
|
| `_templates/platform-layer/*.template.md` | Platform layer templates |
|
||||||
|
|
||||||
|
### Priority 3: Commands
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `.claude/commands/gap-analysis.md` | Parameterized |
|
||||||
|
| `.claude/commands/gap-planning.md` | Parameterized |
|
||||||
|
| `.claude/commands/design.md` | Parameterized |
|
||||||
|
| `.claude/commands/implement.md` | Parameterized |
|
||||||
|
| `.claude/commands/verify.md` | Parameterized |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Template
|
||||||
|
|
||||||
|
- [ ] All framework docs in `_framework/`
|
||||||
|
- [ ] All templates in `_templates/`
|
||||||
|
- [ ] Parameterized commands in `.claude/commands/`
|
||||||
|
- [ ] PROJECT_CONFIG.md template
|
||||||
|
- [ ] SYNC_GUIDE.md documentation
|
||||||
|
|
||||||
|
### Each Derived Project
|
||||||
|
|
||||||
|
- [ ] PROJECT.md configured
|
||||||
|
- [ ] Feature specs in design-spec-layer
|
||||||
|
- [ ] API docs in server-layer
|
||||||
|
- [ ] Index files generated
|
||||||
|
- [ ] Commands working with project config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Command Override Strategy**
|
||||||
|
- Should projects fully override commands or extend them?
|
||||||
|
- How to handle project-specific commands?
|
||||||
|
|
||||||
|
2. **Sync Frequency**
|
||||||
|
- How often should projects sync from template?
|
||||||
|
- Breaking change policy?
|
||||||
|
|
||||||
|
3. **Feature Naming**
|
||||||
|
- Standardize feature names across projects?
|
||||||
|
- Or allow project-specific naming?
|
||||||
|
|
||||||
|
4. **Testing**
|
||||||
|
- Should test framework be in template?
|
||||||
|
- Or project-specific?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Review this plan
|
||||||
|
2. Decide on sync strategy (subtree vs submodule vs copy)
|
||||||
|
3. Start Phase 1: Prepare template
|
||||||
|
4. Create PR to kmp-project-template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands After Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In any derived project
|
||||||
|
/gap-analysis # Reads PROJECT.md, shows project-specific status
|
||||||
|
/gap-planning design # Plans based on project features
|
||||||
|
/implement auth # Implements based on project specs
|
||||||
|
|
||||||
|
# Sync from template
|
||||||
|
git subtree pull --prefix=claude-product-cycle/_framework \
|
||||||
|
https://github.com/openMF/kmp-project-template.git main --squash
|
||||||
|
```
|
||||||
535
claude-product-cycle/TEST_STUBS_GUIDE.md
Normal file
535
claude-product-cycle/TEST_STUBS_GUIDE.md
Normal file
@ -0,0 +1,535 @@
|
|||||||
|
# Test Stubs Guide
|
||||||
|
|
||||||
|
> Auto-generated test scaffolding for TDD support in `/implement` command
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `/implement` command automatically generates test stubs (Phase 5) to support Test-Driven Development. This guide explains the generated files, how to use them, and best practices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generated Test Files
|
||||||
|
|
||||||
|
When running `/implement [feature]`, the following test files are created:
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/[feature]/
|
||||||
|
├── src/
|
||||||
|
│ ├── commonTest/kotlin/org/mifos/mobile/feature/[feature]/
|
||||||
|
│ │ ├── [Feature]ViewModelTest.kt # ViewModel unit tests
|
||||||
|
│ │ └── fake/
|
||||||
|
│ │ └── Fake[Feature]Repository.kt # Test double
|
||||||
|
│ │
|
||||||
|
│ └── androidInstrumentedTest/kotlin/org/mifos/mobile/feature/[feature]/
|
||||||
|
│ └── [Feature]ScreenTest.kt # Compose UI tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Patterns
|
||||||
|
|
||||||
|
### 1. ViewModel Test Pattern
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ${Feature}ViewModelTest {
|
||||||
|
// Rule for coroutine testing
|
||||||
|
private val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private lateinit var viewModel: ${Feature}ViewModel
|
||||||
|
private lateinit var fakeRepository: Fake${Feature}Repository
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
fakeRepository = Fake${Feature}Repository()
|
||||||
|
viewModel = ${Feature}ViewModel(repository = fakeRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Categories:
|
||||||
|
// 1. Initial State Tests
|
||||||
|
@Test
|
||||||
|
fun `initial state is loading`() = runTest {
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = awaitItem()
|
||||||
|
assertTrue(state.uiState is ${Feature}ScreenState.Loading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Success State Tests
|
||||||
|
@Test
|
||||||
|
fun `data loaded successfully shows success state`() = runTest {
|
||||||
|
fakeRepository.setSuccessResponse(testData)
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue(state.uiState is ${Feature}ScreenState.Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Error State Tests
|
||||||
|
@Test
|
||||||
|
fun `data load failure shows error state`() = runTest {
|
||||||
|
fakeRepository.setErrorResponse("Network error")
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue(state.uiState is ${Feature}ScreenState.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Action Tests
|
||||||
|
@Test
|
||||||
|
fun `action updates state correctly`() = runTest {
|
||||||
|
viewModel.trySendAction(${Feature}Action.SomeAction)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
// Verify state change
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Event Tests
|
||||||
|
@Test
|
||||||
|
fun `action triggers navigation event`() = runTest {
|
||||||
|
viewModel.trySendAction(${Feature}Action.ItemClicked(id))
|
||||||
|
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
assertEquals(${Feature}Event.NavigateToDetail(id), awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Screen Test Pattern
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ${Feature}ScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
// Test Categories:
|
||||||
|
// 1. Loading State
|
||||||
|
@Test
|
||||||
|
fun loadingState_displaysLoadingIndicator() {
|
||||||
|
val state = ${Feature}State(uiState = ${Feature}ScreenState.Loading)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(state = state, onAction = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.LOADING)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Success State
|
||||||
|
@Test
|
||||||
|
fun successState_displaysContent() {
|
||||||
|
val state = ${Feature}State(
|
||||||
|
uiState = ${Feature}ScreenState.Success(testData)
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(state = state, onAction = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.SCREEN)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.LIST)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Error State
|
||||||
|
@Test
|
||||||
|
fun errorState_displaysErrorMessage() {
|
||||||
|
val state = ${Feature}State(
|
||||||
|
uiState = ${Feature}ScreenState.Error("Network error")
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(state = state, onAction = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.ERROR)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Network error")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Empty State
|
||||||
|
@Test
|
||||||
|
fun emptyState_displaysEmptyMessage() {
|
||||||
|
val state = ${Feature}State(
|
||||||
|
uiState = ${Feature}ScreenState.Success(emptyList())
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(state = state, onAction = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.EMPTY)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. User Interaction
|
||||||
|
@Test
|
||||||
|
fun itemClick_triggersAction() {
|
||||||
|
var actionReceived: ${Feature}Action? = null
|
||||||
|
val state = ${Feature}State(
|
||||||
|
uiState = ${Feature}ScreenState.Success(testData)
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = state,
|
||||||
|
onAction = { actionReceived = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("${feature}:item:1")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
assertEquals(${Feature}Action.ItemClicked(1), actionReceived)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Fake Repository Pattern
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class Fake${Feature}Repository : ${Feature}Repository {
|
||||||
|
// Call tracking
|
||||||
|
var loadCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
// Configurable response
|
||||||
|
private var response: DataState<List<${Model}>> = DataState.Loading
|
||||||
|
|
||||||
|
// Setup methods
|
||||||
|
fun setSuccessResponse(data: List<${Model}>) {
|
||||||
|
response = DataState.Success(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setErrorResponse(message: String) {
|
||||||
|
response = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEmptyResponse() {
|
||||||
|
response = DataState.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLoadingState() {
|
||||||
|
response = DataState.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository implementation
|
||||||
|
override fun get${Feature}(): Flow<DataState<List<${Model}>>> = flow {
|
||||||
|
loadCallCount++
|
||||||
|
emit(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset for test isolation
|
||||||
|
fun reset() {
|
||||||
|
loadCallCount = 0
|
||||||
|
response = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TestTag Convention
|
||||||
|
|
||||||
|
### Naming Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
{feature}:{component}:{identifier}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standard Tags
|
||||||
|
|
||||||
|
| Component | Pattern | Example |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| Screen container | `{feature}:screen` | `beneficiary:screen` |
|
||||||
|
| Loading indicator | `{feature}:loading` | `beneficiary:loading` |
|
||||||
|
| Error container | `{feature}:error` | `beneficiary:error` |
|
||||||
|
| Empty state | `{feature}:empty` | `beneficiary:empty` |
|
||||||
|
| List container | `{feature}:list` | `beneficiary:list` |
|
||||||
|
| List item | `{feature}:item:{id}` | `beneficiary:item:123` |
|
||||||
|
| Action button | `{feature}:{action}` | `beneficiary:add` |
|
||||||
|
| Retry button | `{feature}:retry` | `beneficiary:retry` |
|
||||||
|
| Input field | `{feature}:input:{name}` | `auth:input:username` |
|
||||||
|
| Form submit | `{feature}:submit` | `auth:submit` |
|
||||||
|
|
||||||
|
### TestTags Object
|
||||||
|
|
||||||
|
Each feature should have a TestTags object:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object ${Feature}TestTags {
|
||||||
|
const val SCREEN = "${feature}:screen"
|
||||||
|
const val LOADING = "${feature}:loading"
|
||||||
|
const val ERROR = "${feature}:error"
|
||||||
|
const val EMPTY = "${feature}:empty"
|
||||||
|
const val LIST = "${feature}:list"
|
||||||
|
const val RETRY = "${feature}:retry"
|
||||||
|
|
||||||
|
fun item(id: Long) = "${feature}:item:$id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Applying TestTags
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun ${Feature}Screen(...) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.testTag(${Feature}TestTags.SCREEN)
|
||||||
|
) {
|
||||||
|
when (state.uiState) {
|
||||||
|
is Loading -> LoadingIndicator(
|
||||||
|
modifier = Modifier.testTag(${Feature}TestTags.LOADING)
|
||||||
|
)
|
||||||
|
is Error -> ErrorView(
|
||||||
|
modifier = Modifier.testTag(${Feature}TestTags.ERROR)
|
||||||
|
)
|
||||||
|
is Success -> ContentList(
|
||||||
|
modifier = Modifier.testTag(${Feature}TestTags.LIST)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Dependencies
|
||||||
|
|
||||||
|
### build.gradle.kts
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
implementation(libs.turbine)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidInstrumentedTest.dependencies {
|
||||||
|
implementation(libs.compose.ui.test.junit4)
|
||||||
|
implementation(libs.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Libraries
|
||||||
|
|
||||||
|
| Library | Purpose | Usage |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| `kotlin-test` | Assertions | `assertEquals`, `assertTrue` |
|
||||||
|
| `kotlinx-coroutines-test` | Coroutine testing | `runTest`, `TestDispatcher` |
|
||||||
|
| `turbine` | Flow testing | `stateFlow.test { }` |
|
||||||
|
| `compose-ui-test` | Compose UI testing | `onNodeWithTag`, `performClick` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Execution
|
||||||
|
|
||||||
|
### Run Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests for a feature
|
||||||
|
./gradlew :feature:${feature}:test
|
||||||
|
|
||||||
|
# Run ViewModel tests only (commonTest)
|
||||||
|
./gradlew :feature:${feature}:jvmTest
|
||||||
|
|
||||||
|
# Run Screen tests (Android instrumented)
|
||||||
|
./gradlew :feature:${feature}:connectedAndroidTest
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
./gradlew :feature:${feature}:test jacocoTestReport
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI Integration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/test.yml
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: ./gradlew testDebug
|
||||||
|
|
||||||
|
- name: Run UI Tests
|
||||||
|
run: ./gradlew connectedAndroidTest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TDD Workflow
|
||||||
|
|
||||||
|
### Red-Green-Refactor
|
||||||
|
|
||||||
|
```
|
||||||
|
1. WRITE FAILING TEST (Red)
|
||||||
|
└─ Generated stub has TODO assertions
|
||||||
|
└─ Test fails because implementation is incomplete
|
||||||
|
|
||||||
|
2. IMPLEMENT (Green)
|
||||||
|
└─ Write minimum code to pass
|
||||||
|
└─ Fill in ViewModel/Screen logic
|
||||||
|
|
||||||
|
3. REFACTOR (Clean)
|
||||||
|
└─ Improve code quality
|
||||||
|
└─ Keep tests passing
|
||||||
|
|
||||||
|
4. VERIFY
|
||||||
|
└─ Run /verify [feature]
|
||||||
|
└─ Check TestTag validation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stub Completion Checklist
|
||||||
|
|
||||||
|
After `/implement` generates stubs:
|
||||||
|
|
||||||
|
- [ ] Fill in test data fixtures
|
||||||
|
- [ ] Complete assertion logic (replace TODOs)
|
||||||
|
- [ ] Add edge case tests
|
||||||
|
- [ ] Verify all TestTags are applied
|
||||||
|
- [ ] Run tests to confirm passing
|
||||||
|
- [ ] Update TESTING_STATUS.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Test Scenarios
|
||||||
|
|
||||||
|
### 1. Pagination Test
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `load more appends to list`() = runTest {
|
||||||
|
fakeRepository.setSuccessResponse(page1Data)
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
fakeRepository.setSuccessResponse(page2Data)
|
||||||
|
viewModel.trySendAction(${Feature}Action.LoadMore)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
val data = (state.uiState as Success).data
|
||||||
|
assertEquals(page1Data + page2Data, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Pull-to-Refresh Test
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `refresh replaces data`() = runTest {
|
||||||
|
fakeRepository.setSuccessResponse(oldData)
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
fakeRepository.setSuccessResponse(newData)
|
||||||
|
viewModel.trySendAction(${Feature}Action.Refresh)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
val data = (state.uiState as Success).data
|
||||||
|
assertEquals(newData, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Form Validation Test
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `invalid input shows validation error`() = runTest {
|
||||||
|
viewModel.trySendAction(${Feature}Action.Submit(invalidInput))
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertNotNull(state.validationError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Dialog Confirmation Test
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `delete shows confirmation dialog`() = runTest {
|
||||||
|
viewModel.trySendAction(${Feature}Action.DeleteClicked(id))
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue(state.dialogState is DialogState.Confirmation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `confirm delete triggers delete action`() = runTest {
|
||||||
|
viewModel.trySendAction(${Feature}Action.ConfirmDelete(id))
|
||||||
|
|
||||||
|
assertEquals(1, fakeRepository.deleteCallCount)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Cause | Solution |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| Test timeout | Missing `runTest` | Wrap in `runTest { }` |
|
||||||
|
| Flow not emitting | Wrong dispatcher | Use `MainDispatcherRule` |
|
||||||
|
| Node not found | Missing testTag | Add `Modifier.testTag()` |
|
||||||
|
| Assertion failure | Stale state | Use `expectMostRecentItem()` |
|
||||||
|
|
||||||
|
### Debug Tips
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Print state for debugging
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
println("Current state: $state")
|
||||||
|
// assertions...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print compose tree
|
||||||
|
composeTestRule.onRoot().printToLog("COMPOSE_TREE")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- [TESTING_STATUS.md](./feature-layer/TESTING_STATUS.md) - Feature test coverage
|
||||||
|
- [/verify command](../.claude/commands/verify.md) - TestTag validation
|
||||||
|
- [/implement command](../.claude/commands/implement.md) - Test stub generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate test stubs for feature
|
||||||
|
/implement [feature] # Phase 5 generates tests
|
||||||
|
|
||||||
|
# Verify TestTag compliance
|
||||||
|
/verify [feature] # Includes TestTag validation
|
||||||
|
|
||||||
|
# Check testing gaps
|
||||||
|
/gap-analysis testing # Shows test coverage gaps
|
||||||
|
```
|
||||||
283
claude-product-cycle/design-spec-layer/DESIGN_TOKENS_INDEX.md
Normal file
283
claude-product-cycle/design-spec-layer/DESIGN_TOKENS_INDEX.md
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
# Design Tokens Index - O(1) Lookup
|
||||||
|
|
||||||
|
> **8 features** with tokens | **2 formats** (Google Stitch, MD3) | **Last Updated**: 2026-01-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Lookup
|
||||||
|
|
||||||
|
| # | Feature | Has Tokens | Format | Colors | Typography | Components | Animations |
|
||||||
|
|:-:|---------|:----------:|--------|:------:|:----------:|:----------:|:----------:|
|
||||||
|
| 1 | auth | ✅ | google-stitch | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| 2 | dashboard | ✅ | md3 | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| 3 | settings | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| 4 | guarantor | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| 5 | qr | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| 6 | passcode | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| 7 | location | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| 8 | client-charge | ✅ | md3 | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| 9 | accounts | ❌ | - | - | - | - | - |
|
||||||
|
| 10 | beneficiary | ❌ | - | - | - | - | - |
|
||||||
|
| 11 | home | ❌ | - | - | - | - | - |
|
||||||
|
| 12 | loan-account | ❌ | - | - | - | - | - |
|
||||||
|
| 13 | notification | ❌ | - | - | - | - | - |
|
||||||
|
| 14 | recent-transaction | ❌ | - | - | - | - | - |
|
||||||
|
| 15 | savings-account | ❌ | - | - | - | - | - |
|
||||||
|
| 16 | share-account | ❌ | - | - | - | - | - |
|
||||||
|
| 17 | transfer | ❌ | - | - | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token Formats
|
||||||
|
|
||||||
|
### Google Stitch Format (v2.0)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"feature": "auth",
|
||||||
|
"generated": "2026-01-03",
|
||||||
|
"tool": "google-stitch",
|
||||||
|
"version": "2.0",
|
||||||
|
"tokens": {
|
||||||
|
"colors": {
|
||||||
|
"primary": { "gradient": {...}, "solid": "#667EEA" },
|
||||||
|
"surface": { "light": "#FFFFFF", "dark": "#0D1117" },
|
||||||
|
"text": { "primary": {...}, "secondary": {...} },
|
||||||
|
"semantic": { "success": "#00D09C", "error": "#FF4757" }
|
||||||
|
},
|
||||||
|
"typography": { "fontFamily": "Inter", "display": {...}, "headline": {...} },
|
||||||
|
"spacing": { "xs": "4dp", "sm": "8dp", "md": "12dp", "lg": "16dp" },
|
||||||
|
"radius": { "sm": "8dp", "md": "12dp", "lg": "16dp" },
|
||||||
|
"shadow": { "button": {...}, "card": {...} }
|
||||||
|
},
|
||||||
|
"screens": [...],
|
||||||
|
"components": [...],
|
||||||
|
"animations": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MD3 Format (Standard)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"feature": "Dashboard",
|
||||||
|
"generated": "2025-12-28",
|
||||||
|
"tokens": {
|
||||||
|
"colors": { "primary": "#6750A4", "surface": "#FFFBFE", ... },
|
||||||
|
"typography": { "displayLarge": {...}, "bodyMedium": {...} },
|
||||||
|
"spacing": { "xs": 4, "sm": 8, "md": 16 },
|
||||||
|
"radius": { "sm": 8, "md": 12, "lg": 16 }
|
||||||
|
},
|
||||||
|
"components": [...],
|
||||||
|
"screens": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O(1) Path Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
design-spec-layer/features/[feature]/mockups/design-tokens.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token → DesignToken Mapping
|
||||||
|
|
||||||
|
| Token JSON | Compose DesignToken | Type |
|
||||||
|
|------------|---------------------|------|
|
||||||
|
| `tokens.spacing.xs` | `DesignToken.spacing.extraSmall` | `Dp` |
|
||||||
|
| `tokens.spacing.sm` | `DesignToken.spacing.small` | `Dp` |
|
||||||
|
| `tokens.spacing.md` | `DesignToken.spacing.medium` | `Dp` |
|
||||||
|
| `tokens.spacing.lg` | `DesignToken.spacing.large` | `Dp` |
|
||||||
|
| `tokens.radius.sm` | `DesignToken.shapes.small` | `Shape` |
|
||||||
|
| `tokens.radius.md` | `DesignToken.shapes.medium` | `Shape` |
|
||||||
|
| `tokens.radius.lg` | `DesignToken.shapes.large` | `Shape` |
|
||||||
|
| `tokens.colors.primary` | `MaterialTheme.colorScheme.primary` | `Color` |
|
||||||
|
| `tokens.colors.surface` | `MaterialTheme.colorScheme.surface` | `Color` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gradient Support
|
||||||
|
|
||||||
|
Features with gradients (Google Stitch format):
|
||||||
|
|
||||||
|
| Feature | Gradient Type | Colors | Usage |
|
||||||
|
|---------|---------------|--------|-------|
|
||||||
|
| auth | primary | `#667EEA → #764BA2` | Buttons, headers |
|
||||||
|
| auth | secondary | `#11998E → #38EF7D` | Success states |
|
||||||
|
|
||||||
|
### Compose Gradient Code
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// From design-tokens.json:
|
||||||
|
// "gradient": { "start": "#667EEA", "end": "#764BA2", "angle": 45 }
|
||||||
|
|
||||||
|
val AuthGradient = Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF667EEA),
|
||||||
|
Color(0xFF764BA2)
|
||||||
|
),
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Usage in Button:
|
||||||
|
Button(
|
||||||
|
onClick = { },
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.background(AuthGradient, shape = DesignToken.shapes.large)
|
||||||
|
) {
|
||||||
|
Text("Login")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Specs
|
||||||
|
|
||||||
|
Features with component specifications:
|
||||||
|
|
||||||
|
| Feature | Components | Details |
|
||||||
|
|---------|:----------:|---------|
|
||||||
|
| auth | 5 | primary-button, text-input, otp-input, auth-card, trust-badge |
|
||||||
|
| dashboard | 7 | NetWorthCard, QuickActions, AccountCard, TransactionItem, BottomNav, TopBar, SectionHeader |
|
||||||
|
|
||||||
|
### Component Spec Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "primary-button",
|
||||||
|
"name": "Primary Button",
|
||||||
|
"specs": {
|
||||||
|
"height": "56dp",
|
||||||
|
"radius": "16dp",
|
||||||
|
"background": "gradient",
|
||||||
|
"textSize": "16sp",
|
||||||
|
"textWeight": "600",
|
||||||
|
"textColor": "#FFFFFF",
|
||||||
|
"shadow": "button"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generated Compose Code
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun AuthPrimaryButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier
|
||||||
|
.height(56.dp)
|
||||||
|
.background(
|
||||||
|
brush = AuthGradient,
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation Specs
|
||||||
|
|
||||||
|
Features with animation specifications:
|
||||||
|
|
||||||
|
| Feature | Animations | Details |
|
||||||
|
|---------|:----------:|---------|
|
||||||
|
| auth | 5 | pageTransition, buttonPress, inputFocus, successCelebration, errorShake |
|
||||||
|
|
||||||
|
### Animation Spec Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
"animations": {
|
||||||
|
"buttonPress": {
|
||||||
|
"scale": "0.98",
|
||||||
|
"duration": "100ms"
|
||||||
|
},
|
||||||
|
"errorShake": {
|
||||||
|
"translateX": "[-10, 10, -5, 5, 0]",
|
||||||
|
"duration": "300ms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage in /feature Command
|
||||||
|
|
||||||
|
### Phase 0: O(1) Context Loading
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Check if design tokens exist
|
||||||
|
val tokensPath = "design-spec-layer/features/$feature/mockups/design-tokens.json"
|
||||||
|
val hasTokens = checkInIndex("DESIGN_TOKENS_INDEX.md", feature)
|
||||||
|
val tokenFormat = getTokenFormat(feature) // "google-stitch" | "md3" | null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Apply Design Tokens
|
||||||
|
|
||||||
|
```
|
||||||
|
IF hasTokens THEN
|
||||||
|
1. Read design-tokens.json
|
||||||
|
2. Extract colors → Generate feature-specific colors if custom
|
||||||
|
3. Extract gradients → Generate Brush definitions
|
||||||
|
4. Extract component specs → Apply to generated components
|
||||||
|
5. Extract animations → Add animation modifiers
|
||||||
|
6. Map spacing/radius → Use DesignToken equivalents
|
||||||
|
ELSE
|
||||||
|
Use default DesignToken values
|
||||||
|
END
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Update Rules
|
||||||
|
|
||||||
|
| Scenario | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| New tokens generated | Add row to Quick Lookup table |
|
||||||
|
| `/design mockup` completes | Update Has Tokens column |
|
||||||
|
| Token format changes | Update Format column |
|
||||||
|
| Components added | Update Components column |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- [FEATURES_INDEX.md](./FEATURES_INDEX.md) - All features
|
||||||
|
- [MOCKUPS_INDEX.md](./MOCKUPS_INDEX.md) - Mockup status
|
||||||
|
- `core/designsystem/theme/DesignToken.kt` - Compose design tokens
|
||||||
|
- `core/designsystem/theme/Color.kt` - Color definitions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check token status
|
||||||
|
/gap-analysis design tokens
|
||||||
|
|
||||||
|
# Generate tokens for feature
|
||||||
|
/design [feature] mockup
|
||||||
|
|
||||||
|
# Feature with token integration
|
||||||
|
/feature [feature] # Auto-applies tokens if available
|
||||||
|
```
|
||||||
116
claude-product-cycle/plans/PLANS_INDEX.md
Normal file
116
claude-product-cycle/plans/PLANS_INDEX.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# Plans Index - O(1) Lookup
|
||||||
|
|
||||||
|
> Track implementation plans created by `/gap-planning`
|
||||||
|
|
||||||
|
**Last Updated**: 2026-01-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Overview
|
||||||
|
|
||||||
|
| Status | Count | Description |
|
||||||
|
|:------:|:-----:|-------------|
|
||||||
|
| 🔄 Active | 0 | Plans in progress |
|
||||||
|
| ✅ Completed | 0 | Finished plans |
|
||||||
|
| ⏸️ Paused | 0 | Plans on hold |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active Plans
|
||||||
|
|
||||||
|
| # | Plan | Target | Progress | Current Step | Created |
|
||||||
|
|:-:|------|--------|:--------:|--------------|---------|
|
||||||
|
| - | (none) | - | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completed Plans
|
||||||
|
|
||||||
|
| # | Plan | Target | Steps | Completed |
|
||||||
|
|:-:|------|--------|:-----:|-----------|
|
||||||
|
| - | (none) | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan File Format
|
||||||
|
|
||||||
|
Each plan file in `active/` or `completed/` follows this format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Plan: [Target Name]
|
||||||
|
|
||||||
|
**Created**: YYYY-MM-DD
|
||||||
|
**Status**: 🔄 Active | ✅ Completed | ⏸️ Paused
|
||||||
|
**Command**: /gap-planning [args]
|
||||||
|
**Progress**: X/Y steps (Z%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **Step 1**: Description
|
||||||
|
- Sub-task 1
|
||||||
|
- Sub-task 2
|
||||||
|
- Files: `path/to/file.kt`
|
||||||
|
|
||||||
|
- [ ] **Step 2**: Description
|
||||||
|
- Sub-task 1
|
||||||
|
- Files: `path/to/file.kt`
|
||||||
|
|
||||||
|
- [x] **Step 3**: Description (COMPLETED)
|
||||||
|
- ✅ Sub-task 1
|
||||||
|
- ✅ Sub-task 2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
| Date | Step | Action | Outcome |
|
||||||
|
|------|------|--------|---------|
|
||||||
|
| YYYY-MM-DD | 1 | Started | In progress |
|
||||||
|
| YYYY-MM-DD | 1 | Completed | Files created |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O(1) Path Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
plans/active/[target]-[type].md # Active plan
|
||||||
|
plans/completed/[target]-[type].md # Completed plan
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `plans/active/design-mockup.md`
|
||||||
|
- `plans/active/testing-auth.md`
|
||||||
|
- `plans/active/feature-beneficiary.md`
|
||||||
|
- `plans/completed/client-layer.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check status of all plans
|
||||||
|
/gap-status # Shows this index + active plans
|
||||||
|
|
||||||
|
# Check status of specific plan
|
||||||
|
/gap-status design mockup # Shows design-mockup.md progress
|
||||||
|
|
||||||
|
# Create new plan
|
||||||
|
/gap-planning [target] # Creates plan in plans/active/
|
||||||
|
|
||||||
|
# Mark plan complete
|
||||||
|
/gap-status complete [plan] # Moves to plans/completed/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Update Rules
|
||||||
|
|
||||||
|
| Trigger | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `/gap-planning [target]` | Create plan file, add to Active Plans |
|
||||||
|
| `/gap-status complete [plan]` | Move to Completed Plans |
|
||||||
|
| Step completed | Update progress in plan file |
|
||||||
|
| `/implement` checkpoint | Update related plan progress |
|
||||||
511
claude-product-cycle/testing-layer/FAKE_REPOS_INDEX.md
Normal file
511
claude-product-cycle/testing-layer/FAKE_REPOS_INDEX.md
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
# Fake Repositories Index - O(1) Lookup
|
||||||
|
|
||||||
|
> **17 repositories** | Test doubles for isolation | **Last Updated**: 2026-01-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Lookup
|
||||||
|
|
||||||
|
| # | Feature | Repository | Fake Repository | Status |
|
||||||
|
|:-:|---------|------------|-----------------|:------:|
|
||||||
|
| 1 | auth | `UserAuthRepository` | `FakeUserAuthRepository` | ❌ |
|
||||||
|
| 2 | home | `HomeRepository` | `FakeHomeRepository` | ❌ |
|
||||||
|
| 3 | accounts | `AccountsRepository` | `FakeAccountsRepository` | ❌ |
|
||||||
|
| 4 | savings-account | `SavingsAccountRepository` | `FakeSavingsAccountRepository` | ❌ |
|
||||||
|
| 5 | loan-account | `LoanRepository` | `FakeLoanRepository` | ❌ |
|
||||||
|
| 6 | share-account | `ShareAccountRepository` | `FakeShareAccountRepository` | ❌ |
|
||||||
|
| 7 | beneficiary | `BeneficiaryRepository` | `FakeBeneficiaryRepository` | ❌ |
|
||||||
|
| 8 | transfer | `TransferRepository` | `FakeTransferRepository` | ❌ |
|
||||||
|
| 9 | recent-transaction | `RecentTransactionRepository` | `FakeRecentTransactionRepository` | ❌ |
|
||||||
|
| 10 | notification | `NotificationRepository` | `FakeNotificationRepository` | ❌ |
|
||||||
|
| 11 | settings | `UserPreferencesRepository` | `FakeUserPreferencesRepository` | ❌ |
|
||||||
|
| 12 | guarantor | `GuarantorRepository` | `FakeGuarantorRepository` | ❌ |
|
||||||
|
| 13 | qr | - | - | N/A |
|
||||||
|
| 14 | location | - | - | N/A |
|
||||||
|
| 15 | client-charge | `ClientChargeRepository` | `FakeClientChargeRepository` | ❌ |
|
||||||
|
| 16 | passcode | - | - | N/A |
|
||||||
|
| 17 | dashboard | - | - | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O(1) Path Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/Fake${Feature}Repository.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fake Repository Pattern
|
||||||
|
|
||||||
|
### Standard Structure
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class Fake${Feature}Repository : ${Feature}Repository {
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// CALL TRACKING
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
var loadCallCount = 0
|
||||||
|
private set
|
||||||
|
var createCallCount = 0
|
||||||
|
private set
|
||||||
|
var updateCallCount = 0
|
||||||
|
private set
|
||||||
|
var deleteCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// CONFIGURABLE RESPONSES
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
private var loadResponse: DataState<List<${Model}>> = DataState.Loading
|
||||||
|
private var createResponse: DataState<${Model}> = DataState.Loading
|
||||||
|
private var deleteResponse: DataState<Unit> = DataState.Loading
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP METHODS (for test configuration)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
fun setLoadSuccessResponse(data: List<${Model}>) {
|
||||||
|
loadResponse = DataState.Success(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLoadErrorResponse(message: String) {
|
||||||
|
loadResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLoadEmptyResponse() {
|
||||||
|
loadResponse = DataState.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCreateSuccessResponse(data: ${Model}) {
|
||||||
|
createResponse = DataState.Success(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCreateErrorResponse(message: String) {
|
||||||
|
createResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDeleteSuccessResponse() {
|
||||||
|
deleteResponse = DataState.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDeleteErrorResponse(message: String) {
|
||||||
|
deleteResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// REPOSITORY IMPLEMENTATION
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
override fun get${Feature}s(): Flow<DataState<List<${Model}>>> = flow {
|
||||||
|
loadCallCount++
|
||||||
|
emit(loadResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun create${Model}(payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
|
||||||
|
createCallCount++
|
||||||
|
emit(createResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete${Model}(id: Long): Flow<DataState<Unit>> = flow {
|
||||||
|
deleteCallCount++
|
||||||
|
emit(deleteResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// RESET (for test isolation)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
fun reset() {
|
||||||
|
loadCallCount = 0
|
||||||
|
createCallCount = 0
|
||||||
|
updateCallCount = 0
|
||||||
|
deleteCallCount = 0
|
||||||
|
loadResponse = DataState.Loading
|
||||||
|
createResponse = DataState.Loading
|
||||||
|
deleteResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Fake Repositories
|
||||||
|
|
||||||
|
### 1. FakeUserAuthRepository
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class FakeUserAuthRepository : UserAuthRepository {
|
||||||
|
var loginCallCount = 0
|
||||||
|
var registerCallCount = 0
|
||||||
|
var verifyOtpCallCount = 0
|
||||||
|
|
||||||
|
private var loginResponse: DataState<User> = DataState.Loading
|
||||||
|
private var registerResponse: DataState<RegisterPayload> = DataState.Loading
|
||||||
|
private var otpResponse: DataState<Unit> = DataState.Loading
|
||||||
|
|
||||||
|
fun setLoginSuccess(user: User = AuthFixtures.validUser) {
|
||||||
|
loginResponse = DataState.Success(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLoginError(message: String = "Invalid credentials") {
|
||||||
|
loginResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRegisterSuccess() {
|
||||||
|
registerResponse = DataState.Success(RegisterPayload())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOtpSuccess() {
|
||||||
|
otpResponse = DataState.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun login(payload: LoginPayload): Flow<DataState<User>> = flow {
|
||||||
|
loginCallCount++
|
||||||
|
emit(loginResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun register(payload: RegisterPayload): Flow<DataState<RegisterPayload>> = flow {
|
||||||
|
registerCallCount++
|
||||||
|
emit(registerResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun verifyOtp(otp: String): Flow<DataState<Unit>> = flow {
|
||||||
|
verifyOtpCallCount++
|
||||||
|
emit(otpResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
loginCallCount = 0
|
||||||
|
registerCallCount = 0
|
||||||
|
verifyOtpCallCount = 0
|
||||||
|
loginResponse = DataState.Loading
|
||||||
|
registerResponse = DataState.Loading
|
||||||
|
otpResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. FakeHomeRepository
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class FakeHomeRepository : HomeRepository {
|
||||||
|
var loadAccountsCallCount = 0
|
||||||
|
var loadUserCallCount = 0
|
||||||
|
|
||||||
|
private var accountsResponse: DataState<ClientAccounts> = DataState.Loading
|
||||||
|
private var userResponse: DataState<Client> = DataState.Loading
|
||||||
|
|
||||||
|
fun setAccountsSuccess(accounts: ClientAccounts = AccountFixtures.fullPortfolio) {
|
||||||
|
accountsResponse = DataState.Success(accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAccountsError(message: String = "Failed to load accounts") {
|
||||||
|
accountsResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUserSuccess(client: Client) {
|
||||||
|
userResponse = DataState.Success(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getClientAccounts(): Flow<DataState<ClientAccounts>> = flow {
|
||||||
|
loadAccountsCallCount++
|
||||||
|
emit(accountsResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentUser(): Flow<DataState<Client>> = flow {
|
||||||
|
loadUserCallCount++
|
||||||
|
emit(userResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
loadAccountsCallCount = 0
|
||||||
|
loadUserCallCount = 0
|
||||||
|
accountsResponse = DataState.Loading
|
||||||
|
userResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. FakeBeneficiaryRepository
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class FakeBeneficiaryRepository : BeneficiaryRepository {
|
||||||
|
var loadCallCount = 0
|
||||||
|
var createCallCount = 0
|
||||||
|
var updateCallCount = 0
|
||||||
|
var deleteCallCount = 0
|
||||||
|
|
||||||
|
private var loadResponse: DataState<List<Beneficiary>> = DataState.Loading
|
||||||
|
private var createResponse: DataState<Beneficiary> = DataState.Loading
|
||||||
|
private var updateResponse: DataState<Beneficiary> = DataState.Loading
|
||||||
|
private var deleteResponse: DataState<Unit> = DataState.Loading
|
||||||
|
|
||||||
|
fun setLoadSuccess(data: List<Beneficiary> = BeneficiaryFixtures.multipleBeneficiaries) {
|
||||||
|
loadResponse = DataState.Success(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLoadError(message: String = "Failed to load beneficiaries") {
|
||||||
|
loadResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLoadEmpty() {
|
||||||
|
loadResponse = DataState.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCreateSuccess(beneficiary: Beneficiary = BeneficiaryFixtures.createBeneficiary()) {
|
||||||
|
createResponse = DataState.Success(beneficiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCreateError(message: String = "Failed to create beneficiary") {
|
||||||
|
createResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDeleteSuccess() {
|
||||||
|
deleteResponse = DataState.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDeleteError(message: String = "Failed to delete beneficiary") {
|
||||||
|
deleteResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getBeneficiaries(): Flow<DataState<List<Beneficiary>>> = flow {
|
||||||
|
loadCallCount++
|
||||||
|
emit(loadResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createBeneficiary(payload: BeneficiaryPayload): Flow<DataState<Beneficiary>> = flow {
|
||||||
|
createCallCount++
|
||||||
|
emit(createResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateBeneficiary(id: Long, payload: BeneficiaryPayload): Flow<DataState<Beneficiary>> = flow {
|
||||||
|
updateCallCount++
|
||||||
|
emit(updateResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteBeneficiary(id: Long): Flow<DataState<Unit>> = flow {
|
||||||
|
deleteCallCount++
|
||||||
|
emit(deleteResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
loadCallCount = 0
|
||||||
|
createCallCount = 0
|
||||||
|
updateCallCount = 0
|
||||||
|
deleteCallCount = 0
|
||||||
|
loadResponse = DataState.Loading
|
||||||
|
createResponse = DataState.Loading
|
||||||
|
updateResponse = DataState.Loading
|
||||||
|
deleteResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. FakeTransferRepository
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class FakeTransferRepository : TransferRepository {
|
||||||
|
var loadTemplateCallCount = 0
|
||||||
|
var makeTransferCallCount = 0
|
||||||
|
|
||||||
|
private var templateResponse: DataState<TransferTemplate> = DataState.Loading
|
||||||
|
private var transferResponse: DataState<Unit> = DataState.Loading
|
||||||
|
|
||||||
|
fun setTemplateSuccess(template: TransferTemplate = TransferFixtures.createTransferTemplate()) {
|
||||||
|
templateResponse = DataState.Success(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTemplateError(message: String = "Failed to load transfer template") {
|
||||||
|
templateResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTransferSuccess() {
|
||||||
|
transferResponse = DataState.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTransferError(message: String = "Transfer failed") {
|
||||||
|
transferResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getTransferTemplate(): Flow<DataState<TransferTemplate>> = flow {
|
||||||
|
loadTemplateCallCount++
|
||||||
|
emit(templateResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun makeTransfer(payload: TransferPayload): Flow<DataState<Unit>> = flow {
|
||||||
|
makeTransferCallCount++
|
||||||
|
emit(transferResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
loadTemplateCallCount = 0
|
||||||
|
makeTransferCallCount = 0
|
||||||
|
templateResponse = DataState.Loading
|
||||||
|
transferResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. FakeNotificationRepository
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class FakeNotificationRepository : NotificationRepository {
|
||||||
|
var loadCallCount = 0
|
||||||
|
var markReadCallCount = 0
|
||||||
|
|
||||||
|
private var loadResponse: DataState<List<Notification>> = DataState.Loading
|
||||||
|
private var markReadResponse: DataState<Unit> = DataState.Loading
|
||||||
|
|
||||||
|
fun setLoadSuccess(data: List<Notification> = NotificationFixtures.createNotificationList()) {
|
||||||
|
loadResponse = DataState.Success(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLoadError(message: String = "Failed to load notifications") {
|
||||||
|
loadResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLoadEmpty() {
|
||||||
|
loadResponse = DataState.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMarkReadSuccess() {
|
||||||
|
markReadResponse = DataState.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getNotifications(): Flow<DataState<List<Notification>>> = flow {
|
||||||
|
loadCallCount++
|
||||||
|
emit(loadResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun markAsRead(id: Long): Flow<DataState<Unit>> = flow {
|
||||||
|
markReadCallCount++
|
||||||
|
emit(markReadResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
loadCallCount = 0
|
||||||
|
markReadCallCount = 0
|
||||||
|
loadResponse = DataState.Loading
|
||||||
|
markReadResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. FakeUserPreferencesRepository
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class FakeUserPreferencesRepository : UserPreferencesRepository {
|
||||||
|
private var _theme: String = "system"
|
||||||
|
private var _language: String = "en"
|
||||||
|
private var _passcodeEnabled: Boolean = false
|
||||||
|
|
||||||
|
val themeFlow = MutableStateFlow(_theme)
|
||||||
|
val languageFlow = MutableStateFlow(_language)
|
||||||
|
val passcodeEnabledFlow = MutableStateFlow(_passcodeEnabled)
|
||||||
|
|
||||||
|
fun setTheme(theme: String) {
|
||||||
|
_theme = theme
|
||||||
|
themeFlow.value = theme
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLanguage(language: String) {
|
||||||
|
_language = language
|
||||||
|
languageFlow.value = language
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPasscodeEnabled(enabled: Boolean) {
|
||||||
|
_passcodeEnabled = enabled
|
||||||
|
passcodeEnabledFlow.value = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getTheme(): Flow<String> = themeFlow
|
||||||
|
override fun getLanguage(): Flow<String> = languageFlow
|
||||||
|
override fun isPasscodeEnabled(): Flow<Boolean> = passcodeEnabledFlow
|
||||||
|
|
||||||
|
override suspend fun updateTheme(theme: String) {
|
||||||
|
setTheme(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateLanguage(language: String) {
|
||||||
|
setLanguage(language)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
_theme = "system"
|
||||||
|
_language = "en"
|
||||||
|
_passcodeEnabled = false
|
||||||
|
themeFlow.value = _theme
|
||||||
|
languageFlow.value = _language
|
||||||
|
passcodeEnabledFlow.value = _passcodeEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage in Tests
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class BeneficiaryViewModelTest {
|
||||||
|
private lateinit var fakeRepository: FakeBeneficiaryRepository
|
||||||
|
private lateinit var viewModel: BeneficiaryViewModel
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
fakeRepository = FakeBeneficiaryRepository()
|
||||||
|
viewModel = BeneficiaryViewModel(repository = fakeRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
fakeRepository.reset() // Ensure test isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load success shows data`() = runTest {
|
||||||
|
fakeRepository.setLoadSuccess()
|
||||||
|
|
||||||
|
viewModel.loadBeneficiaries()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue(state.uiState is Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delete calls repository`() = runTest {
|
||||||
|
fakeRepository.setDeleteSuccess()
|
||||||
|
|
||||||
|
viewModel.deleteBeneficiary(1L)
|
||||||
|
|
||||||
|
assertEquals(1, fakeRepository.deleteCallCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying Calls
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `refresh reloads data`() = runTest {
|
||||||
|
fakeRepository.setLoadSuccess()
|
||||||
|
|
||||||
|
viewModel.loadBeneficiaries()
|
||||||
|
viewModel.trySendAction(Action.Refresh)
|
||||||
|
|
||||||
|
assertEquals(2, fakeRepository.loadCallCount)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate fake repository for feature
|
||||||
|
/implement [feature] # Creates fake in Phase 5
|
||||||
|
|
||||||
|
# Check fake repository status
|
||||||
|
/gap-analysis testing fakes
|
||||||
|
```
|
||||||
194
claude-product-cycle/testing-layer/LAYER_STATUS.md
Normal file
194
claude-product-cycle/testing-layer/LAYER_STATUS.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# Testing Layer - Status Dashboard
|
||||||
|
|
||||||
|
> **17 features** | **49 ViewModels** | **63 Screens** | **Last Updated**: 2026-01-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Overview
|
||||||
|
|
||||||
|
| Metric | Current | Target | Progress |
|
||||||
|
|--------|:-------:|:------:|:--------:|
|
||||||
|
| ViewModel Tests | 0/49 | 49 | [░░░░░░░░░░] 0% |
|
||||||
|
| Screen Tests | 0/63 | 63 | [░░░░░░░░░░] 0% |
|
||||||
|
| Fake Repositories | 6/17 | 17 | [███░░░░░░░] 35% |
|
||||||
|
| TestTags Objects | 17/17 | 17 | [██████████] 100% |
|
||||||
|
| Test Fixtures | 3/10 | 10 | [███░░░░░░░] 30% |
|
||||||
|
| Integration Tests | 0/5 | 5 | [░░░░░░░░░░] 0% |
|
||||||
|
| Screenshot Tests | 0/20 | 20 | [░░░░░░░░░░] 0% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## core:testing Module (KMP)
|
||||||
|
|
||||||
|
**Location**: `core/testing/`
|
||||||
|
**Status**: ✅ Active
|
||||||
|
**Platforms**: Android, iOS, Desktop, Native
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
core/testing/src/
|
||||||
|
├── commonMain/kotlin/org/mifos/mobile/core/testing/
|
||||||
|
│ ├── di/TestModule.kt # Koin test module
|
||||||
|
│ ├── fake/ # Fake repositories (6)
|
||||||
|
│ │ ├── FakeUserAuthRepository.kt
|
||||||
|
│ │ ├── FakeHomeRepository.kt
|
||||||
|
│ │ ├── FakeAccountsRepository.kt
|
||||||
|
│ │ ├── FakeBeneficiaryRepository.kt
|
||||||
|
│ │ ├── FakeTransferRepository.kt
|
||||||
|
│ │ └── FakeNotificationRepository.kt
|
||||||
|
│ ├── fixture/ # Test fixtures (3)
|
||||||
|
│ │ ├── UserFixture.kt
|
||||||
|
│ │ ├── ClientAccountsFixture.kt
|
||||||
|
│ │ └── BeneficiaryFixture.kt
|
||||||
|
│ ├── rule/MainDispatcherRule.kt # Coroutine testing (KMP)
|
||||||
|
│ └── util/
|
||||||
|
│ ├── TestTags.kt # TestTags for all 17 features
|
||||||
|
│ ├── FlowTestExtensions.kt # Flow testing helpers
|
||||||
|
│ └── TestCoroutineExtensions.kt # Coroutine helpers
|
||||||
|
├── androidMain/kotlin/org/mifos/mobile/core/testing/
|
||||||
|
│ ├── rule/MainDispatcherTestRule.kt # JUnit4 TestRule
|
||||||
|
│ └── ComposeTestHelpers.kt # Compose UI test extensions
|
||||||
|
├── iosMain/kotlin/org/mifos/mobile/core/testing/
|
||||||
|
│ └── IosTestUtils.kt # iOS accessibility helpers
|
||||||
|
├── desktopMain/kotlin/org/mifos/mobile/core/testing/
|
||||||
|
│ └── DesktopTestUtils.kt # Desktop/Swing helpers
|
||||||
|
└── nativeMain/kotlin/org/mifos/mobile/core/testing/
|
||||||
|
└── NativeTestUtils.kt # Native platform helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform-Specific Features
|
||||||
|
|
||||||
|
| Platform | Source Set | Features |
|
||||||
|
|----------|------------|----------|
|
||||||
|
| Common | `commonMain` | TestTags, Fakes, Fixtures, MainDispatcherRule |
|
||||||
|
| Android | `androidMain` | JUnit4 TestRule, Compose UI test helpers |
|
||||||
|
| iOS | `iosMain` | Accessibility ID conversion, XCTest helpers |
|
||||||
|
| Desktop | `desktopMain` | Swing dispatcher, Desktop test setup |
|
||||||
|
| Native | `nativeMain` | Native dispatcher setup, timing utils |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Test Coverage
|
||||||
|
|
||||||
|
| # | Feature | VMs | VM Tests | Screens | Screen Tests | Fakes | TestTags | Status |
|
||||||
|
|:-:|---------|:---:|:--------:|:-------:|:------------:|:-----:|:--------:|:------:|
|
||||||
|
| 1 | auth | 5 | 0 | 6 | 0 | ✅ | ✅ | Infrastructure Ready |
|
||||||
|
| 2 | home | 1 | 0 | 1 | 0 | ✅ | ✅ | Infrastructure Ready |
|
||||||
|
| 3 | accounts | 3 | 0 | 3 | 0 | ✅ | ✅ | Infrastructure Ready |
|
||||||
|
| 4 | savings-account | 3 | 0 | 4 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 5 | loan-account | 4 | 0 | 4 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 6 | share-account | 2 | 0 | 2 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 7 | beneficiary | 4 | 0 | 4 | 0 | ✅ | ✅ | Infrastructure Ready |
|
||||||
|
| 8 | transfer | 2 | 0 | 2 | 0 | ✅ | ✅ | Infrastructure Ready |
|
||||||
|
| 9 | recent-transaction | 1 | 0 | 1 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 10 | notification | 1 | 0 | 1 | 0 | ✅ | ✅ | Infrastructure Ready |
|
||||||
|
| 11 | settings | 5 | 0 | 9 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 12 | passcode | 2 | 0 | 2 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 13 | guarantor | 3 | 0 | 3 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 14 | qr | 3 | 0 | 3 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 15 | location | 0 | 0 | 1 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 16 | client-charge | 2 | 0 | 2 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| 17 | dashboard | 0 | 0 | 0 | 0 | ❌ | ✅ | TestTags Ready |
|
||||||
|
| | **TOTAL** | **41** | **0** | **48** | **0** | **6/17** | **17/17** | |
|
||||||
|
|
||||||
|
**Legend**: ✅ Complete | ⚠️ Partial | ❌ Missing
|
||||||
|
|
||||||
|
### Fake Repositories Available (6)
|
||||||
|
|
||||||
|
| Fake | Interface | Key Methods |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| `FakeUserAuthRepository` | `UserAuthRepository` | login, register, logout |
|
||||||
|
| `FakeHomeRepository` | `HomeRepository` | getClientAccounts, getUserData |
|
||||||
|
| `FakeAccountsRepository` | `AccountsRepository` | getLoanAccounts, getSavingsAccounts |
|
||||||
|
| `FakeBeneficiaryRepository` | `BeneficiaryRepository` | getBeneficiaries, createBeneficiary |
|
||||||
|
| `FakeTransferRepository` | `TransferRepository` | makeTransfer, getTransferTemplate |
|
||||||
|
| `FakeNotificationRepository` | `NotificationRepository` | getNotifications, markAsRead |
|
||||||
|
|
||||||
|
### Test Fixtures Available (3)
|
||||||
|
|
||||||
|
| Fixture | Entity | Factory Methods |
|
||||||
|
|---------|--------|-----------------|
|
||||||
|
| `UserFixture` | `User` | createDefault, createAdmin, createUnauthenticated |
|
||||||
|
| `ClientAccountsFixture` | `ClientAccounts` | createEmpty, createWithSampleData, createWithLoansOnly |
|
||||||
|
| `BeneficiaryFixture` | `Beneficiary` | createDefault, createList, createPayload |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Types
|
||||||
|
|
||||||
|
### Unit Tests (commonTest)
|
||||||
|
|
||||||
|
| Category | Location | Framework | Status |
|
||||||
|
|----------|----------|-----------|:------:|
|
||||||
|
| ViewModel Tests | `feature/*/src/commonTest/` | kotlin-test, Turbine | ❌ |
|
||||||
|
| Repository Tests | `core/data/src/commonTest/` | kotlin-test | ✅ 14 |
|
||||||
|
| DataStore Tests | `core/datastore/src/commonTest/` | kotlin-test | ✅ |
|
||||||
|
|
||||||
|
### UI Tests (androidInstrumentedTest)
|
||||||
|
|
||||||
|
| Category | Location | Framework | Status |
|
||||||
|
|----------|----------|-----------|:------:|
|
||||||
|
| Screen Tests | `feature/*/src/androidInstrumentedTest/` | Compose UI Test | ❌ |
|
||||||
|
| Integration Tests | `cmp-android/src/androidTest/` | Compose UI Test | ❌ |
|
||||||
|
|
||||||
|
### Screenshot Tests
|
||||||
|
|
||||||
|
| Category | Location | Framework | Status |
|
||||||
|
|----------|----------|-----------|:------:|
|
||||||
|
| Component Screenshots | `core/designsystem/src/test/` | Roborazzi | ❌ |
|
||||||
|
| Screen Screenshots | `feature/*/src/test/` | Roborazzi | ❌ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Queue
|
||||||
|
|
||||||
|
| Priority | Feature | Reason | Effort |
|
||||||
|
|:--------:|---------|--------|:------:|
|
||||||
|
| P0 | auth | Core flow, most complex | L |
|
||||||
|
| P0 | home | Entry point, high visibility | M |
|
||||||
|
| P0 | accounts | Core business logic | M |
|
||||||
|
| P1 | transfer | Financial operations | L |
|
||||||
|
| P1 | beneficiary | CRUD operations | M |
|
||||||
|
| P1 | loan-account | Complex states | M |
|
||||||
|
| P1 | savings-account | Multiple views | M |
|
||||||
|
| P2 | settings | Many screens, lower risk | L |
|
||||||
|
| P2 | notification | Simple list | S |
|
||||||
|
| P2 | recent-transaction | Simple list | S |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O(1) Index Files
|
||||||
|
|
||||||
|
| File | Purpose | Entries |
|
||||||
|
|------|---------|:-------:|
|
||||||
|
| [TEST_PATTERNS.md](./TEST_PATTERNS.md) | Test pattern reference | 5 |
|
||||||
|
| [TEST_TAGS_INDEX.md](./TEST_TAGS_INDEX.md) | TestTag lookup | 17 |
|
||||||
|
| [TEST_FIXTURES_INDEX.md](./TEST_FIXTURES_INDEX.md) | Fixture lookup | 0 |
|
||||||
|
| [FAKE_REPOS_INDEX.md](./FAKE_REPOS_INDEX.md) | Fake repo lookup | 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check testing status
|
||||||
|
/gap-analysis testing # Overall test coverage
|
||||||
|
|
||||||
|
# Generate tests for feature
|
||||||
|
/implement [feature] # Phase 5 generates test stubs
|
||||||
|
|
||||||
|
# Verify TestTag compliance
|
||||||
|
/verify [feature] # Includes TestTag validation
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
/verify-tests [feature] # Run and report test results
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- [TEST_STUBS_GUIDE.md](../TEST_STUBS_GUIDE.md) - TDD reference guide
|
||||||
|
- [patterns/](./patterns/) - Detailed test patterns
|
||||||
|
- [templates/](./templates/) - Code templates
|
||||||
401
claude-product-cycle/testing-layer/TEST_FIXTURES_INDEX.md
Normal file
401
claude-product-cycle/testing-layer/TEST_FIXTURES_INDEX.md
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
# Test Fixtures Index - O(1) Lookup
|
||||||
|
|
||||||
|
> **17 features** | Reusable test data | **Last Updated**: 2026-01-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Lookup
|
||||||
|
|
||||||
|
| # | Feature | Fixture File | Models | Status |
|
||||||
|
|:-:|---------|--------------|:------:|:------:|
|
||||||
|
| 1 | auth | `AuthFixtures.kt` | User, AuthPayload | ❌ |
|
||||||
|
| 2 | home | `HomeFixtures.kt` | ClientAccounts | ❌ |
|
||||||
|
| 3 | accounts | `AccountFixtures.kt` | SavingAccount, LoanAccount, ShareAccount | ❌ |
|
||||||
|
| 4 | savings-account | `SavingsFixtures.kt` | SavingAccount, Transaction | ❌ |
|
||||||
|
| 5 | loan-account | `LoanFixtures.kt` | LoanAccount, RepaymentSchedule | ❌ |
|
||||||
|
| 6 | share-account | `ShareFixtures.kt` | ShareAccount | ❌ |
|
||||||
|
| 7 | beneficiary | `BeneficiaryFixtures.kt` | Beneficiary, BeneficiaryPayload | ❌ |
|
||||||
|
| 8 | transfer | `TransferFixtures.kt` | TransferPayload, TransferTemplate | ❌ |
|
||||||
|
| 9 | recent-transaction | `TransactionFixtures.kt` | Transaction | ❌ |
|
||||||
|
| 10 | notification | `NotificationFixtures.kt` | Notification | ❌ |
|
||||||
|
| 11 | settings | `SettingsFixtures.kt` | - | ❌ |
|
||||||
|
| 12 | passcode | `PasscodeFixtures.kt` | - | ❌ |
|
||||||
|
| 13 | guarantor | `GuarantorFixtures.kt` | Guarantor | ❌ |
|
||||||
|
| 14 | qr | `QrFixtures.kt` | QrPayload | ❌ |
|
||||||
|
| 15 | location | `LocationFixtures.kt` | Office | ❌ |
|
||||||
|
| 16 | client-charge | `ChargeFixtures.kt` | Charge | ❌ |
|
||||||
|
| 17 | dashboard | `DashboardFixtures.kt` | - | ❌ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O(1) Path Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fixtures/[Feature]Fixtures.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixture Pattern
|
||||||
|
|
||||||
|
### Standard Structure
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object ${Feature}Fixtures {
|
||||||
|
// Single item
|
||||||
|
fun create${Model}(
|
||||||
|
id: Long = 1L,
|
||||||
|
name: String = "Test ${Model}",
|
||||||
|
// ... other params with defaults
|
||||||
|
): ${Model} = ${Model}(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
// ...
|
||||||
|
)
|
||||||
|
|
||||||
|
// List
|
||||||
|
fun create${Model}List(count: Int = 3): List<${Model}> =
|
||||||
|
(1..count).map { create${Model}(id = it.toLong()) }
|
||||||
|
|
||||||
|
// Specific scenarios
|
||||||
|
val empty${Model} = create${Model}(name = "")
|
||||||
|
val invalid${Model} = create${Model}(id = -1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Fixtures
|
||||||
|
|
||||||
|
### 1. AuthFixtures
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object AuthFixtures {
|
||||||
|
fun createUser(
|
||||||
|
userId: Long = 1L,
|
||||||
|
userName: String = "testuser",
|
||||||
|
clientId: Long = 100L,
|
||||||
|
clientName: String = "Test Client",
|
||||||
|
authenticated: Boolean = true
|
||||||
|
): User = User(
|
||||||
|
userId = userId,
|
||||||
|
userName = userName,
|
||||||
|
clientId = clientId,
|
||||||
|
clientName = clientName,
|
||||||
|
authenticated = authenticated
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createAuthPayload(
|
||||||
|
username: String = "testuser",
|
||||||
|
password: String = "password123"
|
||||||
|
): LoginPayload = LoginPayload(
|
||||||
|
username = username,
|
||||||
|
password = password
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-built scenarios
|
||||||
|
val validUser = createUser()
|
||||||
|
val unauthenticatedUser = createUser(authenticated = false)
|
||||||
|
val validCredentials = createAuthPayload()
|
||||||
|
val invalidCredentials = createAuthPayload(password = "wrong")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. BeneficiaryFixtures
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object BeneficiaryFixtures {
|
||||||
|
fun createBeneficiary(
|
||||||
|
id: Long = 1L,
|
||||||
|
name: String = "John Doe",
|
||||||
|
officeName: String = "Main Office",
|
||||||
|
accountNumber: String = "000000001",
|
||||||
|
transferLimit: Double = 10000.0
|
||||||
|
): Beneficiary = Beneficiary(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
officeName = officeName,
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
transferLimit = transferLimit
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createBeneficiaryList(count: Int = 3): List<Beneficiary> =
|
||||||
|
(1..count).map { i ->
|
||||||
|
createBeneficiary(
|
||||||
|
id = i.toLong(),
|
||||||
|
name = "Beneficiary $i",
|
||||||
|
accountNumber = "00000000$i"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createBeneficiaryPayload(
|
||||||
|
name: String = "New Beneficiary",
|
||||||
|
accountNumber: String = "123456789",
|
||||||
|
officeId: Int = 1,
|
||||||
|
accountType: Int = 1
|
||||||
|
): BeneficiaryPayload = BeneficiaryPayload(
|
||||||
|
name = name,
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
officeId = officeId,
|
||||||
|
accountType = accountType
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-built scenarios
|
||||||
|
val emptyList = emptyList<Beneficiary>()
|
||||||
|
val singleBeneficiary = listOf(createBeneficiary())
|
||||||
|
val multipleBeneficiaries = createBeneficiaryList(5)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. AccountFixtures
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object AccountFixtures {
|
||||||
|
fun createSavingAccount(
|
||||||
|
id: Long = 1L,
|
||||||
|
accountNo: String = "SAV-001",
|
||||||
|
productName: String = "Regular Savings",
|
||||||
|
balance: Double = 5000.0,
|
||||||
|
status: Status = Status(active = true)
|
||||||
|
): SavingAccount = SavingAccount(
|
||||||
|
id = id,
|
||||||
|
accountNo = accountNo,
|
||||||
|
productName = productName,
|
||||||
|
accountBalance = balance,
|
||||||
|
status = status
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createLoanAccount(
|
||||||
|
id: Long = 1L,
|
||||||
|
accountNo: String = "LOAN-001",
|
||||||
|
productName: String = "Personal Loan",
|
||||||
|
principal: Double = 50000.0,
|
||||||
|
outstanding: Double = 25000.0,
|
||||||
|
status: Status = Status(active = true)
|
||||||
|
): LoanAccount = LoanAccount(
|
||||||
|
id = id,
|
||||||
|
accountNo = accountNo,
|
||||||
|
productName = productName,
|
||||||
|
principal = principal,
|
||||||
|
loanBalance = outstanding,
|
||||||
|
status = status
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createShareAccount(
|
||||||
|
id: Long = 1L,
|
||||||
|
accountNo: String = "SHR-001",
|
||||||
|
productName: String = "Community Shares",
|
||||||
|
totalShares: Int = 100,
|
||||||
|
status: Status = Status(active = true)
|
||||||
|
): ShareAccount = ShareAccount(
|
||||||
|
id = id,
|
||||||
|
accountNo = accountNo,
|
||||||
|
productName = productName,
|
||||||
|
totalApprovedShares = totalShares,
|
||||||
|
status = status
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createClientAccounts(
|
||||||
|
savingsCount: Int = 2,
|
||||||
|
loansCount: Int = 1,
|
||||||
|
sharesCount: Int = 1
|
||||||
|
): ClientAccounts = ClientAccounts(
|
||||||
|
savingsAccounts = (1..savingsCount).map { createSavingAccount(id = it.toLong()) },
|
||||||
|
loanAccounts = (1..loansCount).map { createLoanAccount(id = it.toLong()) },
|
||||||
|
shareAccounts = (1..sharesCount).map { createShareAccount(id = it.toLong()) }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-built scenarios
|
||||||
|
val emptyAccounts = ClientAccounts(emptyList(), emptyList(), emptyList())
|
||||||
|
val savingsOnly = ClientAccounts(listOf(createSavingAccount()), emptyList(), emptyList())
|
||||||
|
val fullPortfolio = createClientAccounts(3, 2, 1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. TransferFixtures
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object TransferFixtures {
|
||||||
|
fun createTransferPayload(
|
||||||
|
fromAccountId: Long = 1L,
|
||||||
|
fromClientId: Long = 100L,
|
||||||
|
toAccountId: Long = 2L,
|
||||||
|
toClientId: Long = 200L,
|
||||||
|
amount: Double = 1000.0,
|
||||||
|
transferDate: String = "2026-01-05",
|
||||||
|
remark: String = "Test transfer"
|
||||||
|
): TransferPayload = TransferPayload(
|
||||||
|
fromAccountId = fromAccountId,
|
||||||
|
fromClientId = fromClientId,
|
||||||
|
toAccountId = toAccountId,
|
||||||
|
toClientId = toClientId,
|
||||||
|
transferAmount = amount,
|
||||||
|
transferDate = transferDate,
|
||||||
|
transferDescription = remark
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createTransferTemplate(
|
||||||
|
fromAccounts: List<SavingAccount> = listOf(AccountFixtures.createSavingAccount()),
|
||||||
|
beneficiaries: List<Beneficiary> = BeneficiaryFixtures.createBeneficiaryList()
|
||||||
|
): TransferTemplate = TransferTemplate(
|
||||||
|
fromAccountOptions = fromAccounts,
|
||||||
|
toBeneficiaryList = beneficiaries
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-built scenarios
|
||||||
|
val minTransfer = createTransferPayload(amount = 1.0)
|
||||||
|
val maxTransfer = createTransferPayload(amount = 100000.0)
|
||||||
|
val invalidTransfer = createTransferPayload(amount = -100.0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. LoanFixtures
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object LoanFixtures {
|
||||||
|
fun createLoanWithSchedule(
|
||||||
|
account: LoanAccount = AccountFixtures.createLoanAccount(),
|
||||||
|
scheduleCount: Int = 12
|
||||||
|
): LoanWithSchedule = LoanWithSchedule(
|
||||||
|
account = account,
|
||||||
|
schedule = (1..scheduleCount).map { i ->
|
||||||
|
RepaymentSchedule(
|
||||||
|
installment = i,
|
||||||
|
dueDate = "2026-${String.format("%02d", i)}-01",
|
||||||
|
principalDue = account.principal / scheduleCount,
|
||||||
|
interestDue = 100.0,
|
||||||
|
totalDue = (account.principal / scheduleCount) + 100.0,
|
||||||
|
paid = i <= 6
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createLoanTransaction(
|
||||||
|
id: Long = 1L,
|
||||||
|
type: String = "REPAYMENT",
|
||||||
|
amount: Double = 1000.0,
|
||||||
|
date: String = "2026-01-05"
|
||||||
|
): LoanTransaction = LoanTransaction(
|
||||||
|
id = id,
|
||||||
|
type = TransactionType(value = type),
|
||||||
|
amount = amount,
|
||||||
|
date = listOf(2026, 1, 5)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-built scenarios
|
||||||
|
val newLoan = createLoanWithSchedule(scheduleCount = 12)
|
||||||
|
val fullyPaidLoan = LoanWithSchedule(
|
||||||
|
account = AccountFixtures.createLoanAccount(outstanding = 0.0),
|
||||||
|
schedule = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. NotificationFixtures
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object NotificationFixtures {
|
||||||
|
fun createNotification(
|
||||||
|
id: Long = 1L,
|
||||||
|
title: String = "Test Notification",
|
||||||
|
content: String = "This is a test notification",
|
||||||
|
isRead: Boolean = false,
|
||||||
|
createdAt: String = "2026-01-05T10:00:00Z"
|
||||||
|
): Notification = Notification(
|
||||||
|
id = id,
|
||||||
|
objectType = "notification",
|
||||||
|
objectId = id,
|
||||||
|
action = title,
|
||||||
|
content = content,
|
||||||
|
isRead = isRead,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createNotificationList(count: Int = 5): List<Notification> =
|
||||||
|
(1..count).map { i ->
|
||||||
|
createNotification(
|
||||||
|
id = i.toLong(),
|
||||||
|
title = "Notification $i",
|
||||||
|
isRead = i % 2 == 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-built scenarios
|
||||||
|
val unreadNotification = createNotification(isRead = false)
|
||||||
|
val readNotification = createNotification(isRead = true)
|
||||||
|
val emptyNotifications = emptyList<Notification>()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage in Tests
|
||||||
|
|
||||||
|
### ViewModel Test
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class BeneficiaryViewModelTest {
|
||||||
|
@Test
|
||||||
|
fun `load success shows list`() = runTest {
|
||||||
|
val testData = BeneficiaryFixtures.createBeneficiaryList(5)
|
||||||
|
fakeRepository.setSuccessResponse(testData)
|
||||||
|
|
||||||
|
viewModel.loadBeneficiaries()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertEquals(testData, (state.uiState as Success).data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty list shows empty state`() = runTest {
|
||||||
|
fakeRepository.setSuccessResponse(BeneficiaryFixtures.emptyList)
|
||||||
|
|
||||||
|
viewModel.loadBeneficiaries()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue((state.uiState as Success).data.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screen Test
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class BeneficiaryScreenTest {
|
||||||
|
@Test
|
||||||
|
fun successState_displaysItems() {
|
||||||
|
val testData = BeneficiaryFixtures.createBeneficiaryList(3)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
BeneficiaryContent(
|
||||||
|
state = BeneficiaryState(
|
||||||
|
uiState = BeneficiaryUiState.Success(testData)
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
testData.forEach { beneficiary ->
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("beneficiary:item:${beneficiary.id}")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate fixtures for feature
|
||||||
|
/implement [feature] # Creates fixture file in Phase 5
|
||||||
|
|
||||||
|
# Check fixture status
|
||||||
|
/gap-analysis testing fixtures
|
||||||
|
```
|
||||||
390
claude-product-cycle/testing-layer/TEST_PATTERNS.md
Normal file
390
claude-product-cycle/testing-layer/TEST_PATTERNS.md
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
# Test Patterns - O(1) Reference
|
||||||
|
|
||||||
|
> Quick lookup for test patterns used in Mifos Mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern Quick Reference
|
||||||
|
|
||||||
|
| # | Pattern | Use Case | Location | Details |
|
||||||
|
|:-:|---------|----------|----------|---------|
|
||||||
|
| 1 | ViewModel Test | Test state, actions, events | `commonTest/` | [viewmodel-test.md](./patterns/viewmodel-test.md) |
|
||||||
|
| 2 | Screen Test | Test UI composition | `androidInstrumentedTest/` | [screen-test.md](./patterns/screen-test.md) |
|
||||||
|
| 3 | Fake Repository | Test isolation | `commonTest/fake/` | [fake-repository.md](./patterns/fake-repository.md) |
|
||||||
|
| 4 | Integration Test | Test user flows | `cmp-android/androidTest/` | [integration-test.md](./patterns/integration-test.md) |
|
||||||
|
| 5 | Screenshot Test | Visual regression | `test/` (Roborazzi) | [screenshot-test.md](./patterns/screenshot-test.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ViewModel Test Pattern
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- Testing state transitions (Loading → Success → Error)
|
||||||
|
- Testing action handling
|
||||||
|
- Testing event emission (navigation, dialogs)
|
||||||
|
|
||||||
|
### Quick Template
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ${Feature}ViewModelTest {
|
||||||
|
private val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
private lateinit var viewModel: ${Feature}ViewModel
|
||||||
|
private lateinit var fakeRepository: Fake${Feature}Repository
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
fakeRepository = Fake${Feature}Repository()
|
||||||
|
viewModel = ${Feature}ViewModel(repository = fakeRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state is loading`() = runTest {
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertTrue(awaitItem().uiState is Loading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load success updates state`() = runTest {
|
||||||
|
fakeRepository.setSuccessResponse(testData)
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertTrue(expectMostRecentItem().uiState is Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Libraries
|
||||||
|
| Library | Import | Purpose |
|
||||||
|
|---------|--------|---------|
|
||||||
|
| Turbine | `app.cash.turbine.test` | Flow testing |
|
||||||
|
| Coroutines Test | `kotlinx.coroutines.test.runTest` | Coroutine testing |
|
||||||
|
| kotlin-test | `kotlin.test.*` | Assertions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Screen Test Pattern
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- Testing UI renders correctly for each state
|
||||||
|
- Testing user interactions trigger correct actions
|
||||||
|
- Testing accessibility (content descriptions)
|
||||||
|
|
||||||
|
### Quick Template
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ${Feature}ScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadingState_displaysLoader() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(uiState = Loading),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("${feature}:loading")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun itemClick_triggersAction() {
|
||||||
|
var receivedAction: ${Feature}Action? = null
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = successState,
|
||||||
|
onAction = { receivedAction = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("${feature}:item:1")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
assertEquals(${Feature}Action.ItemClicked(1), receivedAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Methods
|
||||||
|
| Method | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `onNodeWithTag(tag)` | Find by testTag |
|
||||||
|
| `onNodeWithText(text)` | Find by text |
|
||||||
|
| `assertIsDisplayed()` | Verify visible |
|
||||||
|
| `assertIsEnabled()` | Verify clickable |
|
||||||
|
| `performClick()` | Simulate tap |
|
||||||
|
| `performTextInput(text)` | Type text |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fake Repository Pattern
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- Isolating ViewModel from real data source
|
||||||
|
- Testing different response scenarios
|
||||||
|
- Verifying repository method calls
|
||||||
|
|
||||||
|
### Quick Template
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class Fake${Feature}Repository : ${Feature}Repository {
|
||||||
|
var loadCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var response: DataState<List<${Model}>> = DataState.Loading
|
||||||
|
|
||||||
|
fun setSuccessResponse(data: List<${Model}>) {
|
||||||
|
response = DataState.Success(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setErrorResponse(message: String) {
|
||||||
|
response = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getData(): Flow<DataState<List<${Model}>>> = flow {
|
||||||
|
loadCallCount++
|
||||||
|
emit(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
loadCallCount = 0
|
||||||
|
response = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Convention
|
||||||
|
| Real | Fake |
|
||||||
|
|------|------|
|
||||||
|
| `BeneficiaryRepository` | `FakeBeneficiaryRepository` |
|
||||||
|
| `HomeRepository` | `FakeHomeRepository` |
|
||||||
|
| `LoanRepository` | `FakeLoanRepository` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Integration Test Pattern
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- Testing complete user flows
|
||||||
|
- Testing navigation between screens
|
||||||
|
- Testing data persistence across screens
|
||||||
|
|
||||||
|
### Quick Template
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltAndroidTest
|
||||||
|
class ${Feature}FlowTest {
|
||||||
|
@get:Rule(order = 0)
|
||||||
|
val hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@get:Rule(order = 1)
|
||||||
|
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginFlow_navigatesToHome() {
|
||||||
|
// Enter credentials
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("auth:input:username")
|
||||||
|
.performTextInput("testuser")
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("auth:input:password")
|
||||||
|
.performTextInput("password123")
|
||||||
|
|
||||||
|
// Click login
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("auth:submit")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
// Verify navigation to home
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("home:screen")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical Flows
|
||||||
|
| Flow | Screens | Priority |
|
||||||
|
|------|---------|:--------:|
|
||||||
|
| Login → Passcode → Home | 3 | P0 |
|
||||||
|
| Home → Transfer → Confirm | 3 | P0 |
|
||||||
|
| Home → Loan Details → Schedule | 3 | P1 |
|
||||||
|
| Settings → Change Password | 2 | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Screenshot Test Pattern
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- Visual regression testing
|
||||||
|
- Documenting UI states
|
||||||
|
- Catching unintended UI changes
|
||||||
|
|
||||||
|
### Quick Template
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class ${Feature}ScreenshotTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val roborazziRule = RoborazziRule(
|
||||||
|
options = RoborazziRule.Options(
|
||||||
|
captureType = RoborazziRule.CaptureType.LastImage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ${feature}Screen_loading() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(uiState = Loading),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Golden Image Location
|
||||||
|
```
|
||||||
|
feature/${feature}/src/test/resources/screenshots/
|
||||||
|
├── ${feature}Screen_loading.png
|
||||||
|
├── ${feature}Screen_success.png
|
||||||
|
├── ${feature}Screen_error.png
|
||||||
|
└── ${feature}Screen_empty.png
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test State Categories
|
||||||
|
|
||||||
|
Every feature should test these states:
|
||||||
|
|
||||||
|
| State | Description | TestTag |
|
||||||
|
|-------|-------------|---------|
|
||||||
|
| Loading | Initial data fetch | `{feature}:loading` |
|
||||||
|
| Success | Data loaded | `{feature}:screen` |
|
||||||
|
| Error | Load failed | `{feature}:error` |
|
||||||
|
| Empty | No data | `{feature}:empty` |
|
||||||
|
| Refreshing | Pull-to-refresh | `{feature}:refreshing` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Test Scenarios
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `load more appends data`() = runTest {
|
||||||
|
fakeRepository.setSuccessResponse(page1)
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
fakeRepository.setSuccessResponse(page2)
|
||||||
|
viewModel.trySendAction(Action.LoadMore)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val data = (expectMostRecentItem().uiState as Success).data
|
||||||
|
assertEquals(page1 + page2, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pull-to-Refresh
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `refresh replaces data`() = runTest {
|
||||||
|
fakeRepository.setSuccessResponse(oldData)
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
fakeRepository.setSuccessResponse(newData)
|
||||||
|
viewModel.trySendAction(Action.Refresh)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val data = (expectMostRecentItem().uiState as Success).data
|
||||||
|
assertEquals(newData, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Validation
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `invalid input shows error`() = runTest {
|
||||||
|
viewModel.trySendAction(Action.Submit(invalidInput))
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertNotNull(expectMostRecentItem().validationError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dialog Confirmation
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `delete shows confirmation`() = runTest {
|
||||||
|
viewModel.trySendAction(Action.DeleteClicked(id))
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertTrue(expectMostRecentItem().dialogState is DialogState.Confirmation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
implementation(libs.turbine)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidInstrumentedTest.dependencies {
|
||||||
|
implementation(libs.compose.ui.test.junit4)
|
||||||
|
implementation(libs.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val androidUnitTest by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.roborazzi)
|
||||||
|
implementation(libs.robolectric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate tests using pattern
|
||||||
|
/implement [feature] # Uses patterns from this file
|
||||||
|
|
||||||
|
# Verify pattern compliance
|
||||||
|
/verify [feature] # Checks TestTag patterns
|
||||||
|
|
||||||
|
# See detailed pattern
|
||||||
|
Read testing-layer/patterns/[pattern].md
|
||||||
|
```
|
||||||
435
claude-product-cycle/testing-layer/TEST_TAGS_INDEX.md
Normal file
435
claude-product-cycle/testing-layer/TEST_TAGS_INDEX.md
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
# TestTags Index - O(1) Lookup
|
||||||
|
|
||||||
|
> **17 features** | **Pattern**: `{feature}:{component}:{id}` | **Last Updated**: 2026-01-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Lookup
|
||||||
|
|
||||||
|
| # | Feature | TestTags Object | Required Tags | Status |
|
||||||
|
|:-:|---------|-----------------|:-------------:|:------:|
|
||||||
|
| 1 | auth | `AuthTestTags` | 8 | ❌ Not Created |
|
||||||
|
| 2 | home | `HomeTestTags` | 5 | ❌ Not Created |
|
||||||
|
| 3 | accounts | `AccountsTestTags` | 6 | ❌ Not Created |
|
||||||
|
| 4 | savings-account | `SavingsTestTags` | 7 | ❌ Not Created |
|
||||||
|
| 5 | loan-account | `LoanTestTags` | 7 | ❌ Not Created |
|
||||||
|
| 6 | share-account | `ShareTestTags` | 5 | ❌ Not Created |
|
||||||
|
| 7 | beneficiary | `BeneficiaryTestTags` | 8 | ❌ Not Created |
|
||||||
|
| 8 | transfer | `TransferTestTags` | 6 | ❌ Not Created |
|
||||||
|
| 9 | recent-transaction | `TransactionTestTags` | 4 | ❌ Not Created |
|
||||||
|
| 10 | notification | `NotificationTestTags` | 4 | ❌ Not Created |
|
||||||
|
| 11 | settings | `SettingsTestTags` | 10 | ❌ Not Created |
|
||||||
|
| 12 | passcode | `PasscodeTestTags` | 5 | ❌ Not Created |
|
||||||
|
| 13 | guarantor | `GuarantorTestTags` | 6 | ❌ Not Created |
|
||||||
|
| 14 | qr | `QrTestTags` | 5 | ❌ Not Created |
|
||||||
|
| 15 | location | `LocationTestTags` | 3 | ❌ Not Created |
|
||||||
|
| 16 | client-charge | `ChargeTestTags` | 4 | ❌ Not Created |
|
||||||
|
| 17 | dashboard | `DashboardTestTags` | 6 | ❌ Not Created |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Convention
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
```
|
||||||
|
{feature}:{component}:{identifier}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standard Components
|
||||||
|
|
||||||
|
| Component | Pattern | Example | Required |
|
||||||
|
|-----------|---------|---------|:--------:|
|
||||||
|
| Screen container | `{feature}:screen` | `auth:screen` | ✅ |
|
||||||
|
| Loading indicator | `{feature}:loading` | `auth:loading` | ✅ |
|
||||||
|
| Error container | `{feature}:error` | `auth:error` | ✅ |
|
||||||
|
| Empty state | `{feature}:empty` | `beneficiary:empty` | ⚠️ |
|
||||||
|
| List container | `{feature}:list` | `beneficiary:list` | ⚠️ |
|
||||||
|
| List item | `{feature}:item:{id}` | `beneficiary:item:123` | ⚠️ |
|
||||||
|
| Retry button | `{feature}:retry` | `auth:retry` | ⚠️ |
|
||||||
|
| Submit button | `{feature}:submit` | `auth:submit` | ⚠️ |
|
||||||
|
| Input field | `{feature}:input:{name}` | `auth:input:username` | ⚠️ |
|
||||||
|
| FAB | `{feature}:fab` | `beneficiary:fab` | ⚠️ |
|
||||||
|
|
||||||
|
**Legend**: ✅ Required | ⚠️ Recommended (if applicable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature TestTags Specifications
|
||||||
|
|
||||||
|
### 1. auth
|
||||||
|
|
||||||
|
**Object**: `feature/auth/src/commonMain/.../util/AuthTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object AuthTestTags {
|
||||||
|
// Screens
|
||||||
|
const val LOGIN_SCREEN = "auth:login:screen"
|
||||||
|
const val REGISTER_SCREEN = "auth:register:screen"
|
||||||
|
const val OTP_SCREEN = "auth:otp:screen"
|
||||||
|
|
||||||
|
// Common
|
||||||
|
const val LOADING = "auth:loading"
|
||||||
|
const val ERROR = "auth:error"
|
||||||
|
|
||||||
|
// Login inputs
|
||||||
|
const val USERNAME_INPUT = "auth:input:username"
|
||||||
|
const val PASSWORD_INPUT = "auth:input:password"
|
||||||
|
const val LOGIN_BUTTON = "auth:submit:login"
|
||||||
|
|
||||||
|
// Register inputs
|
||||||
|
const val EMAIL_INPUT = "auth:input:email"
|
||||||
|
const val FIRST_NAME_INPUT = "auth:input:firstName"
|
||||||
|
const val LAST_NAME_INPUT = "auth:input:lastName"
|
||||||
|
const val MOBILE_INPUT = "auth:input:mobile"
|
||||||
|
const val REGISTER_BUTTON = "auth:submit:register"
|
||||||
|
|
||||||
|
// OTP
|
||||||
|
const val OTP_INPUT = "auth:input:otp"
|
||||||
|
const val VERIFY_BUTTON = "auth:submit:verify"
|
||||||
|
const val RESEND_BUTTON = "auth:resend"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. home
|
||||||
|
|
||||||
|
**Object**: `feature/home/src/commonMain/.../util/HomeTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object HomeTestTags {
|
||||||
|
const val SCREEN = "home:screen"
|
||||||
|
const val LOADING = "home:loading"
|
||||||
|
const val ERROR = "home:error"
|
||||||
|
|
||||||
|
// Content
|
||||||
|
const val USER_NAME = "home:userName"
|
||||||
|
const val TOTAL_SAVINGS = "home:totalSavings"
|
||||||
|
const val TOTAL_LOAN = "home:totalLoan"
|
||||||
|
const val ACCOUNTS_SECTION = "home:accounts"
|
||||||
|
const val QUICK_ACTIONS = "home:quickActions"
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const val TRANSFER_ACTION = "home:action:transfer"
|
||||||
|
const val QR_ACTION = "home:action:qr"
|
||||||
|
const val CHARGES_ACTION = "home:action:charges"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. accounts
|
||||||
|
|
||||||
|
**Object**: `feature/account/src/commonMain/.../util/AccountsTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object AccountsTestTags {
|
||||||
|
const val SCREEN = "accounts:screen"
|
||||||
|
const val LOADING = "accounts:loading"
|
||||||
|
const val ERROR = "accounts:error"
|
||||||
|
const val EMPTY = "accounts:empty"
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
const val SAVINGS_TAB = "accounts:tab:savings"
|
||||||
|
const val LOAN_TAB = "accounts:tab:loan"
|
||||||
|
const val SHARE_TAB = "accounts:tab:share"
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
const val LIST = "accounts:list"
|
||||||
|
fun item(accountId: Long) = "accounts:item:$accountId"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. beneficiary
|
||||||
|
|
||||||
|
**Object**: `feature/beneficiary/src/commonMain/.../util/BeneficiaryTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object BeneficiaryTestTags {
|
||||||
|
const val SCREEN = "beneficiary:screen"
|
||||||
|
const val LOADING = "beneficiary:loading"
|
||||||
|
const val ERROR = "beneficiary:error"
|
||||||
|
const val EMPTY = "beneficiary:empty"
|
||||||
|
const val LIST = "beneficiary:list"
|
||||||
|
const val FAB = "beneficiary:fab"
|
||||||
|
const val RETRY = "beneficiary:retry"
|
||||||
|
|
||||||
|
fun item(id: Long) = "beneficiary:item:$id"
|
||||||
|
|
||||||
|
// Detail screen
|
||||||
|
const val DETAIL_SCREEN = "beneficiary:detail:screen"
|
||||||
|
const val NAME_TEXT = "beneficiary:detail:name"
|
||||||
|
const val ACCOUNT_TEXT = "beneficiary:detail:account"
|
||||||
|
const val EDIT_BUTTON = "beneficiary:detail:edit"
|
||||||
|
const val DELETE_BUTTON = "beneficiary:detail:delete"
|
||||||
|
|
||||||
|
// Add/Edit screen
|
||||||
|
const val FORM_SCREEN = "beneficiary:form:screen"
|
||||||
|
const val NAME_INPUT = "beneficiary:input:name"
|
||||||
|
const val ACCOUNT_INPUT = "beneficiary:input:account"
|
||||||
|
const val OFFICE_INPUT = "beneficiary:input:office"
|
||||||
|
const val SUBMIT_BUTTON = "beneficiary:submit"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. transfer
|
||||||
|
|
||||||
|
**Object**: `feature/transfer-process/src/commonMain/.../util/TransferTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object TransferTestTags {
|
||||||
|
const val SCREEN = "transfer:screen"
|
||||||
|
const val LOADING = "transfer:loading"
|
||||||
|
const val ERROR = "transfer:error"
|
||||||
|
|
||||||
|
// Inputs
|
||||||
|
const val FROM_ACCOUNT = "transfer:input:fromAccount"
|
||||||
|
const val TO_BENEFICIARY = "transfer:input:toBeneficiary"
|
||||||
|
const val AMOUNT_INPUT = "transfer:input:amount"
|
||||||
|
const val REMARK_INPUT = "transfer:input:remark"
|
||||||
|
const val DATE_INPUT = "transfer:input:date"
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const val REVIEW_BUTTON = "transfer:review"
|
||||||
|
const val CONFIRM_BUTTON = "transfer:confirm"
|
||||||
|
const val CANCEL_BUTTON = "transfer:cancel"
|
||||||
|
|
||||||
|
// Success
|
||||||
|
const val SUCCESS_SCREEN = "transfer:success:screen"
|
||||||
|
const val DONE_BUTTON = "transfer:done"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. loan-account
|
||||||
|
|
||||||
|
**Object**: `feature/loan-account/src/commonMain/.../util/LoanTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object LoanTestTags {
|
||||||
|
const val SCREEN = "loan:screen"
|
||||||
|
const val LOADING = "loan:loading"
|
||||||
|
const val ERROR = "loan:error"
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const val LOAN_AMOUNT = "loan:amount"
|
||||||
|
const val OUTSTANDING = "loan:outstanding"
|
||||||
|
const val STATUS = "loan:status"
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
const val SUMMARY_TAB = "loan:tab:summary"
|
||||||
|
const val REPAYMENT_TAB = "loan:tab:repayment"
|
||||||
|
const val TRANSACTIONS_TAB = "loan:tab:transactions"
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
const val SCHEDULE_LIST = "loan:schedule:list"
|
||||||
|
const val TRANSACTION_LIST = "loan:transaction:list"
|
||||||
|
|
||||||
|
fun scheduleItem(index: Int) = "loan:schedule:item:$index"
|
||||||
|
fun transactionItem(id: Long) = "loan:transaction:item:$id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. savings-account
|
||||||
|
|
||||||
|
**Object**: `feature/savings-account/src/commonMain/.../util/SavingsTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object SavingsTestTags {
|
||||||
|
const val SCREEN = "savings:screen"
|
||||||
|
const val LOADING = "savings:loading"
|
||||||
|
const val ERROR = "savings:error"
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const val BALANCE = "savings:balance"
|
||||||
|
const val STATUS = "savings:status"
|
||||||
|
const val ACCOUNT_NUMBER = "savings:accountNumber"
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
const val SUMMARY_TAB = "savings:tab:summary"
|
||||||
|
const val TRANSACTIONS_TAB = "savings:tab:transactions"
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const val WITHDRAW_BUTTON = "savings:withdraw"
|
||||||
|
const val DEPOSIT_BUTTON = "savings:deposit"
|
||||||
|
const val TRANSFER_BUTTON = "savings:transfer"
|
||||||
|
|
||||||
|
// Transaction list
|
||||||
|
const val TRANSACTION_LIST = "savings:transaction:list"
|
||||||
|
fun transactionItem(id: Long) = "savings:transaction:item:$id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. settings
|
||||||
|
|
||||||
|
**Object**: `feature/settings/src/commonMain/.../util/SettingsTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object SettingsTestTags {
|
||||||
|
const val SCREEN = "settings:screen"
|
||||||
|
const val LOADING = "settings:loading"
|
||||||
|
|
||||||
|
// Menu items
|
||||||
|
const val CHANGE_PASSCODE = "settings:item:changePasscode"
|
||||||
|
const val CHANGE_PASSWORD = "settings:item:changePassword"
|
||||||
|
const val LANGUAGE = "settings:item:language"
|
||||||
|
const val THEME = "settings:item:theme"
|
||||||
|
const val NOTIFICATION = "settings:item:notification"
|
||||||
|
const val ABOUT = "settings:item:about"
|
||||||
|
const val LOGOUT = "settings:item:logout"
|
||||||
|
|
||||||
|
// Change password screen
|
||||||
|
const val CURRENT_PASSWORD = "settings:input:currentPassword"
|
||||||
|
const val NEW_PASSWORD = "settings:input:newPassword"
|
||||||
|
const val CONFIRM_PASSWORD = "settings:input:confirmPassword"
|
||||||
|
const val SUBMIT_PASSWORD = "settings:submit:password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. notification
|
||||||
|
|
||||||
|
**Object**: `feature/notification/src/commonMain/.../util/NotificationTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object NotificationTestTags {
|
||||||
|
const val SCREEN = "notification:screen"
|
||||||
|
const val LOADING = "notification:loading"
|
||||||
|
const val ERROR = "notification:error"
|
||||||
|
const val EMPTY = "notification:empty"
|
||||||
|
const val LIST = "notification:list"
|
||||||
|
|
||||||
|
fun item(id: Long) = "notification:item:$id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. qr
|
||||||
|
|
||||||
|
**Object**: `feature/qr-code/src/commonMain/.../util/QrTestTags.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object QrTestTags {
|
||||||
|
const val SCREEN = "qr:screen"
|
||||||
|
const val LOADING = "qr:loading"
|
||||||
|
const val ERROR = "qr:error"
|
||||||
|
|
||||||
|
// Display
|
||||||
|
const val QR_IMAGE = "qr:image"
|
||||||
|
const val SHARE_BUTTON = "qr:share"
|
||||||
|
|
||||||
|
// Scan
|
||||||
|
const val SCAN_SCREEN = "qr:scan:screen"
|
||||||
|
const val CAMERA_VIEW = "qr:scan:camera"
|
||||||
|
const val RESULT_TEXT = "qr:scan:result"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
### Required Tags (Must Exist)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val requiredTags = listOf(
|
||||||
|
"${feature}:screen", // Main screen container
|
||||||
|
"${feature}:loading", // Loading state
|
||||||
|
"${feature}:error" // Error state
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Tags (If Applicable)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val recommendedTags = listOf(
|
||||||
|
"${feature}:empty", // Empty state (for lists)
|
||||||
|
"${feature}:list", // List container
|
||||||
|
"${feature}:retry", // Retry button (for errors)
|
||||||
|
"${feature}:fab" // Floating action button
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Validation Regex
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val testTagPattern = Regex("^[a-z-]+:[a-z-]+(?::[a-z0-9-]+)?$")
|
||||||
|
|
||||||
|
// Valid examples:
|
||||||
|
// "auth:screen" ✅
|
||||||
|
// "beneficiary:item:123" ✅
|
||||||
|
// "transfer:input:amount" ✅
|
||||||
|
|
||||||
|
// Invalid examples:
|
||||||
|
// "AuthScreen" ❌ (no colons, PascalCase)
|
||||||
|
// "auth_screen" ❌ (underscore)
|
||||||
|
// "auth:LOADING" ❌ (uppercase)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage in Compose
|
||||||
|
|
||||||
|
### Applying TestTags
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun BeneficiaryScreen(state: BeneficiaryState) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.testTag(BeneficiaryTestTags.SCREEN)
|
||||||
|
) {
|
||||||
|
when (state.uiState) {
|
||||||
|
is Loading -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.testTag(BeneficiaryTestTags.LOADING)
|
||||||
|
)
|
||||||
|
is Error -> ErrorView(
|
||||||
|
modifier = Modifier.testTag(BeneficiaryTestTags.ERROR)
|
||||||
|
)
|
||||||
|
is Success -> BeneficiaryList(
|
||||||
|
items = state.uiState.data,
|
||||||
|
modifier = Modifier.testTag(BeneficiaryTestTags.LIST)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BeneficiaryItem(
|
||||||
|
beneficiary: Beneficiary,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(BeneficiaryTestTags.item(beneficiary.id))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
) {
|
||||||
|
// Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O(1) Path Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/[feature]/src/commonMain/kotlin/org/mifos/mobile/feature/[feature]/util/[Feature]TestTags.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Update Rules
|
||||||
|
|
||||||
|
| Trigger | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `/implement [feature]` | Create TestTags object if missing |
|
||||||
|
| `/verify [feature]` | Validate TestTags exist and follow convention |
|
||||||
|
| New screen added | Add corresponding tags to object |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check TestTag status
|
||||||
|
/gap-analysis testing tags
|
||||||
|
|
||||||
|
# Generate TestTags for feature
|
||||||
|
/implement [feature] # Creates TestTags in Phase 4
|
||||||
|
|
||||||
|
# Validate TestTags
|
||||||
|
/verify [feature] # Includes TestTag validation report
|
||||||
|
```
|
||||||
467
claude-product-cycle/testing-layer/patterns/fake-repository.md
Normal file
467
claude-product-cycle/testing-layer/patterns/fake-repository.md
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
# Fake Repository Pattern
|
||||||
|
|
||||||
|
> Detailed instructions for creating test doubles in Mifos Mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Fake repositories:
|
||||||
|
- Implement the real repository interface
|
||||||
|
- Provide configurable responses for testing
|
||||||
|
- Track method calls for verification
|
||||||
|
- Enable test isolation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
```
|
||||||
|
core/testing/src/commonMain/kotlin/org/mifos/mobile/core/testing/fake/Fake${Feature}Repository.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Template
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package org.mifos.mobile.core.testing.fake
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import org.mifos.mobile.core.data.repository.${Feature}Repository
|
||||||
|
import org.mifos.mobile.core.model.${Model}
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
|
||||||
|
class Fake${Feature}Repository : ${Feature}Repository {
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// CALL TRACKING
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times load method was called.
|
||||||
|
* Use to verify refresh/retry behavior.
|
||||||
|
*/
|
||||||
|
var loadCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times create method was called.
|
||||||
|
*/
|
||||||
|
var createCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times update method was called.
|
||||||
|
*/
|
||||||
|
var updateCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times delete method was called.
|
||||||
|
*/
|
||||||
|
var deleteCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last payload passed to create method.
|
||||||
|
* Use to verify correct data was sent.
|
||||||
|
*/
|
||||||
|
var lastCreatePayload: ${Model}Payload? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last ID passed to delete method.
|
||||||
|
*/
|
||||||
|
var lastDeleteId: Long? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// CONFIGURABLE RESPONSES
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private var loadResponse: DataState<List<${Model}>> = DataState.Loading
|
||||||
|
private var singleResponse: DataState<${Model}> = DataState.Loading
|
||||||
|
private var createResponse: DataState<${Model}> = DataState.Loading
|
||||||
|
private var updateResponse: DataState<${Model}> = DataState.Loading
|
||||||
|
private var deleteResponse: DataState<Unit> = DataState.Loading
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP METHODS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure load to return success with data.
|
||||||
|
*
|
||||||
|
* @param data The list of items to return
|
||||||
|
*/
|
||||||
|
fun setLoadSuccess(data: List<${Model}>) {
|
||||||
|
loadResponse = DataState.Success(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure load to return error.
|
||||||
|
*
|
||||||
|
* @param message The error message
|
||||||
|
*/
|
||||||
|
fun setLoadError(message: String = "Failed to load") {
|
||||||
|
loadResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure load to return empty list.
|
||||||
|
*/
|
||||||
|
fun setLoadEmpty() {
|
||||||
|
loadResponse = DataState.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure load to return loading state (useful for testing loading UI).
|
||||||
|
*/
|
||||||
|
fun setLoadLoading() {
|
||||||
|
loadResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure get single item to return success.
|
||||||
|
*/
|
||||||
|
fun setSingleSuccess(item: ${Model}) {
|
||||||
|
singleResponse = DataState.Success(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure get single item to return error.
|
||||||
|
*/
|
||||||
|
fun setSingleError(message: String = "Item not found") {
|
||||||
|
singleResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure create to return success.
|
||||||
|
*/
|
||||||
|
fun setCreateSuccess(item: ${Model}) {
|
||||||
|
createResponse = DataState.Success(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure create to return error.
|
||||||
|
*/
|
||||||
|
fun setCreateError(message: String = "Failed to create") {
|
||||||
|
createResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure update to return success.
|
||||||
|
*/
|
||||||
|
fun setUpdateSuccess(item: ${Model}) {
|
||||||
|
updateResponse = DataState.Success(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure update to return error.
|
||||||
|
*/
|
||||||
|
fun setUpdateError(message: String = "Failed to update") {
|
||||||
|
updateResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure delete to return success.
|
||||||
|
*/
|
||||||
|
fun setDeleteSuccess() {
|
||||||
|
deleteResponse = DataState.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure delete to return error.
|
||||||
|
*/
|
||||||
|
fun setDeleteError(message: String = "Failed to delete") {
|
||||||
|
deleteResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// REPOSITORY IMPLEMENTATION
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
override fun get${Feature}s(): Flow<DataState<List<${Model}>>> = flow {
|
||||||
|
loadCallCount++
|
||||||
|
emit(loadResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get${Feature}(id: Long): Flow<DataState<${Model}>> = flow {
|
||||||
|
emit(singleResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun create${Feature}(payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
|
||||||
|
createCallCount++
|
||||||
|
lastCreatePayload = payload
|
||||||
|
emit(createResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update${Feature}(id: Long, payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
|
||||||
|
updateCallCount++
|
||||||
|
emit(updateResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete${Feature}(id: Long): Flow<DataState<Unit>> = flow {
|
||||||
|
deleteCallCount++
|
||||||
|
lastDeleteId = id
|
||||||
|
emit(deleteResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// RESET
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all counters and responses.
|
||||||
|
* Call in @AfterTest to ensure test isolation.
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
// Reset counters
|
||||||
|
loadCallCount = 0
|
||||||
|
createCallCount = 0
|
||||||
|
updateCallCount = 0
|
||||||
|
deleteCallCount = 0
|
||||||
|
|
||||||
|
// Reset captured data
|
||||||
|
lastCreatePayload = null
|
||||||
|
lastDeleteId = null
|
||||||
|
|
||||||
|
// Reset responses to loading
|
||||||
|
loadResponse = DataState.Loading
|
||||||
|
singleResponse = DataState.Loading
|
||||||
|
createResponse = DataState.Loading
|
||||||
|
updateResponse = DataState.Loading
|
||||||
|
deleteResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Test Setup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ${Feature}ViewModelTest {
|
||||||
|
private lateinit var fakeRepository: Fake${Feature}Repository
|
||||||
|
private lateinit var viewModel: ${Feature}ViewModel
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
fakeRepository = Fake${Feature}Repository()
|
||||||
|
viewModel = ${Feature}ViewModel(repository = fakeRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
fakeRepository.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Success State
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `load success updates state`() = runTest {
|
||||||
|
val testData = ${Feature}Fixtures.createList(5)
|
||||||
|
fakeRepository.setLoadSuccess(testData)
|
||||||
|
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertEquals(testData, (state.uiState as Success).data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Error State
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `load error shows error`() = runTest {
|
||||||
|
fakeRepository.setLoadError("Network unavailable")
|
||||||
|
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue(state.uiState is Error)
|
||||||
|
assertEquals("Network unavailable", (state.uiState as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying Method Calls
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `refresh calls repository twice`() = runTest {
|
||||||
|
fakeRepository.setLoadSuccess(emptyList())
|
||||||
|
|
||||||
|
viewModel.loadData()
|
||||||
|
viewModel.trySendAction(Action.Refresh)
|
||||||
|
|
||||||
|
assertEquals(2, fakeRepository.loadCallCount)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying Payload
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `create sends correct payload`() = runTest {
|
||||||
|
val payload = ${Feature}Payload(name = "Test")
|
||||||
|
fakeRepository.setCreateSuccess(${Feature}Fixtures.create())
|
||||||
|
|
||||||
|
viewModel.create(payload)
|
||||||
|
|
||||||
|
assertEquals(payload, fakeRepository.lastCreatePayload)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Delete Flow
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `delete calls repository with correct id`() = runTest {
|
||||||
|
fakeRepository.setDeleteSuccess()
|
||||||
|
|
||||||
|
viewModel.delete(itemId = 42L)
|
||||||
|
|
||||||
|
assertEquals(1, fakeRepository.deleteCallCount)
|
||||||
|
assertEquals(42L, fakeRepository.lastDeleteId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### Sequential Responses
|
||||||
|
|
||||||
|
For testing pagination or retry:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class FakePaginatedRepository : Repository {
|
||||||
|
private val responses = mutableListOf<DataState<List<Item>>>()
|
||||||
|
private var responseIndex = 0
|
||||||
|
|
||||||
|
fun addResponse(response: DataState<List<Item>>) {
|
||||||
|
responses.add(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItems(): Flow<DataState<List<Item>>> = flow {
|
||||||
|
if (responseIndex < responses.size) {
|
||||||
|
emit(responses[responseIndex++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in test:
|
||||||
|
@Test
|
||||||
|
fun `pagination loads next page`() = runTest {
|
||||||
|
fakeRepository.addResponse(DataState.Success(page1))
|
||||||
|
fakeRepository.addResponse(DataState.Success(page2))
|
||||||
|
|
||||||
|
viewModel.loadData() // Gets page1
|
||||||
|
viewModel.loadMore() // Gets page2
|
||||||
|
|
||||||
|
// Verify combined data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delayed Responses
|
||||||
|
|
||||||
|
For testing loading states:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class FakeDelayedRepository : Repository {
|
||||||
|
var delay: Long = 0L
|
||||||
|
|
||||||
|
override fun getItems(): Flow<DataState<List<Item>>> = flow {
|
||||||
|
emit(DataState.Loading)
|
||||||
|
delay(delay)
|
||||||
|
emit(DataState.Success(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
@Test
|
||||||
|
fun `shows loading while fetching`() = runTest {
|
||||||
|
fakeRepository.delay = 1000L
|
||||||
|
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertTrue(awaitItem().uiState is Loading)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Then Success
|
||||||
|
|
||||||
|
For testing retry:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `retry after error succeeds`() = runTest {
|
||||||
|
fakeRepository.setLoadError("Network error")
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
// Verify error state
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertTrue(expectMostRecentItem().uiState is Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure success and retry
|
||||||
|
fakeRepository.setLoadSuccess(testData)
|
||||||
|
viewModel.trySendAction(Action.Retry)
|
||||||
|
|
||||||
|
// Verify success state
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertTrue(expectMostRecentItem().uiState is Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Convention
|
||||||
|
|
||||||
|
| Real Repository | Fake Repository |
|
||||||
|
|-----------------|-----------------|
|
||||||
|
| `UserAuthRepository` | `FakeUserAuthRepository` |
|
||||||
|
| `BeneficiaryRepository` | `FakeBeneficiaryRepository` |
|
||||||
|
| `HomeRepository` | `FakeHomeRepository` |
|
||||||
|
| `LoanRepository` | `FakeLoanRepository` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
When creating a fake repository:
|
||||||
|
|
||||||
|
- [ ] Implements real repository interface
|
||||||
|
- [ ] Has call counters for all methods
|
||||||
|
- [ ] Has configurable responses (success, error, loading)
|
||||||
|
- [ ] Captures payloads for verification
|
||||||
|
- [ ] Has `reset()` method
|
||||||
|
- [ ] Uses fixtures for default data
|
||||||
|
- [ ] Documents public methods
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Not resetting between tests | Call `reset()` in `@AfterTest` |
|
||||||
|
| Returning same response | Use response queues for sequences |
|
||||||
|
| Missing interface methods | Implement all methods |
|
||||||
|
| Not tracking calls | Add counter for each method |
|
||||||
526
claude-product-cycle/testing-layer/patterns/integration-test.md
Normal file
526
claude-product-cycle/testing-layer/patterns/integration-test.md
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
# Integration Test Pattern
|
||||||
|
|
||||||
|
> Detailed instructions for testing user flows in Mifos Mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Integration tests verify:
|
||||||
|
- Complete user flows across screens
|
||||||
|
- Navigation between features
|
||||||
|
- Data persistence across screens
|
||||||
|
- Real user scenarios
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
```
|
||||||
|
cmp-android/src/androidTest/kotlin/org/mifos/mobile/flow/${Feature}FlowTest.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// cmp-android/build.gradle.kts
|
||||||
|
dependencies {
|
||||||
|
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||||
|
androidTestImplementation(libs.compose.ui.test.manifest)
|
||||||
|
androidTestImplementation(libs.hilt.android.testing)
|
||||||
|
kspAndroidTest(libs.hilt.compiler)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltAndroidTest
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ${Feature}FlowTest {
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@get:Rule(order = 0)
|
||||||
|
val hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@get:Rule(order = 1)
|
||||||
|
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var testRepository: Fake${Feature}Repository
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
hiltRule.inject()
|
||||||
|
// Configure initial state
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun teardown() {
|
||||||
|
testRepository.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// FLOW TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginFlow_navigatesToHome() {
|
||||||
|
// Given: User is on login screen
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("auth:login:screen")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
// When: User enters valid credentials
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("auth:input:username")
|
||||||
|
.performTextInput("testuser")
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("auth:input:password")
|
||||||
|
.performTextInput("password123")
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("auth:submit:login")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
// Then: User is navigated to passcode screen
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5000) {
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithTag("passcode:screen")
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// When: User enters passcode
|
||||||
|
enterPasscode("1234")
|
||||||
|
|
||||||
|
// Then: User is navigated to home screen
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5000) {
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithTag("home:screen")
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transferFlow_completesSuccessfully() {
|
||||||
|
// Given: User is logged in and on home
|
||||||
|
navigateToHome()
|
||||||
|
|
||||||
|
// When: User initiates transfer
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("home:action:transfer")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
// Then: Transfer screen is displayed
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("transfer:screen")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
// When: User fills transfer form
|
||||||
|
selectFromAccount(accountIndex = 0)
|
||||||
|
selectBeneficiary(beneficiaryIndex = 0)
|
||||||
|
enterAmount("1000")
|
||||||
|
|
||||||
|
// When: User reviews transfer
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("transfer:review")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
// Then: Review screen shows correct info
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("1000")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
// When: User confirms transfer
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("transfer:confirm")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
// Then: Success screen is displayed
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5000) {
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithTag("transfer:success:screen")
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// HELPER METHODS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private fun navigateToHome() {
|
||||||
|
// Skip login/passcode if already logged in
|
||||||
|
// Or perform login flow
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enterPasscode(passcode: String) {
|
||||||
|
passcode.forEach { digit ->
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(digit.toString())
|
||||||
|
.performClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectFromAccount(accountIndex: Int) {
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("transfer:input:fromAccount")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithTag("dropdown:item")
|
||||||
|
.get(accountIndex)
|
||||||
|
.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectBeneficiary(beneficiaryIndex: Int) {
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("transfer:input:toBeneficiary")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithTag("dropdown:item")
|
||||||
|
.get(beneficiaryIndex)
|
||||||
|
.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enterAmount(amount: String) {
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("transfer:input:amount")
|
||||||
|
.performTextInput(amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical User Flows
|
||||||
|
|
||||||
|
### 1. Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Login Screen → Passcode Setup → Home
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun newUserRegistration_flow() {
|
||||||
|
// 1. Navigate to registration
|
||||||
|
composeTestRule.onNodeWithText("Register").performClick()
|
||||||
|
|
||||||
|
// 2. Fill registration form
|
||||||
|
composeTestRule.onNodeWithTag("auth:input:firstName").performTextInput("John")
|
||||||
|
composeTestRule.onNodeWithTag("auth:input:lastName").performTextInput("Doe")
|
||||||
|
composeTestRule.onNodeWithTag("auth:input:email").performTextInput("john@test.com")
|
||||||
|
composeTestRule.onNodeWithTag("auth:input:mobile").performTextInput("1234567890")
|
||||||
|
composeTestRule.onNodeWithTag("auth:input:username").performTextInput("johndoe")
|
||||||
|
composeTestRule.onNodeWithTag("auth:input:password").performTextInput("password123")
|
||||||
|
composeTestRule.onNodeWithTag("auth:submit:register").performClick()
|
||||||
|
|
||||||
|
// 3. Verify OTP screen
|
||||||
|
waitForScreen("auth:otp:screen")
|
||||||
|
|
||||||
|
// 4. Enter OTP
|
||||||
|
composeTestRule.onNodeWithTag("auth:input:otp").performTextInput("123456")
|
||||||
|
composeTestRule.onNodeWithTag("auth:submit:verify").performClick()
|
||||||
|
|
||||||
|
// 5. Verify navigation to login
|
||||||
|
waitForScreen("auth:login:screen")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Transfer Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Home → Transfer → Review → Confirm → Success
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun thirdPartyTransfer_flow() {
|
||||||
|
navigateToHome()
|
||||||
|
|
||||||
|
// 1. Open transfer
|
||||||
|
composeTestRule.onNodeWithTag("home:action:transfer").performClick()
|
||||||
|
waitForScreen("transfer:screen")
|
||||||
|
|
||||||
|
// 2. Select accounts
|
||||||
|
selectFromAccount(0)
|
||||||
|
selectBeneficiary(0)
|
||||||
|
|
||||||
|
// 3. Enter details
|
||||||
|
enterAmount("500")
|
||||||
|
composeTestRule.onNodeWithTag("transfer:input:remark").performTextInput("Test transfer")
|
||||||
|
|
||||||
|
// 4. Review
|
||||||
|
composeTestRule.onNodeWithTag("transfer:review").performClick()
|
||||||
|
composeTestRule.onNodeWithText("500").assertIsDisplayed()
|
||||||
|
|
||||||
|
// 5. Confirm
|
||||||
|
composeTestRule.onNodeWithTag("transfer:confirm").performClick()
|
||||||
|
|
||||||
|
// 6. Verify success
|
||||||
|
waitForScreen("transfer:success:screen")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Beneficiary CRUD Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
List → Add → Success → List (with new item)
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun addBeneficiary_flow() {
|
||||||
|
navigateToHome()
|
||||||
|
navigateToBeneficiaries()
|
||||||
|
|
||||||
|
// 1. Open add form
|
||||||
|
composeTestRule.onNodeWithTag("beneficiary:fab").performClick()
|
||||||
|
waitForScreen("beneficiary:form:screen")
|
||||||
|
|
||||||
|
// 2. Fill form
|
||||||
|
composeTestRule.onNodeWithTag("beneficiary:input:name").performTextInput("Test Beneficiary")
|
||||||
|
composeTestRule.onNodeWithTag("beneficiary:input:account").performTextInput("123456789")
|
||||||
|
|
||||||
|
// 3. Submit
|
||||||
|
composeTestRule.onNodeWithTag("beneficiary:submit").performClick()
|
||||||
|
|
||||||
|
// 4. Verify navigation back to list
|
||||||
|
waitForScreen("beneficiary:screen")
|
||||||
|
|
||||||
|
// 5. Verify new beneficiary in list
|
||||||
|
composeTestRule.onNodeWithText("Test Beneficiary").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Account Detail Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Home → Accounts → Savings Detail → Transactions
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun viewSavingsAccount_flow() {
|
||||||
|
navigateToHome()
|
||||||
|
|
||||||
|
// 1. Open accounts
|
||||||
|
composeTestRule.onNodeWithTag("home:accounts").performClick()
|
||||||
|
waitForScreen("accounts:screen")
|
||||||
|
|
||||||
|
// 2. Select savings tab
|
||||||
|
composeTestRule.onNodeWithTag("accounts:tab:savings").performClick()
|
||||||
|
|
||||||
|
// 3. Open first account
|
||||||
|
composeTestRule.onAllNodesWithTag("accounts:item").get(0).performClick()
|
||||||
|
waitForScreen("savings:screen")
|
||||||
|
|
||||||
|
// 4. Verify account details
|
||||||
|
composeTestRule.onNodeWithTag("savings:balance").assertIsDisplayed()
|
||||||
|
|
||||||
|
// 5. View transactions
|
||||||
|
composeTestRule.onNodeWithTag("savings:tab:transactions").performClick()
|
||||||
|
composeTestRule.onNodeWithTag("savings:transaction:list").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Settings Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Home → Settings → Change Password → Success
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun changePassword_flow() {
|
||||||
|
navigateToHome()
|
||||||
|
navigateToSettings()
|
||||||
|
|
||||||
|
// 1. Open change password
|
||||||
|
composeTestRule.onNodeWithTag("settings:item:changePassword").performClick()
|
||||||
|
|
||||||
|
// 2. Enter passwords
|
||||||
|
composeTestRule.onNodeWithTag("settings:input:currentPassword").performTextInput("oldpass")
|
||||||
|
composeTestRule.onNodeWithTag("settings:input:newPassword").performTextInput("newpass123")
|
||||||
|
composeTestRule.onNodeWithTag("settings:input:confirmPassword").performTextInput("newpass123")
|
||||||
|
|
||||||
|
// 3. Submit
|
||||||
|
composeTestRule.onNodeWithTag("settings:submit:password").performClick()
|
||||||
|
|
||||||
|
// 4. Verify success message
|
||||||
|
composeTestRule.onNodeWithText("Password changed successfully").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Helper Functions
|
||||||
|
|
||||||
|
### Wait for Screen
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun waitForScreen(screenTag: String, timeoutMillis: Long = 5000) {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis) {
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithTag(screenTag)
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation Helpers
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun navigateToHome() {
|
||||||
|
// If not logged in, perform login
|
||||||
|
// Otherwise just verify home screen
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToBeneficiaries() {
|
||||||
|
composeTestRule.onNodeWithTag("home:menu").performClick()
|
||||||
|
composeTestRule.onNodeWithText("Beneficiaries").performClick()
|
||||||
|
waitForScreen("beneficiary:screen")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToSettings() {
|
||||||
|
composeTestRule.onNodeWithTag("home:menu").performClick()
|
||||||
|
composeTestRule.onNodeWithText("Settings").performClick()
|
||||||
|
waitForScreen("settings:screen")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Helpers
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun fillTextField(tag: String, text: String) {
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(tag)
|
||||||
|
.performTextClearance()
|
||||||
|
.performTextInput(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectDropdownItem(dropdownTag: String, itemIndex: Int) {
|
||||||
|
composeTestRule.onNodeWithTag(dropdownTag).performClick()
|
||||||
|
composeTestRule.onAllNodesWithTag("dropdown:item").get(itemIndex).performClick()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Data Setup
|
||||||
|
|
||||||
|
### Using Fake Repositories
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Module
|
||||||
|
@TestInstallIn(
|
||||||
|
components = [SingletonComponent::class],
|
||||||
|
replaces = [DataModule::class]
|
||||||
|
)
|
||||||
|
object TestDataModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideFakeRepository(): BeneficiaryRepository = FakeBeneficiaryRepository()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Injecting Test Data
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
hiltRule.inject()
|
||||||
|
|
||||||
|
// Configure test data
|
||||||
|
testRepository.setLoadSuccess(
|
||||||
|
listOf(
|
||||||
|
Beneficiary(id = 1, name = "Test Beneficiary 1"),
|
||||||
|
Beneficiary(id = 2, name = "Test Beneficiary 2")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Test User Behavior
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Test what user sees and does
|
||||||
|
@Test
|
||||||
|
fun user_canViewAccountBalance() {
|
||||||
|
navigateToAccount()
|
||||||
|
composeTestRule.onNodeWithText("$5,000").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad: Test implementation details
|
||||||
|
@Test
|
||||||
|
fun viewModel_stateIsCorrect() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Meaningful Names
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good
|
||||||
|
@Test
|
||||||
|
fun transferWithInsufficientFunds_showsError() { ... }
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
@Test
|
||||||
|
fun test1() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Single Assertion Focus
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: One logical assertion per test
|
||||||
|
@Test
|
||||||
|
fun loginSuccess_navigatesToHome() { ... }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginFailure_showsErrorMessage() { ... }
|
||||||
|
|
||||||
|
// Bad: Multiple unrelated assertions
|
||||||
|
@Test
|
||||||
|
fun loginTest() {
|
||||||
|
// Tests success AND failure AND navigation...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
For each critical flow:
|
||||||
|
|
||||||
|
- [ ] Happy path test
|
||||||
|
- [ ] Error handling test
|
||||||
|
- [ ] Edge case tests
|
||||||
|
- [ ] Back navigation test
|
||||||
|
- [ ] State persistence test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Hardcoded waits | Use `waitUntil` |
|
||||||
|
| Missing test data | Inject fake repositories |
|
||||||
|
| Flaky tests | Add proper synchronization |
|
||||||
|
| Testing too much | Split into smaller tests |
|
||||||
529
claude-product-cycle/testing-layer/patterns/screen-test.md
Normal file
529
claude-product-cycle/testing-layer/patterns/screen-test.md
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
# Screen Test Pattern
|
||||||
|
|
||||||
|
> Detailed instructions for testing Compose screens in Mifos Mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Screen tests verify:
|
||||||
|
- UI renders correctly for each state
|
||||||
|
- User interactions trigger correct actions
|
||||||
|
- Accessibility (content descriptions, testTags)
|
||||||
|
- Visual appearance (with screenshots)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/${feature}/src/androidInstrumentedTest/kotlin/org/mifos/mobile/feature/${feature}/${Feature}ScreenTest.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
androidInstrumentedTest.dependencies {
|
||||||
|
implementation(libs.compose.ui.test.junit4)
|
||||||
|
implementation(libs.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ${Feature}ScreenTest {
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// LOADING STATE TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadingState_displaysLoadingIndicator() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(uiState = ${Feature}UiState.Loading),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.LOADING)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.LIST)
|
||||||
|
.assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SUCCESS STATE TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun successState_displaysContent() {
|
||||||
|
val testData = ${Feature}Fixtures.createList(3)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(testData)
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.SCREEN)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.LIST)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun successState_displaysAllItems() {
|
||||||
|
val testData = ${Feature}Fixtures.createList(5)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(testData)
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
testData.forEach { item ->
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.item(item.id))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ERROR STATE TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun errorState_displaysErrorMessage() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Error("Network error")
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.ERROR)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Network error")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun errorState_displaysRetryButton() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Error("Error")
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.RETRY)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
.assertIsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// EMPTY STATE TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyState_displaysEmptyMessage() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(emptyList())
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.EMPTY)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// USER INTERACTION TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun itemClick_triggersAction() {
|
||||||
|
var receivedAction: ${Feature}Action? = null
|
||||||
|
val testData = ${Feature}Fixtures.createList(3)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(testData)
|
||||||
|
),
|
||||||
|
onAction = { receivedAction = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.item(testData[0].id))
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
${Feature}Action.ItemClicked(testData[0].id),
|
||||||
|
receivedAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryClick_triggersRetryAction() {
|
||||||
|
var receivedAction: ${Feature}Action? = null
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Error("Error")
|
||||||
|
),
|
||||||
|
onAction = { receivedAction = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.RETRY)
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
assertEquals(${Feature}Action.Retry, receivedAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fabClick_triggersAddAction() {
|
||||||
|
var receivedAction: ${Feature}Action? = null
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(emptyList())
|
||||||
|
),
|
||||||
|
onAction = { receivedAction = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(${Feature}TestTags.FAB)
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
assertEquals(${Feature}Action.AddClicked, receivedAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// FORM INPUT TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun textInput_updatesState() {
|
||||||
|
var receivedAction: ${Feature}Action? = null
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}FormContent(
|
||||||
|
state = ${Feature}FormState(),
|
||||||
|
onAction = { receivedAction = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("${feature}:input:name")
|
||||||
|
.performTextInput("Test Name")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
${Feature}Action.NameChanged("Test Name"),
|
||||||
|
receivedAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun submitButton_disabledWhenInvalid() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}FormContent(
|
||||||
|
state = ${Feature}FormState(name = ""), // Invalid
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("${feature}:submit")
|
||||||
|
.assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun submitButton_enabledWhenValid() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}FormContent(
|
||||||
|
state = ${Feature}FormState(name = "Valid Name"),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("${feature}:submit")
|
||||||
|
.assertIsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// DIALOG TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun confirmationDialog_displayedWhenDialogStateSet() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(emptyList()),
|
||||||
|
dialogState = DialogState.Confirmation(
|
||||||
|
title = "Delete?",
|
||||||
|
message = "Are you sure?"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Delete?")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Are you sure?")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compose Test API Reference
|
||||||
|
|
||||||
|
### Finding Nodes
|
||||||
|
|
||||||
|
| Method | Purpose | Example |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `onNodeWithTag(tag)` | Find by testTag | `onNodeWithTag("auth:screen")` |
|
||||||
|
| `onNodeWithText(text)` | Find by text | `onNodeWithText("Login")` |
|
||||||
|
| `onNodeWithContentDescription(desc)` | Find by a11y label | `onNodeWithContentDescription("Close")` |
|
||||||
|
| `onAllNodesWithTag(tag)` | Find all matching | `onAllNodesWithTag("item")` |
|
||||||
|
| `onRoot()` | Get root node | `onRoot()` |
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `assertIsDisplayed()` | Verify visible |
|
||||||
|
| `assertDoesNotExist()` | Verify not in tree |
|
||||||
|
| `assertIsEnabled()` | Verify clickable |
|
||||||
|
| `assertIsNotEnabled()` | Verify disabled |
|
||||||
|
| `assertTextEquals(text)` | Verify text content |
|
||||||
|
| `assertHasClickAction()` | Verify clickable |
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `performClick()` | Tap element |
|
||||||
|
| `performTextInput(text)` | Type text |
|
||||||
|
| `performTextClearance()` | Clear text field |
|
||||||
|
| `performScrollTo()` | Scroll to element |
|
||||||
|
| `performSwipeLeft()` | Swipe gesture |
|
||||||
|
| `performTouchInput { swipeUp() }` | Custom touch |
|
||||||
|
|
||||||
|
### Waiting
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Wait for condition
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5000) {
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithTag("item")
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isNotEmpty()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
### 1. State Rendering Tests
|
||||||
|
|
||||||
|
Test each UI state renders correctly.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun loading_showsProgressIndicator() { ... }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun success_showsContent() { ... }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun error_showsErrorView() { ... }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun empty_showsEmptyView() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. User Interaction Tests
|
||||||
|
|
||||||
|
Test all clickable elements trigger correct actions.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun button_click_triggersAction() {
|
||||||
|
var action: Action? = null
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
Screen(onAction = { action = it })
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("button").performClick()
|
||||||
|
|
||||||
|
assertEquals(Action.ButtonClicked, action)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Form Tests
|
||||||
|
|
||||||
|
Test input fields and validation.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun input_updatesOnTyping() { ... }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validation_showsErrorOnInvalid() { ... }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun submit_disabledWhenInvalid() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Navigation Tests
|
||||||
|
|
||||||
|
Test navigation callbacks.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun backButton_triggersNavigateBack() { ... }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun item_click_triggersNavigateToDetail() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TestTag Best Practices
|
||||||
|
|
||||||
|
### Applying Tags
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun ${Feature}Screen() {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.testTag(${Feature}TestTags.SCREEN)
|
||||||
|
) {
|
||||||
|
// Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag Naming
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object ${Feature}TestTags {
|
||||||
|
const val SCREEN = "${feature}:screen"
|
||||||
|
const val LOADING = "${feature}:loading"
|
||||||
|
const val ERROR = "${feature}:error"
|
||||||
|
const val EMPTY = "${feature}:empty"
|
||||||
|
const val LIST = "${feature}:list"
|
||||||
|
const val FAB = "${feature}:fab"
|
||||||
|
|
||||||
|
fun item(id: Long) = "${feature}:item:$id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage Checklist
|
||||||
|
|
||||||
|
For each screen, test:
|
||||||
|
|
||||||
|
- [ ] Loading state displays correctly
|
||||||
|
- [ ] Success state displays content
|
||||||
|
- [ ] Error state displays message and retry
|
||||||
|
- [ ] Empty state displays empty message
|
||||||
|
- [ ] All clickable elements trigger actions
|
||||||
|
- [ ] Form inputs update state
|
||||||
|
- [ ] Form validation works
|
||||||
|
- [ ] Dialogs display correctly
|
||||||
|
- [ ] Accessibility labels present
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug Helpers
|
||||||
|
|
||||||
|
### Print Compose Tree
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
composeTestRule.onRoot().printToLog("COMPOSE_TREE")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Print All Nodes
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodes(hasTestTag("item"))
|
||||||
|
.printToLog("ITEMS")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshot Debugging
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
composeTestRule.onRoot().captureToImage()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Not finding node | Check testTag is applied |
|
||||||
|
| Flaky tests | Use `waitUntil` for async |
|
||||||
|
| Testing implementation | Test behavior, not structure |
|
||||||
|
| Missing states | Test all UI states |
|
||||||
503
claude-product-cycle/testing-layer/patterns/screenshot-test.md
Normal file
503
claude-product-cycle/testing-layer/patterns/screenshot-test.md
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
# Screenshot Test Pattern
|
||||||
|
|
||||||
|
> Visual regression testing with Roborazzi in Mifos Mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Screenshot tests:
|
||||||
|
- Capture golden images of UI states
|
||||||
|
- Detect visual regressions
|
||||||
|
- Document UI appearance
|
||||||
|
- Ensure design consistency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/${feature}/src/test/kotlin/org/mifos/mobile/feature/${feature}/${Feature}ScreenshotTest.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
plugins {
|
||||||
|
id("io.github.takahirom.roborazzi")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation(libs.roborazzi)
|
||||||
|
testImplementation(libs.roborazzi.compose)
|
||||||
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.compose.ui.test.junit4)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [33], qualifiers = "w360dp-h640dp-xhdpi")
|
||||||
|
class ${Feature}ScreenshotTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val roborazziRule = RoborazziRule(
|
||||||
|
composeRule = composeTestRule,
|
||||||
|
captureRoot = composeTestRule.onRoot(),
|
||||||
|
options = RoborazziRule.Options(
|
||||||
|
captureType = RoborazziRule.CaptureType.LastImage(),
|
||||||
|
outputDirectoryPath = "src/test/resources/screenshots/${feature}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// LOADING STATE
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ${feature}Screen_loading() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Loading
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SUCCESS STATE
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ${feature}Screen_success() {
|
||||||
|
val testData = ${Feature}Fixtures.createList(3)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(testData)
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ${feature}Screen_successWithManyItems() {
|
||||||
|
val testData = ${Feature}Fixtures.createList(10)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(testData)
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// EMPTY STATE
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ${feature}Screen_empty() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(emptyList())
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ERROR STATE
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ${feature}Screen_error() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Error("Network error")
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// DIALOG STATES
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ${feature}Screen_confirmationDialog() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(emptyList()),
|
||||||
|
dialogState = DialogState.Confirmation(
|
||||||
|
title = "Delete Item?",
|
||||||
|
message = "This action cannot be undone."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// DARK THEME
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ${feature}Screen_darkTheme() {
|
||||||
|
val testData = ${Feature}Fixtures.createList(3)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme(darkTheme = true) {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(
|
||||||
|
uiState = ${Feature}UiState.Success(testData)
|
||||||
|
),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Golden Image Directory
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/${feature}/src/test/resources/screenshots/${feature}/
|
||||||
|
├── ${feature}Screen_loading.png
|
||||||
|
├── ${feature}Screen_success.png
|
||||||
|
├── ${feature}Screen_successWithManyItems.png
|
||||||
|
├── ${feature}Screen_empty.png
|
||||||
|
├── ${feature}Screen_error.png
|
||||||
|
├── ${feature}Screen_confirmationDialog.png
|
||||||
|
└── ${feature}Screen_darkTheme.png
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roborazzi Commands
|
||||||
|
|
||||||
|
### Record New Golden Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Record all screenshots
|
||||||
|
./gradlew recordRoborazziDebug
|
||||||
|
|
||||||
|
# Record for specific module
|
||||||
|
./gradlew :feature:${feature}:recordRoborazziDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compare Against Golden Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify screenshots match
|
||||||
|
./gradlew verifyRoborazziDebug
|
||||||
|
|
||||||
|
# Verify specific module
|
||||||
|
./gradlew :feature:${feature}:verifyRoborazziDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compare and Generate Report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate comparison report
|
||||||
|
./gradlew compareRoborazziDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Device Configurations
|
||||||
|
|
||||||
|
### Standard Phone
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Config(
|
||||||
|
sdk = [33],
|
||||||
|
qualifiers = "w360dp-h640dp-xhdpi"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Large Phone
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Config(
|
||||||
|
sdk = [33],
|
||||||
|
qualifiers = "w412dp-h915dp-xxhdpi"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tablet
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Config(
|
||||||
|
sdk = [33],
|
||||||
|
qualifiers = "w800dp-h1280dp-mdpi"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Landscape
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Config(
|
||||||
|
sdk = [33],
|
||||||
|
qualifiers = "land-w640dp-h360dp-xhdpi"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Screenshots
|
||||||
|
|
||||||
|
For design system components:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class MifosButtonScreenshotTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mifosButton_primary() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
MifosButton(
|
||||||
|
text = "Login",
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mifosButton_disabled() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
MifosButton(
|
||||||
|
text = "Login",
|
||||||
|
onClick = {},
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mifosButton_loading() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
MifosButton(
|
||||||
|
text = "Login",
|
||||||
|
onClick = {},
|
||||||
|
isLoading = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-State Preview
|
||||||
|
|
||||||
|
Capture multiple states in one image:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun ${feature}Screen_allStates() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MifosTheme {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Loading
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(uiState = Loading),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(uiState = Success(data)),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
${Feature}Content(
|
||||||
|
state = ${Feature}State(uiState = Error("Error")),
|
||||||
|
onAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onRoot().captureRoboImage()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
### GitHub Actions
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/screenshots.yml
|
||||||
|
name: Screenshot Tests
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
screenshot-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '21'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Verify Screenshots
|
||||||
|
run: ./gradlew verifyRoborazziDebug
|
||||||
|
|
||||||
|
- name: Upload Comparison Report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: screenshot-comparison
|
||||||
|
path: '**/build/outputs/roborazzi/**'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Consistent Test Data
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Use fixtures for consistent data
|
||||||
|
val testData = ${Feature}Fixtures.createList(3)
|
||||||
|
|
||||||
|
// Not random data that changes
|
||||||
|
val testData = listOf(
|
||||||
|
Item(name = UUID.randomUUID().toString()) // Bad!
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Fixed Dimensions
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Wrap content in fixed size for consistency
|
||||||
|
Box(modifier = Modifier.size(360.dp, 640.dp)) {
|
||||||
|
${Feature}Content(...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Disable Animations
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
// Disable animations for consistent screenshots
|
||||||
|
composeTestRule.mainClock.autoAdvance = false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test All States
|
||||||
|
|
||||||
|
- Loading
|
||||||
|
- Success (with data)
|
||||||
|
- Success (empty)
|
||||||
|
- Error
|
||||||
|
- Dialogs
|
||||||
|
- Dark theme
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Images differ slightly | Ensure fixed device config |
|
||||||
|
| Animations cause diff | Disable animations |
|
||||||
|
| Font rendering differs | Use Robolectric native graphics |
|
||||||
|
| Random data in image | Use fixtures with fixed data |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
For each screen:
|
||||||
|
|
||||||
|
- [ ] Loading state screenshot
|
||||||
|
- [ ] Success state (with data)
|
||||||
|
- [ ] Success state (empty)
|
||||||
|
- [ ] Error state
|
||||||
|
- [ ] Dialog states
|
||||||
|
- [ ] Dark theme variant
|
||||||
|
- [ ] Different device sizes (optional)
|
||||||
366
claude-product-cycle/testing-layer/patterns/viewmodel-test.md
Normal file
366
claude-product-cycle/testing-layer/patterns/viewmodel-test.md
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
# ViewModel Test Pattern
|
||||||
|
|
||||||
|
> Detailed instructions for testing ViewModels in Mifos Mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
ViewModel tests verify:
|
||||||
|
- State transitions (Loading → Success/Error)
|
||||||
|
- Action handling (user interactions)
|
||||||
|
- Event emission (navigation, dialogs)
|
||||||
|
- Business logic correctness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/${feature}/src/commonTest/kotlin/org/mifos/mobile/feature/${feature}/${Feature}ViewModelTest.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
implementation(libs.turbine) // Flow testing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ${Feature}ViewModelTest {
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private lateinit var viewModel: ${Feature}ViewModel
|
||||||
|
private lateinit var fakeRepository: Fake${Feature}Repository
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
fakeRepository = Fake${Feature}Repository()
|
||||||
|
viewModel = ${Feature}ViewModel(
|
||||||
|
repository = fakeRepository
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
fakeRepository.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// INITIAL STATE TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state is loading`() = runTest {
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = awaitItem()
|
||||||
|
assertTrue(state.uiState is ${Feature}UiState.Loading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SUCCESS STATE TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load success updates state with data`() = runTest {
|
||||||
|
val testData = ${Feature}Fixtures.createList(5)
|
||||||
|
fakeRepository.setLoadSuccess(testData)
|
||||||
|
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue(state.uiState is ${Feature}UiState.Success)
|
||||||
|
assertEquals(testData, (state.uiState as ${Feature}UiState.Success).data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty data shows empty state`() = runTest {
|
||||||
|
fakeRepository.setLoadEmpty()
|
||||||
|
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue(state.uiState is ${Feature}UiState.Success)
|
||||||
|
assertTrue((state.uiState as ${Feature}UiState.Success).data.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ERROR STATE TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load error shows error state`() = runTest {
|
||||||
|
fakeRepository.setLoadError("Network error")
|
||||||
|
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue(state.uiState is ${Feature}UiState.Error)
|
||||||
|
assertEquals("Network error", (state.uiState as ${Feature}UiState.Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ACTION TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh action reloads data`() = runTest {
|
||||||
|
fakeRepository.setLoadSuccess()
|
||||||
|
|
||||||
|
viewModel.loadData()
|
||||||
|
viewModel.trySendAction(${Feature}Action.Refresh)
|
||||||
|
|
||||||
|
assertEquals(2, fakeRepository.loadCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `item click action triggers navigation event`() = runTest {
|
||||||
|
viewModel.trySendAction(${Feature}Action.ItemClicked(itemId = 1L))
|
||||||
|
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
val event = awaitItem()
|
||||||
|
assertEquals(${Feature}Event.NavigateToDetail(1L), event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// DIALOG TESTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delete action shows confirmation dialog`() = runTest {
|
||||||
|
viewModel.trySendAction(${Feature}Action.DeleteClicked(itemId = 1L))
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertTrue(state.dialogState is DialogState.Confirmation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `confirm delete calls repository`() = runTest {
|
||||||
|
fakeRepository.setDeleteSuccess()
|
||||||
|
|
||||||
|
viewModel.trySendAction(${Feature}Action.ConfirmDelete(itemId = 1L))
|
||||||
|
|
||||||
|
assertEquals(1, fakeRepository.deleteCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dialog dismiss clears dialog state`() = runTest {
|
||||||
|
viewModel.trySendAction(${Feature}Action.DeleteClicked(itemId = 1L))
|
||||||
|
viewModel.trySendAction(${Feature}Action.DismissDialog)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val state = expectMostRecentItem()
|
||||||
|
assertNull(state.dialogState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
### 1. Initial State Tests
|
||||||
|
|
||||||
|
Verify the ViewModel starts with correct default state.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `initial state has loading ui state`() = runTest {
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertTrue(awaitItem().uiState is Loading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state has null dialog state`() = runTest {
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertNull(awaitItem().dialogState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Data Loading Tests
|
||||||
|
|
||||||
|
Test success, error, and empty scenarios.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `load with pagination appends data`() = runTest {
|
||||||
|
fakeRepository.setLoadSuccess(page1Data)
|
||||||
|
viewModel.loadData()
|
||||||
|
|
||||||
|
fakeRepository.setLoadSuccess(page2Data)
|
||||||
|
viewModel.trySendAction(Action.LoadMore)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val data = (expectMostRecentItem().uiState as Success).data
|
||||||
|
assertEquals(page1Data + page2Data, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. User Action Tests
|
||||||
|
|
||||||
|
Test all actions defined in the Action sealed class.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// For each action in ${Feature}Action:
|
||||||
|
@Test
|
||||||
|
fun `action X updates state correctly`() = runTest {
|
||||||
|
viewModel.trySendAction(${Feature}Action.X)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
// Verify state change
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Event Tests
|
||||||
|
|
||||||
|
Test navigation and one-time events.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `submit success emits navigation event`() = runTest {
|
||||||
|
fakeRepository.setCreateSuccess()
|
||||||
|
|
||||||
|
viewModel.trySendAction(Action.Submit)
|
||||||
|
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
assertEquals(Event.NavigateBack, awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Validation Tests
|
||||||
|
|
||||||
|
Test form validation logic.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `empty input shows validation error`() = runTest {
|
||||||
|
viewModel.trySendAction(Action.NameChanged(""))
|
||||||
|
viewModel.trySendAction(Action.Submit)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertNotNull(expectMostRecentItem().validationError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid input clears validation error`() = runTest {
|
||||||
|
viewModel.trySendAction(Action.NameChanged(""))
|
||||||
|
viewModel.trySendAction(Action.NameChanged("Valid Name"))
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertNull(expectMostRecentItem().validationError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MainDispatcherRule
|
||||||
|
|
||||||
|
Required for testing coroutines in ViewModels:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// core/testing/src/commonMain/.../rule/MainDispatcherRule.kt
|
||||||
|
|
||||||
|
class MainDispatcherRule(
|
||||||
|
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
|
||||||
|
) {
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Turbine Usage
|
||||||
|
|
||||||
|
### Basic Flow Testing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
val first = awaitItem() // Get first emission
|
||||||
|
val latest = expectMostRecentItem() // Skip to latest
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Turbine Methods
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `awaitItem()` | Wait for next emission |
|
||||||
|
| `expectMostRecentItem()` | Get latest, skip intermediates |
|
||||||
|
| `awaitComplete()` | Wait for flow completion |
|
||||||
|
| `cancelAndIgnoreRemainingEvents()` | Clean up |
|
||||||
|
| `expectNoEvents()` | Verify no emissions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage Checklist
|
||||||
|
|
||||||
|
For each ViewModel, test:
|
||||||
|
|
||||||
|
- [ ] Initial state
|
||||||
|
- [ ] Load success with data
|
||||||
|
- [ ] Load success with empty data
|
||||||
|
- [ ] Load error
|
||||||
|
- [ ] Each action in Action sealed class
|
||||||
|
- [ ] Each event in Event sealed class
|
||||||
|
- [ ] Validation (if applicable)
|
||||||
|
- [ ] Dialog states (if applicable)
|
||||||
|
- [ ] Refresh/Retry
|
||||||
|
- [ ] Pagination (if applicable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Not using `runTest` | Wrap all tests in `runTest { }` |
|
||||||
|
| Missing dispatcher rule | Add `MainDispatcherRule` |
|
||||||
|
| Not resetting fakes | Call `reset()` in `@AfterTest` |
|
||||||
|
| Using `awaitItem()` for latest | Use `expectMostRecentItem()` |
|
||||||
|
| Not testing all states | Check coverage checklist |
|
||||||
@ -0,0 +1,270 @@
|
|||||||
|
package org.mifos.mobile.core.testing.fake
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import org.mifos.mobile.core.data.repository.${Feature}Repository
|
||||||
|
import org.mifos.mobile.core.model.${Model}
|
||||||
|
import org.mifos.mobile.core.model.${Model}Payload
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake implementation of [${Feature}Repository] for testing.
|
||||||
|
*
|
||||||
|
* Provides configurable responses and call tracking for test verification.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val fakeRepository = Fake${Feature}Repository()
|
||||||
|
* fakeRepository.setLoadSuccess(testData)
|
||||||
|
* val viewModel = ${Feature}ViewModel(repository = fakeRepository)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class Fake${Feature}Repository : ${Feature}Repository {
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// CALL TRACKING
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times [get${Feature}s] was called.
|
||||||
|
* Use to verify refresh/retry behavior.
|
||||||
|
*/
|
||||||
|
var loadCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times [get${Feature}] (single item) was called.
|
||||||
|
*/
|
||||||
|
var getByIdCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times [create${Feature}] was called.
|
||||||
|
*/
|
||||||
|
var createCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times [update${Feature}] was called.
|
||||||
|
*/
|
||||||
|
var updateCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times [delete${Feature}] was called.
|
||||||
|
*/
|
||||||
|
var deleteCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last payload passed to [create${Feature}].
|
||||||
|
* Use to verify correct data was sent.
|
||||||
|
*/
|
||||||
|
var lastCreatePayload: ${Model}Payload? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last ID passed to [get${Feature}].
|
||||||
|
*/
|
||||||
|
var lastGetId: Long? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last ID passed to [delete${Feature}].
|
||||||
|
*/
|
||||||
|
var lastDeleteId: Long? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// CONFIGURABLE RESPONSES
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private var loadResponse: DataState<List<${Model}>> = DataState.Loading
|
||||||
|
private var singleResponse: DataState<${Model}> = DataState.Loading
|
||||||
|
private var createResponse: DataState<${Model}> = DataState.Loading
|
||||||
|
private var updateResponse: DataState<${Model}> = DataState.Loading
|
||||||
|
private var deleteResponse: DataState<Unit> = DataState.Loading
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP METHODS - LOAD
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [get${Feature}s] to return success with data.
|
||||||
|
*
|
||||||
|
* @param data The list of items to return
|
||||||
|
*/
|
||||||
|
fun setLoadSuccess(data: List<${Model}>) {
|
||||||
|
loadResponse = DataState.Success(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [get${Feature}s] to return error.
|
||||||
|
*
|
||||||
|
* @param message The error message
|
||||||
|
*/
|
||||||
|
fun setLoadError(message: String = "Failed to load ${feature_lowercase}") {
|
||||||
|
loadResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [get${Feature}s] to return empty list.
|
||||||
|
*/
|
||||||
|
fun setLoadEmpty() {
|
||||||
|
loadResponse = DataState.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [get${Feature}s] to return loading state.
|
||||||
|
*/
|
||||||
|
fun setLoadLoading() {
|
||||||
|
loadResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP METHODS - SINGLE ITEM
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [get${Feature}] to return success.
|
||||||
|
*
|
||||||
|
* @param item The item to return
|
||||||
|
*/
|
||||||
|
fun setSingleSuccess(item: ${Model}) {
|
||||||
|
singleResponse = DataState.Success(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [get${Feature}] to return error.
|
||||||
|
*
|
||||||
|
* @param message The error message
|
||||||
|
*/
|
||||||
|
fun setSingleError(message: String = "${Feature} not found") {
|
||||||
|
singleResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP METHODS - CREATE
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [create${Feature}] to return success.
|
||||||
|
*
|
||||||
|
* @param item The created item to return
|
||||||
|
*/
|
||||||
|
fun setCreateSuccess(item: ${Model}) {
|
||||||
|
createResponse = DataState.Success(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [create${Feature}] to return error.
|
||||||
|
*
|
||||||
|
* @param message The error message
|
||||||
|
*/
|
||||||
|
fun setCreateError(message: String = "Failed to create ${feature_lowercase}") {
|
||||||
|
createResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP METHODS - UPDATE
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [update${Feature}] to return success.
|
||||||
|
*
|
||||||
|
* @param item The updated item to return
|
||||||
|
*/
|
||||||
|
fun setUpdateSuccess(item: ${Model}) {
|
||||||
|
updateResponse = DataState.Success(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [update${Feature}] to return error.
|
||||||
|
*
|
||||||
|
* @param message The error message
|
||||||
|
*/
|
||||||
|
fun setUpdateError(message: String = "Failed to update ${feature_lowercase}") {
|
||||||
|
updateResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SETUP METHODS - DELETE
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [delete${Feature}] to return success.
|
||||||
|
*/
|
||||||
|
fun setDeleteSuccess() {
|
||||||
|
deleteResponse = DataState.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure [delete${Feature}] to return error.
|
||||||
|
*
|
||||||
|
* @param message The error message
|
||||||
|
*/
|
||||||
|
fun setDeleteError(message: String = "Failed to delete ${feature_lowercase}") {
|
||||||
|
deleteResponse = DataState.Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// REPOSITORY IMPLEMENTATION
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
override fun get${Feature}s(): Flow<DataState<List<${Model}>>> = flow {
|
||||||
|
loadCallCount++
|
||||||
|
emit(loadResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get${Feature}(id: Long): Flow<DataState<${Model}>> = flow {
|
||||||
|
getByIdCallCount++
|
||||||
|
lastGetId = id
|
||||||
|
emit(singleResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun create${Feature}(payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
|
||||||
|
createCallCount++
|
||||||
|
lastCreatePayload = payload
|
||||||
|
emit(createResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update${Feature}(id: Long, payload: ${Model}Payload): Flow<DataState<${Model}>> = flow {
|
||||||
|
updateCallCount++
|
||||||
|
emit(updateResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete${Feature}(id: Long): Flow<DataState<Unit>> = flow {
|
||||||
|
deleteCallCount++
|
||||||
|
lastDeleteId = id
|
||||||
|
emit(deleteResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// RESET
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all counters and responses to initial state.
|
||||||
|
* Call in @AfterTest to ensure test isolation.
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
// Reset counters
|
||||||
|
loadCallCount = 0
|
||||||
|
getByIdCallCount = 0
|
||||||
|
createCallCount = 0
|
||||||
|
updateCallCount = 0
|
||||||
|
deleteCallCount = 0
|
||||||
|
|
||||||
|
// Reset captured data
|
||||||
|
lastCreatePayload = null
|
||||||
|
lastGetId = null
|
||||||
|
lastDeleteId = null
|
||||||
|
|
||||||
|
// Reset responses to loading
|
||||||
|
loadResponse = DataState.Loading
|
||||||
|
singleResponse = DataState.Loading
|
||||||
|
createResponse = DataState.Loading
|
||||||
|
updateResponse = DataState.Loading
|
||||||
|
deleteResponse = DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
}
|
||||||
88
core/testing/build.gradle.kts
Normal file
88
core/testing/build.gradle.kts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kmp.library.convention)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "org.mifos.mobile.core.testing"
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(projects.core.common)
|
||||||
|
api(projects.core.data)
|
||||||
|
api(projects.core.model)
|
||||||
|
api(projects.core.network)
|
||||||
|
|
||||||
|
// Coroutines Test - KMP compatible
|
||||||
|
api(libs.kotlinx.coroutines.test)
|
||||||
|
|
||||||
|
// Koin Test - KMP compatible
|
||||||
|
api(libs.koin.test)
|
||||||
|
|
||||||
|
// Kotlin Test - KMP compatible
|
||||||
|
api(libs.kotlin.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidMain.dependencies {
|
||||||
|
// Android Test
|
||||||
|
api(libs.androidx.test.ext.junit)
|
||||||
|
api(libs.androidx.test.rules)
|
||||||
|
api(libs.androidx.test.espresso.core)
|
||||||
|
|
||||||
|
// Note: Compose UI Test dependencies (ui-test-junit4, ui-test-manifest)
|
||||||
|
// should be added by consuming modules that need them, as they require
|
||||||
|
// the Compose BOM for version management.
|
||||||
|
|
||||||
|
// Turbine for Flow testing
|
||||||
|
api(libs.turbine)
|
||||||
|
|
||||||
|
// Mockito
|
||||||
|
api(libs.mockito.core)
|
||||||
|
|
||||||
|
// Truth assertions
|
||||||
|
api(libs.truth)
|
||||||
|
|
||||||
|
// Koin Android Test
|
||||||
|
api(libs.koin.test.junit4)
|
||||||
|
}
|
||||||
|
|
||||||
|
iosMain.dependencies {
|
||||||
|
// iOS-specific test utilities if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopMain.dependencies {
|
||||||
|
// Desktop-specific test utilities
|
||||||
|
api(libs.kotlinx.coroutines.swing)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsMain.dependencies {
|
||||||
|
// JS-specific test utilities
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeMain.dependencies {
|
||||||
|
// Native-specific test utilities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.fake
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
import org.mifos.mobile.core.data.repository.AccountsRepository
|
||||||
|
import org.mifos.mobile.core.model.entity.client.ClientAccounts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake implementation of [AccountsRepository] for testing.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val fakeRepo = FakeAccountsRepository()
|
||||||
|
*
|
||||||
|
* // Set accounts data
|
||||||
|
* fakeRepo.setAccounts(DataState.Success(testAccounts))
|
||||||
|
*
|
||||||
|
* // Use in tests
|
||||||
|
* val viewModel = AccountsViewModel(fakeRepo)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class FakeAccountsRepository : AccountsRepository {
|
||||||
|
|
||||||
|
private val accountsState = MutableStateFlow<DataState<ClientAccounts>>(
|
||||||
|
DataState.Success(ClientAccounts()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track calls for verification
|
||||||
|
var loadAccountsCallCount = 0
|
||||||
|
private set
|
||||||
|
var lastClientId: Long? = null
|
||||||
|
private set
|
||||||
|
var lastAccountType: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun setAccounts(result: DataState<ClientAccounts>) {
|
||||||
|
accountsState.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitLoading() {
|
||||||
|
accountsState.value = DataState.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitSuccess(accounts: ClientAccounts) {
|
||||||
|
accountsState.value = DataState.Success(accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitError(error: Throwable) {
|
||||||
|
accountsState.value = DataState.Error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
accountsState.value = DataState.Success(ClientAccounts())
|
||||||
|
loadAccountsCallCount = 0
|
||||||
|
lastClientId = null
|
||||||
|
lastAccountType = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadAccounts(clientId: Long?, accountType: String?): Flow<DataState<ClientAccounts>> {
|
||||||
|
loadAccountsCallCount++
|
||||||
|
lastClientId = clientId
|
||||||
|
lastAccountType = accountType
|
||||||
|
return accountsState.asStateFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.fake
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
import org.mifos.mobile.core.data.repository.BeneficiaryRepository
|
||||||
|
import org.mifos.mobile.core.model.entity.beneficiary.Beneficiary
|
||||||
|
import org.mifos.mobile.core.model.entity.beneficiary.BeneficiaryPayload
|
||||||
|
import org.mifos.mobile.core.model.entity.beneficiary.BeneficiaryUpdatePayload
|
||||||
|
import org.mifos.mobile.core.model.entity.templates.beneficiary.BeneficiaryTemplate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake implementation of [BeneficiaryRepository] for testing.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val fakeRepo = FakeBeneficiaryRepository()
|
||||||
|
*
|
||||||
|
* // Set beneficiary list
|
||||||
|
* fakeRepo.setBeneficiaryList(DataState.Success(testBeneficiaries))
|
||||||
|
*
|
||||||
|
* // Set create result
|
||||||
|
* fakeRepo.setCreateResult(DataState.Success("Beneficiary created"))
|
||||||
|
*
|
||||||
|
* // Use in tests
|
||||||
|
* val viewModel = BeneficiaryViewModel(fakeRepo)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class FakeBeneficiaryRepository : BeneficiaryRepository {
|
||||||
|
|
||||||
|
private val beneficiaryTemplateState = MutableStateFlow<DataState<BeneficiaryTemplate>>(
|
||||||
|
DataState.Success(BeneficiaryTemplate()),
|
||||||
|
)
|
||||||
|
private val beneficiaryListState = MutableStateFlow<DataState<List<Beneficiary>>>(
|
||||||
|
DataState.Success(emptyList()),
|
||||||
|
)
|
||||||
|
|
||||||
|
private var createResult: DataState<String> = DataState.Success("Beneficiary created")
|
||||||
|
private var updateResult: DataState<String> = DataState.Success("Beneficiary updated")
|
||||||
|
private var deleteResult: DataState<String> = DataState.Success("Beneficiary deleted")
|
||||||
|
|
||||||
|
// Track method calls for verification
|
||||||
|
var createCallCount = 0
|
||||||
|
private set
|
||||||
|
var updateCallCount = 0
|
||||||
|
private set
|
||||||
|
var deleteCallCount = 0
|
||||||
|
private set
|
||||||
|
var lastCreatedPayload: BeneficiaryPayload? = null
|
||||||
|
private set
|
||||||
|
var lastDeletedId: Long? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun setBeneficiaryTemplate(result: DataState<BeneficiaryTemplate>) {
|
||||||
|
beneficiaryTemplateState.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBeneficiaryList(result: DataState<List<Beneficiary>>) {
|
||||||
|
beneficiaryListState.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitBeneficiaryListLoading() {
|
||||||
|
beneficiaryListState.value = DataState.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitBeneficiaryListSuccess(beneficiaries: List<Beneficiary>) {
|
||||||
|
beneficiaryListState.value = DataState.Success(beneficiaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitBeneficiaryListError(error: Throwable) {
|
||||||
|
beneficiaryListState.value = DataState.Error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCreateResult(result: DataState<String>) {
|
||||||
|
createResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUpdateResult(result: DataState<String>) {
|
||||||
|
updateResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDeleteResult(result: DataState<String>) {
|
||||||
|
deleteResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
beneficiaryTemplateState.value = DataState.Success(BeneficiaryTemplate())
|
||||||
|
beneficiaryListState.value = DataState.Success(emptyList())
|
||||||
|
createResult = DataState.Success("Beneficiary created")
|
||||||
|
updateResult = DataState.Success("Beneficiary updated")
|
||||||
|
deleteResult = DataState.Success("Beneficiary deleted")
|
||||||
|
createCallCount = 0
|
||||||
|
updateCallCount = 0
|
||||||
|
deleteCallCount = 0
|
||||||
|
lastCreatedPayload = null
|
||||||
|
lastDeletedId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun beneficiaryTemplate(): Flow<DataState<BeneficiaryTemplate>> {
|
||||||
|
return beneficiaryTemplateState.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createBeneficiary(beneficiaryPayload: BeneficiaryPayload?): DataState<String> {
|
||||||
|
createCallCount++
|
||||||
|
lastCreatedPayload = beneficiaryPayload
|
||||||
|
return createResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateBeneficiary(
|
||||||
|
beneficiaryId: Long?,
|
||||||
|
payload: BeneficiaryUpdatePayload?,
|
||||||
|
): DataState<String> {
|
||||||
|
updateCallCount++
|
||||||
|
return updateResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteBeneficiary(beneficiaryId: Long?): DataState<String> {
|
||||||
|
deleteCallCount++
|
||||||
|
lastDeletedId = beneficiaryId
|
||||||
|
return deleteResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun beneficiaryList(): Flow<DataState<List<Beneficiary>>> {
|
||||||
|
return beneficiaryListState.asStateFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.fake
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
import org.mifos.mobile.core.data.repository.HomeRepository
|
||||||
|
import org.mifos.mobile.core.model.entity.client.Client
|
||||||
|
import org.mifos.mobile.core.model.entity.client.ClientAccounts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake implementation of [HomeRepository] for testing.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val fakeRepo = FakeHomeRepository()
|
||||||
|
*
|
||||||
|
* // Set success response
|
||||||
|
* fakeRepo.setClientAccounts(DataState.Success(testClientAccounts))
|
||||||
|
*
|
||||||
|
* // Emit loading state first, then success
|
||||||
|
* fakeRepo.emitClientAccountsLoading()
|
||||||
|
* fakeRepo.emitClientAccountsSuccess(testClientAccounts)
|
||||||
|
*
|
||||||
|
* // Use in tests
|
||||||
|
* val viewModel = HomeViewModel(fakeRepo)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class FakeHomeRepository : HomeRepository {
|
||||||
|
|
||||||
|
private val clientAccountsState = MutableStateFlow<DataState<ClientAccounts>>(
|
||||||
|
DataState.Success(ClientAccounts()),
|
||||||
|
)
|
||||||
|
private val currentClientState = MutableStateFlow<DataState<Client>>(
|
||||||
|
DataState.Success(createDefaultClient()),
|
||||||
|
)
|
||||||
|
private val clientImageState = MutableStateFlow<DataState<String>>(
|
||||||
|
DataState.Success(""),
|
||||||
|
)
|
||||||
|
private val unreadNotificationsState = MutableStateFlow<DataState<Int>>(
|
||||||
|
DataState.Success(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setClientAccounts(result: DataState<ClientAccounts>) {
|
||||||
|
clientAccountsState.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitClientAccountsLoading() {
|
||||||
|
clientAccountsState.value = DataState.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitClientAccountsSuccess(accounts: ClientAccounts) {
|
||||||
|
clientAccountsState.value = DataState.Success(accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitClientAccountsError(error: Throwable) {
|
||||||
|
clientAccountsState.value = DataState.Error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCurrentClient(result: DataState<Client>) {
|
||||||
|
currentClientState.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setClientImage(result: DataState<String>) {
|
||||||
|
clientImageState.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUnreadNotificationsCount(count: Int) {
|
||||||
|
unreadNotificationsState.value = DataState.Success(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
clientAccountsState.value = DataState.Success(ClientAccounts())
|
||||||
|
currentClientState.value = DataState.Success(createDefaultClient())
|
||||||
|
clientImageState.value = DataState.Success("")
|
||||||
|
unreadNotificationsState.value = DataState.Success(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clientAccounts(clientId: Long): Flow<DataState<ClientAccounts>> {
|
||||||
|
return clientAccountsState.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentClient(clientId: Long): Flow<DataState<Client>> {
|
||||||
|
return currentClientState.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clientImage(clientId: Long): Flow<DataState<String>> {
|
||||||
|
return clientImageState.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unreadNotificationsCount(): Flow<DataState<Int>> {
|
||||||
|
return unreadNotificationsState.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDefaultClient(): Client {
|
||||||
|
return Client(
|
||||||
|
id = 1,
|
||||||
|
displayName = "Test User",
|
||||||
|
firstname = "Test",
|
||||||
|
lastname = "User",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.fake
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
import org.mifos.mobile.core.data.repository.NotificationRepository
|
||||||
|
import org.mifos.mobile.core.model.entity.MifosNotification
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake implementation of [NotificationRepository] for testing.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val fakeRepo = FakeNotificationRepository()
|
||||||
|
*
|
||||||
|
* // Set notifications
|
||||||
|
* fakeRepo.setNotifications(DataState.Success(testNotifications))
|
||||||
|
*
|
||||||
|
* // Set unread count
|
||||||
|
* fakeRepo.setUnreadCount(5)
|
||||||
|
*
|
||||||
|
* // Use in tests
|
||||||
|
* val viewModel = NotificationViewModel(fakeRepo)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class FakeNotificationRepository : NotificationRepository {
|
||||||
|
|
||||||
|
private val notificationsState = MutableStateFlow<DataState<List<MifosNotification>>>(
|
||||||
|
DataState.Success(emptyList()),
|
||||||
|
)
|
||||||
|
private val unreadCountState = MutableStateFlow<DataState<Int>>(
|
||||||
|
DataState.Success(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val savedNotifications = mutableListOf<MifosNotification>()
|
||||||
|
|
||||||
|
// Track method calls for verification
|
||||||
|
var saveCallCount = 0
|
||||||
|
private set
|
||||||
|
var deleteOldCallCount = 0
|
||||||
|
private set
|
||||||
|
var updateReadStatusCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun setNotifications(result: DataState<List<MifosNotification>>) {
|
||||||
|
notificationsState.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitNotificationsLoading() {
|
||||||
|
notificationsState.value = DataState.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitNotificationsSuccess(notifications: List<MifosNotification>) {
|
||||||
|
notificationsState.value = DataState.Success(notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitNotificationsError(error: Throwable) {
|
||||||
|
notificationsState.value = DataState.Error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUnreadCount(count: Int) {
|
||||||
|
unreadCountState.value = DataState.Success(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSavedNotifications(): List<MifosNotification> = savedNotifications.toList()
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
notificationsState.value = DataState.Success(emptyList())
|
||||||
|
unreadCountState.value = DataState.Success(0)
|
||||||
|
savedNotifications.clear()
|
||||||
|
saveCallCount = 0
|
||||||
|
deleteOldCallCount = 0
|
||||||
|
updateReadStatusCallCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadNotifications(): Flow<DataState<List<MifosNotification>>> {
|
||||||
|
return notificationsState.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getUnReadNotificationCount(): Flow<DataState<Int>> {
|
||||||
|
return unreadCountState.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveNotification(notification: MifosNotification) {
|
||||||
|
saveCallCount++
|
||||||
|
savedNotifications.add(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteOldNotifications() {
|
||||||
|
deleteOldCallCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateReadStatus(notification: MifosNotification, isRead: Boolean) {
|
||||||
|
updateReadStatusCallCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.fake
|
||||||
|
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
import org.mifos.mobile.core.data.repository.TransferRepository
|
||||||
|
import org.mifos.mobile.core.model.entity.payload.TransferPayload
|
||||||
|
import org.mifos.mobile.core.model.enums.TransferType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake implementation of [TransferRepository] for testing.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val fakeRepo = FakeTransferRepository()
|
||||||
|
*
|
||||||
|
* // Set success response
|
||||||
|
* fakeRepo.setTransferResult(DataState.Success("Transfer successful"))
|
||||||
|
*
|
||||||
|
* // Set error response
|
||||||
|
* fakeRepo.setTransferResult(DataState.Error(Exception("Insufficient funds")))
|
||||||
|
*
|
||||||
|
* // Use in tests
|
||||||
|
* val viewModel = TransferViewModel(fakeRepo)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class FakeTransferRepository : TransferRepository {
|
||||||
|
|
||||||
|
private var transferResult: DataState<String> = DataState.Success("Transfer successful")
|
||||||
|
|
||||||
|
// Track method calls for verification
|
||||||
|
var transferCallCount = 0
|
||||||
|
private set
|
||||||
|
var lastTransferPayload: TransferPayload? = null
|
||||||
|
private set
|
||||||
|
var lastTransferType: TransferType? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun setTransferResult(result: DataState<String>) {
|
||||||
|
transferResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
transferResult = DataState.Success("Transfer successful")
|
||||||
|
transferCallCount = 0
|
||||||
|
lastTransferPayload = null
|
||||||
|
lastTransferType = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun makeTransfer(
|
||||||
|
payload: TransferPayload,
|
||||||
|
transferType: TransferType?,
|
||||||
|
): DataState<String> {
|
||||||
|
transferCallCount++
|
||||||
|
lastTransferPayload = payload
|
||||||
|
lastTransferType = transferType
|
||||||
|
return transferResult
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.fake
|
||||||
|
|
||||||
|
import org.mifos.mobile.core.common.DataState
|
||||||
|
import org.mifos.mobile.core.data.repository.UserAuthRepository
|
||||||
|
import org.mifos.mobile.core.model.entity.User
|
||||||
|
import org.mifos.mobile.core.model.entity.register.RegisterPayload
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake implementation of [UserAuthRepository] for testing.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val fakeRepo = FakeUserAuthRepository()
|
||||||
|
*
|
||||||
|
* // Set success response
|
||||||
|
* fakeRepo.setLoginResult(DataState.Success(testUser))
|
||||||
|
*
|
||||||
|
* // Set error response
|
||||||
|
* fakeRepo.setLoginResult(DataState.Error(Exception("Invalid credentials")))
|
||||||
|
*
|
||||||
|
* // Use in tests
|
||||||
|
* val viewModel = LoginViewModel(fakeRepo)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class FakeUserAuthRepository : UserAuthRepository {
|
||||||
|
|
||||||
|
private var loginResult: DataState<User> = DataState.Success(createDefaultUser())
|
||||||
|
private var registerResult: DataState<String> = DataState.Success("Registration successful")
|
||||||
|
private var verifyResult: DataState<String> = DataState.Success("Verification successful")
|
||||||
|
private var updatePasswordResult: DataState<String> = DataState.Success("Password updated")
|
||||||
|
|
||||||
|
// Track method calls for verification
|
||||||
|
var loginCallCount = 0
|
||||||
|
private set
|
||||||
|
var lastLoginUsername: String? = null
|
||||||
|
private set
|
||||||
|
var lastLoginPassword: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun setLoginResult(result: DataState<User>) {
|
||||||
|
loginResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRegisterResult(result: DataState<String>) {
|
||||||
|
registerResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVerifyResult(result: DataState<String>) {
|
||||||
|
verifyResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUpdatePasswordResult(result: DataState<String>) {
|
||||||
|
updatePasswordResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
loginResult = DataState.Success(createDefaultUser())
|
||||||
|
registerResult = DataState.Success("Registration successful")
|
||||||
|
verifyResult = DataState.Success("Verification successful")
|
||||||
|
updatePasswordResult = DataState.Success("Password updated")
|
||||||
|
loginCallCount = 0
|
||||||
|
lastLoginUsername = null
|
||||||
|
lastLoginPassword = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun registerUser(registerPayload: RegisterPayload): DataState<String> {
|
||||||
|
return registerResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun login(username: String, password: String): DataState<User> {
|
||||||
|
loginCallCount++
|
||||||
|
lastLoginUsername = username
|
||||||
|
lastLoginPassword = password
|
||||||
|
return loginResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun verifyUser(
|
||||||
|
authenticationToken: String?,
|
||||||
|
requestId: String?,
|
||||||
|
): DataState<String> {
|
||||||
|
return verifyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateAccountPassword(
|
||||||
|
newPassword: String,
|
||||||
|
confirmPassword: String,
|
||||||
|
): DataState<String> {
|
||||||
|
return updatePasswordResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDefaultUser(): User {
|
||||||
|
return User(
|
||||||
|
userId = 1L,
|
||||||
|
username = "testuser",
|
||||||
|
base64EncodedAuthenticationKey = "test-auth-key",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.fixture
|
||||||
|
|
||||||
|
import org.mifos.mobile.core.model.entity.beneficiary.Beneficiary
|
||||||
|
import org.mifos.mobile.core.model.entity.beneficiary.BeneficiaryPayload
|
||||||
|
import org.mifos.mobile.core.model.entity.templates.account.AccountType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fixtures for [Beneficiary] entity.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val testBeneficiary = BeneficiaryFixture.createDefault()
|
||||||
|
* val beneficiaryList = BeneficiaryFixture.createList(count = 5)
|
||||||
|
* val customBeneficiary = BeneficiaryFixture.create(name = "Custom", accountNumber = "123")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
object BeneficiaryFixture {
|
||||||
|
|
||||||
|
fun createDefault(): Beneficiary = Beneficiary(
|
||||||
|
id = 1L,
|
||||||
|
name = "Test Beneficiary",
|
||||||
|
officeName = "Test Office",
|
||||||
|
clientName = "Test Client",
|
||||||
|
accountNumber = "ACC001",
|
||||||
|
accountType = AccountTypeFixture.createSavings(),
|
||||||
|
transferLimit = 10000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createList(count: Int = 3): List<Beneficiary> = (1..count).map { index ->
|
||||||
|
create(
|
||||||
|
id = index.toLong(),
|
||||||
|
name = "Beneficiary $index",
|
||||||
|
accountNumber = "ACC${index.toString().padStart(3, '0')}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
id: Long = 1L,
|
||||||
|
name: String = "Test Beneficiary",
|
||||||
|
officeName: String = "Test Office",
|
||||||
|
clientName: String = "Test Client",
|
||||||
|
accountNumber: String = "ACC001",
|
||||||
|
transferLimit: Double = 10000.0,
|
||||||
|
): Beneficiary = Beneficiary(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
officeName = officeName,
|
||||||
|
clientName = clientName,
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountType = AccountTypeFixture.createSavings(),
|
||||||
|
transferLimit = transferLimit,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createPayload(
|
||||||
|
name: String = "Test Beneficiary",
|
||||||
|
accountNumber: String = "ACC001",
|
||||||
|
transferLimit: Int = 10000,
|
||||||
|
): BeneficiaryPayload = BeneficiaryPayload(
|
||||||
|
name = name,
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
transferLimit = transferLimit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for creating account type fixtures.
|
||||||
|
*/
|
||||||
|
object AccountTypeFixture {
|
||||||
|
fun createSavings() = AccountType(
|
||||||
|
id = 2,
|
||||||
|
code = "accountType.savings",
|
||||||
|
value = "Savings",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createLoan() = AccountType(
|
||||||
|
id = 1,
|
||||||
|
code = "accountType.loan",
|
||||||
|
value = "Loan",
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.fixture
|
||||||
|
|
||||||
|
import org.mifos.mobile.core.model.entity.accounts.loan.LoanAccount
|
||||||
|
import org.mifos.mobile.core.model.entity.accounts.savings.SavingAccount
|
||||||
|
import org.mifos.mobile.core.model.entity.accounts.share.ShareAccount
|
||||||
|
import org.mifos.mobile.core.model.entity.client.ClientAccounts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fixtures for [ClientAccounts] entity.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val emptyAccounts = ClientAccountsFixture.createEmpty()
|
||||||
|
* val accountsWithData = ClientAccountsFixture.createWithSampleData()
|
||||||
|
* val customAccounts = ClientAccountsFixture.create(
|
||||||
|
* loanAccounts = listOf(loanAccount),
|
||||||
|
* savingsAccounts = listOf(savingsAccount),
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
object ClientAccountsFixture {
|
||||||
|
|
||||||
|
fun createEmpty(): ClientAccounts = ClientAccounts(
|
||||||
|
loanAccounts = emptyList(),
|
||||||
|
savingsAccounts = emptyList(),
|
||||||
|
shareAccounts = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createWithSampleData(): ClientAccounts = ClientAccounts(
|
||||||
|
loanAccounts = listOf(
|
||||||
|
createSampleLoanAccount(id = 1L, accountNo = "LOAN001"),
|
||||||
|
createSampleLoanAccount(id = 2L, accountNo = "LOAN002"),
|
||||||
|
),
|
||||||
|
savingsAccounts = listOf(
|
||||||
|
createSampleSavingsAccount(id = 1L, accountNo = "SAV001"),
|
||||||
|
createSampleSavingsAccount(id = 2L, accountNo = "SAV002"),
|
||||||
|
),
|
||||||
|
shareAccounts = listOf(
|
||||||
|
createSampleShareAccount(id = 1L, accountNo = "SHARE001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createWithLoansOnly(): ClientAccounts = ClientAccounts(
|
||||||
|
loanAccounts = listOf(
|
||||||
|
createSampleLoanAccount(id = 1L, accountNo = "LOAN001"),
|
||||||
|
),
|
||||||
|
savingsAccounts = emptyList(),
|
||||||
|
shareAccounts = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createWithSavingsOnly(): ClientAccounts = ClientAccounts(
|
||||||
|
loanAccounts = emptyList(),
|
||||||
|
savingsAccounts = listOf(
|
||||||
|
createSampleSavingsAccount(id = 1L, accountNo = "SAV001"),
|
||||||
|
),
|
||||||
|
shareAccounts = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
loanAccounts: List<LoanAccount> = emptyList(),
|
||||||
|
savingsAccounts: List<SavingAccount> = emptyList(),
|
||||||
|
shareAccounts: List<ShareAccount> = emptyList(),
|
||||||
|
): ClientAccounts = ClientAccounts(
|
||||||
|
loanAccounts = loanAccounts,
|
||||||
|
savingsAccounts = savingsAccounts,
|
||||||
|
shareAccounts = shareAccounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createSampleLoanAccount(
|
||||||
|
id: Long,
|
||||||
|
accountNo: String,
|
||||||
|
): LoanAccount = LoanAccount(
|
||||||
|
id = id,
|
||||||
|
accountNo = accountNo,
|
||||||
|
productName = "Test Loan Product",
|
||||||
|
loanProductName = "Test Loan",
|
||||||
|
currency = null,
|
||||||
|
timeline = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createSampleSavingsAccount(
|
||||||
|
id: Long,
|
||||||
|
accountNo: String,
|
||||||
|
): SavingAccount = SavingAccount(
|
||||||
|
id = id,
|
||||||
|
accountNo = accountNo,
|
||||||
|
productName = "Test Savings Product",
|
||||||
|
accountBalance = 1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createSampleShareAccount(
|
||||||
|
id: Long,
|
||||||
|
accountNo: String,
|
||||||
|
): ShareAccount = ShareAccount(
|
||||||
|
id = id,
|
||||||
|
accountNo = accountNo,
|
||||||
|
productName = "Test Share Product",
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.fixture
|
||||||
|
|
||||||
|
import org.mifos.mobile.core.model.entity.Role
|
||||||
|
import org.mifos.mobile.core.model.entity.User
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fixtures for [User] entity.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val testUser = UserFixture.createDefault()
|
||||||
|
* val adminUser = UserFixture.createAdmin()
|
||||||
|
* val customUser = UserFixture.create(username = "custom", userId = 100L)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
object UserFixture {
|
||||||
|
|
||||||
|
fun createDefault(): User = User(
|
||||||
|
userId = 1L,
|
||||||
|
isAuthenticated = true,
|
||||||
|
username = "testuser",
|
||||||
|
officeId = 1L,
|
||||||
|
officeName = "Test Office",
|
||||||
|
roles = arrayListOf(createDefaultRole()),
|
||||||
|
base64EncodedAuthenticationKey = "test-auth-key-base64",
|
||||||
|
permissions = arrayListOf("READ_ACCOUNT", "WRITE_ACCOUNT"),
|
||||||
|
shouldRenewPassword = false,
|
||||||
|
isTwoFactorAuthenticationRequired = false,
|
||||||
|
clients = arrayListOf(1L),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createAdmin(): User = User(
|
||||||
|
userId = 1L,
|
||||||
|
isAuthenticated = true,
|
||||||
|
username = "admin",
|
||||||
|
officeId = 1L,
|
||||||
|
officeName = "Head Office",
|
||||||
|
roles = arrayListOf(createAdminRole()),
|
||||||
|
base64EncodedAuthenticationKey = "admin-auth-key-base64",
|
||||||
|
permissions = arrayListOf("ALL_FUNCTIONS"),
|
||||||
|
shouldRenewPassword = false,
|
||||||
|
isTwoFactorAuthenticationRequired = false,
|
||||||
|
clients = arrayListOf(1L),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createUnauthenticated(): User = User(
|
||||||
|
userId = 0L,
|
||||||
|
isAuthenticated = false,
|
||||||
|
username = null,
|
||||||
|
base64EncodedAuthenticationKey = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createRequiresPasswordRenewal(): User = createDefault().copy(
|
||||||
|
shouldRenewPassword = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createRequires2FA(): User = createDefault().copy(
|
||||||
|
isTwoFactorAuthenticationRequired = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
userId: Long = 1L,
|
||||||
|
isAuthenticated: Boolean = true,
|
||||||
|
username: String = "testuser",
|
||||||
|
officeId: Long = 1L,
|
||||||
|
officeName: String = "Test Office",
|
||||||
|
roles: ArrayList<Role> = arrayListOf(createDefaultRole()),
|
||||||
|
base64EncodedAuthenticationKey: String = "test-auth-key",
|
||||||
|
permissions: ArrayList<String> = arrayListOf(),
|
||||||
|
shouldRenewPassword: Boolean = false,
|
||||||
|
isTwoFactorAuthenticationRequired: Boolean = false,
|
||||||
|
clients: ArrayList<Long> = arrayListOf(1L),
|
||||||
|
): User = User(
|
||||||
|
userId = userId,
|
||||||
|
isAuthenticated = isAuthenticated,
|
||||||
|
username = username,
|
||||||
|
officeId = officeId,
|
||||||
|
officeName = officeName,
|
||||||
|
roles = roles,
|
||||||
|
base64EncodedAuthenticationKey = base64EncodedAuthenticationKey,
|
||||||
|
permissions = permissions,
|
||||||
|
shouldRenewPassword = shouldRenewPassword,
|
||||||
|
isTwoFactorAuthenticationRequired = isTwoFactorAuthenticationRequired,
|
||||||
|
clients = clients,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createDefaultRole(): Role = Role(
|
||||||
|
id = 1L,
|
||||||
|
name = "Self Service User",
|
||||||
|
description = "Default self-service user role",
|
||||||
|
disabled = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createAdminRole(): Role = Role(
|
||||||
|
id = 1L,
|
||||||
|
name = "Super User",
|
||||||
|
description = "Administrator role with all permissions",
|
||||||
|
disabled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package org.mifos.mobile.core.testing.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow testing utilities for common test scenarios.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* @Test
|
||||||
|
* fun `test flow emissions`() = runTest {
|
||||||
|
* val viewModel = MyViewModel()
|
||||||
|
*
|
||||||
|
* viewModel.stateFlow.assertEmits(
|
||||||
|
* MyState(loading = true),
|
||||||
|
* MyState(loading = false, data = "result")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects all emissions from the flow and asserts they match the expected values.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
suspend fun <T> Flow<T>.assertEmitsInOrder(
|
||||||
|
testScope: TestScope,
|
||||||
|
vararg expected: T,
|
||||||
|
) {
|
||||||
|
val emissions = mutableListOf<T>()
|
||||||
|
val job = testScope.launch(UnconfinedTestDispatcher(testScope.testScheduler)) {
|
||||||
|
toList(emissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
testScope.advanceUntilIdle()
|
||||||
|
job.cancel()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
expected.toList(),
|
||||||
|
emissions.take(expected.size),
|
||||||
|
"Flow emissions did not match expected order",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the flow emits at least one value matching the predicate.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
suspend fun <T> Flow<T>.assertContainsEmission(
|
||||||
|
testScope: TestScope,
|
||||||
|
predicate: (T) -> Boolean,
|
||||||
|
) {
|
||||||
|
val emissions = mutableListOf<T>()
|
||||||
|
val job = testScope.launch(UnconfinedTestDispatcher(testScope.testScheduler)) {
|
||||||
|
toList(emissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
testScope.advanceUntilIdle()
|
||||||
|
job.cancel()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
emissions.any(predicate),
|
||||||
|
"Flow did not emit any value matching the predicate",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the first emission from the flow.
|
||||||
|
*/
|
||||||
|
suspend fun <T> Flow<T>.firstEmission(): T = first()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects emissions until the predicate is satisfied.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
suspend fun <T> Flow<T>.collectUntil(
|
||||||
|
testScope: TestScope,
|
||||||
|
predicate: (T) -> Boolean,
|
||||||
|
): List<T> {
|
||||||
|
val emissions = mutableListOf<T>()
|
||||||
|
val job = testScope.launch(UnconfinedTestDispatcher(testScope.testScheduler)) {
|
||||||
|
collect { value ->
|
||||||
|
emissions.add(value)
|
||||||
|
if (predicate(value)) {
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testScope.advanceUntilIdle()
|
||||||
|
job.cancel()
|
||||||
|
|
||||||
|
return emissions
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -54,7 +54,7 @@ include(":core:network")
|
|||||||
include(":core:database")
|
include(":core:database")
|
||||||
include(":core:datastore")
|
include(":core:datastore")
|
||||||
include(":core:qrcode")
|
include(":core:qrcode")
|
||||||
//include(":core:testing")
|
include(":core:testing")
|
||||||
|
|
||||||
include(":core-base:datastore")
|
include(":core-base:datastore")
|
||||||
include(":core-base:common")
|
include(":core-base:common")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user