update claude.md
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
---
|
||||
name: kotlin-coroutines
|
||||
description: Advanced Kotlin coroutines patterns for AmethystMultiplatform. Use when working with: (1) Structured concurrency (supervisorScope, coroutineScope), (2) Advanced Flow operators (flatMapLatest, combine, merge, shareIn, stateIn), (3) Channels and callbackFlow, (4) Dispatcher management and context switching, (5) Exception handling (CoroutineExceptionHandler, SupervisorJob), (6) Testing async code (runTest, Turbine), (7) Nostr relay connection pools and subscriptions, (8) Backpressure handling in event streams. Delegates to kotlin-expert for basic StateFlow/SharedFlow patterns. Complements nostr-expert for relay communication.
|
||||
---
|
||||
|
||||
# Kotlin Coroutines - Advanced Async Patterns
|
||||
|
||||
Expert guidance for complex async operations in Amethyst: relay pools, event streams, structured concurrency, and testing.
|
||||
|
||||
## Mental Model
|
||||
|
||||
```
|
||||
Async Architecture in Amethyst:
|
||||
|
||||
Relay Pool (supervisorScope)
|
||||
├── Relay 1 (launch) → callbackFlow → Events
|
||||
├── Relay 2 (launch) → callbackFlow → Events
|
||||
└── Relay 3 (launch) → callbackFlow → Events
|
||||
↓
|
||||
merge() → distinctBy(id) → shareIn
|
||||
↓
|
||||
Multiple Collectors (ViewModels, Services)
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
- **supervisorScope** - Children fail independently
|
||||
- **callbackFlow** - Bridge callbacks to Flow
|
||||
- **shareIn/stateIn** - Hot flows from cold
|
||||
- **Backpressure** - buffer(), conflate(), DROP_OLDEST
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use for **advanced** async patterns:
|
||||
- Multi-relay subscriptions with supervisorScope
|
||||
- Complex Flow operators (flatMapLatest, combine, merge)
|
||||
- callbackFlow for Android callbacks (connectivity, location)
|
||||
- Backpressure handling in high-frequency streams
|
||||
- Exception handling with CoroutineExceptionHandler
|
||||
- Testing coroutines with runTest and Turbine
|
||||
|
||||
**Delegate to kotlin-expert for:**
|
||||
- Basic StateFlow/SharedFlow patterns
|
||||
- Simple viewModelScope.launch
|
||||
- MutableStateFlow → asStateFlow()
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Pattern: callbackFlow for Relay Subscriptions
|
||||
|
||||
```kotlin
|
||||
// Real pattern from NostrClientStaticReqAsStateFlow.kt
|
||||
fun INostrClient.reqAsFlow(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
): Flow<List<Event>> = callbackFlow {
|
||||
val subId = RandomInstance.randomChars(10)
|
||||
var hasBeenLive = false
|
||||
val eventIds = mutableSetOf<HexKey>()
|
||||
var currentEvents = listOf<Event>()
|
||||
|
||||
val listener = object : IRequestListener {
|
||||
override fun onEvent(event: Event, ...) {
|
||||
if (event.id !in eventIds) {
|
||||
currentEvents = if (hasBeenLive) {
|
||||
// After EOSE: prepend
|
||||
listOf(event) + currentEvents
|
||||
} else {
|
||||
// Before EOSE: append
|
||||
currentEvents + event
|
||||
}
|
||||
eventIds.add(event.id)
|
||||
trySend(currentEvents)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEose(...) {
|
||||
hasBeenLive = true
|
||||
}
|
||||
}
|
||||
|
||||
openReqSubscription(subId, mapOf(relay to filters), listener)
|
||||
|
||||
awaitClose { close(subId) }
|
||||
}
|
||||
```
|
||||
|
||||
**Key techniques:**
|
||||
1. Deduplication with Set
|
||||
2. EOSE handling (append → prepend strategy)
|
||||
3. trySend (non-blocking from callback)
|
||||
4. awaitClose for cleanup
|
||||
|
||||
### Pattern: Structured Concurrency for Relays
|
||||
|
||||
```kotlin
|
||||
suspend fun connectToRelays(relays: List<Relay>) = supervisorScope {
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
try {
|
||||
relay.connect()
|
||||
relay.subscribe(filters).collect { event ->
|
||||
eventChannel.send(event)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e("Relay", "Connection failed: ${relay.url}", e)
|
||||
// Other relays continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why supervisorScope:**
|
||||
- One relay failure doesn't cancel others
|
||||
- All cancelled together when scope cancelled
|
||||
- Proper cleanup guaranteed
|
||||
|
||||
### Pattern: Exception Handling for Services
|
||||
|
||||
```kotlin
|
||||
// Real pattern from PushNotificationReceiverService.kt
|
||||
class MyService : Service() {
|
||||
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Log.e("Service", "Caught: ${throwable.message}", throwable)
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.IO + SupervisorJob() + exceptionHandler
|
||||
)
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern benefits:**
|
||||
- SupervisorJob: children fail independently
|
||||
- ExceptionHandler: log instead of crash
|
||||
- Scoped lifecycle: cancel all on destroy
|
||||
|
||||
### Pattern: Network Connectivity as Flow
|
||||
|
||||
```kotlin
|
||||
// Real pattern from ConnectivityFlow.kt
|
||||
val status = callbackFlow {
|
||||
val networkCallback = object : NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(ConnectivityStatus.Active(...))
|
||||
}
|
||||
override fun onLost(network: Network) {
|
||||
trySend(ConnectivityStatus.Off)
|
||||
}
|
||||
}
|
||||
|
||||
connectivityManager.registerCallback(networkCallback)
|
||||
|
||||
// Initial state
|
||||
activeNetwork?.let { trySend(ConnectivityStatus.Active(...)) }
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterCallback(networkCallback)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce(200) // Stabilize flapping
|
||||
.flowOn(Dispatchers.IO)
|
||||
```
|
||||
|
||||
**Key patterns:**
|
||||
1. Emit initial state immediately
|
||||
2. Register callback in flow body
|
||||
3. Cleanup in awaitClose
|
||||
4. Stabilize with debounce + distinctUntilChanged
|
||||
|
||||
### Pattern: Merge Events from Multiple Relays
|
||||
|
||||
```kotlin
|
||||
fun observeFromRelays(
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
filters: List<Filter>
|
||||
): Flow<Event> =
|
||||
relays.map { relay ->
|
||||
client.reqAsFlow(relay, filters)
|
||||
.flatMapConcat { it.asFlow() }
|
||||
}.merge()
|
||||
.distinctBy { it.id }
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
- Each relay: `Flow<List<Event>>`
|
||||
- flatMapConcat: flatten to `Flow<Event>`
|
||||
- merge(): combine all relays
|
||||
- distinctBy: deduplicate across relays
|
||||
|
||||
## Advanced Operators
|
||||
|
||||
For comprehensive coverage of Flow operators:
|
||||
- **flatMapLatest, combine, zip, merge** → See [advanced-flow-operators.md](references/advanced-flow-operators.md)
|
||||
- **shareIn, stateIn** → Conversion to hot flows
|
||||
- **buffer, conflate** → Backpressure strategies
|
||||
- **debounce, sample** → Rate limiting
|
||||
|
||||
### Quick Reference
|
||||
|
||||
| Operator | Use Case | Example |
|
||||
|----------|----------|---------|
|
||||
| **flatMapLatest** | Cancel previous, switch to new | Search (cancel old query) |
|
||||
| **combine** | Latest from ALL flows | combine(account, settings, connectivity) |
|
||||
| **merge** | Single stream from multiple | merge(relay1, relay2, relay3) |
|
||||
| **shareIn** | Multiple collectors, single upstream | Share expensive computation |
|
||||
| **stateIn** | StateFlow from Flow | ViewModel state |
|
||||
| **buffer(DROP_OLDEST)** | High-frequency streams | Real-time event feed |
|
||||
| **conflate** | Latest only | UI updates |
|
||||
| **debounce** | Wait for quiet period | Search input |
|
||||
|
||||
## Nostr Relay Patterns
|
||||
|
||||
For complete relay-specific patterns:
|
||||
→ See [relay-patterns.md](references/relay-patterns.md)
|
||||
|
||||
Covers:
|
||||
- Multi-relay subscription management
|
||||
- Connection lifecycle and reconnection
|
||||
- Event deduplication strategies
|
||||
- Backpressure for high-frequency events
|
||||
- EOSE handling patterns
|
||||
|
||||
## Testing
|
||||
|
||||
For comprehensive testing patterns:
|
||||
→ See [testing-coroutines.md](references/testing-coroutines.md)
|
||||
|
||||
**Quick testing pattern:**
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `relay subscription receives events`() = runTest {
|
||||
val client = FakeNostrClient()
|
||||
|
||||
client.reqAsFlow(relay, filters).test {
|
||||
assertEquals(emptyList(), awaitItem())
|
||||
|
||||
client.sendEvent(event1)
|
||||
assertEquals(listOf(event1), awaitItem())
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Testing tools:**
|
||||
- `runTest` - Virtual time, auto cleanup
|
||||
- Turbine `.test {}` - Flow assertions
|
||||
- `advanceTimeBy()` - Control time
|
||||
- Fake implementations over mocks
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario: Implement New Relay Feature
|
||||
|
||||
**Steps:**
|
||||
1. callbackFlow for subscription
|
||||
2. Deduplication (Set of event IDs)
|
||||
3. awaitClose for cleanup
|
||||
4. Test with FakeNostrClient
|
||||
|
||||
**Example:** Add subscription for specific event kind
|
||||
|
||||
```kotlin
|
||||
fun observeKind(kind: Int): Flow<Event> = callbackFlow {
|
||||
val listener = object : IRequestListener {
|
||||
override fun onEvent(event: Event, ...) {
|
||||
if (event.kind == kind) {
|
||||
trySend(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
client.subscribe(listener)
|
||||
awaitClose { client.unsubscribe(listener) }
|
||||
}
|
||||
```
|
||||
|
||||
### Scenario: Handle Network Connectivity Changes
|
||||
|
||||
**Steps:**
|
||||
1. callbackFlow for connectivity
|
||||
2. flatMapLatest to reconnect
|
||||
3. debounce to stabilize
|
||||
4. Exception handling for failures
|
||||
|
||||
**Example:** Reconnect relays on connectivity
|
||||
|
||||
```kotlin
|
||||
connectivityFlow
|
||||
.flatMapLatest { status ->
|
||||
when (status) {
|
||||
Active -> relayPool.observeEvents()
|
||||
else -> emptyFlow()
|
||||
}
|
||||
}
|
||||
.catch { e -> Log.e("Error", e) }
|
||||
.collect { event -> handleEvent(event) }
|
||||
```
|
||||
|
||||
### Scenario: Optimize Multi-Collector Performance
|
||||
|
||||
**Steps:**
|
||||
1. Use shareIn for expensive upstream
|
||||
2. Configure SharingStarted strategy
|
||||
3. Set replay buffer size
|
||||
4. Test with multiple collectors
|
||||
|
||||
**Example:** Share relay subscription
|
||||
|
||||
```kotlin
|
||||
val events: SharedFlow<Event> = client
|
||||
.reqAsFlow(relay, filters)
|
||||
.flatMapConcat { it.asFlow() }
|
||||
.shareIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
replay = 0
|
||||
)
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
❌ **Using GlobalScope**
|
||||
```kotlin
|
||||
GlobalScope.launch { /* Leaks, no structured concurrency */ }
|
||||
```
|
||||
|
||||
✅ **Use scoped coroutines**
|
||||
```kotlin
|
||||
viewModelScope.launch { /* Cancelled with ViewModel */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Forgetting awaitClose**
|
||||
```kotlin
|
||||
callbackFlow {
|
||||
registerCallback()
|
||||
// Missing cleanup!
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Always cleanup**
|
||||
```kotlin
|
||||
callbackFlow {
|
||||
registerCallback()
|
||||
awaitClose { unregisterCallback() }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Blocking in Flow**
|
||||
```kotlin
|
||||
flow.map { Thread.sleep(1000); process(it) }
|
||||
```
|
||||
|
||||
✅ **Suspend, don't block**
|
||||
```kotlin
|
||||
flow.map { delay(1000); process(it) }.flowOn(Dispatchers.Default)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Ignoring backpressure**
|
||||
```kotlin
|
||||
fastProducer.collect { slowConsumer(it) } // Blocks producer!
|
||||
```
|
||||
|
||||
✅ **Handle backpressure**
|
||||
```kotlin
|
||||
fastProducer
|
||||
.buffer(64, BufferOverflow.DROP_OLDEST)
|
||||
.collect { slowConsumer(it) }
|
||||
```
|
||||
|
||||
## Delegation
|
||||
|
||||
**Use kotlin-expert for:**
|
||||
- Basic StateFlow/SharedFlow patterns
|
||||
- viewModelScope.launch usage
|
||||
- Simple MutableStateFlow → asStateFlow()
|
||||
|
||||
**Use nostr-expert for:**
|
||||
- Nostr protocol details (NIPs, event structure)
|
||||
- Event creation and signing
|
||||
- Cryptographic operations
|
||||
|
||||
**This skill provides:**
|
||||
- Advanced async patterns
|
||||
- Structured concurrency
|
||||
- Complex Flow operators
|
||||
- Testing strategies
|
||||
- Relay-specific async patterns
|
||||
|
||||
## Resources
|
||||
|
||||
- **references/advanced-flow-operators.md** - All Flow operators with examples
|
||||
- **references/relay-patterns.md** - Nostr relay async patterns from codebase
|
||||
- **references/testing-coroutines.md** - Complete testing guide
|
||||
|
||||
## Quick Decision Tree
|
||||
|
||||
```
|
||||
Need async operation?
|
||||
├─ Simple ViewModel state update → kotlin-expert (StateFlow)
|
||||
├─ Android callback → This skill (callbackFlow)
|
||||
├─ Multiple concurrent operations → This skill (supervisorScope)
|
||||
├─ Complex Flow transformation → This skill (references/advanced-flow-operators.md)
|
||||
├─ Relay subscription → This skill (references/relay-patterns.md)
|
||||
└─ Testing async code → This skill (references/testing-coroutines.md)
|
||||
```
|
||||
@@ -0,0 +1,309 @@
|
||||
# Advanced Flow Operators
|
||||
|
||||
Comprehensive guide to Flow operators for complex async patterns in Amethyst.
|
||||
|
||||
## Transformation Operators
|
||||
|
||||
### flatMapLatest - Cancel Previous, Switch to New
|
||||
|
||||
**Use when:** Latest value matters, previous operations should cancel
|
||||
|
||||
```kotlin
|
||||
// User types in search box → cancel previous search
|
||||
searchQuery
|
||||
.flatMapLatest { query ->
|
||||
repository.search(query) // Cancels previous search
|
||||
}
|
||||
.collect { results -> updateUI(results) }
|
||||
```
|
||||
|
||||
**Amethyst pattern:**
|
||||
```kotlin
|
||||
// Switch relays based on latest account
|
||||
accountFlow
|
||||
.flatMapLatest { account ->
|
||||
relayPool.observeEvents(account.relays)
|
||||
}
|
||||
```
|
||||
|
||||
### flatMapConcat - Sequential Processing
|
||||
|
||||
**Use when:** Order matters, process one at a time
|
||||
|
||||
```kotlin
|
||||
eventIds
|
||||
.flatMapConcat { id ->
|
||||
repository.fetchEvent(id)
|
||||
}
|
||||
.collect { event -> process(event) }
|
||||
```
|
||||
|
||||
### flatMapMerge - Concurrent Processing
|
||||
|
||||
**Use when:** Process multiple simultaneously, order doesn't matter
|
||||
|
||||
```kotlin
|
||||
relays
|
||||
.flatMapMerge(concurrency = 10) { relay ->
|
||||
relay.subscribe(filters)
|
||||
}
|
||||
.collect { event -> handleEvent(event) }
|
||||
```
|
||||
|
||||
## Combination Operators
|
||||
|
||||
### combine - Latest from Multiple Flows
|
||||
|
||||
**Use when:** Need latest value from ALL flows
|
||||
|
||||
```kotlin
|
||||
combine(
|
||||
accountFlow,
|
||||
settingsFlow,
|
||||
connectivityFlow
|
||||
) { account, settings, connectivity ->
|
||||
AppState(account, settings, connectivity)
|
||||
}.collect { state -> render(state) }
|
||||
```
|
||||
|
||||
**Pattern:** Re-emits whenever ANY source emits
|
||||
|
||||
### zip - Pair Values in Order
|
||||
|
||||
**Use when:** Need corresponding values from flows
|
||||
|
||||
```kotlin
|
||||
zip(requestFlow, responseFlow) { req, res ->
|
||||
Pair(req, res)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:** Waits for BOTH to emit before pairing
|
||||
|
||||
### merge - Combine Multiple Flows
|
||||
|
||||
**Use when:** Treat multiple flows as single stream
|
||||
|
||||
```kotlin
|
||||
merge(
|
||||
relay1.events,
|
||||
relay2.events,
|
||||
relay3.events
|
||||
).collect { event -> handleEvent(event) }
|
||||
```
|
||||
|
||||
## Backpressure & Buffering
|
||||
|
||||
### shareIn - Hot Flow from Cold
|
||||
|
||||
**Use when:** Multiple collectors should share single upstream
|
||||
|
||||
```kotlin
|
||||
val sharedEvents = repository.observeEvents()
|
||||
.shareIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
replay = 0
|
||||
)
|
||||
|
||||
// Multiple collectors share same upstream
|
||||
sharedEvents.collect { /* collector 1 */ }
|
||||
sharedEvents.collect { /* collector 2 */ }
|
||||
```
|
||||
|
||||
**SharingStarted strategies:**
|
||||
- `Eagerly` - Start immediately, never stop
|
||||
- `Lazily` - Start on first subscriber, never stop
|
||||
- `WhileSubscribed(stopTimeout)` - Stop after last unsubscribe + timeout
|
||||
|
||||
### stateIn - StateFlow from Cold Flow
|
||||
|
||||
**Use when:** Convert Flow to StateFlow (always has value)
|
||||
|
||||
```kotlin
|
||||
val uiState: StateFlow<UiState> = repository.observeData()
|
||||
.map { data -> UiState.Success(data) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = UiState.Loading
|
||||
)
|
||||
```
|
||||
|
||||
**Amethyst pattern:**
|
||||
```kotlin
|
||||
// Connectivity status as StateFlow
|
||||
val connectivity: StateFlow<ConnectivityStatus> =
|
||||
connectivityFlow.status
|
||||
.stateIn(
|
||||
scope = serviceScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = ConnectivityStatus.Off
|
||||
)
|
||||
```
|
||||
|
||||
### buffer - Control Backpressure
|
||||
|
||||
**Use when:** Producer faster than consumer
|
||||
|
||||
```kotlin
|
||||
eventFlow
|
||||
.buffer(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
.collect { event -> slowProcessor(event) }
|
||||
```
|
||||
|
||||
**Strategies:**
|
||||
- `SUSPEND` - Slow down producer (default)
|
||||
- `DROP_OLDEST` - Drop oldest in buffer
|
||||
- `DROP_LATEST` - Drop newest emission
|
||||
|
||||
### conflate - Keep Only Latest
|
||||
|
||||
**Use when:** Only latest value matters, skip intermediate
|
||||
|
||||
```kotlin
|
||||
locationFlow
|
||||
.conflate() // Skip intermediate locations
|
||||
.collect { location -> updateMap(location) }
|
||||
```
|
||||
|
||||
## Debouncing & Throttling
|
||||
|
||||
### debounce - Wait for Quiet Period
|
||||
|
||||
**Use when:** Wait for user to stop typing
|
||||
|
||||
```kotlin
|
||||
searchQuery
|
||||
.debounce(300) // Wait 300ms after last emission
|
||||
.flatMapLatest { query -> search(query) }
|
||||
```
|
||||
|
||||
**Amethyst pattern:**
|
||||
```kotlin
|
||||
// ConnectivityFlow.kt:87
|
||||
connectivityFlow
|
||||
.distinctUntilChanged()
|
||||
.debounce(200) // Wait 200ms for network to stabilize
|
||||
.flowOn(Dispatchers.IO)
|
||||
```
|
||||
|
||||
### sample - Periodic Sampling
|
||||
|
||||
**Use when:** Rate-limit high-frequency emissions
|
||||
|
||||
```kotlin
|
||||
sensorData
|
||||
.sample(1000) // Sample every 1 second
|
||||
.collect { data -> process(data) }
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### catch - Handle Upstream Errors
|
||||
|
||||
**Use when:** Graceful degradation needed
|
||||
|
||||
```kotlin
|
||||
repository.fetchData()
|
||||
.catch { e ->
|
||||
Log.e("Error", e)
|
||||
emit(emptyList()) // Fallback value
|
||||
}
|
||||
.collect { data -> updateUI(data) }
|
||||
```
|
||||
|
||||
**Pattern:** Only catches UPSTREAM errors, not in collect block
|
||||
|
||||
### retry/retryWhen - Automatic Retry
|
||||
|
||||
```kotlin
|
||||
relayConnection
|
||||
.retry(3) { cause ->
|
||||
cause is IOException // Only retry on network errors
|
||||
}
|
||||
```
|
||||
|
||||
## Context Switching
|
||||
|
||||
### flowOn - Change Upstream Dispatcher
|
||||
|
||||
**Use when:** Offload work from current context
|
||||
|
||||
```kotlin
|
||||
repository.fetchData()
|
||||
.map { heavyProcessing(it) }
|
||||
.flowOn(Dispatchers.Default) // Heavy work on Default
|
||||
.collect { updateUI(it) } // Collect on Main
|
||||
```
|
||||
|
||||
**Critical:** Only affects UPSTREAM operators
|
||||
|
||||
**Amethyst pattern:**
|
||||
```kotlin
|
||||
// ConnectivityFlow.kt:87
|
||||
callbackFlow { /* ... */ }
|
||||
.distinctUntilChanged()
|
||||
.debounce(200)
|
||||
.flowOn(Dispatchers.IO) // All upstream on IO
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Multi-Relay Subscription
|
||||
|
||||
```kotlin
|
||||
fun observeFromMultipleRelays(relays: List<Relay>, filters: List<Filter>): Flow<Event> =
|
||||
relays.map { relay ->
|
||||
relay.subscribe(filters)
|
||||
}.merge()
|
||||
.distinctBy { it.id }
|
||||
```
|
||||
|
||||
### Pattern: Load + Cache + Observe
|
||||
|
||||
```kotlin
|
||||
fun observeWithCache(id: String): Flow<Data> = flow {
|
||||
// Emit cached value immediately
|
||||
cache[id]?.let { emit(it) }
|
||||
|
||||
// Then observe updates
|
||||
emitAll(repository.observe(id))
|
||||
}.distinctUntilChanged()
|
||||
```
|
||||
|
||||
### Pattern: Retry with Exponential Backoff
|
||||
|
||||
```kotlin
|
||||
fun <T> Flow<T>.retryWithBackoff(
|
||||
maxRetries: Int = 3,
|
||||
initialDelay: Long = 1000
|
||||
): Flow<T> = retryWhen { cause, attempt ->
|
||||
if (attempt >= maxRetries || cause !is IOException) {
|
||||
false
|
||||
} else {
|
||||
delay(initialDelay * (1L shl attempt.toInt()))
|
||||
true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use shareIn for expensive operations**
|
||||
- Compute once, share with multiple collectors
|
||||
|
||||
2. **Choose right backpressure strategy**
|
||||
- UI updates: `conflate()` or `DROP_OLDEST`
|
||||
- Events: `buffer()` with appropriate size
|
||||
|
||||
3. **flowOn placement matters**
|
||||
- Place after expensive operators to offload them
|
||||
|
||||
4. **Avoid unnecessary emissions**
|
||||
- Use `distinctUntilChanged()` when appropriate
|
||||
- Consider `debounce()` for high-frequency sources
|
||||
|
||||
5. **StateFlow vs SharedFlow**
|
||||
- StateFlow: Always has value, conflates
|
||||
- SharedFlow: Optional replay, configurable buffering
|
||||
@@ -0,0 +1,480 @@
|
||||
# Nostr Relay Async Patterns
|
||||
|
||||
Proven coroutine patterns for Nostr relay connections, subscriptions, and event streaming in Amethyst.
|
||||
|
||||
## Core Pattern: callbackFlow for Relay Subscriptions
|
||||
|
||||
### Pattern: Subscription as Flow
|
||||
|
||||
**Real implementation from NostrClientStaticReqAsStateFlow.kt:**
|
||||
|
||||
```kotlin
|
||||
fun INostrClient.reqAsFlow(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
): Flow<List<Event>> =
|
||||
callbackFlow {
|
||||
val subId = RandomInstance.randomChars(10)
|
||||
var hasBeenLive = false
|
||||
val eventIds = mutableSetOf<HexKey>()
|
||||
var currentEvents = listOf<Event>()
|
||||
|
||||
val listener = object : IRequestListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (event.id !in eventIds) {
|
||||
if (hasBeenLive) {
|
||||
// After EOSE: prepend new events
|
||||
val list = ArrayList<Event>(1 + currentEvents.size)
|
||||
list.add(event)
|
||||
list.addAll(currentEvents)
|
||||
currentEvents = list
|
||||
} else {
|
||||
// Before EOSE: append events
|
||||
currentEvents = currentEvents + event
|
||||
}
|
||||
eventIds.add(event.id)
|
||||
trySend(currentEvents)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
hasBeenLive = true
|
||||
}
|
||||
}
|
||||
|
||||
openReqSubscription(subId, mapOf(relay to filters), listener)
|
||||
|
||||
awaitClose {
|
||||
close(subId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key techniques:**
|
||||
1. **callbackFlow** - Bridge callback API to Flow
|
||||
2. **Deduplication** - `eventIds` set prevents duplicates
|
||||
3. **EOSE handling** - Changes insertion strategy (append → prepend)
|
||||
4. **awaitClose** - Cleanup when flow cancelled
|
||||
5. **trySend** - Non-blocking emission from callback
|
||||
|
||||
## Multi-Relay Patterns
|
||||
|
||||
### Pattern: Merge Events from Multiple Relays
|
||||
|
||||
```kotlin
|
||||
fun observeFromRelays(
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
filters: List<Filter>
|
||||
): Flow<Event> =
|
||||
relays.map { relay ->
|
||||
client.reqAsFlow(relay, filters)
|
||||
.flatMapConcat { it.asFlow() }
|
||||
}.merge()
|
||||
.distinctBy { it.id }
|
||||
```
|
||||
|
||||
**Explanation:**
|
||||
- Each relay produces `Flow<List<Event>>`
|
||||
- `flatMapConcat` flattens to `Flow<Event>`
|
||||
- `merge()` combines all relay flows
|
||||
- `distinctBy` deduplicates across relays
|
||||
|
||||
### Pattern: Concurrent Relay Operations with supervisorScope
|
||||
|
||||
```kotlin
|
||||
suspend fun subscribeToRelays(
|
||||
relays: List<Relay>,
|
||||
filters: List<Filter>
|
||||
) = supervisorScope {
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
relay.subscribe(filters).collect { event ->
|
||||
eventChannel.send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why supervisorScope:**
|
||||
- If one relay fails, others continue
|
||||
- All children cancelled when scope cancelled
|
||||
- Structured concurrency maintained
|
||||
|
||||
## Backpressure Handling
|
||||
|
||||
### Pattern: Buffer with Drop Strategy
|
||||
|
||||
**For high-frequency event streams:**
|
||||
|
||||
```kotlin
|
||||
relayFlow
|
||||
.buffer(
|
||||
capacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
.collect { event -> processEvent(event) }
|
||||
```
|
||||
|
||||
**Strategy selection:**
|
||||
- `DROP_OLDEST` - For real-time feeds (lose old events OK)
|
||||
- `DROP_LATEST` - For priority queues (lose new events OK)
|
||||
- `SUSPEND` - For critical events (slow down producer)
|
||||
|
||||
### Pattern: Conflate for UI Updates
|
||||
|
||||
```kotlin
|
||||
val uiEvents: Flow<UiEvent> = relayEvents
|
||||
.map { event -> toUiEvent(event) }
|
||||
.conflate() // Skip intermediate, show latest
|
||||
.flowOn(Dispatchers.Default)
|
||||
```
|
||||
|
||||
## Connection Management
|
||||
|
||||
### Pattern: Network Connectivity as Flow
|
||||
|
||||
**Real implementation from ConnectivityFlow.kt:**
|
||||
|
||||
```kotlin
|
||||
@OptIn(FlowPreview::class)
|
||||
val status = callbackFlow {
|
||||
trySend(ConnectivityStatus.StartingService)
|
||||
|
||||
val connectivityManager = context.getConnectivityManager()
|
||||
|
||||
val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
connectivityManager.getNetworkCapabilities(network)?.let {
|
||||
trySend(ConnectivityStatus.Active(
|
||||
network.networkHandle,
|
||||
it.isMeteredOrMobileData()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
val isMobile = networkCapabilities.isMeteredOrMobileData()
|
||||
trySend(ConnectivityStatus.Active(
|
||||
network.networkHandle,
|
||||
isMobile
|
||||
))
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(ConnectivityStatus.Off)
|
||||
}
|
||||
}
|
||||
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback)
|
||||
|
||||
// Send initial state
|
||||
connectivityManager.activeNetwork?.let { network ->
|
||||
connectivityManager.getNetworkCapabilities(network)?.let {
|
||||
trySend(ConnectivityStatus.Active(
|
||||
network.networkHandle,
|
||||
it.isMeteredOrMobileData()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
trySend(ConnectivityStatus.Off)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce(200) // Stabilize rapid changes
|
||||
.flowOn(Dispatchers.IO)
|
||||
```
|
||||
|
||||
**Key patterns:**
|
||||
1. **Initial state** - Emit current connectivity immediately
|
||||
2. **Callback registration** - Register listener in flow body
|
||||
3. **Cleanup** - Unregister in `awaitClose`
|
||||
4. **Stabilization** - `debounce(200)` prevents flapping
|
||||
5. **Deduplication** - `distinctUntilChanged()` skips redundant updates
|
||||
|
||||
### Pattern: Reconnect on Connectivity Change
|
||||
|
||||
```kotlin
|
||||
connectivityFlow
|
||||
.flatMapLatest { status ->
|
||||
when (status) {
|
||||
is ConnectivityStatus.Active -> {
|
||||
relayPool.connectAll()
|
||||
relayPool.observeEvents()
|
||||
}
|
||||
else -> emptyFlow()
|
||||
}
|
||||
}
|
||||
.collect { event -> handleEvent(event) }
|
||||
```
|
||||
|
||||
## Exception Handling in Async Operations
|
||||
|
||||
### Pattern: CoroutineExceptionHandler + SupervisorJob
|
||||
|
||||
**Real implementation from PushNotificationReceiverService.kt:**
|
||||
|
||||
```kotlin
|
||||
class PushNotificationReceiverService : FirebaseMessagingService() {
|
||||
// Catch all uncaught exceptions
|
||||
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
|
||||
}
|
||||
|
||||
// Children fail independently, handler catches all
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.IO + SupervisorJob() + exceptionHandler
|
||||
)
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
parseMessage(remoteMessage.data)?.let { receiveIfNew(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this pattern:**
|
||||
- **SupervisorJob** - One failure doesn't cancel others
|
||||
- **ExceptionHandler** - Log exceptions, don't crash
|
||||
- **Scoped lifecycle** - Cancel all on destroy
|
||||
|
||||
### Pattern: Retry with Backoff for Relay Connections
|
||||
|
||||
```kotlin
|
||||
fun connectWithRetry(relay: Relay): Flow<ConnectionStatus> = flow {
|
||||
var attempt = 0
|
||||
val maxRetries = 5
|
||||
val baseDelay = 1000L
|
||||
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
emit(ConnectionStatus.Connecting)
|
||||
relay.connect()
|
||||
emit(ConnectionStatus.Connected)
|
||||
return@flow
|
||||
} catch (e: Exception) {
|
||||
attempt++
|
||||
emit(ConnectionStatus.Error(e, attempt))
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
val delay = baseDelay * (1L shl attempt) // Exponential backoff
|
||||
delay(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
emit(ConnectionStatus.Failed)
|
||||
}
|
||||
```
|
||||
|
||||
## Subscription Lifecycle
|
||||
|
||||
### Pattern: Auto-Cleanup Subscription
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ObserveRelayEvents(
|
||||
filters: List<Filter>,
|
||||
onEvent: (Event) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(filters) {
|
||||
val job = scope.launch {
|
||||
relayClient.reqAsFlow(filters).collect { events ->
|
||||
events.forEach { onEvent(it) }
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
job.cancel() // Cancels flow, triggers awaitClose
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
1. Composable enters → subscribe
|
||||
2. filters change → cancel + re-subscribe
|
||||
3. Composable leaves → cancel + cleanup
|
||||
|
||||
### Pattern: Multiple Concurrent Subscriptions
|
||||
|
||||
```kotlin
|
||||
fun observeMultipleFeeds(
|
||||
account: Account
|
||||
): Flow<Event> = channelFlow {
|
||||
supervisorScope {
|
||||
// Home feed
|
||||
launch {
|
||||
client.reqAsFlow(filters = homeFeedFilters)
|
||||
.collect { events -> events.forEach { send(it) } }
|
||||
}
|
||||
|
||||
// Notifications
|
||||
launch {
|
||||
client.reqAsFlow(filters = notificationFilters)
|
||||
.collect { events -> events.forEach { send(it) } }
|
||||
}
|
||||
|
||||
// DMs
|
||||
launch {
|
||||
client.reqAsFlow(filters = dmFilters)
|
||||
.collect { events -> events.forEach { send(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- All subscriptions run concurrently
|
||||
- One failure doesn't affect others (supervisorScope)
|
||||
- Single output channel for all events
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Pattern: Shared Upstream for Multiple Collectors
|
||||
|
||||
```kotlin
|
||||
class RelayViewModel(private val client: INostrClient) : ViewModel() {
|
||||
val events: SharedFlow<Event> = client
|
||||
.reqAsFlow(relay, filters)
|
||||
.flatMapConcat { it.asFlow() }
|
||||
.shareIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
replay = 0
|
||||
)
|
||||
}
|
||||
|
||||
// Multiple collectors share single relay subscription
|
||||
events.collect { /* UI 1 */ }
|
||||
events.collect { /* UI 2 */ }
|
||||
```
|
||||
|
||||
### Pattern: Event Deduplication Cache
|
||||
|
||||
```kotlin
|
||||
class EventCache {
|
||||
private val seen = mutableSetOf<HexKey>()
|
||||
|
||||
fun filterNew(events: List<Event>): List<Event> =
|
||||
events.filter { event ->
|
||||
if (event.id in seen) {
|
||||
false
|
||||
} else {
|
||||
seen.add(event.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deduplicatedEvents = relayEvents
|
||||
.map { events -> cache.filterNew(events) }
|
||||
.filter { it.isNotEmpty() }
|
||||
```
|
||||
|
||||
## Testing Relay Flows
|
||||
|
||||
### Pattern: Test with Fake Relay
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `subscription receives events`() = runTest {
|
||||
val fakeRelay = FakeRelay()
|
||||
val client = NostrClient(fakeRelay)
|
||||
|
||||
val events = mutableListOf<Event>()
|
||||
val job = launch {
|
||||
client.reqAsFlow(relay, filters).collect { list ->
|
||||
events.addAll(list)
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate relay responses
|
||||
fakeRelay.sendEvent(testEvent1)
|
||||
advanceTimeBy(100)
|
||||
fakeRelay.sendEvent(testEvent2)
|
||||
advanceTimeBy(100)
|
||||
|
||||
assertEquals(2, events.size)
|
||||
job.cancel()
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### ❌ Forgetting awaitClose
|
||||
|
||||
```kotlin
|
||||
// BAD: Subscription never cleaned up
|
||||
callbackFlow {
|
||||
relay.subscribe(listener)
|
||||
// Missing awaitClose!
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// GOOD: Proper cleanup
|
||||
callbackFlow {
|
||||
relay.subscribe(listener)
|
||||
awaitClose {
|
||||
relay.unsubscribe(listener)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Using GlobalScope
|
||||
|
||||
```kotlin
|
||||
// BAD: Unstructured, leaks
|
||||
GlobalScope.launch {
|
||||
relay.connect()
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// GOOD: Scoped to lifecycle
|
||||
viewModelScope.launch {
|
||||
relay.connect()
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Blocking in Flow Operators
|
||||
|
||||
```kotlin
|
||||
// BAD: Blocks collector
|
||||
flow.map { event ->
|
||||
Thread.sleep(1000) // Blocks!
|
||||
process(event)
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// GOOD: Use flowOn to offload
|
||||
flow
|
||||
.map { event ->
|
||||
delay(1000) // Suspends, doesn't block
|
||||
process(event)
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
```
|
||||
@@ -0,0 +1,493 @@
|
||||
# Testing Coroutines
|
||||
|
||||
Comprehensive guide for testing async code with runTest, Turbine, and best practices.
|
||||
|
||||
## runTest - Standard Testing
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test suspend function`() = runTest {
|
||||
val result = repository.fetchData()
|
||||
assertEquals(expected, result)
|
||||
}
|
||||
```
|
||||
|
||||
**What runTest does:**
|
||||
- Skips delays automatically
|
||||
- Provides TestScope
|
||||
- Advances virtual time
|
||||
- Waits for all coroutines to complete
|
||||
|
||||
### Testing StateFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `stateflow updates correctly`() = runTest {
|
||||
val viewModel = MyViewModel()
|
||||
|
||||
// Initial state
|
||||
assertEquals(UiState.Loading, viewModel.state.value)
|
||||
|
||||
// Trigger action
|
||||
viewModel.loadData()
|
||||
advanceUntilIdle() // Run all pending coroutines
|
||||
|
||||
// Verify final state
|
||||
assertEquals(UiState.Success(data), viewModel.state.value)
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with Time Control
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `debounce works correctly`() = runTest {
|
||||
val viewModel = SearchViewModel()
|
||||
|
||||
viewModel.search("a")
|
||||
advanceTimeBy(100) // 100ms passed
|
||||
|
||||
viewModel.search("ab")
|
||||
advanceTimeBy(100)
|
||||
|
||||
viewModel.search("abc")
|
||||
advanceTimeBy(300) // Debounce completes
|
||||
|
||||
// Only "abc" should have triggered search
|
||||
assertEquals(listOf("abc"), viewModel.searchQueries)
|
||||
}
|
||||
```
|
||||
|
||||
**Time control functions:**
|
||||
- `advanceTimeBy(millis)` - Move virtual time forward
|
||||
- `advanceUntilIdle()` - Run all pending work
|
||||
- `runCurrent()` - Run currently scheduled tasks only
|
||||
|
||||
## Turbine - Flow Testing Library
|
||||
|
||||
### Basic Collection Testing
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `flow emits expected values`() = runTest {
|
||||
repository.observeData().test {
|
||||
assertEquals(Item1, awaitItem())
|
||||
assertEquals(Item2, awaitItem())
|
||||
assertEquals(Item3, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Flow Transformations
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `map transforms correctly`() = runTest {
|
||||
val source = flowOf(1, 2, 3)
|
||||
|
||||
source
|
||||
.map { it * 2 }
|
||||
.test {
|
||||
assertEquals(2, awaitItem())
|
||||
assertEquals(4, awaitItem())
|
||||
assertEquals(6, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Relay Subscriptions
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `relay subscription receives events`() = runTest {
|
||||
val fakeClient = FakeNostrClient()
|
||||
|
||||
fakeClient.reqAsFlow(relay, filters).test {
|
||||
// Initially empty
|
||||
assertEquals(emptyList(), awaitItem())
|
||||
|
||||
// Send event
|
||||
fakeClient.sendEvent(event1)
|
||||
assertEquals(listOf(event1), awaitItem())
|
||||
|
||||
// Send another
|
||||
fakeClient.sendEvent(event2)
|
||||
assertEquals(listOf(event1, event2), awaitItem())
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Error Handling
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `catch handles errors gracefully`() = runTest {
|
||||
val errorFlow = flow {
|
||||
emit(1)
|
||||
throw IOException("Network error")
|
||||
}.catch { emit(-1) } // Fallback value
|
||||
|
||||
errorFlow.test {
|
||||
assertEquals(1, awaitItem())
|
||||
assertEquals(-1, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing StateFlow with Turbine
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `stateflow emits updates`() = runTest {
|
||||
val viewModel = MyViewModel()
|
||||
|
||||
viewModel.state.test {
|
||||
// Skip initial value
|
||||
assertEquals(UiState.Loading, awaitItem())
|
||||
|
||||
// Trigger update
|
||||
viewModel.loadData()
|
||||
assertEquals(UiState.Success(data), awaitItem())
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Turbine assertions:**
|
||||
- `awaitItem()` - Get next emission or fail
|
||||
- `awaitComplete()` - Verify flow completed
|
||||
- `awaitError()` - Verify flow threw exception
|
||||
- `expectNoEvents()` - Assert no emissions in timeframe
|
||||
- `cancelAndIgnoreRemainingEvents()` - Stop test
|
||||
|
||||
## Testing Patterns for Amethyst
|
||||
|
||||
### Pattern: Test Relay Connection Flow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `reconnects on connectivity change`() = runTest {
|
||||
val connectivityFlow = MutableStateFlow(ConnectivityStatus.Off)
|
||||
val relayPool = FakeRelayPool()
|
||||
|
||||
connectivityFlow
|
||||
.flatMapLatest { status ->
|
||||
when (status) {
|
||||
is ConnectivityStatus.Active -> relayPool.connectAll()
|
||||
else -> emptyFlow()
|
||||
}
|
||||
}
|
||||
.test {
|
||||
// Initially offline
|
||||
expectNoEvents()
|
||||
|
||||
// Go online
|
||||
connectivityFlow.value = ConnectivityStatus.Active(1L, false)
|
||||
assertTrue(relayPool.connected)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Event Deduplication
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `deduplicates events across relays`() = runTest {
|
||||
val relay1 = FakeRelay()
|
||||
val relay2 = FakeRelay()
|
||||
|
||||
merge(relay1.events, relay2.events)
|
||||
.distinctBy { it.id }
|
||||
.test {
|
||||
// Both relays send same event
|
||||
relay1.send(event1)
|
||||
relay2.send(event1)
|
||||
|
||||
// Only one emission
|
||||
assertEquals(event1, awaitItem())
|
||||
expectNoEvents()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Backpressure Handling
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `drops oldest events when buffer full`() = runTest {
|
||||
val fastProducer = flow {
|
||||
repeat(100) { emit(it) }
|
||||
}
|
||||
|
||||
fastProducer
|
||||
.buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
.test {
|
||||
// Slow consumer
|
||||
delay(100)
|
||||
|
||||
// Should have dropped oldest, kept newest
|
||||
val items = mutableListOf<Int>()
|
||||
repeat(10) {
|
||||
items.add(awaitItem())
|
||||
}
|
||||
|
||||
// Newest items present
|
||||
assertTrue(90 in items)
|
||||
assertTrue(99 in items)
|
||||
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Concurrent Subscriptions
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `multiple subscriptions run concurrently`() = runTest {
|
||||
val client = FakeNostrClient()
|
||||
|
||||
val feed1 = async { client.reqAsFlow(relay1, filters1).first() }
|
||||
val feed2 = async { client.reqAsFlow(relay2, filters2).first() }
|
||||
|
||||
client.sendTo(relay1, event1)
|
||||
client.sendTo(relay2, event2)
|
||||
|
||||
assertEquals(listOf(event1), feed1.await())
|
||||
assertEquals(listOf(event2), feed2.await())
|
||||
}
|
||||
```
|
||||
|
||||
## Fakes and Mocks
|
||||
|
||||
### Fake NostrClient
|
||||
|
||||
```kotlin
|
||||
class FakeNostrClient : INostrClient {
|
||||
private val subscriptions = mutableMapOf<String, MutableSharedFlow<Event>>()
|
||||
|
||||
override fun reqAsFlow(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>
|
||||
): Flow<List<Event>> = callbackFlow {
|
||||
val subId = RandomInstance.randomChars(10)
|
||||
val flow = MutableSharedFlow<Event>()
|
||||
subscriptions[subId] = flow
|
||||
|
||||
val events = mutableListOf<Event>()
|
||||
flow.collect { event ->
|
||||
events.add(event)
|
||||
send(events.toList())
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
subscriptions.remove(subId)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendEvent(event: Event) {
|
||||
subscriptions.values.forEach { it.tryEmit(event) }
|
||||
}
|
||||
|
||||
fun sendTo(relay: NormalizedRelayUrl, event: Event) {
|
||||
subscriptions[relay.url]?.tryEmit(event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fake Relay Pool
|
||||
|
||||
```kotlin
|
||||
class FakeRelayPool {
|
||||
var connected = false
|
||||
private val _events = MutableSharedFlow<Event>()
|
||||
val events: SharedFlow<Event> = _events.asSharedFlow()
|
||||
|
||||
fun connectAll(): Flow<Unit> = flow {
|
||||
connected = true
|
||||
emit(Unit)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
connected = false
|
||||
}
|
||||
|
||||
suspend fun sendEvent(event: Event) {
|
||||
_events.emit(event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Exception Handling
|
||||
|
||||
### Test CoroutineExceptionHandler
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `exception handler catches errors`() = runTest {
|
||||
val errors = mutableListOf<Throwable>()
|
||||
|
||||
val handler = CoroutineExceptionHandler { _, throwable ->
|
||||
errors.add(throwable)
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(
|
||||
Dispatchers.Unconfined + SupervisorJob() + handler
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
throw IOException("Test error")
|
||||
}
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, errors.size)
|
||||
assertTrue(errors[0] is IOException)
|
||||
}
|
||||
```
|
||||
|
||||
### Test Retry Logic
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `retries failed connections`() = runTest {
|
||||
var attempts = 0
|
||||
val maxRetries = 3
|
||||
|
||||
flow {
|
||||
attempts++
|
||||
if (attempts < maxRetries) {
|
||||
throw IOException("Connection failed")
|
||||
}
|
||||
emit("Success")
|
||||
}
|
||||
.retry(maxRetries)
|
||||
.test {
|
||||
assertEquals("Success", awaitItem())
|
||||
awaitComplete()
|
||||
assertEquals(3, attempts)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Testing Patterns
|
||||
|
||||
### Pattern: Verify No Emissions After Cancellation
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `no emissions after cancellation`() = runTest {
|
||||
val flow = flow {
|
||||
emit(1)
|
||||
delay(1000)
|
||||
emit(2) // Should not emit
|
||||
}
|
||||
|
||||
flow.test {
|
||||
assertEquals(1, awaitItem())
|
||||
cancel()
|
||||
|
||||
// Verify no more emissions
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Time-Based Operations
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `periodic emission works`() = runTest {
|
||||
flow {
|
||||
repeat(3) {
|
||||
emit(it)
|
||||
delay(1000)
|
||||
}
|
||||
}.test {
|
||||
assertEquals(0, awaitItem())
|
||||
|
||||
advanceTimeBy(1000)
|
||||
assertEquals(1, awaitItem())
|
||||
|
||||
advanceTimeBy(1000)
|
||||
assertEquals(2, awaitItem())
|
||||
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Hot Flow Conversion
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `shareIn creates hot flow`() = runTest {
|
||||
var emissions = 0
|
||||
val source = flow {
|
||||
repeat(3) {
|
||||
emissions++
|
||||
emit(it)
|
||||
}
|
||||
}
|
||||
|
||||
val shared = source.shareIn(
|
||||
scope = this,
|
||||
started = SharingStarted.Eagerly,
|
||||
replay = 1
|
||||
)
|
||||
|
||||
// First collector
|
||||
shared.take(2).collect()
|
||||
assertEquals(2, emissions) // Emitted 0, 1
|
||||
|
||||
// Second collector - shares upstream
|
||||
shared.take(1).collect()
|
||||
assertEquals(3, emissions) // Only emitted 2, not restarted
|
||||
|
||||
cancel()
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use runTest for all coroutine tests**
|
||||
- Provides virtual time
|
||||
- Automatic cleanup
|
||||
|
||||
2. **Use Turbine for Flow testing**
|
||||
- Clearer assertions
|
||||
- Better error messages
|
||||
|
||||
3. **Test both success and error paths**
|
||||
- Normal flow
|
||||
- Exception handling
|
||||
- Edge cases
|
||||
|
||||
4. **Control virtual time explicitly**
|
||||
- Don't rely on real delays
|
||||
- Use `advanceTimeBy()` and `advanceUntilIdle()`
|
||||
|
||||
5. **Create fakes, not mocks**
|
||||
- Simpler to maintain
|
||||
- More realistic behavior
|
||||
- Easier to debug
|
||||
|
||||
6. **Test cancellation behavior**
|
||||
- Verify cleanup happens
|
||||
- Check no emissions after cancel
|
||||
|
||||
7. **Test concurrent operations**
|
||||
- Use `async` to spawn concurrent work
|
||||
- Verify independence with SupervisorJob
|
||||
Reference in New Issue
Block a user