Skip to content

Commit d35636b

Browse files
committed
fix: ignore static fields and add Kotlin interop tests
Static fields (other than serialVersionUID, which had a name-based filter) previously leaked into config declarations because no general isStatic() filter existed. Most visibly this broke Kotlin compatibility: companion objects emit a synthetic public static final Companion field on the enclosing class, causing save() to crash with "cannot simplify type ...$Companion". Adds the missing Modifier.isStatic() check in FieldDeclaration.of and removes the now-redundant serialVersionUID name check (since it is always static). Adds a Java regression test (StaticFieldFilterTest) covering static final, static mutable, serialVersionUID, and all-static-fields cases. Adds a new core-test-kotlin module with broader Kotlin interop coverage: companion objects (including named, empty, const val, @JvmStatic), plain Kotlin classes, var/val properties, nullable types, and default value handling. Supersedes #54 (which lacked tests).
1 parent eaa3bbb commit d35636b

9 files changed

Lines changed: 754 additions & 1 deletion

File tree

core-test-kotlin/pom.xml

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xmlns="http://maven.apache.org/POM/4.0.0"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<parent>
8+
<groupId>eu.okaeri</groupId>
9+
<artifactId>okaeri-configs</artifactId>
10+
<version>6.1.0-beta.1</version>
11+
</parent>
12+
13+
<artifactId>okaeri-configs-core-test-kotlin</artifactId>
14+
<name>okaeri-configs (core-test-kotlin)</name>
15+
<description>Kotlin interop tests for okaeri-configs</description>
16+
17+
<properties>
18+
<maven.compiler.source>21</maven.compiler.source>
19+
<maven.compiler.target>21</maven.compiler.target>
20+
<kotlin.jvmTarget>21</kotlin.jvmTarget>
21+
</properties>
22+
23+
<dependencies>
24+
<!-- Core library -->
25+
<dependency>
26+
<groupId>eu.okaeri</groupId>
27+
<artifactId>okaeri-configs-core</artifactId>
28+
<version>${project.version}</version>
29+
</dependency>
30+
31+
<!-- Test commons (shared test utilities) -->
32+
<dependency>
33+
<groupId>eu.okaeri</groupId>
34+
<artifactId>okaeri-configs-test-commons</artifactId>
35+
<version>${project.version}</version>
36+
</dependency>
37+
38+
<!-- YAML SnakeYAML for serialization round-trip testing -->
39+
<dependency>
40+
<groupId>eu.okaeri</groupId>
41+
<artifactId>okaeri-configs-yaml-snakeyaml</artifactId>
42+
<version>${project.version}</version>
43+
</dependency>
44+
45+
<!-- Kotlin -->
46+
<dependency>
47+
<groupId>org.jetbrains.kotlin</groupId>
48+
<artifactId>kotlin-stdlib</artifactId>
49+
<version>${kotlin.version}</version>
50+
</dependency>
51+
52+
<!-- JUnit 5 -->
53+
<dependency>
54+
<groupId>org.junit.jupiter</groupId>
55+
<artifactId>junit-jupiter-api</artifactId>
56+
<version>${junit.version}</version>
57+
<scope>test</scope>
58+
</dependency>
59+
<dependency>
60+
<groupId>org.junit.jupiter</groupId>
61+
<artifactId>junit-jupiter-engine</artifactId>
62+
<version>${junit.version}</version>
63+
<scope>test</scope>
64+
</dependency>
65+
66+
<!-- AssertJ -->
67+
<dependency>
68+
<groupId>org.assertj</groupId>
69+
<artifactId>assertj-core</artifactId>
70+
<version>${assertj.version}</version>
71+
<scope>test</scope>
72+
</dependency>
73+
</dependencies>
74+
75+
<build>
76+
<testSourceDirectory>src/test/kotlin</testSourceDirectory>
77+
<plugins>
78+
<plugin>
79+
<groupId>org.jetbrains.kotlin</groupId>
80+
<artifactId>kotlin-maven-plugin</artifactId>
81+
<version>${kotlin.version}</version>
82+
<executions>
83+
<execution>
84+
<id>test-compile</id>
85+
<phase>test-compile</phase>
86+
<goals>
87+
<goal>test-compile</goal>
88+
</goals>
89+
</execution>
90+
</executions>
91+
<configuration>
92+
<jvmTarget>${kotlin.jvmTarget}</jvmTarget>
93+
</configuration>
94+
</plugin>
95+
<plugin>
96+
<artifactId>maven-source-plugin</artifactId>
97+
<configuration>
98+
<skipSource>true</skipSource>
99+
</configuration>
100+
</plugin>
101+
<plugin>
102+
<artifactId>maven-javadoc-plugin</artifactId>
103+
<configuration>
104+
<skip>true</skip>
105+
</configuration>
106+
</plugin>
107+
<plugin>
108+
<artifactId>maven-deploy-plugin</artifactId>
109+
<configuration>
110+
<skip>true</skip>
111+
</configuration>
112+
</plugin>
113+
<plugin>
114+
<groupId>org.jacoco</groupId>
115+
<artifactId>jacoco-maven-plugin</artifactId>
116+
<executions>
117+
<execution>
118+
<id>report-aggregate</id>
119+
<phase>verify</phase>
120+
<goals>
121+
<goal>report-aggregate</goal>
122+
</goals>
123+
<configuration>
124+
<excludes>
125+
<exclude>eu/okaeri/configs/test/**/*</exclude>
126+
</excludes>
127+
</configuration>
128+
</execution>
129+
</executions>
130+
</plugin>
131+
</plugins>
132+
</build>
133+
134+
</project>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package eu.okaeri.configs.kotlin
2+
3+
import eu.okaeri.configs.ConfigManager
4+
import eu.okaeri.configs.OkaeriConfig
5+
import eu.okaeri.configs.yaml.snakeyaml.YamlSnakeYamlConfigurer
6+
import org.assertj.core.api.Assertions.assertThat
7+
import org.junit.jupiter.api.Test
8+
import org.junit.jupiter.api.io.TempDir
9+
import java.nio.file.Path
10+
11+
/**
12+
* Verifies that Kotlin classes extending OkaeriConfig can be saved and loaded
13+
* correctly, preserving values through round-trip.
14+
*
15+
* Kotlin idiomatic configs use plain classes with `var` properties because
16+
* OkaeriConfig requires a no-arg constructor (incompatible with Kotlin
17+
* `data class` primary-constructor parameters).
18+
*/
19+
class KotlinClassRoundTripTest {
20+
21+
class SimpleKotlinConfig : OkaeriConfig() {
22+
var stringField: String = "default"
23+
var intField: Int = 42
24+
var booleanField: Boolean = false
25+
var doubleField: Double = 3.14
26+
}
27+
28+
class CollectionKotlinConfig : OkaeriConfig() {
29+
var stringList: MutableList<String> = mutableListOf("a", "b", "c")
30+
var intMap: MutableMap<String, Int> = mutableMapOf("one" to 1, "two" to 2)
31+
}
32+
33+
@Test
34+
fun `simple config round-trip preserves all values`(@TempDir tempDir: Path) {
35+
val file = tempDir.resolve("simple.yml").toFile()
36+
37+
val original = ConfigManager.create(SimpleKotlinConfig::class.java) { it ->
38+
it.configure { opt ->
39+
opt.configurer(YamlSnakeYamlConfigurer())
40+
opt.bindFile(file)
41+
}
42+
}
43+
original.stringField = "modified"
44+
original.intField = 999
45+
original.booleanField = true
46+
original.doubleField = 2.718
47+
original.save()
48+
49+
val loaded = ConfigManager.create(SimpleKotlinConfig::class.java) { it ->
50+
it.configure { opt ->
51+
opt.configurer(YamlSnakeYamlConfigurer())
52+
opt.bindFile(file)
53+
}
54+
it.load()
55+
}
56+
57+
assertThat(loaded.stringField).isEqualTo("modified")
58+
assertThat(loaded.intField).isEqualTo(999)
59+
assertThat(loaded.booleanField).isTrue()
60+
assertThat(loaded.doubleField).isEqualTo(2.718)
61+
}
62+
63+
@Test
64+
fun `collections round-trip preserves contents`(@TempDir tempDir: Path) {
65+
val file = tempDir.resolve("collections.yml").toFile()
66+
67+
val original = ConfigManager.create(CollectionKotlinConfig::class.java) { it ->
68+
it.configure { opt ->
69+
opt.configurer(YamlSnakeYamlConfigurer())
70+
opt.bindFile(file)
71+
}
72+
}
73+
original.stringList = mutableListOf("x", "y", "z")
74+
original.intMap = mutableMapOf("alpha" to 10, "beta" to 20)
75+
original.save()
76+
77+
val loaded = ConfigManager.create(CollectionKotlinConfig::class.java) { it ->
78+
it.configure { opt ->
79+
opt.configurer(YamlSnakeYamlConfigurer())
80+
opt.bindFile(file)
81+
}
82+
it.load()
83+
}
84+
85+
assertThat(loaded.stringList).containsExactly("x", "y", "z")
86+
assertThat(loaded.intMap).containsEntry("alpha", 10).containsEntry("beta", 20)
87+
}
88+
89+
@Test
90+
fun `default values are picked up by declaration`() {
91+
val config = ConfigManager.create(SimpleKotlinConfig::class.java)
92+
val fieldNames = config.declaration.fields.map { it.name }
93+
assertThat(fieldNames).containsExactlyInAnyOrder(
94+
"stringField", "intField", "booleanField", "doubleField"
95+
)
96+
}
97+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package eu.okaeri.configs.kotlin
2+
3+
import eu.okaeri.configs.ConfigManager
4+
import eu.okaeri.configs.OkaeriConfig
5+
import eu.okaeri.configs.configurer.InMemoryConfigurer
6+
import eu.okaeri.configs.yaml.snakeyaml.YamlSnakeYamlConfigurer
7+
import org.assertj.core.api.Assertions.assertThat
8+
import org.junit.jupiter.api.Test
9+
import org.junit.jupiter.api.io.TempDir
10+
import java.nio.file.Path
11+
12+
/**
13+
* Verifies that Kotlin companion objects do not interfere with config serialization.
14+
* The Kotlin compiler synthesizes a `public static final Companion` field on the
15+
* enclosing class. Without static field filtering, this would crash on save with
16+
* "cannot simplify type ...$Companion".
17+
*/
18+
class KotlinCompanionObjectTest {
19+
20+
class ConfigWithCompanion : OkaeriConfig() {
21+
var name: String = "default"
22+
var count: Int = 0
23+
24+
companion object {
25+
const val MAX_COUNT = 100
26+
fun create(): ConfigWithCompanion = ConfigWithCompanion()
27+
}
28+
}
29+
30+
class ConfigWithEmptyCompanion : OkaeriConfig() {
31+
var value: String = "hello"
32+
33+
companion object
34+
}
35+
36+
class ConfigWithNamedCompanion : OkaeriConfig() {
37+
var label: String = "n"
38+
39+
companion object Helper {
40+
fun build(): ConfigWithNamedCompanion = ConfigWithNamedCompanion()
41+
}
42+
}
43+
44+
@Test
45+
fun `companion field is not in declaration`() {
46+
val config = ConfigManager.create(ConfigWithCompanion::class.java)
47+
assertThat(config.declaration.getField("Companion").orElse(null)).isNull()
48+
}
49+
50+
@Test
51+
fun `const val from companion is not in declaration`() {
52+
val config = ConfigManager.create(ConfigWithCompanion::class.java)
53+
assertThat(config.declaration.getField("MAX_COUNT").orElse(null)).isNull()
54+
}
55+
56+
@Test
57+
fun `regular fields are still in declaration`() {
58+
val config = ConfigManager.create(ConfigWithCompanion::class.java)
59+
assertThat(config.declaration.getField("name").isPresent).isTrue()
60+
assertThat(config.declaration.getField("count").isPresent).isTrue()
61+
}
62+
63+
@Test
64+
fun `declaration only contains instance fields`() {
65+
val config = ConfigManager.create(ConfigWithCompanion::class.java)
66+
val fieldNames = config.declaration.fields.map { it.name }
67+
assertThat(fieldNames).containsExactlyInAnyOrder("name", "count")
68+
}
69+
70+
@Test
71+
fun `save with companion does not crash`() {
72+
val config = ConfigManager.create(ConfigWithCompanion::class.java) { it ->
73+
it.configure { opt -> opt.configurer(InMemoryConfigurer()) }
74+
}
75+
config.name = "saved"
76+
config.count = 5
77+
78+
val data = config.asMap(InMemoryConfigurer(), true)
79+
80+
assertThat(data).containsOnlyKeys("name", "count")
81+
assertThat(data["name"]).isEqualTo("saved")
82+
assertThat(data["count"]).isEqualTo(5)
83+
}
84+
85+
@Test
86+
fun `save and load round-trip preserves values`(@TempDir tempDir: Path) {
87+
val file = tempDir.resolve("config.yml").toFile()
88+
89+
val original = ConfigManager.create(ConfigWithCompanion::class.java) { it ->
90+
it.configure { opt ->
91+
opt.configurer(YamlSnakeYamlConfigurer())
92+
opt.bindFile(file)
93+
}
94+
}
95+
original.name = "persisted"
96+
original.count = 42
97+
original.save()
98+
99+
val loaded = ConfigManager.create(ConfigWithCompanion::class.java) { it ->
100+
it.configure { opt ->
101+
opt.configurer(YamlSnakeYamlConfigurer())
102+
opt.bindFile(file)
103+
}
104+
it.load()
105+
}
106+
107+
assertThat(loaded.name).isEqualTo("persisted")
108+
assertThat(loaded.count).isEqualTo(42)
109+
}
110+
111+
@Test
112+
fun `companion still accessible from code`() {
113+
assertThat(ConfigWithCompanion.MAX_COUNT).isEqualTo(100)
114+
assertThat(ConfigWithCompanion.create()).isInstanceOf(ConfigWithCompanion::class.java)
115+
}
116+
117+
@Test
118+
fun `empty companion does not break declaration`() {
119+
val config = ConfigManager.create(ConfigWithEmptyCompanion::class.java)
120+
val fieldNames = config.declaration.fields.map { it.name }
121+
assertThat(fieldNames).containsExactly("value")
122+
}
123+
124+
@Test
125+
fun `named companion is filtered too`() {
126+
val config = ConfigManager.create(ConfigWithNamedCompanion::class.java)
127+
val fieldNames = config.declaration.fields.map { it.name }
128+
assertThat(fieldNames).containsExactly("label")
129+
}
130+
}

0 commit comments

Comments
 (0)