Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .opencode/skills/kotlin-multiplatform/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
name: kotlin-multiplatform
description: Use when working on Kotlin Multiplatform code — module structure, source set layout, build configuration, convention plugins, Koin DI, and project architecture conventions for this KMP project.
---

# Kotlin Multiplatform Conventions

## Project Structure

This is a **KMP** project targeting **Android + iOS** (no desktop, no JS). Root project name: `StreamPlayerApp-KMP`.

### Module Naming

| Prefix | Purpose | Examples |
|---|---|---|
| `:composeApp` | Umbrella app module, assembles all features and core modules | `composeApp/` |
| `:androidApp` | Android-specific entry point (Application, MainActivity) | `androidApp/` |
| `:core-*` | Shared infrastructure (networking, storage, UI, navigation, permissions, etc.) | `core-shared`, `core-networking`, `core-shared-ui` |
| `:feature-*` | Feature modules (screen-level functionality, isolated from each other) | `feature-detail`, `feature-list-streams`, `feature-search` |

### Source Set Layout

Every KMP module applies `com.streamplayer.kmp-library` and configures:

```
src/
commonMain/ # Shared code (business logic, models, interfaces)
androidMain/ # Android-specific expect/actual implementations
iosMain/ # iOS-specific expect/actual implementations
commonTest/ # Multiplatform tests (kotlin.test)
androidUnitTest/ # Android-only tests (JUnit4, MockK)
```

Package root: `com.codandotv.streamplayerapp`

## Build System

### Convention Plugins (`build-logic/src/main/java/`)

| Plugin ID | Purpose |
|---|---|
| `com.streamplayer.kmp-library` | Base for all KMP modules: applies `kotlin.multiplatform`, `android.kotlin.multiplatform.library`, `serialization`, `parcelize`, `dokka`, `detekt`. Configures Android namespace, compileSdk/minSdk, iOS targets. |
| `com.streamplayer.koin-annotations-setup` | Koin annotations + KSP compiler config with `KOIN_CONFIG_CHECK=true` |
| `com.streamplayer.detekt` | Static analysis via Detekt (`config/detekt/detekt.yml`) |
| `com.streamplayer.dokka` | Dokka documentation generation |
| `popcorngp-setup-plugin` | Enforces: core modules don't depend on other core modules; feature modules don't depend on other feature modules |

### Version Catalog (`gradle/libs.versions.toml`)

Key versions:
- Kotlin: `2.3.20`
- AGP: `9.1.0`
- Koin: `4.2.0` / Koin Annotations: `2.3.1` / KSP: `2.3.5`
- Compose Multiplatform: `1.10.0` / Material3: `1.9.0`
- Ktor: `3.0.1`
- Room: `2.7.0-alpha13`
- Kotzilla SDK: `2.1.3`

### Build Config (`build-logic/src/main/java/Config.kt`)

Central config with applicationId, SDK versions, JVM target, API URLs, TMDB bearer token (resolved from environment variable).

### iOS Framework

Exported as a **static framework** with `baseName = "streamplayerapp"`. Three architectures: `iosX64`, `iosArm64`, `iosSimulatorArm64`. Configured via `iosTarget()` extension in `build-logic/src/main/java/extensions/KotlinMultiPlatformExt.kt`.

## Dependency Injection (Koin)

### Two complementary styles

**Style A — Traditional DSL (`module {}`)**
Used by most modules. Example (`feature-list-streams`):
```kotlin
val ListStreamModule = module {
viewModel { ListStreamViewModel(get(), get()) }
factory { GetStreamsUseCaseImpl(get()) as GetStreamsUseCase }
single { ListStreamRepositoryImpl(get()) as ListStreamRepository }
}
```

**Style B — Koin Annotations (`@Module`, `@Single`, `@ComponentScan`) + KSP**
Used by `core-networking`, `feature-news`, `feature-search`, `feature-profile`. Generates `.module` extension via KSP.

### Central Assembly

In `composeApp/src/commonMain/kotlin/.../di/AppModule.kt`:
```kotlin
fun streamPlayerApplication(platformBlock: KoinApplication.() -> Unit): KoinApplication {
return startKoin {
platformBlock()
lazyModules(PermissionsModule.module)
modules(
module { single(QualifierDispatcherIO) { Dispatchers.IO } },
NetworkModule().module,
LocalStorageModule.module,
SyncModule.module,
ListStreamModule.module,
SearchModule().module,
NewsScreenModule().module,
ProfilePickerStreamModule().module
)
monitoring { onConfig { useIosCrashReport = false } }
}
}
```

### Koin Configuration Check

The `com.streamplayer.koin-annotations-setup` plugin enables `KOIN_CONFIG_CHECK=true` by default (set via KSP options). This validates all Koin definitions at compile time.

## Key Libraries

- **Networking**: Ktor with platform engines (OkHttp Android, Darwin iOS)
- **Local DB**: Room with KSP and bundled SQLite
- **Image loading**: Coil 3 (multiplatform)
- **Navigation**: JetBrains Navigation Compose (multiplatform)
- **Secrets**: BuildKonfig plugin for compile-time build config fields
- **Monitoring**: Kotzilla SDK
- **Notifications**: KMPNotifier
- **Permissions**: moko-permissions

## Code Quality

- **Detekt**: configured at `config/detekt/detekt.yml`
- **Dokka**: applied to all modules via convention plugin
- **Popcorn Guinea Pig**: enforces module dependency rules
221 changes: 221 additions & 0 deletions .opencode/skills/testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
---
name: testing
description: Use when writing, modifying, or debugging tests — commonTest vs androidUnitTest, test dependencies, naming conventions, fake patterns, and test infrastructure for this KMP project.
---

# Testing Conventions

## Test Source Sets

| Source Set | Framework | Where to Use |
|---|---|---|
| `commonTest` | `kotlin.test` (`Test`, `BeforeTest`, `AfterTest`, `assertEquals`, `assertTrue`) | Shared business logic (use cases, repositories, view models, domain models) |
| `androidUnitTest` | JUnit4 + MockK + AndroidX Arch + Koin Test | Android-specific code (platform implementations, Android ViewModels with platform deps) |

## Test Dependencies (from `gradle/libs.versions.toml`)

| Bundle | Contents |
|---|---|
| `test_multiplatform` | `kotlin_test`, `kotlin_test_common`, `coroutines_test` |
| `test` | `junit`, `mockk`, `mockk_android`, `viewmodel_test` (`androidx.arch.core:core-testing`), `koin_test` (`koin-test-junit4`), `coroutines_test` |

To add tests to a module, include the appropriate bundle in its `build.gradle.kts`:
```kotlin
// commonTest
commonTest.dependencies {
implementation(libs.bundles.test_multiplatform)
}

// androidUnitTest
androidUnitTest.dependencies {
implementation(libs.bundles.test)
}
```

## Existing Test Modules

| Module | Test Source Set | Files |
|---|---|---|
| `feature-detail` | `commonTest` | `DetailStreamViewModelTest.kt`, `DetailStreamUseCaseTest.kt`, `DetailStreamRepositoryTest.kt`, 5 fakes, `Shared.kt` |
| `feature-news` | `androidUnitTest` | No test files yet — bundle declared, source set ready |

## Test Patterns (from `feature-detail/commonTest`)

### 1. Hand-written Fakes (no mocking frameworks)

Fakes implement the production interface and track method calls with boolean flags:

```kotlin
class FakeDetailStreamRepository(
private val movie: DetailStream
) : DetailStreamRepository {

var getMovieCalled = false
var deleteCalledWith: String? = null
var insertCalledWith: DetailStream? = null

override suspend fun getMovie(): Flow<DetailStream> {
getMovieCalled = true
return flowOf(movie)
}

override suspend fun deleteFromMyList(movie: String) {
deleteCalledWith = movie
}

override suspend fun insertToMyList(movie: DetailStream) {
insertCalledWith = movie
}

override suspend fun isFavorite(movieId: String): Boolean {
isFavoriteCalledWith = movieId
return false
}

override suspend fun getVideoStreams(): Flow<List<VideoStream>> {
return flowOf(emptyList())
}
}
```

**Fake conventions**:
- Package: `com.codandotv.streamplayerapp.{module}.fake`
- Name: `Fake{InterfaceName}`
- Tracks calls: `var {method}Called = false` (set to `true` when method is invoked)
- Tracks arguments: `var {param}CalledWith: Type? = null`
- Configurable return values via constructor parameters or public mutable properties
- Default return values are sensible defaults (empty lists, false, etc.)

### 2. Shared Test Data (`Shared.kt`)

```kotlin
package com.codandotv.streamplayerapp.feature.detail

val fakeStream = DetailStream(
id = "1",
title = "Fake Movie",
overview = "Overview of the fake movie",
tagline = "The ultimate test movie",
url = "https://example.com/fake.jpg",
releaseYear = "2025",
isFavorite = false
)
```

### 3. Repository Test Pattern

```kotlin
class DetailStreamRepositoryTest {
private lateinit var repository: DetailStreamRepository
private val movieId = "123"
private lateinit var service: FakeDetailStreamService
private lateinit var favoriteDao: FakeFavoriteDao

@BeforeTest
fun setUp() {
service = FakeDetailStreamService()
favoriteDao = FakeFavoriteDao()
repository = DetailStreamRepositoryImpl(
movieId = movieId,
service = service,
favoriteDao = favoriteDao
)
}

@Test
fun `getMovie should load the movie when passed a movieId`() = runTest {
service.movieResponse = NetworkResponse.Success(expectedDetailStream)
var collected = false
repository.getMovie().collect { result ->
collected = true
assertEquals(expectedDetailStream.toDetailStream(), result)
}
assertTrue(collected, "Expected flow to emit a value")
assertTrue(service.getMovieCalled, "Service should have been called")
assertTrue(favoriteDao.fetchAllCalled, "FavoriteDao should have been called")
}
}
```

### 4. Use Case Test Pattern

```kotlin
class DetailStreamUseCaseTest {
private lateinit var detailStreamUseCase: DetailStreamUseCase
private lateinit var detailStreamRepository: FakeDetailStreamRepository

@BeforeTest
fun setUp() {
detailStreamRepository = FakeDetailStreamRepository(movie = fakeStream)
detailStreamUseCase = DetailStreamUseCaseImpl(
detailStreamRepository = detailStreamRepository
)
}

@Test
fun `load movies`() = runTest {
var collected: DetailStream? = null
detailStreamUseCase.getMovie().collect {
collected = it
}
assertEquals(fakeStream, collected)
assertTrue(detailStreamRepository.getMovieCalled)
}
}
```

### 5. ViewModel Test Pattern

```kotlin
class DetailStreamViewModelTest {

private lateinit var detailStreamViewModel: DetailStreamViewModel
private lateinit var detailUseCase: FakeDetailStreamUseCase
private lateinit var videoUseCase: FakeVideoStreamsUseCase

@OptIn(ExperimentalCoroutinesApi::class)
@BeforeTest
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
detailUseCase = FakeDetailStreamUseCase()
videoUseCase = FakeVideoStreamsUseCase()
detailStreamViewModel = DetailStreamViewModel(
detailStreamUseCase = detailUseCase,
videoStreamsUseCase = videoUseCase,
dispatcher = StandardTestDispatcher()
)
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `should load the movies with videoId`() = runTest {
detailStreamViewModel.loadDetail()
advanceUntilIdle()
assertEquals(expectedDetailStreamLoadedUI, detailStreamViewModel.uiState.value)
assertEquals(true, detailUseCase.getMovieCalled)
assertEquals(true, videoUseCase.getVideoStreamsCalled)
}

@OptIn(ExperimentalCoroutinesApi::class)
@AfterTest
fun after() {
Dispatchers.resetMain()
}
}
```

**ViewModel conventions**:
- Injects dependencies via constructor (not Koin — tests create view models directly)
- Sets `Dispatchers.setMain(StandardTestDispatcher())` in `@BeforeTest`
- Resets `Dispatchers.resetMain()` in `@AfterTest`
- Uses `advanceUntilIdle()` for coroutine completion
- Asserts UI state (`viewModel.uiState.value`) and side-effect flags

## When writing new tests

- Always use `kotlin.test.*` annotations in `commonTest` (not JUnit)
- Add fakes in the `fake` subpackage
- Add test data fixtures in a `Shared.kt` file at the package root
- Name test methods with backticks: `` fun `should do something`() ``
- Use `runTest` from `kotlinx.coroutines.test` for all coroutine-based code
- Assert both output values and side-effect tracking booleans
Loading
Loading