diff --git a/.opencode/skills/kotlin-multiplatform/SKILL.md b/.opencode/skills/kotlin-multiplatform/SKILL.md new file mode 100644 index 00000000..2bc2f76b --- /dev/null +++ b/.opencode/skills/kotlin-multiplatform/SKILL.md @@ -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 diff --git a/.opencode/skills/testing/SKILL.md b/.opencode/skills/testing/SKILL.md new file mode 100644 index 00000000..a7b67bf9 --- /dev/null +++ b/.opencode/skills/testing/SKILL.md @@ -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 { + 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> { + 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 diff --git a/.opencode/skills/unit-test-generator/SKILL.md b/.opencode/skills/unit-test-generator/SKILL.md new file mode 100644 index 00000000..6768aaa7 --- /dev/null +++ b/.opencode/skills/unit-test-generator/SKILL.md @@ -0,0 +1,252 @@ +--- +name: unit-test-generator +description: Use ONLY when the user asks to generate, write, or create unit tests. Do NOT use for test infrastructure questions or debugging tests. This skill generates tests for only the files changed between the current branch and main. +--- + +# Unit Test Generator + +## Step 1: Identify changed production files + +Run `git diff main...HEAD --name-only` to find modified files. Filter to only the source directories (not test files, not build files, not config files): + +```bash +git diff main...HEAD --name-only | grep 'src/commonMain\|src/androidMain\|src/iosMain' +``` + +## Step 2: For each changed file, determine the test type + +### File → Test mapping + +| Source file pattern | Test to generate | Fakes needed | +|---|---|---| +| `.../data/*ServiceImpl.kt` or `.../data/*Service.kt` with interface | Service/API layer | Skip — service tests not yet established, focus on repos + use cases + VMs | +| `.../data/*RepositoryImpl.kt` or `.../data/*Repository.kt` with interface | `*RepositoryTest.kt` in `commonTest` | Fake{Service}, Fake{Dao} for each dependency | +| `.../domain/*UseCaseImpl.kt` or `.../domain/*UseCase.kt` with interface | `*UseCaseTest.kt` in `commonTest` | Fake{Repository} | +| `.../presentation/*ViewModel.kt` | `*ViewModelTest.kt` in `commonTest` | Fake{UseCase} for each use case dependency | +| Any other file | Skip — only generate for repo/use case/ViewModel layers | + +### Where to create the test files + +- If the source is in `commonMain` → test goes in `commonTest` with the same package path +- If the source is in `androidMain` → test goes in `androidUnitTest` with the same package path +- If the source is in `iosMain` → no test generated (no iosTest source set exists) + +Check the module's `build.gradle.kts` to confirm the test source set is configured before generating. + +## Step 3: Generate fakes (one per dependency interface) + +### Fake conventions + +Place fakes in a `fake` subpackage under the test root: +``` +src/commonTest/kotlin/com/codandotv/streamplayerapp/{module}/fake/Fake{InterfaceName}.kt +``` + +**Pattern**: implement the interface, track calls with boolean flags, configurable return values. + +```kotlin +package com.codandotv.streamplayerapp.feature.detail.fake + +import com.codandotv.streamplayerapp.feature.detail.domain.DetailStream +import com.codandotv.streamplayerapp.feature.detail.domain.DetailStreamUseCase +import com.codandotv.streamplayerapp.feature.detail.fakeStream +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class FakeDetailStreamUseCase : DetailStreamUseCase { + var getMovieCalled = false + var lastToggledMovie: DetailStream? = null + + override suspend fun getMovie(): Flow { + getMovieCalled = true + return flowOf(fakeStream) + } + + override suspend fun toggleItemInFavorites(movie: DetailStream) { + lastToggledMovie = movie + } +} +``` + +**For suspend functions returning `Flow`**: use `flowOf(...)` with a configurable value. +**For suspend functions returning a value directly**: configurable via constructor or public var. +**For interfaces with `NetworkResponse`**: use `NetworkResponse.Success(...)` as default. + +## Step 4: Generate or update Shared.kt + +If the module doesn't have a `Shared.kt` yet, create one at the package root of the test source set: +``` +src/commonTest/kotlin/com/codandotv/streamplayerapp/{module}/Shared.kt +``` + +Fill it with test fixture data objects used across multiple test files. + +## Step 5: Generate the test class + +### Repository test pattern + +```kotlin +package com.codandotv.streamplayerapp.feature.detail.data + +import com.codandotv.streamplayerapp.core.networking.handleError.NetworkResponse +import com.codandotv.streamplayerapp.feature.detail.domain.toDetailStream +import com.codandotv.streamplayerapp.feature.detail.expectedDetailStream +import com.codandotv.streamplayerapp.feature.detail.fake.FakeDetailStreamService +import com.codandotv.streamplayerapp.feature.detail.fake.FakeFavoriteDao +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +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") + } +} +``` + +### Use case test pattern + +```kotlin +package com.codandotv.streamplayerapp.feature.detail.domain + +import FakeDetailStreamRepository +import com.codandotv.streamplayerapp.feature.detail.fakeStream +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +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) + } +} +``` + +### ViewModel test pattern + +```kotlin +package com.codandotv.streamplayerapp.feature.detail.presentation + +import com.codandotv.streamplayerapp.feature.detail.expectedDetailStreamLoadedUI +import com.codandotv.streamplayerapp.feature.detail.fake.FakeDetailStreamUseCase +import com.codandotv.streamplayerapp.feature.detail.fake.FakeVideoStreamsUseCase +import com.codandotv.streamplayerapp.feature.detail.presentation.screens.DetailStreamViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +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() + } +} +``` + +## Test method guidelines + +- Name tests with backtick strings: `` fun `should do something when condition`() `` +- Use `kotlin.test.Test`, `kotlin.test.BeforeTest`, `kotlin.test.AfterTest` (NOT JUnit annotations) +- Use `kotlin.test.assertEquals`, `kotlin.test.assertTrue`, `kotlin.test.assertFalse` +- Wrap coroutine tests with `runTest { ... }` from `kotlinx.coroutines.test` +- For ViewModel tests: set `Dispatchers.setMain(StandardTestDispatcher())` in `@BeforeTest` and `Dispatchers.resetMain()` in `@AfterTest` +- Use `advanceUntilIdle()` to complete all coroutines in the test dispatcher +- Assert both the output result AND the side-effect tracking booleans on fakes + +## Step 6: Verify + +After generating tests, run: + +```bash +./gradlew :{module}:compileTestKotlinMetadata # for commonTest +# OR +./gradlew :{module}:compileDebugUnitTestKotlin # for androidUnitTest +``` + +If the module uses Koin annotations, also run: +```bash +./gradlew :{module}:kspCommonMainKotlinMetadata +``` diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..2e7466d3 --- /dev/null +++ b/opencode.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "Kotzilla": { + "type": "remote", + "url": "https://mcp.kotzilla.io/mcp", + "oauth": {} + } + }, + "skills": { + "paths": [".opencode/skills"] + } +} \ No newline at end of file