From 8b29ec10e58584f941580241479ad57b60f6d003 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 23 Jun 2026 14:17:25 +0200 Subject: [PATCH] feat: add tests and ci check for it --- .github/workflows/ci.yml | 10 ++++ .github/workflows/codeql.yml | 23 ++------ build.gradle.kts | 19 ++++++- gradle/libs.versions.toml | 9 +++ .../example/ExampleModuleContentTest.kt | 55 +++++++++++++++++++ .../example/ExampleModuleFactoryTest.kt | 32 +++++++++++ .../modules/example/ExampleModuleTest.kt | 20 +++++++ 7 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 src/test/kotlin/org/speculum/modules/example/ExampleModuleContentTest.kt create mode 100644 src/test/kotlin/org/speculum/modules/example/ExampleModuleFactoryTest.kt create mode 100644 src/test/kotlin/org/speculum/modules/example/ExampleModuleTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69e1ff7..be011ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: permissions: contents: read packages: read + checks: write # dorny/test-reporter publishes a check run steps: - uses: actions/checkout@v7 @@ -41,6 +42,15 @@ jobs: GITHUB_ACTOR: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish test report + if: always() + uses: dorny/test-reporter@v2 + with: + name: JUnit Tests + path: "**/build/test-results/test/*.xml" + reporter: java-junit + fail-on-error: false + - name: Upload reports if: always() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c38ebfb..06c5b35 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,32 +21,19 @@ jobs: if: github.actor != 'dependabot[bot]' permissions: contents: read - packages: read security-events: write steps: - uses: actions/checkout@v7 - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "21" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - + # build-mode: none extracts source without compiling. Avoids CodeQL's + # Kotlin extractor, which only supports Kotlin < 2.3.30 (this project is + # on 2.4.0) and otherwise fails compileKotlin with KotlinVersionTooRecent. + # No build means no mirror-api resolution, so packages: read is unneeded. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: java-kotlin - build-mode: manual - - - name: Build - run: ./gradlew build -x test --stacktrace - env: - # mirror-api resolves from GitHub Packages (see settings.gradle.kts). - GITHUB_ACTOR: ${{ github.actor }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-mode: none - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 diff --git a/build.gradle.kts b/build.gradle.kts index 7095b3e..4a67f4d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,13 +32,30 @@ dependencies { compileOnly(libs.compose.foundation) compileOnly(libs.compose.material3) compileOnly(libs.kotlinx.coroutines.core) + + // Tests need the real artifacts on the classpath (main uses compileOnly + // because the host app supplies them at runtime). + testImplementation(libs.kotlin.test) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mirror.api) + testImplementation(libs.compose.runtime) + testImplementation(libs.compose.foundation) + testImplementation(libs.compose.material3) + testImplementation(libs.kotlinx.coroutines.core) + // Compose desktop UI testing (runComposeUiTest + Skiko rendering runtime). + testImplementation(libs.compose.ui.test.junit4) + testImplementation(compose.desktop.currentOs) } +tasks.withType { useJUnitPlatform() } + // Copy the built JAR into a Speculum install's `plugins/` folder so the app // discovers it at startup. Point it at your checkout with // ./gradlew deployToMirror -Pspeculum.pluginsDir=/path/to/Speculum/plugins // or the SPECULUM_PLUGINS_DIR env var. Defaults to build/plugins/ otherwise. -val deployToMirror by tasks.registering(Copy::class) { +tasks.register("deployToMirror") { description = "Builds the module JAR and copies it into a Speculum install's plugins/ folder." from(tasks.named("jar")) into( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31cc93a..9acfdff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,8 @@ mirrorApi = "1.0.0" # Quality gates. ktlint = "14.2.0" # gradle plugin org.jlleitschuh.gradle.ktlint detekt = "1.23.8" +# Testing. +junit = "6.1.0" [libraries] # Compose artifacts declared directly (the `compose.runtime`/`material3`/… DSL @@ -18,6 +20,13 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } # Speculum plugin API: MirrorModule, ModuleFactory, ModuleConfig, MirrorColors, … mirror-api = { module = "org.speculum:mirror-api", version.ref = "mirrorApi" } +# Test-only. +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "6.1.0" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +# Compose desktop UI test (pulls ui-test + Skiko desktop test runtime). +compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "compose" } [plugins] composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" } diff --git a/src/test/kotlin/org/speculum/modules/example/ExampleModuleContentTest.kt b/src/test/kotlin/org/speculum/modules/example/ExampleModuleContentTest.kt new file mode 100644 index 0000000..2d13629 --- /dev/null +++ b/src/test/kotlin/org/speculum/modules/example/ExampleModuleContentTest.kt @@ -0,0 +1,55 @@ +package org.speculum.modules.example + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runComposeUiTest +import org.speculum.config.ModuleConfig +import org.speculum.core.Notification +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class ExampleModuleContentTest { + private fun module(config: Map = emptyMap()): ExampleModule = + ExampleModule(ModuleConfig(module = "example", config = config)) + + @Test + fun `renders the default greeting`() = + runComposeUiTest { + setContent { module().Content() } + onNodeWithText("Hello, Speculum!").assertIsDisplayed() + } + + @Test + fun `renders a configured greeting`() = + runComposeUiTest { + setContent { module(mapOf("greeting" to "Hi there")).Content() } + onNodeWithText("Hi there").assertIsDisplayed() + } + + @Test + fun `refresh increments the tick counter by tickStep`() = + runComposeUiTest { + val mod = module(mapOf("tickStep" to "2")) + setContent { mod.Content() } + onNodeWithText("Refreshes: 0").assertIsDisplayed() + + mod.refresh() + waitForIdle() + + onNodeWithText("Refreshes: 2").assertIsDisplayed() + } + + @Test + fun `onNotification updates the last event line`() = + runComposeUiTest { + val mod = module() + setContent { mod.Content() } + onNodeWithText("Last notification: (none)").assertIsDisplayed() + + mod.onNotification(Notification("evt")) + waitForIdle() + + onNodeWithText("Last notification: evt").assertIsDisplayed() + } +} diff --git a/src/test/kotlin/org/speculum/modules/example/ExampleModuleFactoryTest.kt b/src/test/kotlin/org/speculum/modules/example/ExampleModuleFactoryTest.kt new file mode 100644 index 0000000..6ac64af --- /dev/null +++ b/src/test/kotlin/org/speculum/modules/example/ExampleModuleFactoryTest.kt @@ -0,0 +1,32 @@ +package org.speculum.modules.example + +import org.speculum.config.ModuleConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ExampleModuleFactoryTest { + private val factory = ExampleModuleFactory() + + @Test + fun `name is example`() { + assertEquals("example", factory.name) + } + + @Test + fun `create returns an ExampleModule`() { + val module = factory.create(ModuleConfig(module = "example")) + assertIs(module) + } + + @Test + fun `defaultConfig exposes the documented defaults`() { + val config = factory.defaultConfig() + + assertEquals("example", config.module) + assertEquals("top_center", config.position) + assertEquals(3000L, config.refreshIntervalMs) + assertEquals("Loaded from JAR!", config.config["greeting"]) + assertEquals("2", config.config["tickStep"]) + } +} diff --git a/src/test/kotlin/org/speculum/modules/example/ExampleModuleTest.kt b/src/test/kotlin/org/speculum/modules/example/ExampleModuleTest.kt new file mode 100644 index 0000000..b742db4 --- /dev/null +++ b/src/test/kotlin/org/speculum/modules/example/ExampleModuleTest.kt @@ -0,0 +1,20 @@ +package org.speculum.modules.example + +import org.speculum.config.ModuleConfig +import kotlin.test.Test +import kotlin.test.assertEquals + +class ExampleModuleTest { + private fun moduleWithInterval(intervalMs: Long): ExampleModule = + ExampleModule(ModuleConfig(module = "example", refreshIntervalMs = intervalMs)) + + @Test + fun `refreshIntervalMs is coerced up to the 1000ms floor`() { + assertEquals(1000L, moduleWithInterval(500L).refreshIntervalMs) + } + + @Test + fun `refreshIntervalMs above the floor passes through unchanged`() { + assertEquals(5000L, moduleWithInterval(5000L).refreshIntervalMs) + } +}