Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
permissions:
contents: read
packages: read
checks: write # dorny/test-reporter publishes a check run
steps:
- uses: actions/checkout@v7

Expand All @@ -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
Expand Down
23 changes: 5 additions & 18 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Test> { 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<Copy>("deployToMirror") {
description = "Builds the module JAR and copies it into a Speculum install's plugins/ folder."
from(tasks.named("jar"))
into(
Expand Down
9 changes: 9 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> = 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()
}
}
Original file line number Diff line number Diff line change
@@ -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<ExampleModule>(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"])
}
}
20 changes: 20 additions & 0 deletions src/test/kotlin/org/speculum/modules/example/ExampleModuleTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading