Keep tests out of the user profile; small socket fixes (#127)

* Keep tests out of the user profile

The no-argument ChatPreferences constructor seeds preferences.xml in
~/.praktiKST when it is missing, so 21 older test files could create a
profile file for whoever ran the build. They now use ChatPreferences(Path)
with a @TempDir. None of them reads the file: their assertions are on the
field initialisers, which both constructors share, so no assertion changes
meaning. ChatPreferencesStationMapVisibilityTest passes its file to the
constructor instead of redirecting it with the setter afterwards.

The two opt-in ON4KST practice tests keep the no-argument constructor: they
deliberately read the operator's real profile for live credentials.

As a net, the test tasks run with user.home pointed at build/test-user-home,
emptied before each run, and fail if a preferences.xml appears there. This
also keeps the sound files, the map tile cache and the Skiko native library
that tests unpacked into the real home directory out of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make DXClusterThreadPooledServer.serverSocket volatile

run() assigns the server socket on the server thread, and stop() closes it
from another thread. closeServerSocket() is synchronized, but the write in
run() is not, so nothing guaranteed that stop() saw the socket run() had
opened; it could find null and leave the socket listening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the ineffective SO_REUSEADDR from the Win-Test sked socket

WinTestSkedSender.sendUdp() set reuse address on a socket the constructor
had already bound, where it has no effect, so it was never in effect in any
shipped version. The socket binds an ephemeral port and only sends, which
gains nothing from it, and enabling it for real would change UDP multi-bind
semantics on Windows. Removing the call keeps the behaviour exactly as
shipped and stops the code from claiming an option it does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check the test user.home in a finalizer task

The stray-preferences check ran as a doLast of the test task, which Gradle
skips once a test fails, so a failing run could hide the file. It is now a
separate task, checkTestUserHome, that every test task is finalized by;
finalizers run when the test task fails too. It captures only a File, so it
stays configuration-cache safe.

Each test task now gets its own scratch home, build/test-user-home/<task
name>, so a second test task cannot wipe another's. Emptying it checks the
result of deleteRecursively(), which reports failure instead of throwing,
and stops the task, so a leftover file is never blamed on the current run.

The check stays limited to preferences.xml. Other files land in the scratch
.praktiKST by design, such as the sound files PlayAudioUtils seeds from the
bundled resources; absorbing those is the redirection's job, and a wider
check would need an exclusion list that grows with every seeded file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep generateStrings configuration-cache safe

The argument provider of generateStrings referenced the script-level
generatedI18nDirectory, which captures the build script object, so storing
the configuration cache failed for every build that included app-desktop.
The task now works on a local copy of the provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Declare the test user.home as a test task output

Test tasks are cacheable, and a cache hit skips doFirst, so the scratch home
was neither emptied nor rewritten and checkTestUserHome judged whatever an
earlier run had left there: a false alarm after a violating run, or a hidden
violation when a passing result for violating inputs came from the cache.
The scratch home is now a declared output, restored and replaced together
with the cached result, so the check always sees the files belonging to the
inputs. The doFirst wipe stays for real executions.

Skiko unpacked its native library, about 30 MB, into the scratch home, which
would now land in every app-desktop cache entry. skiko.data.path points it
at build/skiko-data instead, outside the output and kept between runs.

user.home and skiko.data.path are passed through an argument provider: as
system properties they would be task inputs, and their absolute paths would
make the cache key differ between checkouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 10:48:33 +02:00
1 parent 2898c530d9
commit 81b16ad4c5
25 files changed
+214 -84

No files matched your search

@@ -20,6 +20,8 @@ import kst4contest.view.compose.tabs.MessageHandlingTabState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The four ways the drawing layer went wrong while the build was green.
@@ -41,7 +43,7 @@ import org.junit.jupiter.api.Test
* these were written, because the spec left it open and a net that only works on a
* developer's desk is worth saying so about.
*/
class DrawingLayerRegressionTest {
class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() }
@@ -52,7 +54,7 @@ class DrawingLayerRegressionTest {
@OptIn(ExperimentalTestApi::class)
@Test
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerTextMainCat("")
val state = MessageHandlingTabState(prefs)
@@ -6,6 +6,8 @@ import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies
@@ -13,11 +15,11 @@ import org.junit.jupiter.api.Test
* above the send line follow the settings window — the reason ShortcutsTabState has been
* calling refreshShortcutButtons into thin air since Etappe 3b.
*/
class MainWindowStateRosterTest {
class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
@Test
fun `the shortcuts start out as what the profile holds`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -31,7 +33,7 @@ class MainWindowStateRosterTest {
*/
@Test
fun `a roster change reaches the mirror when it is refreshed`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -44,7 +46,7 @@ class MainWindowStateRosterTest {
@Test
fun `an emptied roster empties the mirror`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -60,7 +62,7 @@ class MainWindowStateRosterTest {
*/
@Test
fun `drawing the buttons subscribes to the mirror`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -79,9 +81,9 @@ class MainWindowStateRosterTest {
* A mismatch here is silent and total: SplitterState falls back to an even split, so
* every operator would lose the layout they arranged without any error saying so.
*/
class MainWindowSplitterDefaultsTest {
class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
private val prefs = ChatPreferences()
private val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
@Test
fun `the message column has as many dividers as the profile stores positions`() {
@@ -5,6 +5,8 @@ import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* ChatPreferences hands out MutableValue, which notifies its own listeners and nothing
@@ -15,11 +17,11 @@ import org.junit.jupiter.api.Test
* The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is
* the same idea, ending in Compose state instead of a TextField.
*/
class ObservedValueTest {
class ObservedValueTest(@TempDir private val tempDir: Path) {
@Test
fun `the mirror starts at the stored value`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -30,7 +32,7 @@ class ObservedValueTest {
/** The rotator moving must reach the screen without anything asking it to. */
@Test
fun `a change to the underlying value reaches the mirror by itself`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getActualQTF().set(90.0)
val observed = ObservedValue(prefs.getActualQTF())
@@ -41,7 +43,7 @@ class ObservedValueTest {
@Test
fun `a null value is carried through rather than turned into a default`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val observed = ObservedValue(prefs.getMYQRGSecondCat())
prefs.getMYQRGSecondCat().set(null)
@@ -55,7 +57,7 @@ class ObservedValueTest {
*/
@Test
fun `drawing from the mirror subscribes to it`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -73,7 +75,7 @@ class ObservedValueTest {
*/
@Test
fun `releasing the mirror stops it following`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -85,7 +87,7 @@ class ObservedValueTest {
@Test
fun `releasing twice is harmless`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val observed = ObservedValue(prefs.getMYQRGFirstCat())
observed.release()
observed.release()
@@ -7,6 +7,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The factory binds the store to one profile's preferences and one profile's stylesheet.
@@ -15,7 +17,7 @@ import java.io.File
* another's colours. The stylesheet path is injected here rather than derived inside, so the
* test does not have to write into the operator's home directory to check which file is read.
*/
class PaletteStoreFactoryTest {
class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
private val daylight = OperatorProfilePaletteFiles(
dayFile = File("/nonexistent/day.css"),
@@ -24,7 +26,7 @@ class PaletteStoreFactoryTest {
@Test
fun aChangeLandsInTheDesignsOwnPreference() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000")
@@ -35,7 +37,7 @@ class PaletteStoreFactoryTest {
@Test
fun anEveningChangeLandsInTheEveningPreference() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000")
@@ -47,7 +49,7 @@ class PaletteStoreFactoryTest {
@Test
fun whatIsAlreadyStoredIsInForceFromTheStart() {
// The store is built at startup; a colour saved last session has to be there at once.
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.guiOptions_paletteOverridesDay = "accent=#FF0000"
val store = PaletteStoreFactory.create(prefs, daylight)
@@ -57,7 +59,7 @@ class PaletteStoreFactoryTest {
@Test
fun aMissingStylesheetLeavesEveryRoleWithTheShippedSheet() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val store = PaletteStoreFactory.create(prefs, daylight)
@@ -73,7 +75,7 @@ class PaletteStoreFactoryTest {
file.writeText(".root { -fx-accent: #123456; }")
val store = PaletteStoreFactory.create(
ChatPreferences(),
ChatPreferences(tempDir.resolve("preferences.xml")),
OperatorProfilePaletteFiles(dayFile = file, eveningFile = file),
)
@@ -5,11 +5,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class AirscoutTabStateTest {
class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state by lazy { AirscoutTabState(prefs) }
}
@@ -8,11 +8,13 @@ import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class BeaconTabStateTest {
class BeaconTabStateTest(@TempDir private val tempDir: Path) {
private class Session(private val templateRefusal: String? = null) {
val prefs = ChatPreferences()
private inner class Session(private val templateRefusal: String? = null) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var timerRestarts = 0
var validated = mutableListOf<String>()
@@ -9,8 +9,10 @@ import java.util.Locale
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class GuiOptionsTabStateTest {
class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
/*
* Over lambdas rather than over the preferences, so switching the language in a test does
@@ -33,7 +35,7 @@ class GuiOptionsTabStateTest {
* that gets saved, and it has to do so through the store, which is what repaints the
* open windows. A store wired to anything else would pass a weaker test.
*/
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK))
state.language = "de"
@@ -45,7 +47,7 @@ class GuiOptionsTabStateTest {
@Test
fun `reading comes straight from the preferences`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setGUI_darkModeActiveByDefault(true)
assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault)
@@ -53,7 +55,7 @@ class GuiOptionsTabStateTest {
@Test
fun `writing reaches the preferences at once`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
@@ -68,7 +70,7 @@ class GuiOptionsTabStateTest {
// The JavaFX tab offered them as four separate checkboxes, not a choice of
// one. Turning them into a radio group would take a combination away that
// an operator may have configured.
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
@@ -5,11 +5,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class LogSynchTabStateTest {
class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
private class Session(detected: String? = null) {
val prefs = ChatPreferences()
private inner class Session(detected: String? = null) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var restarts = 0
var stops = 0
val detectedBroadcast = detected
@@ -5,12 +5,14 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class MessageHandlingTabStateTest {
class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
@Test
fun `the state starts from the stored preferences`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerEnabled(true)
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
@@ -26,7 +28,7 @@ class MessageHandlingTabStateTest {
fun `one checkbox drives both category flags`() {
// The JavaFX window offered a single checkbox and wrote both preferences from
// it; keeping only the main one would leave the second chat on its old value.
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
state.autoAnswerEnabled = true
@@ -42,7 +44,7 @@ class MessageHandlingTabStateTest {
@Test
fun `one text field drives both category texts`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
state.autoAnswerText = "qrv 432.200"
@@ -53,7 +55,7 @@ class MessageHandlingTabStateTest {
@Test
fun `the QRG reply flag writes through on its own`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
state.autoAnswerToQRGRequestEnabled = true
@@ -11,11 +11,13 @@ import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class NotificationTabStateTest {
class NotificationTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val monitored = SimpleRoster<String>()
var serverEnabledApplied: Boolean? = null
var portApplied: Int? = null
@@ -5,11 +5,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class ShortcutsTabStateTest {
class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var shortcutRefreshes = 0
var snippetRefreshes = 0
@@ -6,11 +6,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class StationTabStateTest {
class StationTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var mainApplied: ChatCategory? = null
var secondApplied: ChatCategory? = null
var secondWasApplied = false
@@ -87,7 +89,7 @@ class StationTabStateTest {
fun `the second chat switch lives with the login settings`() {
// It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab
// next to the category pickers, and it decides what the Connect button does.
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = StationTabState(prefs, { }, { })
state.loginToSecondChatEnabled = true
@@ -5,11 +5,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class TrxSynchTabStateTest {
class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val followerCalls = mutableListOf<Boolean>()
val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } }
}
@@ -5,6 +5,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.lang.reflect.Modifier
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* Nothing that goes to the server or to another radio amateur has got into the texts.
@@ -17,7 +19,7 @@ import java.lang.reflect.Modifier
* deliberately not: a partial file is the normal state of a contribution in progress, and a
* check over every language would turn the arrival of one into a red build.
*/
class TranslationBoundaryTest {
class TranslationBoundaryTest(@TempDir private val tempDir: Path) {
private val everyText: List<String> =
Translations.BY_LANGUAGE.values.flatMap { it.values }
@@ -63,7 +65,7 @@ class TranslationBoundaryTest {
@Test
fun noTextIsOneOfThePreferenceDefaultsThatGoToOtherRadioAmateurs() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val defaults = ChatPreferences::class.java.declaredFields
.filter { it.type == String::class.java && !Modifier.isStatic(it.modifiers) }